diff --git a/.env.example.ini b/.env.example.ini new file mode 100644 index 0000000..d460500 --- /dev/null +++ b/.env.example.ini @@ -0,0 +1,30 @@ +; Absolute pathname to a folder where blobs will be stored +blob_storage_path = ""; + +; Credentials for MaraiaDB +mariadb_host = "" +mariadb_user = "" +mariadb_pass = "" +mariadb_db = "" + +; UUID API endpoint +uuid_api = "https://uuidgenerator.dev/api/uuid/v7" + +[access] +; Allow anyone to upload blobs from /new +enable_public_upload = false + +; Allow anyone to create a collection of blobs from /new +enable_public_collection = true + +; Allow content to display with inline disposition from / (setting this to false will force-download) +enable_public_inline = true + +; Allow anyone to view blob metadata from //meta +enable_public_metadata = true + +; Allow force download of any blob from //download +enable_public_download = true + +[features] +enable_blob_versioning = true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..1a6ac11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Bootstrapping # +################# +vendor +.env.ini + +# OS generated files # +###################### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Icon? +ehthumbs.db +Thumbs.db +.directory diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..495067b --- /dev/null +++ b/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "victorwesterlund/libmysqldriver": "^3.6" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..e37aa45 --- /dev/null +++ b/composer.lock @@ -0,0 +1,56 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c8788fa2c53c43271a259b60098e3faf", + "packages": [ + { + "name": "victorwesterlund/libmysqldriver", + "version": "3.6.1", + "source": { + "type": "git", + "url": "https://github.com/VictorWesterlund/php-libmysqldriver.git", + "reference": "adc2fda90a3b8308e8a9df202d5ec418a9220ff8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/VictorWesterlund/php-libmysqldriver/zipball/adc2fda90a3b8308e8a9df202d5ec418a9220ff8", + "reference": "adc2fda90a3b8308e8a9df202d5ec418a9220ff8", + "shasum": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "libmysqldriver\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-only" + ], + "authors": [ + { + "name": "Victor Westerlund", + "email": "victor.vesterlund@gmail.com" + } + ], + "description": "Abstraction library for common mysqli features", + "support": { + "issues": "https://github.com/VictorWesterlund/php-libmysqldriver/issues", + "source": "https://github.com/VictorWesterlund/php-libmysqldriver/tree/3.6.1" + }, + "time": "2024-04-29T08:17:12+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.0.0" +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..0d567c1 --- /dev/null +++ b/public/index.php @@ -0,0 +1,7 @@ +value]; + } + } + + $_ENV = parse_ini_file("../.env.ini"); \ No newline at end of file diff --git a/src/Router.php b/src/Router.php new file mode 100644 index 0000000..61f8737 --- /dev/null +++ b/src/Router.php @@ -0,0 +1,106 @@ +db = new MySQL( + $_ENV["mariadb_host"], + $_ENV["mariadb_user"], + $_ENV["mariadb_pass"], + $_ENV["mariadb_db"] + ); + + $this->route(); + } + + // Get blob UUID from request pathname + private static function get_blob_id(): string|false { + if (strlen($_SERVER["REQUEST_URI"]) !== UUID_SUBSTR_LENGTH + 1) { + return false; + } + + return substr($_SERVER["REQUEST_URI"], 1, UUID_SUBSTR_LENGTH); + } + + private static function resp_error(?Error $error): never { + // Treat custom error codes as HTTP 500 + http_response_code($error->value < 600 ? $error->value : 500); + + // Return HTML page if supported by client + if ($_SERVER["HTTP_ACCEPT"] === "*/*" || strpos($_SERVER["HTTP_ACCEPT"] ?? "", "text/html") !== false) { + die(sprintf(file_get_contents(__DIR__ . "/error.html"), $error->value, $error->name)); + } + + die($error->name); + } + + private function get_blob_meta(): bool { + $this->blob_meta = $this->db + ->for(BlobsModel::TABLE) + ->where([ + BlobsModel::ID->value => self::get_blob_id(), + BlobsModel::IS_REMOVED->value => false + ]) + ->limit(1) + ->select([ + BlobsModel::MIME_TYPE->value + ]); + + // Return true if blob exists in database + return $this->blob_meta and $this->blob_meta->num_rows === 1; + } + + private function resp_blob_inline(): never { + $meta = $this->blob_meta->fetch_assoc(); + + header("Content-Type: " . $meta[BlobsModel::MIME_TYPE->value]); + header("Content-Disposition: inline"); + + exit(file_get_contents($this->get_blob_path())); + } + + private function get_blob_path(): string { + $base_path = ENV::BLOB_STORAGE_PATH->get(); + + // Append tailing slash to pathname if absent + $base_path = substr($base_path, -1, 1) === "/" ? $base_path : $base_path . "/"; + + if (!is_dir($base_path)) { + return self::resp_error(Error::BLOB_DIR_NOT_READABLE); + } + + return $base_path . self::get_blob_id(); + } + + private function route() { + // Verify database is connected + if (!$this->db->ping()) { + return self::resp_error(Error::DATABASE_NOT_CONNECTED); + } + + // Check that we have a 36-char string and then test it against the database + if (!self::get_blob_id() || !$this->get_blob_meta()) { + return self::resp_error(Error::BLOB_NOT_FOUND); + } + + return $this->resp_blob_inline(); + } + } \ No newline at end of file diff --git a/src/database/blobber.sql b/src/database/blobber.sql new file mode 100644 index 0000000..1056cc6 --- /dev/null +++ b/src/database/blobber.sql @@ -0,0 +1,67 @@ +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +START TRANSACTION; +SET time_zone = "+00:00"; + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; + + +CREATE TABLE `blobs` ( + `id` char(36) NOT NULL, + `mime_type` varchar(255) NOT NULL DEFAULT 'application/octet-stream', + `modify_token` char(255) NOT NULL, + `ref_branch_blob` char(36) DEFAULT NULL, + `is_removed` tinyint(1) NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE `collections` ( + `id` char(36) NOT NULL, + `modify_token` char(255) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE `rel_collections_blobs` ( + `ref_collection_id` char(36) NOT NULL, + `ref_blob_id` char(36) NOT NULL, + `label` varchar(255) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE `rel_collections_collections` ( + `ref_collection_id` char(36) NOT NULL, + `ref_child_collection_id` char(36) NOT NULL, + `label` varchar(255) DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + + +ALTER TABLE `blobs` + ADD PRIMARY KEY (`id`), + ADD KEY `ref_replaces_blob` (`ref_branch_blob`); + +ALTER TABLE `collections` + ADD PRIMARY KEY (`id`); + +ALTER TABLE `rel_collections_blobs` + ADD KEY `ref_collection_id` (`ref_collection_id`), + ADD KEY `ref_blob_id` (`ref_blob_id`); + +ALTER TABLE `rel_collections_collections` + ADD KEY `ref_collection_id` (`ref_collection_id`,`ref_child_collection_id`), + ADD KEY `ref_child_collection_id` (`ref_child_collection_id`); + + +ALTER TABLE `blobs` + ADD CONSTRAINT `blobs_ibfk_1` FOREIGN KEY (`ref_branch_blob`) REFERENCES `blobs` (`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE `rel_collections_blobs` + ADD CONSTRAINT `rel_collections_blobs_ibfk_1` FOREIGN KEY (`ref_blob_id`) REFERENCES `blobs` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `rel_collections_blobs_ibfk_2` FOREIGN KEY (`ref_collection_id`) REFERENCES `collections` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `rel_collections_collections` + ADD CONSTRAINT `rel_collections_collections_ibfk_1` FOREIGN KEY (`ref_collection_id`) REFERENCES `collections` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `rel_collections_collections_ibfk_2` FOREIGN KEY (`ref_child_collection_id`) REFERENCES `collections` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +COMMIT; + +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; diff --git a/src/database/models/Blobs.php b/src/database/models/Blobs.php new file mode 100644 index 0000000..516e4d7 --- /dev/null +++ b/src/database/models/Blobs.php @@ -0,0 +1,13 @@ + + + + + + %1$s %2$s + + + ERROR::%1$s::%2$s + + \ No newline at end of file