diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Digitigrade/Model/StorageObject.php | 107 | ||||
| -rw-r--r-- | Digitigrade/StorageProvider.php | 62 | ||||
| -rw-r--r-- | Digitigrade/StorageProvider/FilesystemStorage.php | 52 | ||||
| -rw-r--r-- | migrations/20250117_224217_create_storage_object.php | 21 | ||||
| -rw-r--r-- | misc/random_printable_bytes.php | 2 | ||||
| -rw-r--r-- | routes/storage.php | 27 | ||||
| -rw-r--r-- | upload/.gitkeep | 0 |
8 files changed, 272 insertions, 2 deletions
@@ -1,2 +1,3 @@ vendor/ -config.ini
\ No newline at end of file +config.ini +upload/*
\ No newline at end of file diff --git a/Digitigrade/Model/StorageObject.php b/Digitigrade/Model/StorageObject.php new file mode 100644 index 0000000..333b5b1 --- /dev/null +++ b/Digitigrade/Model/StorageObject.php @@ -0,0 +1,107 @@ +<?php +namespace Digitigrade\Model; + +use Digitigrade\Model; +use Digitigrade\StorageProvider; + +class StorageObject extends Model { + public string $oid; + public string $provider; + public string $token; + public string $filename; + public string $mimetype; + public \DateTimeImmutable $created; + + /** + * Creates a new storage object on the given provider by consuming a local file + * @param string $provider class name of the storage provider to use + * @param string $path path to local file (will be deleted) + * @param bool $checkIsUpload if true, ensures that the local file was uploaded via HTTP + * @return self + */ + public static function createFromFile(string $provider, string $path, string $filename, bool $checkIsUpload = false): self { + if (!is_file($path)) { + throw new \RuntimeException('local file does not exist'); + } + $mimetype = mime_content_type($path); + $p = new $provider(); + assert($p instanceof StorageProvider); + $token = $p->storeFile($path, $checkIsUpload); + + $obj = new self(); + $obj->oid = random_printable_bytes(); + $obj->provider = $p::class; + $obj->token = $token; + $obj->filename = $filename; + $obj->mimetype = $mimetype; + $obj->created = new \DateTimeImmutable(); + + $obj->save(); + return $obj; + } + + public static function findByOid(string $oid): ?self { + return self::findWhere('oid = ?', [$oid]); + } + + public static function findByProviderTokenPair(string $provider, string $token): ?self { + return self::findWhere('provider = ? AND token = ?', [$provider, $token]); + } + + protected function getUpdateWhereClause(\PDO $db): ?string { + if (isset($this->oid) && self::findByOid($this->oid) != null) { + return 'oid = ' . $db->quote($this->oid); + } + if (self::findByProviderTokenPair($this->provider, $this->token) != null) { + return 'provider = ' . $db->quote($this->provider) . ' AND token = ' . $db->quote($this->token); + } + return null; + } + + private function getProvider(): StorageProvider { + return new $this->provider(); + } + + /** + * Deletes this object from the storage backend and removes it from the database. + * @return void + */ + public function delete() { + $this->getProvider()->delete($this->token); + $this->remove(); + } + + /** + * Gets the entire content of this object. + * @return string + */ + public function retrieve(): string { + return $this->getProvider()->retrieve($this->token); + } + + /** + * Streams the content of this object to the web client (stdout). + * @return void + */ + public function passthrough() { + $this->getProvider()->passthrough($this->token, $this->mimetype); + } + + /** + * Gets a direct public URL to this object, if one exists. + * @return ?string the URL or null if none is available + */ + public function getDirectUrl(): ?string { + return $this->getProvider()->directUrl($this->token); + } + + public function getUrl(bool $allowDirect = true): string { + if ($allowDirect) { + $url = $this->getDirectUrl(); + if ($url != null) { + return $url; + } + } + return path_to_uri("/storage/$this->oid"); + } +}
\ No newline at end of file diff --git a/Digitigrade/StorageProvider.php b/Digitigrade/StorageProvider.php new file mode 100644 index 0000000..53b3549 --- /dev/null +++ b/Digitigrade/StorageProvider.php @@ -0,0 +1,62 @@ +<?php +namespace Digitigrade; + +abstract class StorageProvider { + public function __construct() { + } + + /** + * Moves a file from the local filesystem to the storage backend. Deletes + * the file from the local filesystem. + * @param string $path the path to the file to store + * @param bool $checkIsUpload if true, verifies that the local file was + * uploaded via PHP's HHTP upload mechanism before doing anything with it + * @return ?string a token that uniquely identifies the new object on this + * provider, or null if it could not be stored for any reason + */ + public function storeFile(string $path, bool $checkIsUpload = false): ?string { + if ($checkIsUpload && !is_uploaded_file($path)) { + return null; + } + return $this->storeFileInternal($path); + } + + abstract protected function storeFileInternal(string $path): ?string; + + /** + * @param string $token the token of the object to look for + * @return bool whether the requested object exists or not + */ + abstract public function exists(string $token): bool; + + /** + * Retrieves an object's contents from the storage backend. + * @param string $token the token of the object to retrieve + * @return ?string the entire content, or null if not found + */ + abstract public function retrieve(string $token): ?string; + + /** + * Gets a direct, public URL to the given object, if possible. + * @param string $token the token of the object to look up + * @return ?string the URL or null if one is not available + */ + abstract public function directUrl(string $token): ?string; + + /** + * Streams the data of an object to the web client (stdout). + * @param string $token the token of the object to retrieve + * @param ?string $mimetype value of the Content-Type header to send (if + * null, will try to figure it out anyway) + * @return void + */ + abstract public function passthrough(string $token, ?string $mimetype = null); + + /** + * Deletes an object from the backend. If the object doesn't exist, does + * nothing. + * @param string $token the token of the object to delete + * @return void + */ + abstract public function delete(string $token); +}
\ No newline at end of file diff --git a/Digitigrade/StorageProvider/FilesystemStorage.php b/Digitigrade/StorageProvider/FilesystemStorage.php new file mode 100644 index 0000000..8f67653 --- /dev/null +++ b/Digitigrade/StorageProvider/FilesystemStorage.php @@ -0,0 +1,52 @@ +<?php +namespace Digitigrade\StorageProvider; + +use Digitigrade\StorageProvider; + +class FilesystemStorage extends StorageProvider { + private const BASE_PATH = __DIR__ . '/../../upload/'; + + private static function tokenToPath(string $token): string { + return self::BASE_PATH . $token; + } + + protected function storeFileInternal(string $path): ?string { + $token = hash_file('sha256', $path); + rename($path, self::tokenToPath($token)); + return $token; + } + + public function exists(string $token): bool { + return is_file(self::tokenToPath($token)); + } + + public function retrieve(string $token): ?string { + $path = self::tokenToPath($token); + if (!is_readable($path)) { + return null; + } + return file_get_contents($path); + } + + public function directUrl(string $token): ?string { + return path_to_uri("/upload/$token"); + } + + public function passthrough(string $token, ?string $mimetype = null) { + if (!$this->exists($token)) { + throw new \RuntimeException('requested object does not exist'); + } + $path = self::tokenToPath($token); + if (!headers_sent()) { + header('Content-Type: ' . ($mimetype ?? (mime_content_type($path) ?: 'application/octet-stream'))); + } + readfile($path); + } + + public function delete(string $token) { + $path = self::tokenToPath($token); + if (is_file($path)) { + unlink($path); + } + } +}
\ No newline at end of file diff --git a/migrations/20250117_224217_create_storage_object.php b/migrations/20250117_224217_create_storage_object.php new file mode 100644 index 0000000..a1a5cc0 --- /dev/null +++ b/migrations/20250117_224217_create_storage_object.php @@ -0,0 +1,21 @@ +<?php + +use \Digitigrade\Db\Migrator; + +Migrator::getInstance()->register(20250117_224217, function (PDO $db) { + // storage objects for uploaded files and whatever else needs storing + // backend agnostic! that's cool i think + // note: i'm using random strings for the primary key here because i don't + // want them to be easily guessable. i think this is an okay tradeoff + $db->exec(<<<END + CREATE TABLE storage_object ( + oid text primary key, + provider text not null, + token text not null, + filename text not null, + mimetype text not null, + created timestamp with time zone not null default current_timestamp, + unique(provider, token) + ); + END); +}); diff --git a/misc/random_printable_bytes.php b/misc/random_printable_bytes.php index d2b8770..9e55fe7 100644 --- a/misc/random_printable_bytes.php +++ b/misc/random_printable_bytes.php @@ -6,5 +6,5 @@ */ function random_printable_bytes(): string { // i think 128 bytes should be enough .. i hope so anyway - return base64_encode(random_bytes(128)); + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(random_bytes(128))); }
\ No newline at end of file diff --git a/routes/storage.php b/routes/storage.php new file mode 100644 index 0000000..341770b --- /dev/null +++ b/routes/storage.php @@ -0,0 +1,27 @@ +<?php + +use Digitigrade\HttpResponseStatus\NotFound; +use Digitigrade\Model\StorageObject; +use Digitigrade\Router; +use Digitigrade\StorageProvider\FilesystemStorage; + +Router::getInstance()->mount('/storage/:oid', function (array $args) { + // find the storage object regardless of backend provider + $obj = StorageObject::findByOid($args['oid']); + if ($obj == null) { + throw new NotFound('unknown object'); + } + header("Content-Disposition: inline; filename*=UTF-8''" . urlencode($obj->filename)); + $obj->passthrough(); +}); + +Router::getInstance()->mount('/upload/:token', function (array $args) { + // for direct urls for the FilesystemStorage backend, in case the server + // admin didn't put a special handler in their reverse proxy + $storage = new FilesystemStorage(); + $token = $args['token']; + if (!$storage->exists($token)) { + throw new NotFound('unknown object'); + } + $storage->passthrough($token); +});
\ No newline at end of file diff --git a/upload/.gitkeep b/upload/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/upload/.gitkeep |
