mirror of
git://f0xx.org/ac/ac-ms-url-shortener
synced 2026-07-29 05:18:12 +03:00
initial
This commit is contained in:
18
src/AuditLog.php
Normal file
18
src/AuditLog.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class AuditLog {
|
||||
public static function write(
|
||||
string $event,
|
||||
?int $bearerId,
|
||||
?string $slug,
|
||||
string $detail = ''
|
||||
): void {
|
||||
$pdo = Database::pdo();
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO audit_log (event, bearer_id, slug, ip, detail) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$event, $bearerId, $slug, $ip, $detail]);
|
||||
}
|
||||
}
|
||||
68
src/BearerRepository.php
Normal file
68
src/BearerRepository.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class BearerRepository {
|
||||
public static function hashToken(string $token): string {
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function findByToken(string $token): ?array {
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT * FROM bearer_tokens WHERE token_hash = ? AND revoked_at IS NULL LIMIT 1'
|
||||
);
|
||||
$stmt->execute([self::hashToken($token)]);
|
||||
$row = $stmt->fetch();
|
||||
return $row === false ? null : $row;
|
||||
}
|
||||
|
||||
public static function isTrusted(array $bearer): bool {
|
||||
return (int) ($bearer['trusted'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
public static function canTemporary(array $bearer): bool {
|
||||
return (int) ($bearer['can_temporary'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
public static function canPermanent(array $bearer): bool {
|
||||
return (int) ($bearer['can_permanent'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
public static function rateLimitPerHour(array $bearer): int {
|
||||
return max(1, (int) ($bearer['rate_limit_per_hour'] ?? 1000));
|
||||
}
|
||||
|
||||
public static function countCreatesLastHour(int $bearerId): int {
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) AS c FROM links WHERE bearer_id = ? AND created_at >= (NOW() - INTERVAL 1 HOUR)'
|
||||
);
|
||||
$stmt->execute([$bearerId]);
|
||||
$row = $stmt->fetch();
|
||||
return (int) ($row['c'] ?? 0);
|
||||
}
|
||||
|
||||
/** @return array{token: string, id: int} */
|
||||
public static function createTrusted(
|
||||
string $label,
|
||||
bool $canTemporary = true,
|
||||
bool $canPermanent = false,
|
||||
int $rateLimit = 1000
|
||||
): array {
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO bearer_tokens (token_hash, label, trusted, can_temporary, can_permanent, rate_limit_per_hour)
|
||||
VALUES (?, ?, 1, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
self::hashToken($token),
|
||||
$label,
|
||||
$canTemporary ? 1 : 0,
|
||||
$canPermanent ? 1 : 0,
|
||||
$rateLimit,
|
||||
]);
|
||||
return ['token' => $token, 'id' => (int) $pdo->lastInsertId()];
|
||||
}
|
||||
}
|
||||
32
src/Database.php
Normal file
32
src/Database.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Database {
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function pdo(): PDO {
|
||||
if (self::$pdo !== null) {
|
||||
return self::$pdo;
|
||||
}
|
||||
$m = cfg('db.mysql', []);
|
||||
$db = (string) ($m['database'] ?? 'url_shortener');
|
||||
$charset = (string) ($m['charset'] ?? 'utf8mb4');
|
||||
$socket = trim((string) ($m['socket'] ?? ''));
|
||||
if ($socket !== '') {
|
||||
$dsn = sprintf('mysql:unix_socket=%s;dbname=%s;charset=%s', $socket, $db, $charset);
|
||||
} else {
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$m['host'] ?? '127.0.0.1',
|
||||
(int) ($m['port'] ?? 3306),
|
||||
$db,
|
||||
$charset
|
||||
);
|
||||
}
|
||||
self::$pdo = new PDO($dsn, (string) ($m['username'] ?? ''), (string) ($m['password'] ?? ''), [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
return self::$pdo;
|
||||
}
|
||||
}
|
||||
23
src/JsonResponse.php
Normal file
23
src/JsonResponse.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class JsonResponse {
|
||||
/** @param array<string,mixed> $extra */
|
||||
public static function error(int $http, string $code, string $desc, array $extra = []): void {
|
||||
http_response_code($http);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(array_merge([
|
||||
'code' => $code,
|
||||
'desc' => $desc,
|
||||
], $extra), JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $body */
|
||||
public static function ok(array $body, int $http = 200): void {
|
||||
http_response_code($http);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($body, JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
109
src/LinkRepository.php
Normal file
109
src/LinkRepository.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class LinkRepository {
|
||||
public static function publicBase(): string {
|
||||
return rtrim((string) cfg('public_base', 'https://s.f0xx.org'), '/');
|
||||
}
|
||||
|
||||
public static function shortUrl(string $slug): string {
|
||||
return self::publicBase() . '/' . $slug;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function findActiveByBearerOrigin(int $bearerId, string $originHash): ?array {
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT * FROM links WHERE bearer_id = ? AND origin_hash = ?
|
||||
ORDER BY id DESC LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$bearerId, $originHash]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
return self::isExpired($row) ? null : $row;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function findExpiredByBearerOrigin(int $bearerId, string $originHash): ?array {
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT * FROM links WHERE bearer_id = ? AND origin_hash = ?
|
||||
ORDER BY id DESC LIMIT 1'
|
||||
);
|
||||
$stmt->execute([$bearerId, $originHash]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
return self::isExpired($row) ? $row : null;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
public static function isExpired(array $row): bool {
|
||||
$exp = $row['expires_at'] ?? null;
|
||||
if ($exp === null || $exp === '') {
|
||||
return false;
|
||||
}
|
||||
return strtotime((string) $exp) <= time();
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
public static function remainingTtlSeconds(array $row): string {
|
||||
$exp = $row['expires_at'] ?? null;
|
||||
if ($exp === null || $exp === '') {
|
||||
return '0';
|
||||
}
|
||||
$rem = max(0, strtotime((string) $exp) - time());
|
||||
return (string) $rem;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function findBySlug(string $slug): ?array {
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare('SELECT * FROM links WHERE slug = ? LIMIT 1');
|
||||
$stmt->execute([$slug]);
|
||||
$row = $stmt->fetch();
|
||||
return $row === false ? null : $row;
|
||||
}
|
||||
|
||||
public static function slugExists(string $slug): bool {
|
||||
return self::findBySlug($slug) !== null;
|
||||
}
|
||||
|
||||
public static function insert(
|
||||
int $bearerId,
|
||||
?int $signerId,
|
||||
string $slug,
|
||||
string $origin,
|
||||
string $originHash,
|
||||
int $ttlSeconds
|
||||
): void {
|
||||
$pdo = Database::pdo();
|
||||
$expiresAt = $ttlSeconds <= 0
|
||||
? null
|
||||
: date('Y-m-d H:i:s', time() + $ttlSeconds);
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO links (bearer_id, signer_id, slug, origin, origin_hash, ttl_seconds, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([
|
||||
$bearerId,
|
||||
$signerId,
|
||||
$slug,
|
||||
$origin,
|
||||
$originHash,
|
||||
max(0, $ttlSeconds),
|
||||
$expiresAt,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordRedirect(string $slug): void {
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE links SET accessed_at = NOW(), access_count = access_count + 1 WHERE slug = ?'
|
||||
);
|
||||
$stmt->execute([$slug]);
|
||||
}
|
||||
}
|
||||
50
src/QrHandler.php
Normal file
50
src/QrHandler.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class QrHandler {
|
||||
public static function handle(string $slug, int $size): void {
|
||||
$link = LinkRepository::findBySlug($slug);
|
||||
if ($link === null) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['code' => 'NOT_FOUND', 'desc' => 'unknown slug'], JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
if (LinkRepository::isExpired($link)) {
|
||||
http_response_code(410);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['code' => 'TTL_EXPIRED', 'desc' => 'link expired'], JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
$size = max(128, min(512, $size));
|
||||
$target = LinkRepository::shortUrl($slug);
|
||||
$png = self::renderPng($target, $size);
|
||||
if ($png === null) {
|
||||
JsonResponse::error(503, 'INTERNAL', 'QR generation unavailable (install qrencode on BE)');
|
||||
}
|
||||
header('Content-Type: image/png');
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
echo $png;
|
||||
exit;
|
||||
}
|
||||
|
||||
private static function renderPng(string $text, int $size): ?string {
|
||||
$qrencode = trim((string) shell_exec('command -v qrencode 2>/dev/null') ?? '');
|
||||
if ($qrencode === '') {
|
||||
return null;
|
||||
}
|
||||
$moduleSize = max(2, (int) round($size / 40));
|
||||
$cmd = sprintf(
|
||||
'%s -t PNG -s %d -m 1 -o - %s 2>/dev/null',
|
||||
escapeshellcmd($qrencode),
|
||||
$moduleSize,
|
||||
escapeshellarg($text)
|
||||
);
|
||||
$out = shell_exec($cmd);
|
||||
if (!is_string($out) || strlen($out) < 8) {
|
||||
return null;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
48
src/RedirectHandler.php
Normal file
48
src/RedirectHandler.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class RedirectHandler {
|
||||
public static function handle(string $slug, string $method): void {
|
||||
$link = LinkRepository::findBySlug($slug);
|
||||
if ($link === null) {
|
||||
self::respondMissing($method);
|
||||
}
|
||||
if (LinkRepository::isExpired($link)) {
|
||||
self::respondExpired($method);
|
||||
}
|
||||
LinkRepository::recordRedirect($slug);
|
||||
AuditLog::write('redirect', (int) $link['bearer_id'], $slug, '');
|
||||
http_response_code(302);
|
||||
header('Location: ' . (string) $link['origin']);
|
||||
exit;
|
||||
}
|
||||
|
||||
private static function respondMissing(string $method): void {
|
||||
http_response_code(404);
|
||||
if (self::wantsJson()) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['code' => 'NOT_FOUND', 'desc' => 'unknown slug'], JSON_UNESCAPED_SLASHES);
|
||||
} else {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Not found';
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
private static function respondExpired(string $method): void {
|
||||
http_response_code(410);
|
||||
if (self::wantsJson()) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['code' => 'TTL_EXPIRED', 'desc' => 'link expired'], JSON_UNESCAPED_SLASHES);
|
||||
} else {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo 'Gone';
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
private static function wantsJson(): bool {
|
||||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||
return str_contains($accept, 'application/json');
|
||||
}
|
||||
}
|
||||
113
src/ShortenService.php
Normal file
113
src/ShortenService.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ShortenService {
|
||||
/** @return array<string,mixed> */
|
||||
public static function handle(string $rawUrl, string $bearerToken, string $signerKey, ?int $ttl): array {
|
||||
$bearerToken = trim($bearerToken);
|
||||
if ($bearerToken === '') {
|
||||
return self::err(401, 'MISSING_BEARER', 'missing bearer', $rawUrl, '');
|
||||
}
|
||||
|
||||
$bearer = BearerRepository::findByToken($bearerToken);
|
||||
if ($bearer === null) {
|
||||
AuditLog::write('deny', null, null, 'unknown bearer');
|
||||
return self::err(403, 'ACCESS_DENIED', 'access denied', $rawUrl, $bearerToken);
|
||||
}
|
||||
$bearerId = (int) $bearer['id'];
|
||||
|
||||
try {
|
||||
$normalized = UrlNormalizer::normalize($rawUrl);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return self::err(400, 'INVALID_URL', $e->getMessage(), $rawUrl, $bearerToken);
|
||||
}
|
||||
$originHash = UrlNormalizer::originHash($normalized);
|
||||
|
||||
$cached = LinkRepository::findActiveByBearerOrigin($bearerId, $originHash);
|
||||
if ($cached !== null) {
|
||||
return self::success($normalized, $cached, $bearerToken);
|
||||
}
|
||||
|
||||
$expired = LinkRepository::findExpiredByBearerOrigin($bearerId, $originHash);
|
||||
if ($expired !== null) {
|
||||
return self::err(409, 'TTL_EXPIRED', 'link expired', $normalized, $bearerToken);
|
||||
}
|
||||
|
||||
if (!BearerRepository::isTrusted($bearer)) {
|
||||
AuditLog::write('deny', $bearerId, null, 'untrusted bearer');
|
||||
return self::err(403, 'ACCESS_DENIED', 'access denied', $normalized, $bearerToken);
|
||||
}
|
||||
|
||||
$ttlSeconds = $ttl ?? 86400;
|
||||
if ($ttlSeconds < 0) {
|
||||
$ttlSeconds = 86400;
|
||||
}
|
||||
|
||||
$signerId = null;
|
||||
if ($ttlSeconds === 0) {
|
||||
$signer = SignerRepository::findValid($signerKey);
|
||||
if ($signer === null) {
|
||||
return self::err(403, 'SIGNER_REQUIRED', 'signer required for permanent links', $normalized, $bearerToken);
|
||||
}
|
||||
if (!BearerRepository::canPermanent($bearer)) {
|
||||
return self::err(403, 'ACCESS_DENIED', 'bearer cannot create permanent links', $normalized, $bearerToken);
|
||||
}
|
||||
$signerId = (int) $signer['id'];
|
||||
} else {
|
||||
if (!BearerRepository::canTemporary($bearer)) {
|
||||
return self::err(403, 'ACCESS_DENIED', 'bearer cannot create temporary links', $normalized, $bearerToken);
|
||||
}
|
||||
}
|
||||
|
||||
$limit = BearerRepository::rateLimitPerHour($bearer);
|
||||
if (BearerRepository::countCreatesLastHour($bearerId) >= $limit) {
|
||||
return self::err(429, 'RATE_LIMITED', 'rate limit exceeded', $normalized, $bearerToken);
|
||||
}
|
||||
|
||||
$slug = self::allocateSlug($bearerId, $normalized);
|
||||
LinkRepository::insert($bearerId, $signerId, $slug, $normalized, $originHash, $ttlSeconds);
|
||||
AuditLog::write('shorten', $bearerId, $slug, 'created');
|
||||
|
||||
$row = LinkRepository::findBySlug($slug);
|
||||
if ($row === null) {
|
||||
return self::err(500, 'INTERNAL', 'insert failed', $normalized, $bearerToken);
|
||||
}
|
||||
return self::success($normalized, $row, $bearerToken);
|
||||
}
|
||||
|
||||
private static function allocateSlug(int $bearerId, string $normalized): string {
|
||||
foreach (SlugGenerator::candidates($bearerId, $normalized) as $candidate) {
|
||||
if (!LinkRepository::slugExists($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
AuditLog::write('warn', $bearerId, $candidate, 'slug collision');
|
||||
}
|
||||
throw new RuntimeException('slug exhaustion');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private static function success(string $normalized, array $row, string $bearerToken): array {
|
||||
$slug = (string) $row['slug'];
|
||||
$body = [
|
||||
'http' => 200,
|
||||
'code' => '0',
|
||||
'url' => $normalized,
|
||||
'u' => LinkRepository::shortUrl($slug),
|
||||
'bearer' => $bearerToken,
|
||||
'ttl' => LinkRepository::remainingTtlSeconds($row),
|
||||
'qr' => LinkRepository::publicBase() . '/api/v1/qr/' . $slug . '.png',
|
||||
];
|
||||
return $body;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function err(int $http, string $code, string $desc, string $url, string $bearer): array {
|
||||
return [
|
||||
'http' => $http,
|
||||
'code' => $code,
|
||||
'url' => $url,
|
||||
'bearer' => $bearer,
|
||||
'desc' => $desc,
|
||||
];
|
||||
}
|
||||
}
|
||||
33
src/SignerRepository.php
Normal file
33
src/SignerRepository.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class SignerRepository {
|
||||
public static function hashKey(string $key): string {
|
||||
return hash('sha256', $key);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function findValid(string $signerKey): ?array {
|
||||
if ($signerKey === '') {
|
||||
return null;
|
||||
}
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT * FROM signer_keys WHERE key_hash = ? AND revoked_at IS NULL AND can_permanent = 1 LIMIT 1'
|
||||
);
|
||||
$stmt->execute([self::hashKey($signerKey)]);
|
||||
$row = $stmt->fetch();
|
||||
return $row === false ? null : $row;
|
||||
}
|
||||
|
||||
/** @return array{key: string, id: int} */
|
||||
public static function create(string $label): array {
|
||||
$key = bin2hex(random_bytes(24));
|
||||
$pdo = Database::pdo();
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO signer_keys (key_hash, label, can_permanent) VALUES (?, ?, 1)'
|
||||
);
|
||||
$stmt->execute([self::hashKey($key), $label]);
|
||||
return ['key' => $key, 'id' => (int) $pdo->lastInsertId()];
|
||||
}
|
||||
}
|
||||
19
src/SlugGenerator.php
Normal file
19
src/SlugGenerator.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class SlugGenerator {
|
||||
public static function baseSlug(int $bearerId, string $normalizedUrl): string {
|
||||
$material = $bearerId . "\x1f" . $normalizedUrl;
|
||||
return substr(hash('sha256', $material), 0, 12);
|
||||
}
|
||||
|
||||
/** @return list<string> suffix candidates on collision */
|
||||
public static function candidates(int $bearerId, string $normalizedUrl): array {
|
||||
$slugs = [self::baseSlug($bearerId, $normalizedUrl)];
|
||||
$base = $bearerId . "\x1f" . $normalizedUrl;
|
||||
for ($i = 1; $i <= 15; $i++) {
|
||||
$slugs[] = substr(hash('sha256', $base . "\x1e" . $i), 0, 12);
|
||||
}
|
||||
return $slugs;
|
||||
}
|
||||
}
|
||||
60
src/UrlNormalizer.php
Normal file
60
src/UrlNormalizer.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class UrlNormalizer {
|
||||
private const MAX_LEN = 2048;
|
||||
|
||||
public static function normalize(string $raw): string {
|
||||
$url = trim($raw);
|
||||
if ($url === '') {
|
||||
throw new InvalidArgumentException('empty url');
|
||||
}
|
||||
if (strlen($url) > self::MAX_LEN) {
|
||||
throw new InvalidArgumentException('url too long');
|
||||
}
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
|
||||
throw new InvalidArgumentException('malformed url');
|
||||
}
|
||||
$scheme = strtolower((string) $parts['scheme']);
|
||||
if ($scheme !== 'http' && $scheme !== 'https') {
|
||||
throw new InvalidArgumentException('unsupported scheme');
|
||||
}
|
||||
$host = strtolower((string) $parts['host']);
|
||||
self::assertHostAllowed($host);
|
||||
|
||||
$port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
|
||||
$path = $parts['path'] ?? '';
|
||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||
$fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
|
||||
|
||||
return $scheme . '://' . $host . $port . $path . $query . $fragment;
|
||||
}
|
||||
|
||||
public static function originHash(string $normalized): string {
|
||||
return hash('sha256', $normalized);
|
||||
}
|
||||
|
||||
private static function assertHostAllowed(string $host): void {
|
||||
if ($host === 'localhost' || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) {
|
||||
throw new InvalidArgumentException('blocked host');
|
||||
}
|
||||
if ($host === 'metadata.google.internal' || $host === 'metadata') {
|
||||
throw new InvalidArgumentException('blocked host');
|
||||
}
|
||||
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
self::assertIpAllowed($host);
|
||||
return;
|
||||
}
|
||||
$resolved = @gethostbyname($host);
|
||||
if ($resolved !== $host && filter_var($resolved, FILTER_VALIDATE_IP)) {
|
||||
self::assertIpAllowed($resolved);
|
||||
}
|
||||
}
|
||||
|
||||
private static function assertIpAllowed(string $ip): void {
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
throw new InvalidArgumentException('blocked host');
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/bootstrap.php
Normal file
34
src/bootstrap.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||
if (!is_file($configPath)) {
|
||||
$configPath = dirname(__DIR__) . '/config/config.example.php';
|
||||
}
|
||||
/** @var array<string,mixed> $config */
|
||||
$config = require $configPath;
|
||||
|
||||
function cfg(string $key, $default = null) {
|
||||
global $config;
|
||||
$parts = explode('.', $key);
|
||||
$v = $config;
|
||||
foreach ($parts as $p) {
|
||||
if (!is_array($v) || !array_key_exists($p, $v)) {
|
||||
return $default;
|
||||
}
|
||||
$v = $v[$p];
|
||||
}
|
||||
return $v;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/Database.php';
|
||||
require_once __DIR__ . '/JsonResponse.php';
|
||||
require_once __DIR__ . '/UrlNormalizer.php';
|
||||
require_once __DIR__ . '/SlugGenerator.php';
|
||||
require_once __DIR__ . '/AuditLog.php';
|
||||
require_once __DIR__ . '/BearerRepository.php';
|
||||
require_once __DIR__ . '/SignerRepository.php';
|
||||
require_once __DIR__ . '/LinkRepository.php';
|
||||
require_once __DIR__ . '/ShortenService.php';
|
||||
require_once __DIR__ . '/RedirectHandler.php';
|
||||
require_once __DIR__ . '/QrHandler.php';
|
||||
Reference in New Issue
Block a user