97 lines
5.2 KiB
PHP
97 lines
5.2 KiB
PHP
<?php
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
// PURPOSE
|
|
// Script and document reader. Returns the full text of one .sh or .md file inside
|
|
// SCRIPTS_DIR — the source view behind the scheduler page's script viewer and the docs tab.
|
|
//
|
|
// OPERATIONAL MODEL
|
|
// Validate, resolve, read. The id is checked against a character class and an extension list
|
|
// before it is joined to SCRIPTS_DIR, so nothing reaches the filesystem that did not already
|
|
// look like a repo-relative path.
|
|
//
|
|
// The whole file is returned in one response — no ranges, no pagination. These are scripts and
|
|
// documents, not logs; the largest is a few hundred kilobytes, and a viewer that had to stitch
|
|
// pages together would be more machinery than the thing it displays.
|
|
//
|
|
// Every failure is a JSON body with ok:false, never an HTTP error code. The scheduler's viewer
|
|
// and the docs tab both parse the response before looking at anything else, so a 404 would
|
|
// surface as a parse failure rather than as "that file is not there".
|
|
//
|
|
// DESIGN PRINCIPLES
|
|
// Two extensions, one endpoint.
|
|
// Scripts and their READMEs are read the same way because they are read for the same
|
|
// reason — someone wants to see what a job actually does. Splitting them would mean two
|
|
// endpoints with identical validation.
|
|
//
|
|
// Returns text, never renders it.
|
|
// Markdown arrives as source. Rendering is the browser's job, and doing it here would
|
|
// make this endpoint an HTML producer with an HTML producer's escaping problems.
|
|
//
|
|
// Relative ids only.
|
|
// The id is a path relative to SCRIPTS_DIR, so the caller never learns or supplies the
|
|
// installation root. That is also what makes the same id valid in internal and appdata
|
|
// storage modes.
|
|
//
|
|
// OPERATIONAL SAFEGUARDS
|
|
// Read-only. There is no write counterpart in this file; edits go through movescript.php
|
|
// and import_script.php, which have their own validation.
|
|
//
|
|
// Traversal is blocked before the path is composed.
|
|
// str_contains($id, '..') is checked explicitly, and the pattern
|
|
// ^[A-Za-z0-9_.\-/]+\.(sh|md)$ excludes null bytes, backslashes, spaces, and every
|
|
// shell metacharacter. The slash has to be permitted because ids are Category/name.sh,
|
|
// so the '..' check carries the traversal guarantee on its own rather than being
|
|
// implied by the character class.
|
|
//
|
|
// The extension allowlist is the real access boundary.
|
|
// Only .sh, .md, .php and .template can be named at all, which is what keeps
|
|
// Configurations/*.conf — the files holding every credential in the system — outside
|
|
// this endpoint's reach. Any future extension added here has to be checked against that
|
|
// first.
|
|
//
|
|
// .php and .template were added for the AI tab's source viewer, whose retrieval results
|
|
// span every tracked file type. They are safe by the same argument that makes the AI
|
|
// index safe: only git-tracked content is involved, the conf files were never tracked,
|
|
// and the repository is pushed to a remote — anything reachable here is already
|
|
// published. .conf is deliberately still absent, and must stay that way.
|
|
//
|
|
// A missing file is reported, not opened.
|
|
// file_exists() precedes file_get_contents(), so a bad id returns a named error rather
|
|
// than a PHP warning leaking the absolute path into the JSON body.
|
|
//
|
|
// Known limit: symlinks inside SCRIPTS_DIR are followed.
|
|
// Containment is enforced on the id, not on the resolved path, so a symlink placed
|
|
// inside the tree pointing outside it would be read. Not tightened with a realpath
|
|
// check, because the plugin's own boot model installs SCRIPTS_DIR as a symlink and a
|
|
// naive containment test would break it. The tree is git-managed; a rogue symlink in it
|
|
// is a repo compromise, which is a larger problem than this endpoint.
|
|
//
|
|
// REQUEST
|
|
// GET ?id=<Category/name.sh|Category/README-name.md>
|
|
//
|
|
// RESPONSE
|
|
// {"ok":true,"content":"<full file text>"}
|
|
// {"ok":false,"error":"Invalid id"|"Not found"}
|
|
//
|
|
// DEPENDS ON
|
|
// include/config.php SCRIPTS_DIR
|
|
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
header('Content-Type: application/json');
|
|
require_once dirname(__DIR__) . '/include/config.php';
|
|
|
|
$id = trim($_GET['id'] ?? '');
|
|
|
|
// Must be relative path within SCRIPTS_DIR, no traversal, must end in .sh or .md
|
|
if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.(sh|md|php|template)$/', $id)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Invalid id']);
|
|
exit;
|
|
}
|
|
|
|
$path = SCRIPTS_DIR . '/' . $id;
|
|
if (!file_exists($path)) {
|
|
echo json_encode(['ok' => false, 'error' => 'Not found']);
|
|
exit;
|
|
}
|
|
|
|
echo json_encode(['ok' => true, 'content' => file_get_contents($path)]);
|