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) {