Initial commit

This commit is contained in:
Robert Nasarek 2026-06-25 09:11:23 +02:00
commit 05c65aad4d
155 changed files with 93617 additions and 0 deletions

59
viewer/admin/actions.php Normal file
View file

@ -0,0 +1,59 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) { header('Location: login.php'); exit; }
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maintenance</title>
<link rel="stylesheet" href="style.css">
<style>button{margin:.3rem}</style>
</head>
<body>
<div class="wrap">
<header class="site"><h1>Maintenance</h1><nav class="admin-links"><a href="index.php">Wróć</a></nav></header>
<div class="card">
<h2>Cache & Rebuilds</h2>
<div>
<button data-action="clear_cache">Clear Cache</button>
<button data-action="rebuild_thumbs">Rebuild Thumbnails</button>
<button data-action="diagnostics">Diagnostics</button>
</div>
<pre id="out" style="margin-top:1rem;padding:1rem;border:1px solid #ddd;background:#f9f9f9;white-space:pre-wrap"></pre>
</div>
<div class="card">
<h2>Entity Re-save (Drupal)</h2>
<p class="muted">Trigger re-save for a specific entity to queue it for model conversion.</p>
<form id="entityResaveForm">
<label>Entity ID<input name="entity_id" type="text" placeholder="e.g. 12345" required></label>
<label>Entity Type<input name="entity_type" type="text" placeholder="e.g. wisski_individual" value="wisski_individual"></label>
<div style="margin-top:1rem"><button type="submit">Re-save Entity</button></div>
</form>
<pre id="entityOut" style="margin-top:1rem;padding:1rem;border:1px solid #ddd;background:#f9f9f9;white-space:pre-wrap;display:none"></pre>
</div>
</div>
<script>
document.querySelectorAll('button[data-action]').forEach(b=>b.addEventListener('click', async ()=>{
const action=b.getAttribute('data-action');
const out=document.getElementById('out'); out.textContent='...';
const res=await fetch('api/actions.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify({action})});
const j=await res.json(); out.textContent=JSON.stringify(j, null, 2);
}));
document.getElementById('entityResaveForm').addEventListener('submit', async (e)=>{
e.preventDefault();
const entity_id = document.querySelector('input[name="entity_id"]').value;
const entity_type = document.querySelector('input[name="entity_type"]').value;
const outEl = document.getElementById('entityOut');
outEl.style.display='block';
outEl.textContent='...';
const res = await fetch('api/actions.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'entity_resave', entity_id, entity_type})});
const j = await res.json();
outEl.textContent = (j.output || JSON.stringify(j, null, 2));
});
</script>
</body>
</html>

View file

@ -0,0 +1,89 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data || !isset($data['action'])) { http_response_code(400); json_response(['error'=>'invalid_request']); }
$action = $data['action'];
if ($action === 'clear_cache') {
$target = $root . '/viewer/build';
$out = '';
if (is_dir($target)) {
// remove all files
$cmd = 'rm -rf ' . escapeshellarg($target) . '/*';
$out = shell_exec($cmd . ' 2>&1');
} else { $out = 'no build dir'; }
json_response(['ok'=>true,'output'=>$out]);
}
if ($action === 'rebuild_thumbs') {
$script = $root . '/scripts/render.sh';
if (file_exists($script) && is_executable($script)) {
$out = shell_exec(escapeshellcmd($script) . ' 2>&1');
json_response(['ok'=>true,'output'=>$out]);
}
http_response_code(500); json_response(['error'=>'script_missing']);
}
if ($action === 'diagnostics') {
$out = [];
$out['php'] = shell_exec('php -v 2>&1');
$out['uname'] = shell_exec('uname -a 2>&1');
$out['df'] = shell_exec('df -h 2>&1');
json_response(['ok'=>true,'output'=>$out]);
}
if ($action === 'entity_resave') {
$entity_id = $data['entity_id'] ?? null;
$entity_type = $data['entity_type'] ?? 'wisski_individual';
if (!$entity_id) { http_response_code(400); json_response(['error'=>'missing_entity_id']); }
$out = "Attempting to re-save entity $entity_type:$entity_id ...\n\n";
// Try to bootstrap Drupal if available
$drupal_root = $root;
if (file_exists($drupal_root . '/index.php')) {
$out .= "Found index.php, attempting Drupal bootstrap...\n";
try {
// Push to Drupal context
$cwd = getcwd();
chdir($drupal_root);
// Simple Drupal load without full bootstrap (safer)
if (function_exists('drush_main')) {
$out .= "Drush detected, will attempt re-save.\n";
}
// Try using Drupal's entity loader via autoloader
if (file_exists($drupal_root . '/vendor/autoload.php')) {
require_once $drupal_root . '/vendor/autoload.php';
$out .= "Drupal autoloader loaded.\n";
// Simple check: can we access Drupal\Core ?
if (class_exists('Drupal\Core\Entity\EntityTypeManager')) {
$out .= "Drupal namespace available - attempting entity save.\n";
// Real bootstrap would require more setup
$out .= "INFO: Full entity re-save requires Drupal context.\n";
}
}
chdir($cwd);
$out .= "\nTo re-save this entity, use Drupal CLI:\n";
$out .= " drush entity:save $entity_type $entity_id\n";
$out .= "Or access Drupal admin panel and update the entity.\n";
} catch (\Throwable $e) {
$out .= "Bootstrap check failed: " . $e->getMessage() . "\n";
$out .= "\nTo re-save this entity, use Drupal CLI:\n";
$out .= " drush entity:save $entity_type $entity_id\n";
}
} else {
$out .= "Drupal not detected in this directory.\n";
$out .= "To re-save an entity, use:\n";
$out .= " drush entity:save $entity_type $entity_id\n";
$out .= "Or trigger via Drupal admin panel.\n";
}
json_response(['ok'=>true,'output'=>$out]);
}
http_response_code(400); json_response(['error'=>'unknown_action']);

View file

@ -0,0 +1,62 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
// GET ?target=settings|env -> list backups (most recent first, limited to 5)
// POST restore {target, file}
function list_backups($target) {
global $root;
if ($target === 'settings') {
$path = $root . '/viewer-settings.json';
$base = 'viewer-settings.json';
} elseif ($target === 'env') {
$path = $root . '/scripts/.env';
$base = '.env';
$dir = dirname($path);
$base = basename($path);
} else {
return ['error'=>'invalid_target'];
}
$dir = dirname($path);
$pattern = $dir . '/' . $base . '.*';
$files = glob($pattern);
// filter timestamped copies only (YYYYmmdd-HHMMSS)
$backups = array_filter($files, function($f) use ($base){ return preg_match('/' . preg_quote($base, '/') . '\.\d{8}-\d{6}$/', $f); });
usort($backups, function($a,$b){ return filemtime($b) - filemtime($a); });
$backups = array_slice($backups, 0, 5);
$out = [];
foreach($backups as $b) $out[] = ['file'=>basename($b),'path'=>$b,'ts'=>date('c', filemtime($b))];
return ['backups'=>$out];
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$target = $_GET['target'] ?? null;
if (!$target) { http_response_code(400); json_response(['error'=>'missing_target']); }
json_response(list_backups($target));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data || !isset($data['target']) || !isset($data['file'])) { http_response_code(400); json_response(['error'=>'invalid_request']); }
$target = $data['target'];
$file = basename($data['file']);
if ($target === 'settings') {
$path = $root . '/viewer-settings.json';
$dir = dirname($path);
$src = $dir . '/' . $file;
} elseif ($target === 'env') {
$path = $root . '/scripts/.env';
$dir = dirname($path);
$src = $dir . '/' . $file;
} else { http_response_code(400); json_response(['error'=>'invalid_target']); }
if (!file_exists($src)) { http_response_code(404); json_response(['error'=>'not_found']); }
// make a backup of current file before restore
if (file_exists($path)) backup_file($path);
if (!copy($src, $path)) { http_response_code(500); json_response(['error'=>'restore_failed']); }
json_response(['ok'=>true]);
}
http_response_code(405); json_response(['error'=>'method_not_allowed']);

View file

@ -0,0 +1,55 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized']);
exit;
}
function json_response($data) {
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function workspace_root() {
// admin/api is at viewer/admin/api -> go up 3
return realpath(__DIR__ . '/../../../');
}
function backup_file($path, $keep = 5) {
if (!file_exists($path)) return;
$dir = dirname($path);
$base = basename($path);
// simple .bak (latest)
copy($path, $dir . '/' . $base . '.bak');
// timestamped
$ts = date('Ymd-His');
$copy = $dir . '/' . $base . '.' . $ts;
copy($path, $copy);
// rotate old backups: match $base.* in same dir (exclude .bak)
$pattern = $dir . '/' . $base . '.*';
$files = glob($pattern);
// filter timestamped copies only (YYYYmmdd-HHMMSS)
$backups = array_filter($files, function($f) use ($base){ return preg_match('/' . preg_quote($base, '/') . '\.\d{8}-\d{6}$/', $f); });
usort($backups, function($a,$b){ return filemtime($b) - filemtime($a); });
if (count($backups) > $keep) {
$remove = array_slice($backups, $keep);
foreach($remove as $r) @unlink($r);
}
}
function save_uploaded_schema($targetPath) {
if (!isset($_FILES['file'])) return ['error'=>'no_file'];
$f = $_FILES['file'];
if ($f['error'] !== UPLOAD_ERR_OK) return ['error'=>'upload_error'];
// validate json
$content = file_get_contents($f['tmp_name']);
if (json_decode($content) === null) return ['error'=>'invalid_json'];
// backup existing
if (file_exists($targetPath)) backup_file($targetPath, intval(getenv('ADMIN_BACKUP_KEEP') ?: 10));
if (!is_dir(dirname($targetPath))) mkdir(dirname($targetPath), 0755, true);
if (!move_uploaded_file($f['tmp_name'], $targetPath)) return ['error'=>'move_failed'];
return ['ok'=>true];
}

53
viewer/admin/api/env.php Normal file
View file

@ -0,0 +1,53 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$target = $root . '/scripts/.env';
$example = $root . '/scripts/.env.example';
// Ensure .env exists by copying example
if (!file_exists($target)) {
if (file_exists($example)) copy($example, $target);
else file_put_contents($target, "# .env\n");
}
function parse_env($path) {
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$out = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || strpos($line, '#') === 0) continue;
if (strpos($line, '=') !== false) {
list($k,$v) = explode('=', $line, 2);
$out[trim($k)] = trim($v);
}
}
return $out;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
json_response(parse_env($target));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$body = file_get_contents('php://input');
$decoded = json_decode($body, true);
if (!is_array($decoded)) {
http_response_code(400);
json_response(['error' => 'invalid_json']);
}
// backup
backup_file($target);
// write env
$lines = [];
foreach ($decoded as $k => $v) {
$lines[] = $k . '=' . $v;
}
$tmp = $target . '.tmp';
file_put_contents($tmp, implode("\n", $lines) . "\n");
rename($tmp, $target);
json_response(['ok' => true]);
}
http_response_code(405);
json_response(['error' => 'method_not_allowed']);

View file

@ -0,0 +1,17 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$p = $root . '/scripts/.env.schema.json';
if (file_exists($p)) {
if (isset($_GET['delete']) && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (@unlink($p)) json_response(['ok'=>true]);
http_response_code(500); json_response(['error'=>'delete_failed']);
}
header('Content-Type: application/json');
echo file_get_contents($p);
exit;
}
http_response_code(204);
exit;

36
viewer/admin/api/hdri.php Normal file
View file

@ -0,0 +1,36 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$dir = $root . '/viewer/hdri';
if (!is_dir($dir)) mkdir($dir, 0755, true);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$files = array_values(array_filter(scandir($dir), function($f){ return !in_array($f, ['.','..']); }));
json_response(['files' => $files]);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// If multipart upload
if (!empty($_FILES['file'])) {
$f = $_FILES['file'];
if ($f['error'] !== UPLOAD_ERR_OK) { http_response_code(400); json_response(['error'=>'upload_error']); }
$name = basename($f['name']);
$target = $dir . '/' . $name;
if (!move_uploaded_file($f['tmp_name'], $target)) { http_response_code(500); json_response(['error'=>'move_failed']); }
json_response(['ok'=>true,'file'=>$name]);
}
// JSON action (delete)
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data) { http_response_code(400); json_response(['error'=>'invalid_json']); }
if (isset($data['action']) && $data['action'] === 'delete' && isset($data['file'])) {
$file = basename($data['file']);
$path = $dir . '/' . $file;
if (file_exists($path)) { unlink($path); json_response(['ok'=>true]); }
http_response_code(404); json_response(['error'=>'not_found']);
}
http_response_code(400); json_response(['error'=>'unknown_action']);
}
http_response_code(405); json_response(['error'=>'method_not_allowed']);

View file

@ -0,0 +1,41 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$target = $root . '/viewer-settings.json';
$example1 = $root . '/viewer-settings-example.json';
$example2 = $root . '/viewer/viewer-settings-example.json';
// Ensure file exists by copying example if needed
if (!file_exists($target)) {
if (file_exists($example1)) copy($example1, $target);
elseif (file_exists($example2)) copy($example2, $target);
else file_put_contents($target, json_encode(new stdClass()));
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$data = file_get_contents($target);
header('Content-Type: application/json');
echo $data;
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$body = file_get_contents('php://input');
// validate JSON
$decoded = json_decode($body, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
json_response(['error' => 'invalid_json']);
}
// backup
backup_file($target);
// atomic write
$tmp = $target . '.tmp';
file_put_contents($tmp, json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
rename($tmp, $target);
json_response(['ok' => true]);
}
http_response_code(405);
json_response(['error' => 'method_not_allowed']);

View file

@ -0,0 +1,24 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$candidates = [
$root . '/viewer-settings.schema.json',
$root . '/viewer/viewer-settings.schema.json',
];
foreach ($candidates as $p) {
if (file_exists($p)) {
if (isset($_GET['delete']) && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (@unlink($p)) json_response(['ok'=>true]);
http_response_code(500); json_response(['error'=>'delete_failed']);
}
header('Content-Type: application/json');
echo file_get_contents($p);
exit;
}
}
// no schema found
http_response_code(204);
exit;

View file

@ -0,0 +1,24 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
// expects multipart/form-data with field 'file' and 'target' in POST (values: 'settings' or 'env')
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); json_response(['error'=>'method_not_allowed']);
}
$target = $_POST['target'] ?? $_GET['target'] ?? null;
if (!$target) { http_response_code(400); json_response(['error'=>'missing_target']); }
if ($target === 'settings') {
$dest = $root . '/viewer-settings.schema.json';
} elseif ($target === 'env') {
$dest = $root . '/scripts/.env.schema.json';
} else {
http_response_code(400); json_response(['error'=>'invalid_target']);
}
$res = save_uploaded_schema($dest);
if (isset($res['ok'])) json_response($res);
http_response_code(400); json_response($res);

View file

@ -0,0 +1,24 @@
<?php
// CLI script: php create_admin.php username password
if (php_sapi_name() !== 'cli') {
echo "This script must be run from CLI\n";
exit(1);
}
if ($argc < 3) {
echo "Usage: php create_admin.php username password\n";
exit(1);
}
$username = $argv[1];
$password = $argv[2];
$pdo = require __DIR__ . '/db.php';
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO admins (username, password, created_at) VALUES (?, ?, ?)');
try {
$stmt->execute([$username, $hash, date('c')]);
echo "Admin user created: $username\n";
} catch (Exception $e) {
echo "Error creating admin: " . $e->getMessage() . "\n";
exit(1);
}

15
viewer/admin/db.php Normal file
View file

@ -0,0 +1,15 @@
<?php
// Simple SQLite PDO connection for admin panel
$dbFile = __DIR__ . '/admin.sqlite';
$pdo = new PDO('sqlite:' . $dbFile);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Ensure table exists when connection is first used
$pdo->exec("CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at TEXT NOT NULL
)");
return $pdo;

100
viewer/admin/env.php Normal file
View file

@ -0,0 +1,100 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) { header('Location: login.php'); exit; }
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Environment</title>
<link rel="stylesheet" href="style.css">
<style>table{border-collapse:collapse} td,th{padding:.3rem;border:1px solid #ddd}</style>
</head>
<body>
<div class="wrap">
<header class="site"><h1>Environment (.env)</h1><nav class="admin-links"><a href="index.php">Wróć</a></nav></header>
<div class="card">
<table id="tbl"><thead><tr><th>Key</th><th>Value</th><th></th></tr></thead><tbody></tbody></table>
<div style="margin-top:.5rem"><button id="add">Dodaj</button> <button id="save">Zapisz</button> <a href="index.php">Wróć</a></div>
<hr>
<h3>Env Schema</h3>
<form id="uploadEnvSchema" enctype="multipart/form-data">
<input type="file" name="file" accept="application/json"> <input type="hidden" name="target" value="env"> <button>Upload Env Schema</button>
</form>
<button id="deleteEnvSchema">Delete Env Schema</button>
<div id="envSchemaMsg" style="margin-top:.5rem;color:green"></div>
<h3>Backups</h3>
<div id="envBackups">Loading backups...</div>
<div id="envBackupMsg" style="margin-top:.5rem;color:green"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/ajv@8.12.0/dist/ajv7.min.js"></script>
<script>
let envSchema = null;
async function loadSchema(){
try{
const res = await fetch('api/env_schema.php', {credentials:'same-origin'});
if (res.status === 200) { envSchema = await res.json(); const el=document.createElement('div'); el.style.fontSize='90%'; el.style.margin='0.5rem 0'; el.textContent='Env schema loaded — validation enabled.'; document.body.insertBefore(el, document.getElementById('tbl')) }
}catch(e){/* ignore */}
}
async function load(){
const res = await fetch('api/env.php', {credentials:'same-origin'});
const data = await res.json();
const tbody = document.querySelector('#tbl tbody'); tbody.innerHTML='';
for(const k of Object.keys(data)){
addRow(k, data[k]);
}
}
function addRow(k='',v=''){ const tbody=document.querySelector('#tbl tbody'); const tr=document.createElement('tr'); tr.innerHTML=`<td><input class="k" value="${k}"></td><td><input class="v" value="${v}"></td><td><button class="del">X</button></td>`; tbody.appendChild(tr); tr.querySelector('.del').addEventListener('click',()=>tr.remove()); }
document.getElementById('add').addEventListener('click',()=>addRow());
document.getElementById('save').addEventListener('click', async ()=>{
const rows = document.querySelectorAll('#tbl tbody tr'); const out={};
rows.forEach(r=>{ const k=r.querySelector('.k').value; const v=r.querySelector('.v').value; if(k) out[k]=v; });
// client-side validation
if (envSchema) {
const Ajv = window.ajv7.Ajv; const ajv = new Ajv({allErrors:true});
const validate = ajv.compile(envSchema);
const valid = validate(out);
if (!valid) {
alert('Env validation errors:\n' + validate.errors.map(e=>`${e.instancePath} ${e.message}`).join('\n'));
return;
}
} else {
// basic validation: keys uppercase letters, digits and underscore
for(const k of Object.keys(out)){
if (!/^[A-Z0-9_]+$/.test(k)) { alert('Invalid key: ' + k + '\nKeys should be uppercase letters, digits or underscore'); return; }
}
}
const res = await fetch('api/env.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify(out)});
const j = await res.json(); alert(JSON.stringify(j));
});
(async ()=>{
await loadSchema();
load();
document.getElementById('uploadEnvSchema').addEventListener('submit', async (e)=>{
e.preventDefault(); const fd = new FormData(e.target); const res = await fetch('api/upload_schema.php',{method:'POST',credentials:'same-origin', body: fd}); const j = await res.json(); document.getElementById('envSchemaMsg').textContent = JSON.stringify(j); if (j.ok) setTimeout(()=>location.reload(),700);
});
document.getElementById('deleteEnvSchema').addEventListener('click', async ()=>{
if (!confirm('Delete env schema file?')) return; const res = await fetch('api/env_schema.php?delete=1',{method:'POST',credentials:'same-origin'}); const j = await res.json(); alert(JSON.stringify(j)); if (j.ok) setTimeout(()=>location.reload(),700);
});
async function loadEnvBackups(){
const res = await fetch('api/backups.php?target=env',{credentials:'same-origin'});
const j = await res.json();
const el = document.getElementById('envBackups');
if (j.error) { el.textContent = JSON.stringify(j); return; }
if (!j.backups || j.backups.length===0) { el.textContent='No backups found'; return; }
el.innerHTML='';
j.backups.forEach(b=>{
const div=document.createElement('div'); div.textContent = b.file + ' ('+b.ts+') '; const btn=document.createElement('button'); btn.textContent='Restore'; btn.addEventListener('click', async ()=>{ if(!confirm('Restore '+b.file+' ?')) return; const r = await fetch('api/backups.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify({target:'env', file: b.file})}); const R=await r.json(); document.getElementById('envBackupMsg').textContent = JSON.stringify(R); if(R.ok) setTimeout(()=>location.reload(),700); }); div.appendChild(btn); el.appendChild(div);
});
}
loadEnvBackups();
})();
</script>
</div> <!-- .wrap -->
</body>
</html>

40
viewer/admin/hdri.php Normal file
View file

@ -0,0 +1,40 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) { header('Location: login.php'); exit; }
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HDRI</title>
<link rel="stylesheet" href="style.css">
<style>li{margin:.3rem 0}</style>
</head>
<body>
<div class="wrap">
<header class="site"><h1>HDRI</h1><nav class="admin-links"><a href="index.php">Wróć</a></nav></header>
<div class="card">
<form id="upload" enctype="multipart/form-data">
<input type="file" name="file"> <button>Upload</button>
</form>
<ul id="list"></ul>
</div>
<p class="footer">Manage HDRI files used by the viewer.</p>
</div>
<script>
async function load(){
const res = await fetch('api/hdri.php', {credentials:'same-origin'});
const data = await res.json();
const ul = document.getElementById('list'); ul.innerHTML='';
data.files.forEach(f=>{ const li=document.createElement('li'); li.textContent=f + ' '; const del=document.createElement('button'); del.textContent='Usuń'; del.addEventListener('click', ()=> delFile(f)); li.appendChild(del); ul.appendChild(li); });
}
async function delFile(filename){ if(!confirm('Usuń '+filename+' ?')) return; const res=await fetch('api/hdri.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'delete',file:filename})}); const j=await res.json(); alert(JSON.stringify(j)); load(); }
document.getElementById('upload').addEventListener('submit', async (e)=>{
e.preventDefault(); const f=e.target.file.files[0]; if(!f){alert('Wybierz plik'); return;} const fd=new FormData(); fd.append('file', f); const res=await fetch('api/hdri.php',{method:'POST',credentials:'same-origin', body: fd}); const j=await res.json(); alert(JSON.stringify(j)); load();
});
load();
</script>
</body>
</html>

30
viewer/admin/index.php Normal file
View file

@ -0,0 +1,30 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) {
header('Location: login.php');
exit;
}
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin Panel</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="wrap">
<header class="site"><h1>Admin Panel</h1><nav class="admin-links"><a href="logout.php">Logout</a></nav></header>
<div class="card">
<p>Logged in as <strong><?php echo htmlentities($_SESSION['admin']) ?></strong></p>
<div class="flex">
<a class="small" href="settings.php">Viewer Settings</a>
<a class="small" href="env.php">Environment</a>
<a class="small" href="hdri.php">HDRI</a>
<a class="small" href="actions.php">Maintenance</a>
</div>
</div>
</div>
</body>
</html>

45
viewer/admin/login.php Normal file
View file

@ -0,0 +1,45 @@
<?php
session_start();
if (isset($_SESSION['admin'])) {
header('Location: index.php');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$pdo = require __DIR__ . '/db.php';
$stmt = $pdo->prepare('SELECT * FROM admins WHERE username = ?');
$stmt->execute([$username]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row && password_verify($password, $row['password'])) {
$_SESSION['admin'] = $row['username'];
header('Location: index.php');
exit;
}
$error = 'Invalid login credentials. Please try again.';
}
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin Login</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="wrap">
<header class="site"><h1>Panel Admin</h1></header>
<div class="card">
<?php if ($error): ?><div class="msg err"><?php echo htmlentities($error) ?></div><?php endif ?>
<form method="post" class="login-form">
<label>Username<input name="username" required></label>
<label>Password<input name="password" type="password" required></label>
<div style="margin-top:1rem"><button type="submit">Login</button></div>
</form>
</div>
<div class="footer">If you don't have an account, run <code>php viewer/admin/create_admin.php &lt;user&gt; &lt;pass&gt;</code></div>
</div>
</body>
</html>

6
viewer/admin/logout.php Normal file
View file

@ -0,0 +1,6 @@
<?php
session_start();
session_unset();
session_destroy();
header('Location: login.php');
exit;

119
viewer/admin/settings.php Normal file
View file

@ -0,0 +1,119 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) { header('Location: login.php'); exit; }
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Viewer Settings</title>
<link href="https://cdn.jsdelivr.net/npm/jsoneditor@9.9.0/dist/jsoneditor.min.css" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<style>#editor{height:60vh;border:1px solid #ddd}</style>
</head>
<body>
<div class="wrap">
<header class="site"><h1>Viewer Settings</h1><nav class="admin-links"><a href="index.php">Wróć</a></nav></header>
<div class="card">
<div style="margin-bottom:.5rem">
<button id="viewTree">Tree View</button>
<button id="viewForm">Form View</button>
</div>
<div id="editor"></div>
<div style="margin-top:1rem">
<button id="save">Zapisz</button>
<button id="reload">Wczytaj</button>
<a href="index.php">Wróć</a>
</div>
<hr>
<h3>Schema management</h3>
<form id="uploadSchema" enctype="multipart/form-data">
<input type="file" name="file" accept="application/json"> <input type="hidden" name="target" value="settings"> <button>Upload Schema</button>
</form>
<button id="deleteSchema">Delete Schema</button>
<div id="schemaMsg" style="margin-top:.5rem;color:green"></div>
<h3>Backups</h3>
<div id="backupsList">Loading backups...</div>
<div id="backupMsg" style="margin-top:.5rem;color:green"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jsoneditor@9.9.0/dist/jsoneditor.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/ajv@8.12.0/dist/ajv7.min.js"></script>
<script>
(async ()=>{
const container = document.getElementById('editor');
let editor = null;
let schema = null;
function createEditor(mode){
if(editor) editor.destroy();
const options = {mode: mode};
if (mode === 'form' && schema) options.schema = schema;
editor = new JSONEditor(container, options);
}
async function loadSchema() {
const res = await fetch('api/settings_schema.php', {credentials:'same-origin'});
if (res.status === 200) {
schema = await res.json();
const el = document.createElement('div'); el.style.fontSize='90%'; el.style.margin='0.5rem 0'; el.textContent = 'Schema loaded — validation enabled.'; document.body.insertBefore(el, container);
}
}
async function load() {
const res = await fetch('api/settings.php', {credentials: 'same-origin'});
const data = await res.json();
if (!editor) createEditor('tree');
try { editor.set(data); } catch(e) { console.error(e); }
}
document.getElementById('reload').addEventListener('click', load);
document.getElementById('viewTree').addEventListener('click', ()=>{ createEditor('tree'); load(); });
document.getElementById('viewForm').addEventListener('click', ()=>{ createEditor('form'); load(); });
document.getElementById('save').addEventListener('click', async ()=>{
const data = editor.get();
if (schema) {
const Ajv = window.ajv7.Ajv; const ajv = new Ajv({allErrors:true});
const validate = ajv.compile(schema);
const valid = validate(data);
if (!valid) {
alert('Validation errors:\n' + validate.errors.map(e=>`${e.instancePath} ${e.message}`).join('\n'));
return;
}
}
const res = await fetch('api/settings.php', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)});
const j = await res.json();
alert(JSON.stringify(j));
});
// load schema then data
await loadSchema();
createEditor('tree');
load();
// upload schema
document.getElementById('uploadSchema').addEventListener('submit', async (e)=>{
e.preventDefault(); const fd = new FormData(e.target); const res = await fetch('api/upload_schema.php',{method:'POST',credentials:'same-origin', body: fd}); const j = await res.json(); document.getElementById('schemaMsg').textContent = JSON.stringify(j); if (j.ok) setTimeout(()=>location.reload(),700);
});
document.getElementById('deleteSchema').addEventListener('click', async ()=>{
if (!confirm('Delete schema file?')) return; const res = await fetch('api/settings_schema.php?delete=1',{method:'POST',credentials:'same-origin'}); const j = await res.json(); alert(JSON.stringify(j)); if (j.ok) setTimeout(()=>location.reload(),700);
});
// backups
async function loadBackups(){
const res = await fetch('api/backups.php?target=settings',{credentials:'same-origin'});
const j = await res.json();
const el = document.getElementById('backupsList');
if (j.error) { el.textContent = JSON.stringify(j); return; }
if (!j.backups || j.backups.length===0) { el.textContent='No backups found'; return; }
el.innerHTML='';
j.backups.forEach(b=>{
const div=document.createElement('div'); div.textContent = b.file + ' ('+b.ts+') '; const btn=document.createElement('button'); btn.textContent='Restore'; btn.addEventListener('click', async ()=>{ if(!confirm('Restore '+b.file+' ?')) return; const r = await fetch('api/backups.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify({target:'settings', file: b.file})}); const R=await r.json(); document.getElementById('backupMsg').textContent = JSON.stringify(R); if(R.ok) setTimeout(()=>location.reload(),700); }); div.appendChild(btn); el.appendChild(div);
});
}
loadBackups();
})();
</script>
</div> <!-- .wrap -->
</body>
</html>

34
viewer/admin/style.css Normal file
View file

@ -0,0 +1,34 @@
/* Basic admin panel styling */
:root{
--bg:#f6f8fb;
--card:#ffffff;
--muted:#6b7280;
--accent:#0b74de;
--danger:#dc2626;
}
body{font-family:Inter, Roboto, Arial, Helvetica, sans-serif;background:var(--bg);color:#111;margin:0;padding:0}
.wrap{max-width:1100px;margin:28px auto;padding:20px}
.card{background:var(--card);border:1px solid #e6e9ef;border-radius:8px;padding:18px;box-shadow:0 1px 2px rgba(16,24,40,0.03)}
header.site{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
header.site h1{margin:0;font-size:1.25rem}
nav.admin-links a{margin-right:10px;color:var(--accent);text-decoration:none}
nav.admin-links a:hover{text-decoration:underline}
form.login-form{max-width:360px}
label{display:block;margin-bottom:8px;font-size:.95rem}
input[type=text],input[type=password],input[type=file],textarea,select{width:100%;padding:8px 10px;border:1px solid #d6d9e0;border-radius:6px;background:#fff}
button{background:var(--accent);color:#fff;border:none;padding:8px 12px;border-radius:6px;cursor:pointer}
button.secondary{background:#e6eefb;color:var(--accent)}
button.danger{background:var(--danger)}
table{width:100%;border-collapse:collapse}
td,th{padding:8px;border-bottom:1px solid #f1f3f6}
#editor{min-height:420px;border-radius:6px}
.muted{color:var(--muted)}
.msg{padding:8px;border-radius:6px;margin-top:8px}
.msg.ok{background:#ecfdf5;color:#065f46}
.msg.err{background:#fff1f2;color:#7f1d1d}
.flex{display:flex;gap:10px;align-items:center}
.small{font-size:.9rem}
.backups div{margin-bottom:8px}
.footer{margin-top:20px;color:var(--muted);font-size:.9rem}
@media (max-width:600px){.wrap{padding:12px;margin:12px}header.site{flex-direction:column;align-items:flex-start} }