diff options
-rw-r--r-- | config.php | 3 | ||||
-rw-r--r-- | src/BoundVal.php | 2 | ||||
-rw-r--r-- | src/Env.php | 9 | ||||
-rw-r--r-- | src/Handlers/AttachmentHandler.php | 26 | ||||
-rw-r--r-- | src/Handlers/NewNote.php | 4 | ||||
-rw-r--r-- | src/Handlers/NoteHandler.php | 7 | ||||
-rw-r--r-- | src/Main.php | 3 | ||||
-rw-r--r-- | src/Models/Attachment.php | 258 | ||||
-rw-r--r-- | src/Models/Note.php | 9 | ||||
-rw-r--r-- | src/Schema.php | 25 | ||||
-rw-r--r-- | src/TemplateModelWrappers/NoteForTemplate.php | 44 | ||||
-rw-r--r-- | templates/macros.twig | 16 | ||||
-rw-r--r-- | templates/note.twig | 2 |
13 files changed, 402 insertions, 6 deletions
@@ -2,5 +2,6 @@ return [ "templates_path" => __DIR__ . "/templates", "templates_cache" => false, //__DIR__ . "/.templates_cache", - "sqlitedb" => __DIR__ . "/notes.db", + "sqlitedb" => __DIR__ . "/notes-dev.db", + "attachments" => __DIR__ . "/attachments", ]; diff --git a/src/BoundVal.php b/src/BoundVal.php index 7a3560a..892396e 100644 --- a/src/BoundVal.php +++ b/src/BoundVal.php @@ -24,7 +24,7 @@ class BoundVal public static function ofFloat($val): self { return new self($val, SQLITE3_FLOAT); } public static function ofText($val): self { return new self($val, SQLITE3_TEXT); } public static function ofBlob($val): self { return new self($val, SQLITE3_BLOB); } - public static function ofNull($val): self { return new self($val, SQLITE3_NULL); } + public static function ofNull(): self { return new self(null, SQLITE3_NULL); } public function bind(SQLite3Stmt $stmt, $where): void { diff --git a/src/Env.php b/src/Env.php index 34b9f1f..38d9279 100644 --- a/src/Env.php +++ b/src/Env.php @@ -2,6 +2,7 @@ namespace Micropoly; +use RuntimeException; use SQLite3; use Twig\Environment; use Twig\Loader\FilesystemLoader; @@ -77,4 +78,12 @@ class Env return $db; }); } + + public function attachmentsPath(): string + { + $attachments = $this->config['attachments']; + if (!is_dir($attachments) || !is_writable($attachments)) + throw new RuntimeException("Attachment directory '$attachments' is not a writable directory."); + return $attachments; + } } diff --git a/src/Handlers/AttachmentHandler.php b/src/Handlers/AttachmentHandler.php new file mode 100644 index 0000000..9b329b0 --- /dev/null +++ b/src/Handlers/AttachmentHandler.php @@ -0,0 +1,26 @@ +<?php + + +namespace Micropoly\Handlers; + + +use Micropoly\Env; +use Micropoly\Handler; +use Micropoly\Models\Attachment; + +class AttachmentHandler implements Handler +{ + public function handle(\Micropoly\Env $env, array $variables) + { + $db = $env->db(); + + $attachment = Attachment::byId($db, $variables["id"]); + if ($attachment === null) { + (new NotFoundHandler())->handle($env, []); + return; + } + + header("Content-Type: {$attachment->getMime()}"); + readfile($attachment->getFilePath($env->attachmentsPath())); + } +}
\ No newline at end of file diff --git a/src/Handlers/NewNote.php b/src/Handlers/NewNote.php index 9c60757..9d4286d 100644 --- a/src/Handlers/NewNote.php +++ b/src/Handlers/NewNote.php @@ -7,6 +7,7 @@ namespace Micropoly\Handlers; use Micropoly\Env; use Micropoly\Esc; use Micropoly\Handler; +use Micropoly\Models\Attachment; use Micropoly\Models\Note; class NewNote implements Handler @@ -29,6 +30,9 @@ class NewNote implements Handler $note->setTags($_POST["tag"]); $note->save($env->db()); + if (isset($_FILES['attachments'])) + Attachment::createFromUploads($env->db(), $env->attachmentsPath(), $note, $_FILES['attachments']); + $url = $env->documentRoot() . "n/" . $note->getId(); http_response_code(303); header("Location: {$url}"); diff --git a/src/Handlers/NoteHandler.php b/src/Handlers/NoteHandler.php index afdabb5..aa1cf78 100644 --- a/src/Handlers/NoteHandler.php +++ b/src/Handlers/NoteHandler.php @@ -6,7 +6,9 @@ namespace Micropoly\Handlers; use Micropoly\Env; use Micropoly\Handler; +use Micropoly\Models\Attachment; use Micropoly\Models\Note; +use Micropoly\TemplateModelWrappers\NoteForTemplate; class NoteHandler implements Handler { @@ -32,8 +34,11 @@ class NoteHandler implements Handler $note->setContent($_POST["content"]); $note->setTags($_POST["tag"]); $note->save($db); + + if (isset($_FILES['attachments'])) + Attachment::createFromUploads($env->db(), $env->attachmentsPath(), $note, $_FILES['attachments']); } - echo $env->twig()->render("/note.twig", ["note" => $note]); + echo $env->twig()->render("/note.twig", ["note" => new NoteForTemplate($db, $note)]); } }
\ No newline at end of file diff --git a/src/Main.php b/src/Main.php index 443960b..c82a334 100644 --- a/src/Main.php +++ b/src/Main.php @@ -6,6 +6,7 @@ use Closure; use FastRoute\Dispatcher; use FastRoute\RouteCollector; use Micropoly\Handlers\ApiTagsHandler; +use Micropoly\Handlers\AttachmentHandler; use Micropoly\Handlers\Index; use Micropoly\Handlers\MethodNotAllowedHandler; use Micropoly\Handlers\NewNote; @@ -13,6 +14,7 @@ use Micropoly\Handlers\NoteHandler; use Micropoly\Handlers\NotFoundHandler; use Micropoly\Handlers\Search; +use Micropoly\Models\Attachment; use function FastRoute\simpleDispatcher; class Main implements Entrypoint @@ -24,6 +26,7 @@ class Main implements Entrypoint $r->addRoute(["GET"], "/search", Search::class); $r->addRoute(["GET", "POST"], "/n/{id}", NoteHandler::class); $r->addRoute(["GET"], "/api/tags", ApiTagsHandler::class); + $r->addRoute(["GET"], "/attachments/{id}", AttachmentHandler::class); } public function run(Env $env) diff --git a/src/Models/Attachment.php b/src/Models/Attachment.php new file mode 100644 index 0000000..bbbd0ee --- /dev/null +++ b/src/Models/Attachment.php @@ -0,0 +1,258 @@ +<?php + + +namespace Micropoly\Models; + + +use InvalidArgumentException; +use Micropoly\BoundVal; +use Micropoly\DbQuery; +use RuntimeException; +use SQLite3; + +class Attachment +{ + public const HASH_ALGO = "sha3-256"; + + private const HASH_PREFIX_LEN = 2; + + private string $id; + private string $noteId; + private string $hash; + private ?string $fileName; + private string $mime; + + private function __construct() {} + + /** + * @param string $hash + * @return string[] + */ + private static function splitHash(string $hash): array + { + if (strlen($hash) <= self::HASH_PREFIX_LEN+1) { // +1 so $tail won't be empty + throw new InvalidArgumentException("Invalid hash '$hash' given"); + } + + $head = substr($hash, 0, self::HASH_PREFIX_LEN); + $tail = substr($hash, self::HASH_PREFIX_LEN); + return [$head, $tail]; + } + + private static function relativeFilePathFromHash(string $hash): string + { + [$head, $tail] = self::splitHash($hash); + + return $head . DIRECTORY_SEPARATOR . $tail; + } + + /** + * @param string $attachmentPath + * @param string $hash + * @return string + */ + private static function fullFilePathFromHash(string $attachmentPath, string $hash): string + { + return $attachmentPath . DIRECTORY_SEPARATOR . self::relativeFilePathFromHash($hash); + } + + private static function deleteFileByHash(string $attachmentPath, string $hash): void + { + @unlink(self::fullFilePathFromHash($attachmentPath, $hash)); + } + + public function clearAbandoned(SQLite3 $db, string $attachmentPath): void + { + $db->exec("BEGIN"); + foreach ((new DbQuery(" + SELECT a.hash + FROM attachments a + LEFT JOIN note_attachments na + ON na.hash = a.hash + WHERE na.id IS NULL + "))->fetchRows($db) as $hash) { + self::deleteFileByHash($attachmentPath, $hash); + } + + $db->exec(" + DELETE FROM attachments + WHERE hash NOT IN ( + SELECT hash + FROM note_attachments + ) + "); + $db->exec("COMMIT"); + } + + private static function fromRow(array $row): self + { + $out = new self(); + + $out->id = $row["id"]; + $out->noteId = $row["note_id"]; + $out->hash = $row["hash"]; + $out->fileName = $row["file_name"]; + $out->mime = $row["mime"]; + + return $out; + } + + /** + * @param SQLite3 $db + * @param DbQuery $query + * @return self[] Indexed by id + */ + private static function byQuery(SQLite3 $db, DbQuery $query): array + { + return array_map([self::class, "fromRow"], $query->fetchIndexedRows($db, "id")); + } + + /** + * @param SQLite3 $db + * @param string[] $ids + * @return self[] Indexed by id + */ + public static function byIds(SQLite3 $db, array $ids): array + { + $ids = array_map("trim", $ids); + $ids = array_filter($ids); + + if (empty($ids)) + return []; + + return self::byQuery( + $db, + (new DbQuery(" + SELECT id, note_id, hash, file_name, mime + FROM note_attachments + WHERE id IN (" . DbQuery::valueListPlaceholders($ids) . ") + ")) + ->bindMultiText($ids) + ); + } + + public static function byId(SQLite3 $db, string $id): ?self + { + return self::byIds($db, [$id])[$id] ?? null; + } + + /** + * @param SQLite3 $db + * @param string $noteId + * @return self[] Indexed by id + */ + public static function byNoteId(SQLite3 $db, string $noteId): array + { + return self::byQuery( + $db, + (new DbQuery(" + SELECT id, note_id, hash, file_name, mime + FROM note_attachments + WHERE note_id = ? + ")) + ->bind(1, BoundVal::ofText($noteId)) + ); + } + + private static function transposeUploadsArray(array $uploads): array + { + $out = []; + foreach ($uploads as $key => $values) { + if (!is_array($values)) + $values = [$values]; + + foreach ($values as $i => $v) + $out[$i][$key] = $v; + } + + return $out; + } + + private static function hasHash(SQLite3 $db, string $hash): bool + { + return (new DbQuery("SELECT COUNT(*) FROM attachments WHERE hash = ?")) + ->bind(1, BoundVal::ofText($hash)) + ->fetchRow($db)[0] > 0; + } + + private static function mkUploadDir(string $attachmentPath, string $hash): void + { + [$head] = self::splitHash($hash); + $dir = $attachmentPath . DIRECTORY_SEPARATOR . $head; + + if (!is_dir($dir)) + if (!mkdir($dir)) + throw new RuntimeException("Failed creating upload dir '$dir'"); + } + + /** + * @param SQLite3 $db + * @param string $attachmentPath + * @param Note $note + * @param array $uploads a $_FILES[$name] like array. + * Can be populated by multiple files, like + * {@see https://www.php.net/manual/en/features.file-upload.multiple.php} describes it. + * @return self[] + */ + public static function createFromUploads(SQLite3 $db, string $attachmentPath, Note $note, array $uploads): array + { + $out = []; + $inserts = []; + + foreach (self::transposeUploadsArray($uploads) as $upload) { + $hash = hash_file(self::HASH_ALGO, $upload["tmp_name"]); + if (self::hasHash($db, $hash)) { + unlink($upload["tmp_name"]); + } else { + self::mkUploadDir($attachmentPath, $hash); + if (!move_uploaded_file($upload["tmp_name"], self::fullFilePathFromHash($attachmentPath, $hash))) { + throw new RuntimeException("Failed uploading file '{$upload["tmp_name"]}', original name was: '{$upload["name"]}'"); + } + DbQuery::insertKV($db, "attachments", ["hash" => BoundVal::ofText($hash)]); + } + + $obj = new self(); + + $obj->id = uniqid("", true); + $obj->noteId = $note->getId(); + $obj->hash = $hash; + $obj->fileName = $upload["name"] ?? null; + $obj->mime = (string)($upload["type"] ?? "application/octet-stream"); + + $out[] = $obj; + $inserts[] = $obj->buildInsertValues(); + } + + DbQuery::insert($db, "note_attachments", ["id", "note_id", "hash", "file_name", "mime"], $inserts); + + return $out; + } + + private function buildInsertValues() + { + return [ + BoundVal::ofText($this->id), + BoundVal::ofText($this->noteId), + BoundVal::ofText($this->hash), + $this->fileName === null ? BoundVal::ofNull() : BoundVal::ofText($this->fileName), + BoundVal::ofText($this->mime), + ]; + } + + public function delete(SQLite3 $db, string $attachmentPath): void + { + (new DbQuery("DELETE FROM note_attachments WHERE id = ?"))->bind(1, BoundVal::ofText($this->id))->exec($db); + self::clearAbandoned($db, $attachmentPath); + } + + public function getId(): string { return $this->id; } + public function getNoteId(): string { return $this->noteId; } + public function getHash(): string { return $this->hash; } + public function getFileName(): ?string { return $this->fileName; } + public function getMime(): string { return $this->mime; } + + public function getFilePath(string $attachmentPath): string + { + return self::fullFilePathFromHash($attachmentPath, $this->hash); + } +}
\ No newline at end of file diff --git a/src/Models/Note.php b/src/Models/Note.php index 901f5aa..594bc79 100644 --- a/src/Models/Note.php +++ b/src/Models/Note.php @@ -247,4 +247,13 @@ class Note ->exec($db); $this->savedToDb = false; } + + /** + * @param SQLite3 $db + * @return Attachment[] + */ + public function getAttachments(SQLite3 $db): array + { + return Attachment::byNoteId($db, $this->id); + } }
\ No newline at end of file diff --git a/src/Schema.php b/src/Schema.php index bbb47de..25dff5d 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -36,7 +36,10 @@ class Schema switch ($version) { case 0: $this->v1(); - $this->setSchemaVersion(1); + // fallthrough + case 1: + $this->v2(); + $this->setSchemaVersion(2); } } @@ -77,4 +80,24 @@ class Schema GROUP BY tag "); } + + private function v2() + { + $this->db->exec(" + CREATE TABLE attachments (hash TEXT NOT NULL PRIMARY KEY) WITHOUT ROWID + "); + + $this->db->exec(" + CREATE TABLE note_attachments ( + id VARCHAR(23) NOT NULL PRIMARY KEY, + note_id VARCHAR(23) NOT NULL REFERENCES notes (id) ON UPDATE CASCADE ON DELETE CASCADE, + hash TEXT NOT NULL REFERENCES attachments (hash) ON UPDATE CASCADE ON DELETE CASCADE, + file_name TEXT NULL DEFAULT NULL, + mime TEXT NOT NULL DEFAULT 'application/octet-stream' + ) WITHOUT ROWID; + "); + + $this->db->exec("CREATE INDEX note_id ON note_attachments (note_id)"); + $this->db->exec("CREATE INDEX hash ON note_attachments (hash)"); + } } diff --git a/src/TemplateModelWrappers/NoteForTemplate.php b/src/TemplateModelWrappers/NoteForTemplate.php new file mode 100644 index 0000000..baafe21 --- /dev/null +++ b/src/TemplateModelWrappers/NoteForTemplate.php @@ -0,0 +1,44 @@ +<?php + + +namespace Micropoly\TemplateModelWrappers; + + +use Micropoly\Models\Note; +use SQLite3; + +class NoteForTemplate +{ + private SQLite3 $db; + private Note $note; + + /** + * NoteForModel constructor. + * @param SQLite3 $db + * @param Note $note + */ + public function __construct(SQLite3 $db, Note $note) + { + $this->db = $db; + $this->note = $note; + } + + /** + * @param SQLite3 $db + * @param Note[] $notes + * @return self[] + */ + public static function wrapMany(SQLite3 $db, array $notes): array + { + return array_map(static fn(Note $note) => new self($db, $note), $notes); + } + + public function getId(): string { return $this->note->getId(); } + public function getContent(): string { return $this->note->getContent(); } + public function getTags(): array { return $this->note->getTags(); } + + public function getAttachments(): array + { + return $this->note->getAttachments($this->db); + } +}
\ No newline at end of file diff --git a/templates/macros.twig b/templates/macros.twig index dc7e721..4e1b336 100644 --- a/templates/macros.twig +++ b/templates/macros.twig @@ -37,12 +37,26 @@ <input type="text" name="tag[]" /> {% endfor %} </fieldset> + <div class="attachments-container"> + <h2>Attachments</h2> + <ul class="attachment-list"> + {% for att in note.attachments %} + <li> + <a href="{{ url("attachments/%s", att.id) }}">{{ att.fileName ? att.fileName : att.id }}</a> + </li> + {% endfor %} + </ul> + + <ul class="attachment-inputs"> + <li><input type="file" name="attachments[]"></li> + </ul> + </div> {% endmacro %} {% macro new_note() %} <section class="new-note"> <h2>New Note</h2> - <form action="{{ url("new-note") }}" method="post"> + <form action="{{ url("new-note") }}" method="post" enctype="multipart/form-data"> {{ _self.note_form_content({}) }} <button type="submit">Create</button> </form> diff --git a/templates/note.twig b/templates/note.twig index 14989aa..cb0b6d8 100644 --- a/templates/note.twig +++ b/templates/note.twig @@ -1,7 +1,7 @@ {% extends "skeleton.twig" %} {% import "macros.twig" as macros %} {% block body %} - <form action="{{ url("n/%s", note.id) }}" method="post"> + <form action="{{ url("n/%s", note.id) }}" method="post" enctype="multipart/form-data"> {{ macros.note_form_content(note) }} <button type="submit">Save</button> <button |