Initial code commit

This commit is contained in:
Victor Westerlund 2024-09-16 15:13:02 +02:00
parent 8b2f369215
commit 42716ed7da
11 changed files with 350 additions and 0 deletions

30
.env.example.ini Normal file
View file

@ -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 /<uuid7> (setting this to false will force-download)
enable_public_inline = true
; Allow anyone to view blob metadata from /<uuid7>/meta
enable_public_metadata = true
; Allow force download of any blob from /<uuid7>/download
enable_public_download = true
[features]
enable_blob_versioning = true

16
.gitignore vendored Executable file
View file

@ -0,0 +1,16 @@
# Bootstrapping #
#################
vendor
.env.ini
# OS generated files #
######################
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
Icon?
ehthumbs.db
Thumbs.db
.directory

5
composer.json Normal file
View file

@ -0,0 +1,5 @@
{
"require": {
"victorwesterlund/libmysqldriver": "^3.6"
}
}

56
composer.lock generated Normal file
View file

@ -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"
}

7
public/index.php Normal file
View file

@ -0,0 +1,7 @@
<?php
use Blobber\Router;
require_once "../src/Init.php";
(new Router());

15
src/Error.php Normal file
View file

@ -0,0 +1,15 @@
<?php
namespace Blobber;
enum Error: int {
case SERVICE_UNAVAILABLE = 503;
case BLOB_NOT_FOUND = 404;
case DATABASE_NOT_CONNECTED = 1000;
case BLOB_DIR_NOT_READABLE = 1001;
public static function default(): self {
return self::SERVICE_UNAVAILABLE;
}
}

24
src/Init.php Normal file
View file

@ -0,0 +1,24 @@
<?php
namespace Blobber;
require_once "../vendor/autoload.php";
require_once "Router.php";
enum ENV: string {
// Core configration
case BLOB_STORAGE_PATH = "blob_storage_path";
// Database credentials
case MARIADB_HOST = "mariadb_host";
case MARIADB_USER = "mariadb_user";
case MARIADB_PASS = "mariadb_pass";
case MARIADB_DB = "mariadb_db";
public function get(): string {
return $_ENV[$this->value];
}
}
$_ENV = parse_ini_file("../.env.ini");

106
src/Router.php Normal file
View file

@ -0,0 +1,106 @@
<?php
namespace Blobber;
use \mysqli_result;
use libmysqldriver\MySQL;
use Blobber\ENV;
use Blobber\Error;
use Blobber\Database\Models\{
BlobsModel
};
require_once "Error.php";
require_once "database/models/Blobs.php";
const UUID_SUBSTR_LENGTH = 36;
class Router {
private readonly MySQL $db;
private readonly mysqli_result|false $blob_meta;
public function __construct() {
$this->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();
}
}

67
src/database/blobber.sql Normal file
View file

@ -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 */;

View file

@ -0,0 +1,13 @@
<?php
namespace Blobber\Database\Models;
enum BlobsModel: string {
const TABLE = "blobs";
case ID = "id";
case MIME_TYPE = "mime_type";
case MODIFY_TOKEN = "modify_token";
case REF_BRANCH_BLOB = "ref_branch_blob";
case IS_REMOVED = "is_removed";
}

11
src/error.html Normal file
View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%1$s %2$s</title>
</head>
<body>
<code>ERROR::<strong>%1$s</strong>::<strong>%2$s</strong></code>
</body>
</html>