2026-05-31 21:05:59 +09:00
|
|
|
<?php
|
|
|
|
|
// =====================================================
|
2026-05-31 22:23:51 +09:00
|
|
|
// Runtime configuration
|
2026-05-31 21:05:59 +09:00
|
|
|
// =====================================================
|
2026-05-31 22:23:51 +09:00
|
|
|
// ADMIN_PASSWORD_HASH is loaded from api/config.local.php or the environment.
|
|
|
|
|
// Do not commit real password hashes or other secrets to the repository.
|
2026-05-31 21:05:59 +09:00
|
|
|
// =====================================================
|
|
|
|
|
|
2026-05-31 22:23:51 +09:00
|
|
|
$localConfig = __DIR__ . '/config.local.php';
|
|
|
|
|
if (is_file($localConfig)) {
|
|
|
|
|
require_once $localConfig;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!defined('ADMIN_PASSWORD_HASH')) {
|
|
|
|
|
$adminPasswordHash = getenv('ADMIN_PASSWORD_HASH');
|
|
|
|
|
define('ADMIN_PASSWORD_HASH', is_string($adminPasswordHash) ? trim($adminPasswordHash) : '');
|
|
|
|
|
}
|
2026-05-31 21:05:59 +09:00
|
|
|
|
2026-05-31 22:23:51 +09:00
|
|
|
// Data paths
|
2026-05-31 21:05:59 +09:00
|
|
|
define('DATA_DIR', __DIR__ . '/../data');
|
|
|
|
|
define('UPLOADS_DIR', __DIR__ . '/../uploads');
|
|
|
|
|
define('PROJECTS_FILE', DATA_DIR . '/projects.json');
|
|
|
|
|
define('PROFILE_FILE', DATA_DIR . '/profile.json');
|
|
|
|
|
|
2026-05-31 22:23:51 +09:00
|
|
|
// Session settings
|
|
|
|
|
define('SESSION_LIFETIME', 3600 * 4);
|
|
|
|
|
|
|
|
|
|
function start_secure_session() {
|
|
|
|
|
if (session_status() !== PHP_SESSION_NONE) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
session_set_cookie_params([
|
|
|
|
|
'lifetime' => 0,
|
|
|
|
|
'path' => '/',
|
|
|
|
|
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
|
|
|
|
'httponly' => true,
|
|
|
|
|
'samesite' => 'Lax',
|
|
|
|
|
]);
|
|
|
|
|
session_start();
|
|
|
|
|
}
|
2026-05-31 21:05:59 +09:00
|
|
|
|
|
|
|
|
function set_json_headers() {
|
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
|
|
|
header('X-Content-Type-Options: nosniff');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function read_json_safe($filepath) {
|
|
|
|
|
if (!file_exists($filepath)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
$fp = fopen($filepath, 'r');
|
|
|
|
|
if (!$fp) return null;
|
2026-05-31 22:23:51 +09:00
|
|
|
|
2026-05-31 21:05:59 +09:00
|
|
|
if (flock($fp, LOCK_SH)) {
|
|
|
|
|
$content = stream_get_contents($fp);
|
|
|
|
|
flock($fp, LOCK_UN);
|
|
|
|
|
fclose($fp);
|
|
|
|
|
return json_decode($content, true);
|
|
|
|
|
}
|
|
|
|
|
fclose($fp);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 22:23:51 +09:00
|
|
|
// Write JSON atomically so a crash during write does not leave a 0-byte file.
|
2026-05-31 21:05:59 +09:00
|
|
|
function write_json_safe($filepath, $data) {
|
|
|
|
|
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
|
|
|
if ($json === false) return false;
|
2026-05-31 22:23:51 +09:00
|
|
|
|
|
|
|
|
$tmp = $filepath . '.tmp.' . uniqid('', true);
|
|
|
|
|
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
|
|
|
|
|
@unlink($tmp);
|
|
|
|
|
return false;
|
2026-05-31 21:05:59 +09:00
|
|
|
}
|
2026-05-31 22:23:51 +09:00
|
|
|
if (!rename($tmp, $filepath)) {
|
|
|
|
|
@unlink($tmp);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
2026-05-31 21:05:59 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function require_auth() {
|
2026-05-31 22:23:51 +09:00
|
|
|
start_secure_session();
|
|
|
|
|
|
2026-05-31 21:05:59 +09:00
|
|
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
|
|
|
|
http_response_code(401);
|
|
|
|
|
echo json_encode(['error' => 'Unauthorized']);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
2026-05-31 22:23:51 +09:00
|
|
|
|
|
|
|
|
if (isset($_SESSION['login_time']) &&
|
2026-05-31 21:05:59 +09:00
|
|
|
(time() - $_SESSION['login_time']) > SESSION_LIFETIME) {
|
|
|
|
|
session_destroy();
|
|
|
|
|
http_response_code(401);
|
|
|
|
|
echo json_encode(['error' => 'Session expired']);
|
|
|
|
|
exit;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 22:23:51 +09:00
|
|
|
function ensure_csrf_token() {
|
|
|
|
|
start_secure_session();
|
|
|
|
|
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
|
|
|
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
|
|
|
}
|
|
|
|
|
return $_SESSION['csrf_token'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function require_csrf() {
|
|
|
|
|
start_secure_session();
|
|
|
|
|
$sessionToken = $_SESSION['csrf_token'] ?? '';
|
|
|
|
|
$requestToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
|
|
|
|
|
|
|
|
if (!is_string($sessionToken) || $sessionToken === '' ||
|
|
|
|
|
!is_string($requestToken) || !hash_equals($sessionToken, $requestToken)) {
|
|
|
|
|
json_response(['error' => 'Invalid CSRF token'], 403);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 21:05:59 +09:00
|
|
|
function get_json_input() {
|
|
|
|
|
$input = file_get_contents('php://input');
|
|
|
|
|
return json_decode($input, true);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 22:23:51 +09:00
|
|
|
function is_safe_public_url($url) {
|
|
|
|
|
if (!is_string($url)) return false;
|
|
|
|
|
$url = trim($url);
|
|
|
|
|
if ($url === '') return true;
|
|
|
|
|
|
|
|
|
|
if (preg_match('#^uploads/[A-Za-z0-9가-힣._~!$&\'()*+,;=:@%/-]+$#u', $url)) {
|
|
|
|
|
return !str_contains($url, '..') && !preg_match('#(^|/)\.#', $url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$parts = parse_url($url);
|
|
|
|
|
if ($parts === false || empty($parts['scheme'])) return false;
|
|
|
|
|
return in_array(strtolower($parts['scheme']), ['http', 'https'], true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clean_public_url($url) {
|
|
|
|
|
$url = is_string($url) ? trim($url) : '';
|
|
|
|
|
return is_safe_public_url($url) ? $url : '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clean_public_urls($urls) {
|
|
|
|
|
if (!is_array($urls)) return [];
|
|
|
|
|
$clean = [];
|
|
|
|
|
foreach ($urls as $url) {
|
|
|
|
|
$url = clean_public_url($url);
|
|
|
|
|
if ($url !== '') $clean[] = $url;
|
|
|
|
|
}
|
|
|
|
|
return array_values(array_unique($clean));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clean_css_color($value, $fallback = '#00f2ff') {
|
|
|
|
|
$value = is_string($value) ? trim($value) : '';
|
|
|
|
|
if (preg_match('/^#[0-9a-fA-F]{6}$/', $value) || preg_match('/^#[0-9a-fA-F]{3}$/', $value)) {
|
|
|
|
|
return $value;
|
|
|
|
|
}
|
|
|
|
|
return $fallback;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 21:05:59 +09:00
|
|
|
function json_response($data, $status = 200) {
|
|
|
|
|
http_response_code($status);
|
|
|
|
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
|
|
|
|
exit;
|
|
|
|
|
}
|