<?php
// Finds and moves stray copies from a blog folder. Run it from the command line, from inside the blog folder:
// php find-strays.php lists what it would move (changes nothing)
// php find-strays.php --move moves the strays into a folder named _strays (nothing is deleted)
//
// What it looks for: files with a timestamp added to their name (like admin-20261026155027.php), and copies of
// the blog's files that ended up in the wrong folder (for example lib/admin.php sitting in the blog folder, or in
// tools). Your posts, settings, pictures and the real files in their real places are never touched.
if (PHP_SAPI !== 'cli') { http_response_code(403); exit; }
$root = getcwd();
$move = in_array('--move', $argv, true);
$manifest = json_decode(<<<'JSON'
[".gitignore", ".htaccess", ".user.ini", "README.md", "assets/admin/admin-theme.js", "assets/admin/admin.css", "assets/admin/analytics.js", "assets/admin/auth-init.js", "assets/admin/comments.js", "assets/admin/editor.js", "assets/admin/fa/fa.css", "assets/admin/fa/webfonts/fa-solid-900.woff2", "assets/admin/ipcard.js", "assets/admin/jquery.min.js", "assets/admin/list.js", "assets/admin/probe.js", "assets/admin/prompts.js", "assets/admin/settings-ui.js", "assets/admin/settings.js", "assets/admin/themestrap/namespace.js", "assets/admin/themestrap/themestrap.plugin.auth.js", "assets/admin/themestrap/themestrap.plugin.calendar.js", "assets/admin/themestrap/themestrap.plugin.dialog.js", "assets/admin/themestrap/themestrap.plugin.hovercard.js", "assets/admin/themestrap/themestrap.plugin.markdown.js", "assets/admin/themestrap/themestrap.plugin.markdowneditor.js", "assets/admin/themestrap/themestrap.plugin.numberinput.js", "assets/admin/themestrap/themestrap.plugin.richeditor.js", "assets/admin/themestrap/themestrap.plugin.select.js", "assets/admin/themestrap/themestrap.plugin.tagsinput.js", "assets/admin/themestrap/themestrap.plugin.timepicker.js", "assets/admin/themestrap/themestrap.plugin.toolbar.js", "assets/admin/update.js", "assets/track.js", "config.sample.php", "docs/analytics.md", "docs/architecture.md", "docs/comments.md", "docs/configuration.md", "docs/deployment.md", "docs/getting-started.md", "docs/security.md", "docs/storage.md", "docs/themes.md", "docs/troubleshooting.md", "docs/updates.md", "docs/writing.md", "index.php", "lib/admin.php", "lib/analytics.php", "lib/app.php", "lib/comments.php", "lib/compat.php", "lib/front.php", "lib/markdown.php", "lib/media.php", "lib/settings.php", "lib/store.php", "lib/update.php", "lib/vendor/sodium_compat/LICENSE", "lib/vendor/sodium_compat/autoload.php", "lib/vendor/sodium_compat/composer.json", "lib/vendor/sodium_compat/lib/namespaced.php", "lib/vendor/sodium_compat/lib/php72compat.php", "lib/vendor/sodium_compat/lib/php72compat_const.php", "lib/vendor/sodium_compat/lib/php84compat.php", "lib/vendor/sodium_compat/lib/php84compat_const.php", "lib/vendor/sodium_compat/lib/ristretto255.php", "lib/vendor/sodium_compat/lib/stream-xchacha20.php", "lib/vendor/sodium_compat/namespaced/Compat.php", "lib/vendor/sodium_compat/namespaced/Core/BLAKE2b.php", "lib/vendor/sodium_compat/namespaced/Core/ChaCha20.php", "lib/vendor/sodium_compat/namespaced/Core/ChaCha20/Ctx.php", "lib/vendor/sodium_compat/namespaced/Core/ChaCha20/IetfCtx.php", "lib/vendor/sodium_compat/namespaced/Core/Curve25519.php", "lib/vendor/sodium_compat/namespaced/Core/Curve25519/Fe.php", "lib/vendor/sodium_compat/namespaced/Core/Curve25519/Ge/Cached.php", "lib/vendor/sodium_compat/namespaced/Core/Curve25519/Ge/P1p1.php", "lib/vendor/sodium_compat/namespaced/Core/Curve25519/Ge/P2.php", "lib/vendor/sodium_compat/namespaced/Core/Curve25519/Ge/P3.php", "lib/vendor/sodium_compat/namespaced/Core/Curve25519/Ge/Precomp.php", "lib/vendor/sodium_compat/namespaced/Core/Curve25519/H.php", "lib/vendor/sodium_compat/namespaced/Core/Ed25519.php", "lib/vendor/sodium_compat/namespaced/Core/HChaCha20.php", "lib/vendor/sodium_compat/namespaced/Core/HSalsa20.php", "lib/vendor/sodium_compat/namespaced/Core/Poly1305.php", "lib/vendor/sodium_compat/namespaced/Core/Poly1305/State.php", "lib/vendor/sodium_compat/namespaced/Core/Salsa20.php", "lib/vendor/sodium_compat/namespaced/Core/SipHash.php", "lib/vendor/sodium_compat/namespaced/Core/Util.php", "lib/vendor/sodium_compat/namespaced/Core/X25519.php", "lib/vendor/sodium_compat/namespaced/Core/XChaCha20.php", "lib/vendor/sodium_compat/namespaced/Core/Xsalsa20.php", "lib/vendor/sodium_compat/namespaced/Crypto.php", "lib/vendor/sodium_compat/namespaced/File.php", "lib/vendor/sodium_compat/src/Compat.php", "lib/vendor/sodium_compat/src/Core/AEGIS/State128L.php", "lib/vendor/sodium_compat/src/Core/AEGIS/State256.php", "lib/vendor/sodium_compat/src/Core/AEGIS128L.php", "lib/vendor/sodium_compat/src/Core/AEGIS256.php", "lib/vendor/sodium_compat/src/Core/AES.php", "lib/vendor/sodium_compat/src/Core/AES/Block.php", "lib/vendor/sodium_compat/src/Core/AES/Expanded.php", "lib/vendor/sodium_compat/src/Core/AES/KeySchedule.php", "lib/vendor/sodium_compat/src/Core/BLAKE2b.php", "lib/vendor/sodium_compat/src/Core/Base64/Original.php", "lib/vendor/sodium_compat/src/Core/Base64/UrlSafe.php", "lib/vendor/sodium_compat/src/Core/ChaCha20.php", "lib/vendor/sodium_compat/src/Core/ChaCha20/Ctx.php", "lib/vendor/sodium_compat/src/Core/ChaCha20/IetfCtx.php", "lib/vendor/sodium_compat/src/Core/Curve25519.php", "lib/vendor/sodium_compat/src/Core/Curve25519/Fe.php", "lib/vendor/sodium_compat/src/Core/Curve25519/Ge/Cached.php", "lib/vendor/sodium_compat/src/Core/Curve25519/Ge/P1p1.php", "lib/vendor/sodium_compat/src/Core/Curve25519/Ge/P2.php", "lib/vendor/sodium_compat/src/Core/Curve25519/Ge/P3.php", "lib/vendor/sodium_compat/src/Core/Curve25519/Ge/Precomp.php", "lib/vendor/sodium_compat/src/Core/Curve25519/H.php", "lib/vendor/sodium_compat/src/Core/Curve25519/README.md", "lib/vendor/sodium_compat/src/Core/Ed25519.php", "lib/vendor/sodium_compat/src/Core/HChaCha20.php", "lib/vendor/sodium_compat/src/Core/HSalsa20.php", "lib/vendor/sodium_compat/src/Core/Poly1305.php", "lib/vendor/sodium_compat/src/Core/Poly1305/State.php", "lib/vendor/sodium_compat/src/Core/Ristretto255.php", "lib/vendor/sodium_compat/src/Core/Salsa20.php", "lib/vendor/sodium_compat/src/Core/SecretStream/State.php", "lib/vendor/sodium_compat/src/Core/SipHash.php", "lib/vendor/sodium_compat/src/Core/Util.php", "lib/vendor/sodium_compat/src/Core/X25519.php", "lib/vendor/sodium_compat/src/Core/XChaCha20.php", "lib/vendor/sodium_compat/src/Core/XSalsa20.php", "lib/vendor/sodium_compat/src/Crypto.php", "lib/vendor/sodium_compat/src/File.php", "lib/vendor/sodium_compat/src/SodiumException.php", "lib/version.php", "lib/views/analytics.php", "lib/views/comments.php", "lib/views/edit.php", "lib/views/layout.php", "lib/views/list.php", "lib/views/login.php", "lib/views/settings.php", "theme/default/404.php", "theme/default/alerts.js", "theme/default/comments.js", "theme/default/home.php", "theme/default/layout.php", "theme/default/post.js", "theme/default/post.php", "theme/default/reading.js", "theme/default/style.css", "theme/default/tags.php", "theme/default/theme.js", "theme/default/vendor/jquery.min.js", "theme/default/vendor/namespace.js", "theme/default/vendor/themestrap.plugin.alert.js", "theme/default/vendor/themestrap.plugin.scrollshadow.js", "theme/themestrap/404.php", "theme/themestrap/alerts.js", "theme/themestrap/comments.js", "theme/themestrap/functions.php", "theme/themestrap/home.php", "theme/themestrap/layout.php", "theme/themestrap/post.js", "theme/themestrap/post.php", "theme/themestrap/reading.js", "theme/themestrap/style.css", "theme/themestrap/tags.php", "theme/themestrap/theme.js", "theme/themestrap/vendor/jquery.min.js", "theme/themestrap/vendor/namespace.js", "theme/themestrap/vendor/themestrap.plugin.alert.js", "theme/themestrap/vendor/themestrap.plugin.scrollshadow.js", "tools/build-release.php", "tools/migrate.php"]
JSON, true);
$real = array_flip($manifest);
$byName = [];
foreach ($manifest as $rel) $byName[basename($rel)][] = dirname($rel) === '.' ? '' : dirname($rel);
$dataLike = '/^\.gitkeep$|(\.md\.php|\.md|\.cache\.php|\.db|\.tmp)$|^(hits-\d{4}-\d{2}|settings|state|admin|log|ipinfo|comments|index\.cache)\.php$/i';
$stamp = '/-\d{14}(?=\.|$|-\d{14})/';
if (!is_file($root . '/index.php') || !is_dir($root . '/lib')) {
fwrite(STDERR, "This does not look like the blog folder (no index.php and lib folder here). Run it from inside the blog folder.\n");
exit(1);
}
// If the blog is set up to keep its data in this very folder, data-like files here are real, so leave them
$cfgFile = is_file($root . '/config.php') ? @include $root . '/config.php' : [];
$cfgFile = is_array($cfgFile) ? $cfgFile : [];
$dataHere = false;
foreach (['data_dir', 'content_dir'] as $k) if (!empty($cfgFile[$k]) && realpath((string) $cfgFile[$k]) !== false && in_array(realpath((string) $cfgFile[$k]), [realpath($root), realpath($root . '/tools')], true)) $dataHere = true;
$strays = [];
// Walk the folder, skipping the ones that are not ours to look at (private session files, git, pictures) and
// any folder this account is not allowed to open
$skipDirs = ['_strays', '.git', 'uploads', 'data/sessions'];
$skipped = [];
$filter = new RecursiveCallbackFilterIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
function ($f) use ($root, $skipDirs, &$skipped) {
if (!$f->isDir() || $f->isLink()) return true;
$rel = str_replace('\\', '/', substr($f->getPathname(), strlen($root) + 1));
if (in_array($rel, $skipDirs, true)) return false;
if (!is_readable($f->getPathname()) || !is_executable($f->getPathname())) { $skipped[] = $rel; return false; }
return true;
});
$it = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD);
foreach ($it as $f) {
if (!$f->isFile() || $f->isLink()) continue;
$rel = str_replace('\\', '/', substr($f->getPathname(), strlen($root) + 1));
$top = explode('/', $rel)[0];
if (in_array($top, ['_strays', '.git', 'uploads'], true)) continue;
if (isset($real[$rel])) continue;
$dir = dirname($rel) === '.' ? '' : dirname($rel);
$name = basename($rel);
$why = '';
if (preg_match($stamp, $name)) $why = 'name has a timestamp added';
elseif (($dir === '' || $dir === 'tools') && $name !== 'config.php') {
if (isset($byName[$name]) && !in_array($dir, $byName[$name], true)) $why = 'a copy of ' . ltrim($byName[$name][0] . '/' . $name, '/');
elseif (!$dataHere && preg_match($dataLike, $name)) $why = 'a data file that belongs in data or content';
}
if ($why !== '') $strays[$rel] = $why;
}
ksort($strays);
$missing = array_values(array_filter($manifest, fn($m) => !is_file($root . '/' . $m)));
if ($skipped) echo "Skipped folders this account cannot open: " . implode(", ", $skipped) . "\n";
printf("%d stray file(s) found.\n", count($strays));
foreach ($strays as $rel => $why) printf(" %-70s %s\n", $rel, $why);
if ($missing) {
printf("\n%d of the blog's own files are missing from their proper place (an update or a fresh upload will restore them):\n", count($missing));
foreach ($missing as $m) echo " $m\n";
} else {
echo "\nEvery one of the blog's own files is in its proper place.\n";
}
if (!$strays) exit(0);
if (!$move) { echo "\nNothing was changed. Run again with --move to put these in the _strays folder.\n"; exit(0); }
$moved = 0;
foreach ($strays as $rel => $why) {
$dest = $root . '/_strays/' . $rel;
if (!is_dir(dirname($dest)) && !@mkdir(dirname($dest), 0775, true)) { echo "Could not create a folder for $rel\n"; continue; }
$n = 1; $base = $dest;
while (file_exists($dest)) $dest = $base . '.' . $n++;
if (@rename($root . '/' . $rel, $dest)) $moved++; else echo "Could not move $rel\n";
}
@file_put_contents($root . '/_strays/.htaccess', "Require all denied\n");
@file_put_contents($root . '/_strays/index.html', '');
echo "\nMoved $moved file(s) into _strays. Check the blog, then delete that folder when you are happy.\n";