initial commit

This commit is contained in:
Jon Vanvik 2026-06-12 11:17:07 +02:00
commit 72a78b55fb
9 changed files with 2984 additions and 0 deletions

307
netops-todo-server/api.php Normal file
View file

@ -0,0 +1,307 @@
<?php
/**
* NetOps To-Do REST API
*
* Lagrer oppgaver i data/tasks.json. Alle skrive-operasjoner bruker
* fil-låsing (flock) parallelle requests ikke ødelegger fila.
*
* Endepunkt (alle via POST unntatt list som også godtar GET):
* ?action=list { user, tasks: [...], known_users: [...] }
* ?action=create body: task JSON { task }
* ?action=update body: task JSON { task }
* ?action=delete body: {id} { ok: true }
* ?action=replace body: [task,...] { ok: true, count }
*
* Brukernavn hentes fra (i denne rekkefølgen):
* 1. $_SERVER['REMOTE_USER'] (satt av Apache Basic Auth / mod_auth_*)
* 2. $_SERVER['PHP_AUTH_USER'] (Basic Auth håndtert av PHP)
* 3. $_SERVER['HTTP_X_REMOTE_USER'](en reverse-proxy kan sette dette)
* 4. 'anon'
*/
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
// ---------- Config ----------
define('DATA_DIR', __DIR__ . '/data');
define('DATA_FILE', DATA_DIR . '/tasks.json');
define('USERS_FILE', DATA_DIR . '/known_users.json');
$VALID_STATUS = ['todo', 'progress', 'blocked', 'done'];
$VALID_PRIORITIES = ['P1', 'P2', 'P3', 'P4'];
$VALID_CATEGORIES = ['L1','L2','L3','L4','BGP','PEER','SEC','NMS','AUTO','DOC','INC','PROJ'];
// ---------- Helpers ----------
function current_user(): string {
foreach (['REMOTE_USER', 'PHP_AUTH_USER', 'HTTP_X_REMOTE_USER'] as $k) {
if (!empty($_SERVER[$k])) {
$u = preg_replace('/[^A-Za-z0-9._@\-]/', '', $_SERVER[$k]);
if ($u !== '') return substr($u, 0, 64);
}
}
return 'anon';
}
function json_out($data, int $code = 200): void {
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function bad(string $msg, int $code = 400): void {
json_out(['error' => $msg], $code);
}
function ensure_data_dir(): void {
if (!is_dir(DATA_DIR)) {
if (!@mkdir(DATA_DIR, 0750, true)) bad('Kunne ikke opprette data-mappe', 500);
}
if (!file_exists(DATA_FILE)) {
@file_put_contents(DATA_FILE, "[]\n");
@chmod(DATA_FILE, 0640);
}
}
function read_tasks(): array {
ensure_data_dir();
$fp = @fopen(DATA_FILE, 'r');
if (!$fp) bad('Kunne ikke lese data', 500);
try {
if (!flock($fp, LOCK_SH)) bad('Kunne ikke låse for lesing', 500);
$size = filesize(DATA_FILE);
$raw = $size > 0 ? fread($fp, $size) : '[]';
flock($fp, LOCK_UN);
} finally { fclose($fp); }
$data = json_decode($raw, true);
if (!is_array($data)) return [];
return $data;
}
function write_tasks(array $tasks): void {
ensure_data_dir();
$fp = @fopen(DATA_FILE, 'c+');
if (!$fp) bad('Kunne ikke åpne data for skriving', 500);
try {
if (!flock($fp, LOCK_EX)) bad('Kunne ikke låse for skriving', 500);
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode(array_values($tasks), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
fflush($fp);
flock($fp, LOCK_UN);
} finally { fclose($fp); }
}
function add_known_user(string $user): void {
ensure_data_dir();
$known = @file_exists(USERS_FILE) ? json_decode(@file_get_contents(USERS_FILE), true) : [];
if (!is_array($known)) $known = [];
if (!in_array($user, $known, true)) {
$known[] = $user;
sort($known);
@file_put_contents(USERS_FILE, json_encode($known, JSON_UNESCAPED_UNICODE));
}
}
function known_users(): array {
if (!file_exists(USERS_FILE)) return [];
$list = json_decode(@file_get_contents(USERS_FILE), true);
return is_array($list) ? $list : [];
}
function uid(): string {
return 't_' . bin2hex(random_bytes(5));
}
function body_json(): array {
$raw = file_get_contents('php://input');
if ($raw === '' || $raw === false) return [];
$d = json_decode($raw, true);
return is_array($d) ? $d : [];
}
function sanitize_str($v, int $max = 500): string {
if (!is_string($v)) return '';
$v = trim($v);
// Fjern kontrolltegn (bortsett fra \n \r \t)
$v = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $v);
if (mb_strlen($v) > $max) $v = mb_substr($v, 0, $max);
return $v;
}
function sanitize_tags($v): array {
if (!is_array($v)) return [];
$out = [];
foreach ($v as $t) {
$t = sanitize_str($t, 40);
if ($t !== '') $out[] = $t;
if (count($out) >= 20) break;
}
return $out;
}
function normalize_task($in, string $user, ?array $existing = null): array {
global $VALID_STATUS, $VALID_PRIORITIES, $VALID_CATEGORIES;
$t = $existing ?: [];
$t['id'] = $existing['id'] ?? (isset($in['id']) && is_string($in['id']) ? preg_replace('/[^a-z0-9_]/i', '', $in['id']) : uid());
if (!$t['id']) $t['id'] = uid();
$t['title'] = sanitize_str($in['title'] ?? ($existing['title'] ?? ''), 200);
$t['desc'] = sanitize_str($in['desc'] ?? ($existing['desc'] ?? ''), 4000);
$cat = $in['category'] ?? ($existing['category'] ?? 'L3');
$t['category'] = in_array($cat, $VALID_CATEGORIES, true) ? $cat : 'L3';
$prio = $in['priority'] ?? ($existing['priority'] ?? 'P3');
$t['priority'] = in_array($prio, $VALID_PRIORITIES, true) ? $prio : 'P3';
$stat = $in['status'] ?? ($existing['status'] ?? 'todo');
$t['status'] = in_array($stat, $VALID_STATUS, true) ? $stat : 'todo';
$t['estHours'] = isset($in['estHours']) ? max(0, min(999, (float)$in['estHours'])) : ($existing['estHours'] ?? 0);
$t['location'] = sanitize_str($in['location'] ?? ($existing['location'] ?? ''), 120);
$dl = $in['deadline'] ?? ($existing['deadline'] ?? '');
if (is_string($dl) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dl)) $t['deadline'] = $dl;
else $t['deadline'] = '';
$t['tags'] = sanitize_tags($in['tags'] ?? ($existing['tags'] ?? []));
// Owner
$owner = isset($in['owner']) ? sanitize_str($in['owner'], 64) : ($existing['owner'] ?? '');
$t['owner'] = $owner !== '' ? $owner : ($existing['owner'] ?? $user);
// Audit
$t['created'] = $existing['created'] ?? gmdate('c');
$t['createdBy'] = $existing['createdBy'] ?? $user;
$t['updated'] = gmdate('c');
$t['updatedBy'] = $user;
// Completed timestamp logic
$wasDone = isset($existing['status']) && $existing['status'] === 'done';
if ($t['status'] === 'done' && !$wasDone) {
$t['completed'] = gmdate('Y-m-d');
$t['completedBy'] = $user;
} elseif ($t['status'] !== 'done') {
$t['completed'] = null;
$t['completedBy'] = null;
} else {
// still done, keep existing
$t['completed'] = $existing['completed'] ?? gmdate('Y-m-d');
$t['completedBy'] = $existing['completedBy'] ?? $user;
}
return $t;
}
// ---------- Dispatch ----------
$user = current_user();
add_known_user($user);
$action = $_GET['action'] ?? '';
$method = $_SERVER['REQUEST_METHOD'];
try {
switch ($action) {
case 'list': {
$tasks = read_tasks();
// Samle eiere som også er kjent fra oppgavene
$owners = known_users();
foreach ($tasks as $t) {
foreach (['owner','createdBy','updatedBy','completedBy'] as $k) {
if (!empty($t[$k]) && !in_array($t[$k], $owners, true)) $owners[] = $t[$k];
}
}
sort($owners);
json_out([
'user' => $user,
'tasks' => $tasks,
'known_users' => array_values(array_unique($owners)),
]);
break;
}
case 'create': {
if ($method !== 'POST') bad('POST required', 405);
$in = body_json();
if (empty($in['title'])) bad('title er påkrevd');
$tasks = read_tasks();
$new = normalize_task($in, $user, null);
$tasks[] = $new;
write_tasks($tasks);
json_out(['task' => $new]);
break;
}
case 'update': {
if ($method !== 'POST') bad('POST required', 405);
$in = body_json();
if (empty($in['id'])) bad('id er påkrevd');
$tasks = read_tasks();
$updated = null;
foreach ($tasks as $i => $t) {
if (($t['id'] ?? '') === $in['id']) {
$tasks[$i] = normalize_task($in, $user, $t);
$updated = $tasks[$i];
break;
}
}
if (!$updated) bad('Fant ikke oppgaven', 404);
write_tasks($tasks);
json_out(['task' => $updated]);
break;
}
case 'delete': {
if ($method !== 'POST') bad('POST required', 405);
$in = body_json();
if (empty($in['id'])) bad('id er påkrevd');
$tasks = read_tasks();
$before = count($tasks);
$tasks = array_values(array_filter($tasks, fn($t) => ($t['id'] ?? '') !== $in['id']));
if (count($tasks) === $before) bad('Fant ikke oppgaven', 404);
write_tasks($tasks);
json_out(['ok' => true]);
break;
}
case 'replace': {
if ($method !== 'POST') bad('POST required', 405);
$in = body_json();
if (!is_array($in) || !isset($in[0]) && !empty($in)) bad('Forventet array');
$out = [];
foreach ($in as $raw) {
if (!is_array($raw) || empty($raw['title'])) continue;
$out[] = normalize_task($raw, $user, null);
}
write_tasks($out);
json_out(['ok' => true, 'count' => count($out)]);
break;
}
case 'seed': {
// Last inn eksempel-data. Nekter hvis allerede data finnes.
if ($method !== 'POST') bad('POST required', 405);
$existing = read_tasks();
if (count($existing) > 0) bad('Data finnes allerede', 409);
$seed = json_decode(@file_get_contents(__DIR__ . '/seed.json'), true);
if (!is_array($seed)) bad('Mangler seed.json', 500);
$out = [];
foreach ($seed as $raw) {
if (!is_array($raw) || empty($raw['title'])) continue;
$out[] = normalize_task($raw, $user, null);
}
write_tasks($out);
json_out(['ok' => true, 'count' => count($out)]);
break;
}
default:
bad('Ukjent action. Bruk list/create/update/delete/replace/seed');
}
} catch (Throwable $e) {
bad('Serverfeil: ' . $e->getMessage(), 500);
}