<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Stale lock removal. Deletes one lock file from /tmp/unraid_locks so a script whose
//   previous run died without releasing its lock can be started again from the UI.
//
// OPERATIONAL MODEL
//   The manual escape hatch for the one failure the lock discipline cannot resolve on its
//   own. Scripts take a lock to prevent concurrent runs; a run killed by OOM, a reboot mid-
//   job, or kill -9 leaves the lock behind and every subsequent run refuses to start. This
//   endpoint is how a person clears that, and it is deliberately the only way — nothing
//   automatically reaps locks, because "the lock is old" and "the job is still running" are
//   not distinguishable from the file alone.
//
// DESIGN PRINCIPLES
//   Loads nothing.
//       This is the only endpoint in the api layer that requires no include. The operation is
//       one unlink in one fixed directory; pulling in the config layer to perform it would
//       add failure modes to something that must work when other things are broken.
//
//   Deletes exactly one lock, never sweeps.
//       There is no clear-all. Releasing every lock at once would restart the concurrent runs
//       the locks exist to prevent.
//
// OPERATIONAL SAFEGUARDS
//   POST only. A GET cannot delete a lock, so a stray link, prefetch, or browser history
//   entry cannot release one.
//
//   The filename is stripped and then pattern-matched, in that order.
//       basename() removes any directory component, and the surviving name must match
//       ^[a-zA-Z0-9_\-]+\.lock$ — no dots beyond the extension, no slashes, no traversal. The
//       two together mean the composed path cannot leave /tmp/unraid_locks, and the .lock
//       suffix means nothing but a lock file is a candidate in the first place.
//
//   Deleting an absent lock is success, not an error.
//       file_exists() is checked and @unlink() suppressed, so two clicks, or a race with the
//       script releasing its own lock, both end with the lock gone and ok:true. The caller
//       wants the lock absent; it does not care who removed it.
//
//   Scope is one fixed directory, hardcoded here.
//       /tmp/unraid_locks is a literal, not a config value, so no conf edit or empty variable
//       can redirect this delete somewhere else.
//
// REQUEST
//   POST file=<name>.lock
//
// RESPONSE
//   {"ok":true}                     lock removed, or already absent
//   {"ok":false,"error":string}     wrong method or a filename that failed validation
//
// DEPENDS ON
//   nothing — /tmp/unraid_locks is written by the shell layer's locking helper
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'POST only']);
    exit;
}
$file = basename($_POST['file'] ?? '');
if (!$file || !preg_match('/^[a-zA-Z0-9_\-]+\.lock$/', $file)) {
    echo json_encode(['ok' => false, 'error' => 'Invalid filename']);
    exit;
}
$path = '/tmp/unraid_locks/' . $file;
if (file_exists($path)) @unlink($path);
echo json_encode(['ok' => true]);
