diff --git a/index.html b/index.php similarity index 84% rename from index.html rename to index.php index 5ed3ceb..d7a2aaa 100644 --- a/index.html +++ b/index.php @@ -1,6 +1,15 @@ + + commie ☭ diff --git a/lib/PasteManager.php b/lib/PasteManager.php index b363fd5..9000a40 100644 --- a/lib/PasteManager.php +++ b/lib/PasteManager.php @@ -1,130 +1,109 @@ savedir = __DIR__ . '/../data/'; - } + public function __construct( + private readonly string $savedir = __DIR__ . '/../data/', + ) {} /** - * Saves the given content under a new UID + * Save the given content under a freshly generated UID. * - * @param $content - * @return bool|string UID or false on error + * @return false|string the UID, or false on write failure */ - function savePaste($content) + public function savePaste(string $content): false|string { - $uid = $this->mkuid(); do { + $uid = $this->mkuid(); $path = $this->fn($uid, 'paste'); } while (file_exists($path)); @mkdir(dirname($path), 0777, true); - if(file_put_contents($path, $content)){ - return $uid; - } - return false; + return file_put_contents($path, $content) !== false ? $uid : false; } /** - * Loads a paste file - * - * @param $uid - * @return bool|string the paste's content, false on error + * @return false|string the paste's content, or false if it does not exist */ - function loadPaste($uid) { + public function loadPaste(string $uid): false|string + { $path = $this->fn($uid, 'paste'); - if(!file_exists($path)) return false; - return file_get_contents($path); + return file_exists($path) ? file_get_contents($path) : false; } /** - * Stores a comment + * Append a comment to the paste's comments file. * - * @param string $uid - * @param int $line - * @param string $comment - * @param string $user - * @return bool|array + * @return false|array the stored comment record, or false on failure */ - function saveComment($uid, $line, $comment, $user) { - $paste = $this->fn($uid, 'paste'); - if(!file_exists($paste)) return false; - - $path = $this->fn($uid, 'comments'); - - $data = compact('line', 'comment', 'user'); - $data['time'] = time(); - $data['color'] = substr(md5($user),0,6); - $json = json_encode($data); - if(file_put_contents($path, $json."\n", FILE_APPEND)) { - return $data; + public function saveComment(string $uid, int $line, string $comment, string $user): false|array + { + if (!file_exists($this->fn($uid, 'paste'))) { + return false; } - return false; + + $data = [ + 'line' => $line, + 'comment' => $comment, + 'user' => $user, + 'time' => time(), + 'color' => substr(md5($user), 0, 6), + ]; + + $written = file_put_contents( + $this->fn($uid, 'comments'), + json_encode($data) . "\n", + FILE_APPEND, + ); + return $written !== false ? $data : false; } /** - * Return all comments - * - * @param string $uid - * @return array|bool + * @return false|array list of comment records, or false if the paste does not exist */ - function loadComments($uid) { - $paste = $this->fn($uid, 'paste'); - if(!file_exists($paste)) return false; - - $data = array(); + public function loadComments(string $uid): false|array + { + if (!file_exists($this->fn($uid, 'paste'))) { + return false; + } $path = $this->fn($uid, 'comments'); - if(!file_exists($path)) return $data; - $json = file($path); - foreach($json as $line) { - $data[] = json_decode($line, true); + if (!file_exists($path)) { + return []; + } + + $out = []; + foreach (file($path) ?: [] as $line) { + $decoded = json_decode($line, true); + if (is_array($decoded)) { + $out[] = $decoded; + } } - return $data; + return $out; } /** - * Get the full path to the file for the given UID and extension - * - * @param string $uid - * @param string $ext - * @return string + * Build the full path to the UID's file for the given extension. */ - public function fn($uid, $ext) + public function fn(string $uid, string $ext): string { - $prefix = substr($uid, 0, 1); - return $this->savedir . $prefix . '/' . $uid . '.' . $ext; + return $this->savedir . substr($uid, 0, 1) . '/' . $uid . '.' . $ext; } /** - * Creates a unique name - * - * @link http://stackoverflow.com/a/3537633/172068 - * @param int $len - * @return string + * Generate a random alphanumeric UID of the requested length. */ - public function mkuid($len = 8) + private function mkuid(int $len = 8): string { - - $hex = md5("yourSaltHere" . uniqid("", true)); - - $pack = pack('H*', $hex); - $tmp = base64_encode($pack); - - $uid = preg_replace("#(*UTF8)[^A-Za-z0-9]#", "", $tmp); - $len = max(4, min(128, $len)); - + $uid = ''; while (strlen($uid) < $len) { - $uid .= gen_uuid(22); + $uid .= preg_replace('#[^A-Za-z0-9]#', '', base64_encode(random_bytes(16))); } - return substr($uid, 0, $len); } - -} \ No newline at end of file +} diff --git a/lib/Token.php b/lib/Token.php new file mode 100644 index 0000000..d2f4410 --- /dev/null +++ b/lib/Token.php @@ -0,0 +1,61 @@ +:` + * keyed by a per-installation secret. Verification recomputes the HMAC against + * the scope passed at verification time, so a token issued for one action + * cannot be replayed against another. + */ +final class Token +{ + /** Maximum age of a token, in seconds. */ + public const TTL = 1800; + + private const SECRET_PATH = __DIR__ . '/../data/.secret'; + + private static ?string $secret = null; + + private function __construct() {} + + public static function issue(string $scope): string + { + $ts = time(); + return $ts . '.' . hash_hmac('sha256', $ts . ':' . $scope, self::secret()); + } + + public static function verify(string $scope, string $token): bool + { + if (!str_contains($token, '.')) { + return false; + } + [$ts, $hmac] = explode('.', $token, 2); + if (!ctype_digit($ts)) { + return false; + } + $expected = hash_hmac('sha256', $ts . ':' . $scope, self::secret()); + return hash_equals($expected, $hmac) + && abs(time() - (int) $ts) <= self::TTL; + } + + private static function secret(): string + { + if (self::$secret !== null) { + return self::$secret; + } + + if (!file_exists(self::SECRET_PATH)) { + $fresh = bin2hex(random_bytes(32)); + @file_put_contents(self::SECRET_PATH, $fresh, LOCK_EX); + @chmod(self::SECRET_PATH, 0600); + return self::$secret = $fresh; + } + + return self::$secret = trim((string) file_get_contents(self::SECRET_PATH)); + } +} diff --git a/lib/router.php b/lib/router.php index 4612b4a..4255463 100644 --- a/lib/router.php +++ b/lib/router.php @@ -1,31 +1,122 @@ savePaste($_REQUEST['content']); - break; - case 'load': - $ret = htmlspecialchars($paste->loadPaste($_REQUEST['uid'])); - break; - case 'loadcomments': - $ret = $paste->loadComments($_REQUEST['uid']); - break; - case 'savecomment': - $ret = $paste->saveComment($_REQUEST['uid'], (int) $_REQUEST['line'], $_REQUEST['comment'], $_REQUEST['user']); - break; - default: - $ret = false; +/** + * Strip everything that isn't an ASCII alphanumeric from a uid candidate. + */ +function clean_uid(mixed $raw): string +{ + return preg_replace('#(*UTF8)[^A-Za-z0-9]#', '', (string) ($raw ?? '')); } +$paste = new PasteManager(); +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$do = match ($method) { + 'POST' => (string) ($_POST['do'] ?? ''), + default => (string) ($_GET['do'] ?? ''), +}; + +$ret = match ($do) { + 'save' => handle_save($paste), + 'load' => handle_load($paste), + 'loadcomments' => handle_loadcomments($paste), + 'savecomment' => handle_savecomment($paste), + default => false, +}; + header('Content-Type: application/json'); echo json_encode($ret); +function handle_save(PasteManager $paste): false|string +{ + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + fail(405); + } + $content = (string) ($_POST['content'] ?? ''); + if (strlen($content) > MAX_PASTE_BYTES) { + fail(413); + } + if (!Token::verify('save', (string) ($_POST['token'] ?? ''))) { + fail(403); + } + return $paste->savePaste($content); +} + +function handle_load(PasteManager $paste): false|string +{ + $uid = clean_uid($_GET['uid'] ?? null); + if ($uid === '') { + return false; + } + $content = $paste->loadPaste($uid); + return $content === false ? false : htmlspecialchars($content); +} + +function handle_loadcomments(PasteManager $paste): false|array +{ + $uid = clean_uid($_GET['uid'] ?? null); + if ($uid === '') { + return false; + } + $comments = $paste->loadComments($uid); + if ($comments === false) { + return false; + } + return [ + 'comments' => $comments, + 'token' => Token::issue($uid), + ]; +} + +function handle_savecomment(PasteManager $paste): false|array +{ + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + fail(405); + } + $uid = clean_uid($_POST['uid'] ?? null); + $line = (int) ($_POST['line'] ?? 0); + $comment = (string) ($_POST['comment'] ?? ''); + $user = (string) ($_POST['user'] ?? ''); + + if ($uid === '') { + fail(400); + } + if (strlen($comment) > MAX_COMMENT_BYTES) { + fail(413); + } + if (strlen($user) > MAX_USER_BYTES) { + fail(413); + } + if (!Token::verify($uid, (string) ($_POST['token'] ?? ''))) { + fail(403); + } + + $commentsPath = $paste->fn($uid, 'comments'); + if (file_exists($commentsPath) && (time() - filemtime($commentsPath)) < COMMENT_DELAY) { + fail(429); + } + + return $paste->saveComment($uid, $line, $comment, $user); +} diff --git a/lib/script.js b/lib/script.js index dbcd936..f82b92c 100644 --- a/lib/script.js +++ b/lib/script.js @@ -1,4 +1,6 @@ var API = 'lib/router.php'; +var saveToken = $('meta[name="commie-token"]').attr('content') || ''; +var commentToken = ''; /** * Saves a new paste @@ -12,7 +14,8 @@ function save() { API, { do: 'save', - 'content': content + 'content': content, + token: saveToken }, function (data) { if (data === false) { @@ -59,10 +62,13 @@ function loadcomments(uid) { uid: uid }, function (data) { + var comments = (data && data.comments) || []; + commentToken = (data && data.token) || ''; + var $lines = $('#paste').find('li'); - for (var i = 0; i < data.length; i++) { - commentshow($($lines.get(data[i].line)), data[i]); + for (var i = 0; i < comments.length; i++) { + commentshow($($lines.get(comments[i].line)), comments[i]); } $lines.click(function (e) { @@ -129,7 +135,8 @@ function commentsave(uid, $form) { uid: uid, comment: comment, line: $form.parent().index(), - user: user + user: user, + token: commentToken }, function (data) { if (!data) {