mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:57:40 +03:00
Remove vendored url-shortener tree before submodule.
Standalone repo: git://f0xx.org/androidcast_project/url-shortener Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,72 +0,0 @@
|
|||||||
# URL shortener — deploy (FE → BE)
|
|
||||||
|
|
||||||
**Topology:** same as [INFRA.md](../../docs/INFRA.md) — TLS on Gentoo FE, app on Alpine BE `:80`.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
| Item | Status |
|
|
||||||
|------|--------|
|
|
||||||
| DNS `s.f0xx.org` → FE | Done |
|
|
||||||
| FE TLS + BE nginx | Done |
|
|
||||||
| MariaDB `url_shortener` | Done |
|
|
||||||
| DB user | **`androidcast`** (same password as crashes `config.php`) |
|
|
||||||
|
|
||||||
## Sync to BE
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From dev machine (example — adjust paths)
|
|
||||||
rsync -av backend/url-shortener/ alpine-be:/var/www/localhost/htdocs/apps/s/
|
|
||||||
|
|
||||||
# On BE
|
|
||||||
cp /var/www/localhost/htdocs/apps/s/config/config.example.php \
|
|
||||||
/var/www/localhost/htdocs/apps/s/config/config.php
|
|
||||||
# set db.mysql.password = same as crash reporter
|
|
||||||
|
|
||||||
apk add libqrencode-tools # QR PNG (/api/v1/qr/…)
|
|
||||||
rc-service php-fpm81 restart
|
|
||||||
```
|
|
||||||
|
|
||||||
## MariaDB (one-time)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysql -u root -p < sql/schema.mariadb.sql
|
|
||||||
./scripts/init-mariadb-grants.sh
|
|
||||||
php scripts/seed_bearer.php --label=prod # save printed bearer token
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional cron (purge expired links + audit >90d):
|
|
||||||
|
|
||||||
```cron
|
|
||||||
0 4 * * * php81 /var/www/localhost/htdocs/apps/s/scripts/purge_url_shortener.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## FE (Gentoo)
|
|
||||||
|
|
||||||
See [deploy/nginx.fe-s.f0xx.org.snippet](deploy/nginx.fe-s.f0xx.org.snippet) — full URI → BE `:80`.
|
|
||||||
|
|
||||||
## BE nginx
|
|
||||||
|
|
||||||
See [deploy/nginx.be-url-shortener.fragment](deploy/nginx.be-url-shortener.fragment).
|
|
||||||
|
|
||||||
**Quoted regex required:** `location ~ "^/[a-f0-9]{6,16}$"`
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS https://s.f0xx.org/api/v1/health
|
|
||||||
export BEARER='…' ./scripts/smoke_shorten.sh
|
|
||||||
./scripts/demo_curl.sh
|
|
||||||
examples/crash_reporter/backend/scripts/validate_be_services.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hub admin (Short links card)
|
|
||||||
|
|
||||||
Operator UI lives in the **crashes console** (shared `ac_crash_sess`), not on `s.f0xx.org`:
|
|
||||||
|
|
||||||
- `https://apps.f0xx.org/app/androidcast_project/crashes/?view=short_links`
|
|
||||||
- Enable in crashes `config.php`: `url_shortener.enabled => true` + MariaDB `url_shortener` block (see `examples/crash_reporter/backend/config/config.example.php`)
|
|
||||||
- Smoke: `examples/crash_reporter/backend/scripts/test_short_links_api.sh`
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
[config/config.example.php](config/config.example.php) — prefer **`socket`** `/run/mysqld/mysqld.sock` on Alpine (same as crashes when TCP refused).
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# URL shortener (`s.f0xx.org`)
|
|
||||||
|
|
||||||
Standalone opt-in service — **not** part of `examples/crash_reporter/backend/`.
|
|
||||||
|
|
||||||
| Doc | Path |
|
|
||||||
|-----|------|
|
|
||||||
| SPEC | [docs/specs/20100611_3_url_shortener.md](../../docs/specs/20100611_3_url_shortener.md) |
|
|
||||||
| DR | [docs/DRs/20100611_3_url_shortener.md](../../docs/DRs/20100611_3_url_shortener.md) |
|
|
||||||
| Deploy | [DEPLOY.md](DEPLOY.md) |
|
|
||||||
|
|
||||||
**Status:** PHP API implemented (shorten, redirect, QR via `qrencode`).
|
|
||||||
|
|
||||||
**Git remote:** `git://f0xx.org/androicast_project/url-shortener`
|
|
||||||
**Public URL:** `https://s.f0xx.org`
|
|
||||||
|
|
||||||
## Quick start (BE)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mysql -u root -p < sql/schema.mariadb.sql
|
|
||||||
./scripts/init-mariadb-grants.sh
|
|
||||||
cp config/config.example.php config/config.php # password = crashes androidcast user
|
|
||||||
php scripts/seed_bearer.php --label=prod
|
|
||||||
apk add libqrencode-tools # QR PNG
|
|
||||||
# sync tree → /var/www/.../apps/s/
|
|
||||||
rc-service php-fpm81 restart
|
|
||||||
export BEARER=… ./scripts/smoke_shorten.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/run-php-tests.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```text
|
|
||||||
backend/url-shortener/
|
|
||||||
public/index.php # front controller
|
|
||||||
src/ # ShortenService, redirect, QR, repos
|
|
||||||
config/config.example.php
|
|
||||||
sql/schema.mariadb.sql
|
|
||||||
openapi.yaml
|
|
||||||
scripts/seed_bearer.php
|
|
||||||
scripts/purge_url_shortener.php
|
|
||||||
scripts/demo_curl.sh
|
|
||||||
scripts/smoke_shorten.sh
|
|
||||||
```
|
|
||||||
1
backend/url-shortener/config/.gitignore
vendored
1
backend/url-shortener/config/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
config.php
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* URL shortener — BE config (copy to config.php on Alpine).
|
|
||||||
* DB user: reuse crash reporter app user (same password as androidcast_crashes in crashes config.php).
|
|
||||||
*/
|
|
||||||
return [
|
|
||||||
'db' => [
|
|
||||||
'driver' => 'mysql',
|
|
||||||
'mysql' => [
|
|
||||||
'host' => '127.0.0.1',
|
|
||||||
'port' => 3306,
|
|
||||||
'socket' => '/run/mysqld/mysqld.sock',
|
|
||||||
'database' => 'url_shortener',
|
|
||||||
'username' => 'androidcast',
|
|
||||||
'password' => 'change-me-same-as-crashes-config',
|
|
||||||
'charset' => 'utf8mb4',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'public_base' => 'https://s.f0xx.org',
|
|
||||||
];
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* URL shortener — BE config (copy to config.php on Alpine).
|
|
||||||
* DB user: reuse crash reporter app user (same password as androidcast_crashes in crashes config.php).
|
|
||||||
*/
|
|
||||||
return [
|
|
||||||
'db' => [
|
|
||||||
'driver' => 'mysql',
|
|
||||||
'mysql' => [
|
|
||||||
'host' => '127.0.0.1',
|
|
||||||
'port' => 3306,
|
|
||||||
'socket' => '/run/mysqld/mysqld.sock',
|
|
||||||
'database' => 'url_shortener',
|
|
||||||
'username' => 'androidcast',
|
|
||||||
'password' => 'androidcastrootowned',
|
|
||||||
'charset' => 'utf8mb4',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'public_base' => 'https://s.f0xx.org',
|
|
||||||
];
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# BE Alpine — url-shortener vhost fragment
|
|
||||||
# Include from /etc/nginx/conf.d/apps.conf or separate file.
|
|
||||||
#
|
|
||||||
# Docroot: /var/www/localhost/htdocs/apps/s/public
|
|
||||||
# PHP-FPM: same socket as crash reporter unless load warrants a pool.
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name s.f0xx.org;
|
|
||||||
|
|
||||||
root /var/www/localhost/htdocs/apps/s/public;
|
|
||||||
index index.php;
|
|
||||||
|
|
||||||
# API + QR
|
|
||||||
location ^~ /api/ {
|
|
||||||
try_files $uri /index.php?$query_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Short slug redirect (6–16 hex chars). Regex MUST be quoted — bare {6,16} breaks nginx -t.
|
|
||||||
location ~ "^/[a-f0-9]{6,16}$" {
|
|
||||||
try_files $uri /index.php?$query_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ \.php$ {
|
|
||||||
include fastcgi_params;
|
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
|
||||||
fastcgi_pass unix:/run/php-fpm.socket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# FE Gentoo — server block for s.f0xx.org (URL shortener)
|
|
||||||
# File: tmp/FE_gentoo/etc/nginx/nginx.conf OR conf.d/s.f0xx.org.conf
|
|
||||||
#
|
|
||||||
# TLS cert (confirmed on FE):
|
|
||||||
# ssl_certificate /etc/letsencrypt/live/s.f0xx.org/fullchain.pem;
|
|
||||||
# ssl_certificate_key /etc/letsencrypt/live/s.f0xx.org/privkey.pem;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name s.f0xx.org;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name s.f0xx.org;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/s.f0xx.org/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/s.f0xx.org/privkey.pem;
|
|
||||||
|
|
||||||
access_log /var/log/nginx/s.f0xx.org.access_log main;
|
|
||||||
error_log /var/log/nginx/s.f0xx.org.error_log info;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/s.f0xx.org/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/s.f0xx.org/privkey.pem;
|
|
||||||
|
|
||||||
|
|
||||||
# All paths → BE :80 (PHP-FPM url-shortener vhost)
|
|
||||||
location / {
|
|
||||||
proxy_pass http://artc0.intra.raptor.org:80;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_connect_timeout 120s;
|
|
||||||
proxy_send_timeout 120s;
|
|
||||||
proxy_read_timeout 180s;
|
|
||||||
proxy_buffers 16 16m;
|
|
||||||
proxy_buffer_size 16m;
|
|
||||||
client_body_buffer_size 16m;
|
|
||||||
client_max_body_size 16m;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: Android Cast URL Shortener
|
|
||||||
description: |
|
|
||||||
Standalone service at https://s.f0xx.org — SPEC docs/specs/20100611_3_url_shortener.md
|
|
||||||
version: 0.1.0
|
|
||||||
servers:
|
|
||||||
- url: https://s.f0xx.org
|
|
||||||
description: Production (FE TLS → BE :80)
|
|
||||||
|
|
||||||
paths:
|
|
||||||
/api/v1/health:
|
|
||||||
get:
|
|
||||||
summary: Service health
|
|
||||||
operationId: health
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
service:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
/api/v1/shorten:
|
|
||||||
get:
|
|
||||||
summary: Shorten URL (query params)
|
|
||||||
description: Prefer POST for long URLs or sensitive query strings.
|
|
||||||
operationId: shortenGet
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/url'
|
|
||||||
- $ref: '#/components/parameters/bearer'
|
|
||||||
- $ref: '#/components/parameters/signer'
|
|
||||||
- $ref: '#/components/parameters/ttl'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
$ref: '#/components/responses/ShortenSuccess'
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/ShortenError'
|
|
||||||
'401':
|
|
||||||
$ref: '#/components/responses/ShortenError'
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/ShortenError'
|
|
||||||
'409':
|
|
||||||
$ref: '#/components/responses/ShortenError'
|
|
||||||
'429':
|
|
||||||
$ref: '#/components/responses/ShortenError'
|
|
||||||
post:
|
|
||||||
summary: Shorten URL (JSON body) — preferred
|
|
||||||
operationId: shortenPost
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ShortenRequest'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
$ref: '#/components/responses/ShortenSuccess'
|
|
||||||
'400':
|
|
||||||
$ref: '#/components/responses/ShortenError'
|
|
||||||
'401':
|
|
||||||
$ref: '#/components/responses/ShortenError'
|
|
||||||
'403':
|
|
||||||
$ref: '#/components/responses/ShortenError'
|
|
||||||
'409':
|
|
||||||
$ref: '#/components/responses/ShortenError'
|
|
||||||
'429':
|
|
||||||
$ref: '#/components/responses/ShortenError'
|
|
||||||
|
|
||||||
/api/v1/qr/{slug}.png:
|
|
||||||
get:
|
|
||||||
summary: QR code PNG for short URL
|
|
||||||
operationId: qrPng
|
|
||||||
parameters:
|
|
||||||
- name: slug
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
pattern: '^[a-f0-9]{6,16}$'
|
|
||||||
- name: size
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
default: 256
|
|
||||||
minimum: 128
|
|
||||||
maximum: 512
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: PNG image
|
|
||||||
content:
|
|
||||||
image/png:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: binary
|
|
||||||
'404':
|
|
||||||
description: Unknown slug
|
|
||||||
'410':
|
|
||||||
description: Expired slug
|
|
||||||
|
|
||||||
/{slug}:
|
|
||||||
get:
|
|
||||||
summary: Redirect to original URL
|
|
||||||
operationId: redirect
|
|
||||||
parameters:
|
|
||||||
- name: slug
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
pattern: '^[a-f0-9]{6,16}$'
|
|
||||||
responses:
|
|
||||||
'302':
|
|
||||||
description: Found — Location is original URL
|
|
||||||
headers:
|
|
||||||
Location:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
'404':
|
|
||||||
description: Unknown slug
|
|
||||||
'410':
|
|
||||||
description: Expired slug
|
|
||||||
head:
|
|
||||||
summary: Redirect probe (no body)
|
|
||||||
operationId: redirectHead
|
|
||||||
parameters:
|
|
||||||
- name: slug
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'302':
|
|
||||||
description: Found
|
|
||||||
'404':
|
|
||||||
description: Unknown
|
|
||||||
'410':
|
|
||||||
description: Expired
|
|
||||||
|
|
||||||
components:
|
|
||||||
parameters:
|
|
||||||
url:
|
|
||||||
name: url
|
|
||||||
in: query
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
maxLength: 2048
|
|
||||||
bearer:
|
|
||||||
name: bearer
|
|
||||||
in: query
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
minLength: 8
|
|
||||||
signer:
|
|
||||||
name: signer
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
ttl:
|
|
||||||
name: ttl
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
description: Seconds until expiry; 0 = permanent (signer required)
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
default: 86400
|
|
||||||
|
|
||||||
schemas:
|
|
||||||
ShortenRequest:
|
|
||||||
type: object
|
|
||||||
required: [url, bearer]
|
|
||||||
properties:
|
|
||||||
url:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
maxLength: 2048
|
|
||||||
bearer:
|
|
||||||
type: string
|
|
||||||
signer:
|
|
||||||
type: string
|
|
||||||
ttl:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
default: 86400
|
|
||||||
ShortenSuccess:
|
|
||||||
type: object
|
|
||||||
required: [code, url, u, bearer, ttl]
|
|
||||||
properties:
|
|
||||||
code:
|
|
||||||
type: string
|
|
||||||
enum: ['0']
|
|
||||||
url:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
u:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
description: Short URL
|
|
||||||
bearer:
|
|
||||||
type: string
|
|
||||||
ttl:
|
|
||||||
type: string
|
|
||||||
description: Remaining seconds as string; "0" if permanent
|
|
||||||
qr:
|
|
||||||
type: string
|
|
||||||
format: uri
|
|
||||||
description: Optional v1.1 — URL to QR PNG
|
|
||||||
ShortenError:
|
|
||||||
type: object
|
|
||||||
required: [code, desc]
|
|
||||||
properties:
|
|
||||||
code:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- ACCESS_DENIED
|
|
||||||
- MISSING_BEARER
|
|
||||||
- INVALID_URL
|
|
||||||
- TTL_EXPIRED
|
|
||||||
- SIGNER_REQUIRED
|
|
||||||
- RATE_LIMITED
|
|
||||||
- INTERNAL
|
|
||||||
- NOT_IMPLEMENTED
|
|
||||||
url:
|
|
||||||
type: string
|
|
||||||
bearer:
|
|
||||||
type: string
|
|
||||||
desc:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
responses:
|
|
||||||
ShortenSuccess:
|
|
||||||
description: Short URL created or cached
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ShortenSuccess'
|
|
||||||
ShortenError:
|
|
||||||
description: Error
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ShortenError'
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/src/bootstrap.php';
|
|
||||||
|
|
||||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
|
||||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
|
||||||
$uri = rtrim($uri, '/') ?: '/';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($uri === '/api/v1/health') {
|
|
||||||
Database::pdo()->query('SELECT 1');
|
|
||||||
JsonResponse::ok([
|
|
||||||
'status' => 'ok',
|
|
||||||
'service' => 'url-shortener',
|
|
||||||
'db' => 'connected',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($uri === '/api/v1/shorten') {
|
|
||||||
if ($method === 'GET') {
|
|
||||||
$result = ShortenService::handle(
|
|
||||||
(string) ($_GET['url'] ?? ''),
|
|
||||||
(string) ($_GET['bearer'] ?? ''),
|
|
||||||
(string) ($_GET['signer'] ?? ''),
|
|
||||||
isset($_GET['ttl']) ? (int) $_GET['ttl'] : null
|
|
||||||
);
|
|
||||||
} elseif ($method === 'POST') {
|
|
||||||
$raw = file_get_contents('php://input') ?: '';
|
|
||||||
$body = json_decode($raw, true);
|
|
||||||
if (!is_array($body)) {
|
|
||||||
JsonResponse::error(400, 'INVALID_URL', 'expected JSON body');
|
|
||||||
}
|
|
||||||
$result = ShortenService::handle(
|
|
||||||
(string) ($body['url'] ?? ''),
|
|
||||||
(string) ($body['bearer'] ?? ''),
|
|
||||||
(string) ($body['signer'] ?? ''),
|
|
||||||
isset($body['ttl']) ? (int) $body['ttl'] : null
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
JsonResponse::error(405, 'INTERNAL', 'method not allowed');
|
|
||||||
}
|
|
||||||
$http = (int) ($result['http'] ?? 200);
|
|
||||||
unset($result['http']);
|
|
||||||
if (($result['code'] ?? '') === '0') {
|
|
||||||
JsonResponse::ok($result, $http);
|
|
||||||
}
|
|
||||||
JsonResponse::error($http, (string) $result['code'], (string) $result['desc'], [
|
|
||||||
'url' => $result['url'] ?? '',
|
|
||||||
'bearer' => $result['bearer'] ?? '',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('#^/api/v1/qr/([a-f0-9]{6,16})\.png$#', $uri, $m)) {
|
|
||||||
$size = isset($_GET['size']) ? (int) $_GET['size'] : 256;
|
|
||||||
QrHandler::handle($m[1], $size);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('#^/([a-f0-9]{6,16})$#', $uri, $m)) {
|
|
||||||
if ($method === 'GET' || $method === 'HEAD') {
|
|
||||||
RedirectHandler::handle($m[1], $method);
|
|
||||||
}
|
|
||||||
JsonResponse::error(405, 'INTERNAL', 'method not allowed');
|
|
||||||
}
|
|
||||||
|
|
||||||
http_response_code(404);
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
echo json_encode(['code' => 'NOT_FOUND', 'desc' => 'not found'], JSON_UNESCAPED_SLASHES);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
error_log('url-shortener: ' . $e->getMessage());
|
|
||||||
JsonResponse::error(500, 'INTERNAL', 'server error');
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# End-to-end curl cookbook — SPEC appendix A.
|
|
||||||
# Usage:
|
|
||||||
# export BASE=https://s.f0xx.org
|
|
||||||
# export BEARER=your-trusted-token
|
|
||||||
# export SIGNER=optional-for-ttl0
|
|
||||||
# ./scripts/demo_curl.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE="${BASE:-https://s.f0xx.org}"
|
|
||||||
BEARER="${BEARER:?set BEARER to a trusted API token}"
|
|
||||||
SIGNER="${SIGNER:-}"
|
|
||||||
TARGET="${TARGET:-https://defender.net/very/long/link?demo=1}"
|
|
||||||
TTL="${TTL:-3600}"
|
|
||||||
|
|
||||||
echo "==> 1) Health"
|
|
||||||
curl -fsS "$BASE/api/v1/health" | head -c 200
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo "==> 2) Shorten (POST preferred)"
|
|
||||||
SHORT_JSON="$(curl -fsS -X POST "$BASE/api/v1/shorten" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$(jq -nc --arg url "$TARGET" --arg bearer "$BEARER" --argjson ttl "$TTL" \
|
|
||||||
'{url:$url,bearer:$bearer,ttl:$ttl}')")"
|
|
||||||
echo "$SHORT_JSON" | jq .
|
|
||||||
|
|
||||||
SHORT="$(echo "$SHORT_JSON" | jq -r '.u // empty')"
|
|
||||||
if [[ -z "$SHORT" ]]; then
|
|
||||||
echo "shorten failed (no .u in response)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
SLUG="${SHORT##*/}"
|
|
||||||
|
|
||||||
echo "==> 3) Shorten cache hit (same bearer + url)"
|
|
||||||
curl -fsS -X POST "$BASE/api/v1/shorten" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$(jq -nc --arg url "$TARGET" --arg bearer "$BEARER" --argjson ttl "$TTL" \
|
|
||||||
'{url:$url,bearer:$bearer,ttl:$ttl}')" | jq .
|
|
||||||
|
|
||||||
echo "==> 4) Redirect (HEAD — no body)"
|
|
||||||
curl -sSI "$SHORT" | grep -E '^(HTTP|Location:)'
|
|
||||||
|
|
||||||
echo "==> 5) QR PNG"
|
|
||||||
curl -fsS -o "/tmp/url-shortener-qr-${SLUG}.png" "$BASE/api/v1/qr/${SLUG}.png?size=256"
|
|
||||||
file "/tmp/url-shortener-qr-${SLUG}.png"
|
|
||||||
|
|
||||||
if [[ -n "$SIGNER" ]]; then
|
|
||||||
echo "==> 6) Permanent link (ttl=0, signer required)"
|
|
||||||
curl -fsS -X POST "$BASE/api/v1/shorten" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$(jq -nc --arg url "$TARGET&perm=1" --arg bearer "$BEARER" --arg signer "$SIGNER" \
|
|
||||||
'{url:$url,bearer:$bearer,signer:$signer,ttl:0}')" | jq .
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> Done"
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Grant existing androidcast app user access to url_shortener (after schema import as root).
|
|
||||||
# Password is NOT stored here — same as examples/crash_reporter/backend/config/config.php db.mysql.password
|
|
||||||
#
|
|
||||||
# Usage (on BE):
|
|
||||||
# mysql -u root -p < sql/schema.mariadb.sql
|
|
||||||
# ./scripts/init-mariadb-grants.sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
DB_NAME="${URL_SHORTENER_DB_NAME:-url_shortener}"
|
|
||||||
DB_USER="${URL_SHORTENER_DB_USER:-androidcast}"
|
|
||||||
|
|
||||||
echo "Granting ${DB_USER}@localhost and @127.0.0.1 on ${DB_NAME}.* ..."
|
|
||||||
mysql -u root -p <<EOF
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ${DB_NAME}.* TO '${DB_USER}'@'127.0.0.1';
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "Smoke: mysql -u ${DB_USER} -p -h 127.0.0.1 ${DB_NAME} -e 'SHOW TABLES;'"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/src/bootstrap.php';
|
|
||||||
|
|
||||||
$pdo = Database::pdo();
|
|
||||||
$deletedLinks = $pdo->exec(
|
|
||||||
'DELETE FROM links WHERE expires_at IS NOT NULL AND expires_at < NOW()'
|
|
||||||
);
|
|
||||||
$deletedAudit = $pdo->exec(
|
|
||||||
'DELETE FROM audit_log WHERE created_at < (NOW() - INTERVAL 90 DAY)'
|
|
||||||
);
|
|
||||||
|
|
||||||
echo "purge: links=$deletedLinks audit=$deletedAudit\n";
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# PHP unit-style checks (no phpunit required).
|
|
||||||
set -euo pipefail
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
php "$ROOT/tests/run_tests.php"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/src/bootstrap.php';
|
|
||||||
|
|
||||||
$label = 'cli-seed';
|
|
||||||
$canPermanent = false;
|
|
||||||
foreach (array_slice($argv, 1) as $arg) {
|
|
||||||
if ($arg === '--permanent') {
|
|
||||||
$canPermanent = true;
|
|
||||||
} elseif (str_starts_with($arg, '--label=')) {
|
|
||||||
$label = substr($arg, 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$created = BearerRepository::createTrusted($label, true, $canPermanent);
|
|
||||||
echo "Bearer token (store securely — shown once):\n";
|
|
||||||
echo $created['token'] . "\n";
|
|
||||||
echo "bearer_id=" . $created['id'] . " label=" . $label . "\n";
|
|
||||||
|
|
||||||
if ($canPermanent) {
|
|
||||||
$signer = SignerRepository::create($label . '-signer');
|
|
||||||
echo "Signer key (for ttl=0):\n";
|
|
||||||
echo $signer['key'] . "\n";
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Minimal deploy smoke — exits non-zero on failure.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE="${BASE:-https://s.f0xx.org}"
|
|
||||||
BEARER="${BEARER:-}"
|
|
||||||
|
|
||||||
echo "==> health $BASE/api/v1/health"
|
|
||||||
body="$(curl -fsS "$BASE/api/v1/health")"
|
|
||||||
echo "$body" | grep -q '"status":"ok"' || { echo "health not ok: $body"; exit 1; }
|
|
||||||
|
|
||||||
if [[ -z "$BEARER" ]]; then
|
|
||||||
echo "SKIP shorten (set BEARER to run full smoke)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> shorten POST"
|
|
||||||
resp="$(curl -fsS -X POST "$BASE/api/v1/shorten" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"url":"https://example.com/smoke-'"$(date +%s)"'","bearer":"'"$BEARER"'","ttl":300}')"
|
|
||||||
echo "$resp"
|
|
||||||
short="$(echo "$resp" | sed -n 's/.*"u":"\([^"]*\)".*/\1/p')"
|
|
||||||
[[ -n "$short" ]] || { echo "no short url in response"; exit 1; }
|
|
||||||
|
|
||||||
echo "==> redirect HEAD"
|
|
||||||
curl -fsSI "$short" | grep -qi '^location:' || { echo "missing redirect"; exit 1; }
|
|
||||||
|
|
||||||
echo "OK"
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
-- URL shortener — MariaDB schema (SPEC docs/specs/20100611_3_url_shortener.md §9)
|
|
||||||
CREATE DATABASE IF NOT EXISTS url_shortener
|
|
||||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
USE url_shortener;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bearer_tokens (
|
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
token_hash CHAR(64) NOT NULL,
|
|
||||||
label VARCHAR(128) NOT NULL DEFAULT '',
|
|
||||||
trusted TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
can_temporary TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
can_permanent TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
rate_limit_per_hour INT UNSIGNED NOT NULL DEFAULT 1000,
|
|
||||||
revoked_at DATETIME NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uq_bearer_token_hash (token_hash)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS signer_keys (
|
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
key_hash CHAR(64) NOT NULL,
|
|
||||||
label VARCHAR(128) NOT NULL DEFAULT '',
|
|
||||||
can_permanent TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
revoked_at DATETIME NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uq_signer_key_hash (key_hash)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS links (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
bearer_id INT UNSIGNED NOT NULL,
|
|
||||||
signer_id INT UNSIGNED NULL,
|
|
||||||
slug VARCHAR(16) NOT NULL,
|
|
||||||
origin TEXT NOT NULL,
|
|
||||||
origin_hash CHAR(64) NOT NULL,
|
|
||||||
ttl_seconds INT UNSIGNED NOT NULL DEFAULT 86400,
|
|
||||||
expires_at DATETIME NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
accessed_at DATETIME NULL,
|
|
||||||
access_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uq_slug (slug),
|
|
||||||
KEY idx_bearer_origin (bearer_id, origin_hash),
|
|
||||||
CONSTRAINT fk_links_bearer FOREIGN KEY (bearer_id) REFERENCES bearer_tokens (id),
|
|
||||||
CONSTRAINT fk_links_signer FOREIGN KEY (signer_id) REFERENCES signer_keys (id)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
event VARCHAR(32) NOT NULL,
|
|
||||||
bearer_id INT UNSIGNED NULL,
|
|
||||||
slug VARCHAR(16) NULL,
|
|
||||||
ip VARCHAR(45) NOT NULL DEFAULT '',
|
|
||||||
detail TEXT,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
KEY idx_audit_created (created_at)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?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()];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<?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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?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()];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?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';
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/src/UrlNormalizer.php';
|
|
||||||
require_once dirname(__DIR__) . '/src/SlugGenerator.php';
|
|
||||||
|
|
||||||
function assert_true(bool $cond, string $msg): void {
|
|
||||||
if (!$cond) {
|
|
||||||
fwrite(STDERR, "FAIL: $msg\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$n = UrlNormalizer::normalize('HTTPS://Example.COM/path?q=1#frag');
|
|
||||||
assert_true($n === 'https://example.com/path?q=1#frag', 'normalize host case');
|
|
||||||
|
|
||||||
$h = UrlNormalizer::originHash($n);
|
|
||||||
assert_true(strlen($h) === 64, 'origin hash length');
|
|
||||||
|
|
||||||
try {
|
|
||||||
UrlNormalizer::normalize('http://127.0.0.1/');
|
|
||||||
fwrite(STDERR, "FAIL: should block localhost\n");
|
|
||||||
exit(1);
|
|
||||||
} catch (InvalidArgumentException $e) {
|
|
||||||
// ok
|
|
||||||
}
|
|
||||||
|
|
||||||
$slug = SlugGenerator::baseSlug(42, 'https://example.com/a');
|
|
||||||
assert_true(strlen($slug) === 12, 'slug length 12');
|
|
||||||
assert_true(ctype_xdigit($slug), 'slug hex');
|
|
||||||
|
|
||||||
$candidates = SlugGenerator::candidates(1, 'https://a.test/');
|
|
||||||
assert_true(count($candidates) >= 2, 'collision candidates');
|
|
||||||
|
|
||||||
echo "OK url-shortener php tests\n";
|
|
||||||
Reference in New Issue
Block a user