<?php
/**
 * Rebuilds files.json from whatever is actually sitting in the cache directory.
 *
 * Place this next to files.json at the web root, i.e.
 *
 *   /public_html/update_files_json.php   <- this script
 *   /public_html/files.json              <- generated
 *   /public_html/files/                  <- the cache (anim/, audio/, data/, ...)
 *
 * Run it from a browser:  https://samp-mobile.shop/update_files_json.php?key=SECRET
 * or from cron:           php /home/user/public_html/update_files_json.php
 *
 * The output keeps the exact same structure the launcher already parses:
 * a "files" array of { name, path, url, size }.
 */

// ---------------------------------------------------------------- config ---

/** Directory that holds the cache. Absolute path, no trailing slash. */
$ROOT = __DIR__ . '/files';

/** Where the manifest is written. */
$OUTPUT = __DIR__ . '/files.json';

/** Public base URL of $ROOT. No trailing slash. */
$BASE_URL = 'https://files.samp.plus/mobile/files';

/**
 * Shared secret for the web entry point. Requests without ?key=<this> are
 * rejected. Running from the CLI (cron) skips the check.
 *
 * Set this to '' to turn the check off entirely and let anyone trigger a
 * rebuild. The endpoint only rescans and rewrites the manifest, so the worst
 * an outsider can do is force disk scans on the host.
 */
$SECRET = '1307157c2df6e903a185d5819872007fce1a05b598dc3919';

/**
 * How the "url" field is built.
 *
 *   true  -> $BASE_URL . '/' . path   (nested, matches where the file is)
 *   false -> $BASE_URL . '/' . name   (flat, what files.json used to contain)
 *
 * The flat form was wrong: it pointed at /files/gta_int.dxt.dat when the file
 * lives at /files/texdb/gta_int.dxt.dat, and it collided for the six names
 * that exist under both data/ and SAMP/ - gta.dat, handling.cfg, peds.ide,
 * timecyc.dat, tracks2.dat, tracks4.dat - which have different contents but
 * would have shared one URL.
 */
$URL_FROM_PATH = true;

/**
 * How the "name" field is built.
 *
 *   true  -> "texdb/gta3/gta3.txt"  (full path, same value as "path")
 *   false -> "gta3.txt"             (bare filename)
 *
 * See the note in the README section at the top of this file: the launcher
 * currently treats "name" as a bare filename, so true needs a matching change
 * in UpdateService.java.
 */
$NAME_WITH_PATH = true;

/** Names skipped anywhere in the tree. */
$SKIP_NAMES = array('.', '..', '.git', '.svn', '.DS_Store', 'Thumbs.db');

/**
 * Exact relative paths left out of the manifest entirely.
 *
 * The launcher only downloads what it finds in files.json, so anything listed
 * here is never touched on the device. settings.ini holds the player's own
 * settings - if it appears in the manifest, every update overwrites them.
 *
 * This is the reliable place to enforce it. UpdateService.java has its own
 * skip list, but it compares against "name", so it stopped matching once
 * $NAME_WITH_PATH made that field a full path.
 */
$SKIP_PATHS = array(
    'SAMP/settings.ini',
);

// ------------------------------------------------------------------ setup ---

$isCli = (PHP_SAPI === 'cli');

if (!$isCli) {
    header('Content-Type: text/plain; charset=utf-8');

    $given = isset($_GET['key']) && is_string($_GET['key']) ? $_GET['key'] : '';
    $authorised = ($SECRET === '') || hash_equals($SECRET, $given);

    // ?debug=1 answers "why does this keep saying forbidden" without printing
    // the secret: it reports which copy of the file is actually running, when
    // that copy was uploaded, and a fingerprint of the key it expects next to
    // the one it received. Matching fingerprints but no authorisation means
    // something between the browser and PHP is eating the query string.
    if (isset($_GET['debug'])) {
        echo 'script:       ' . __FILE__ . "\n";
        echo 'uploaded:     ' . date('Y-m-d H:i:s', (int) filemtime(__FILE__)) . "\n";
        echo 'php:          ' . PHP_VERSION . ' (' . PHP_SAPI . ")\n";
        echo 'query string: ' . (isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '(none)') . "\n";
        echo 'key received: ' . (isset($_GET['key']) ? strlen($given) . ' chars' : 'no ?key= in query string') . "\n";
        echo 'key expected: ' . ($SECRET === '' ? 'none, check disabled' : strlen($SECRET) . ' chars') . "\n";
        echo 'expected fp:  ' . substr(hash('sha256', $SECRET), 0, 12) . "\n";
        echo 'received fp:  ' . substr(hash('sha256', $given), 0, 12) . "\n";
        echo 'authorised:   ' . ($authorised ? 'yes' : 'no') . "\n";

        // ?probe=<relative path> walks that path one segment at a time and
        // reports type, permissions and ownership for each. This is the fast
        // way to explain a file PHP can read but the web server 404s: some
        // directory along the way is missing o+x, so the static handler cannot
        // traverse into it even though PHP, running as the owner, can.
        if (isset($_GET['probe']) && is_string($_GET['probe'])) {
            if ($authorised) {
                probePath($ROOT, $_GET['probe']);
            } else {
                echo "\nprobe: add ?key=... as well\n";
            }
        }

        exit;
    }

    if (!$authorised) {
        http_response_code(403);
        exit("forbidden - add ?key=..., or ?debug=1 to see why\n");
    }
}

@set_time_limit(0);
ignore_user_abort(true);

$ROOT = rtrim($ROOT, "/\\");
$BASE_URL = rtrim($BASE_URL, '/');

if (!is_dir($ROOT)) {
    fail("cache directory not found: $ROOT");
}

// ------------------------------------------------------------------ scan ---

/**
 * Walks $dir and appends one entry per file to $out.
 *
 * Entries are emitted files-first then directories, each group sorted
 * case-insensitively. That is the order the existing files.json is in, so a
 * regeneration only ever moves lines that genuinely changed.
 *
 * @param string $dir      absolute directory to read
 * @param string $relative path of $dir relative to the cache root, '' at the top
 */
function scanDirectory($dir, $relative, array &$out)
{
    global $SKIP_NAMES, $BASE_URL, $URL_FROM_PATH, $NAME_WITH_PATH, $OUTPUT;

    $handle = @opendir($dir);
    if ($handle === false) {
        fail("cannot read directory: $dir");
    }

    $files = array();
    $dirs = array();

    while (($name = readdir($handle)) !== false) {
        if (in_array($name, $SKIP_NAMES, true)) {
            continue;
        }

        $absolute = $dir . '/' . $name;

        // Do not descend into symlinks - a link pointing back up the tree would
        // loop forever.
        if (is_link($absolute)) {
            continue;
        }

        if (is_dir($absolute)) {
            $dirs[] = $name;
        } elseif (is_file($absolute)) {
            // Never list the script or the manifest, in case either one ends up
            // inside the cache directory.
            if (realpath($absolute) === realpath(__FILE__)) {
                continue;
            }
            if (realpath($absolute) === realpath($OUTPUT)) {
                continue;
            }
            $files[] = $name;
        }
    }
    closedir($handle);

    usort($files, 'strcasecmp');
    usort($dirs, 'strcasecmp');

    foreach ($files as $name) {
        $path = ($relative === '') ? $name : $relative . '/' . $name;

        if (isPathSkipped($path)) {
            continue;
        }

        $size = fileSizeOf($dir . '/' . $name);

        $out[] = array(
            'name' => $NAME_WITH_PATH ? $path : $name,
            'path' => $path,
            'url'  => $BASE_URL . '/' . urlEncodePath($URL_FROM_PATH ? $path : $name),
            'size' => $size,
        );
    }

    foreach ($dirs as $name) {
        scanDirectory(
            $dir . '/' . $name,
            ($relative === '') ? $name : $relative . '/' . $name,
            $out
        );
    }
}

/** True when $path is in $SKIP_PATHS and must stay out of the manifest. */
function isPathSkipped($path)
{
    global $SKIP_PATHS;

    foreach ($SKIP_PATHS as $skip) {
        $skip = trim(str_replace('\\', '/', $skip), '/');
        if (strcasecmp($skip, $path) === 0) {
            return true;
        }
    }

    return false;
}

/**
 * Size in bytes. filesize() returns a signed 32-bit int on some builds, which
 * wraps negative past 2GB; fall back to seeking the end of the file when that
 * happens so a large .img is never reported with a bogus size.
 */
function fileSizeOf($absolute)
{
    clearstatcache(true, $absolute);
    $size = @filesize($absolute);

    if ($size !== false && $size >= 0) {
        return $size;
    }

    $fp = @fopen($absolute, 'rb');
    if ($fp === false) {
        fail("cannot open file: $absolute");
    }
    $size = 0;
    fseek($fp, 0, SEEK_END);
    $size = ftell($fp);
    fclose($fp);

    return $size;
}

/**
 * Prints one line per path segment describing what is actually on disk, then
 * lists the parent directory. Used by ?debug=1&probe=...
 */
function probePath($root, $relative)
{
    $relative = trim(str_replace('\\', '/', $relative), '/');

    if ($relative === '' || strpos($relative, '..') !== false) {
        echo "\nprobe: bad path\n";
        return;
    }

    echo "\nprobe: " . $relative . "\n";

    $current = rtrim($root, '/');
    describeEntry($current, '(cache root)');

    foreach (explode('/', $relative) as $segment) {
        $current .= '/' . $segment;
        describeEntry($current, $segment);
    }

    $parent = dirname($current);
    $names = is_dir($parent) ? @scandir($parent) : false;

    if ($names === false) {
        echo "\n  cannot list " . $parent . "\n";
        return;
    }

    $names = array_values(array_diff($names, array('.', '..')));
    echo "\n  " . $parent . ' contains ' . count($names) . " entries:\n";
    foreach (array_slice($names, 0, 25) as $entry) {
        echo '    ' . $entry . "\n";
    }
    if (count($names) > 25) {
        echo '    ... and ' . (count($names) - 25) . " more\n";
    }
}

/** One "type perms owner:group" line for a single path. */
function describeEntry($absolute, $label)
{
    clearstatcache(true, $absolute);

    $label = str_pad($label, 22);

    if (is_link($absolute)) {
        echo '  ' . $label . ' symlink -> ' . readlink($absolute)
            . (file_exists($absolute) ? '' : '  (BROKEN)') . "\n";
        return;
    }

    if (!file_exists($absolute)) {
        echo '  ' . $label . " MISSING\n";
        return;
    }

    $perms = fileperms($absolute);
    $isDir = is_dir($absolute);

    $owner = function_exists('posix_getpwuid') ? posix_getpwuid(fileowner($absolute)) : null;
    $group = function_exists('posix_getgrgid') ? posix_getgrgid(filegroup($absolute)) : null;

    echo '  ' . $label
        . ' ' . ($isDir ? 'dir ' : 'file')
        . ' ' . substr(sprintf('%o', $perms), -4)
        . ' ' . ($owner ? $owner['name'] : fileowner($absolute))
        . ':' . ($group ? $group['name'] : filegroup($absolute));

    // The bit that matters. A directory needs o+x for the web server to
    // traverse it; a file needs o+r to be served.
    if ($isDir && !($perms & 0001)) {
        echo '   <-- no o+x, web server cannot enter';
    } elseif (!$isDir && !($perms & 0004)) {
        echo '   <-- no o+r, web server cannot read';
    }

    echo "\n";
}

/** Percent-encodes each path segment, leaving the separators alone. */
function urlEncodePath($path)
{
    $parts = explode('/', $path);
    foreach ($parts as $i => $part) {
        $parts[$i] = rawurlencode($part);
    }
    return implode('/', $parts);
}

function fail($message)
{
    if (PHP_SAPI !== 'cli') {
        http_response_code(500);
    }
    fwrite(PHP_SAPI === 'cli' ? STDERR : fopen('php://output', 'w'), "error: $message\n");
    exit(1);
}

$entries = array();
scanDirectory($ROOT, '', $entries);

if (empty($entries)) {
    fail("no files found under $ROOT - refusing to write an empty manifest");
}

// ------------------------------------------------------------- diff -------

$previous = array();
if (is_file($OUTPUT)) {
    $decoded = json_decode((string) file_get_contents($OUTPUT), true);
    if (isset($decoded['files']) && is_array($decoded['files'])) {
        foreach ($decoded['files'] as $entry) {
            if (isset($entry['path'])) {
                $previous[$entry['path']] = isset($entry['size']) ? (int) $entry['size'] : -1;
            }
        }
    }
}

$added = array();
$changed = array();
$totalBytes = 0;
$current = array();

foreach ($entries as $entry) {
    $current[$entry['path']] = true;
    $totalBytes += $entry['size'];

    if (!array_key_exists($entry['path'], $previous)) {
        $added[] = $entry['path'];
    } elseif ($previous[$entry['path']] !== $entry['size']) {
        $changed[] = $entry['path'] . ' (' . $previous[$entry['path']] . ' -> ' . $entry['size'] . ')';
    }
}

$removed = array();
foreach ($previous as $path => $size) {
    if (!isset($current[$path])) {
        $removed[] = $path;
    }
}

// ------------------------------------------------------------- write ------

$json = json_encode(
    array('files' => $entries),
    JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);

if ($json === false) {
    fail('json_encode failed: ' . json_last_error_msg());
}

// Write to a temp file in the same directory and rename over the target, so a
// launcher fetching files.json mid-run never sees a half-written manifest.
$temp = $OUTPUT . '.tmp' . getmypid();

if (@file_put_contents($temp, $json, LOCK_EX) === false) {
    fail("cannot write $temp - check directory permissions");
}
@chmod($temp, 0644);

if (!@rename($temp, $OUTPUT)) {
    @unlink($temp);
    fail("cannot replace $OUTPUT");
}

// ------------------------------------------------------------- report -----
// https://samp-mobile.shop/update_files_json.php?key=1307157c2df6e903a185d5819872007fce1a05b598dc3919


$report = array();
$report[] = 'wrote ' . $OUTPUT;
$report[] = 'files: ' . count($entries);
$report[] = 'total: ' . round($totalBytes / 1048576, 2) . ' MB (' . $totalBytes . ' bytes)';
$report[] = 'added: ' . count($added);
$report[] = 'changed: ' . count($changed);
$report[] = 'removed: ' . count($removed);

foreach ($added as $path) {
    $report[] = '  + ' . $path;
}
foreach ($changed as $line) {
    $report[] = '  ~ ' . $line;
}
foreach ($removed as $path) {
    $report[] = '  - ' . $path;
}

echo implode("\n", $report) . "\n";
