├── .gitignore ├── Makefile ├── .github ├── issue_template.md └── workflows │ └── unit_tests.yml ├── Schema ├── Sqlite.php ├── Postgres.php └── Mysql.php ├── Test └── PluginTest.php ├── Plugin.php ├── LICENSE ├── README.md ├── Command └── MigrateCommand.php └── DatabaseObjectStorage.php /.gitignore: -------------------------------------------------------------------------------- 1 | *.zip 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | @ echo "Build archive for plugin ${plugin} version=${version}" 3 | @ git archive HEAD --prefix=${plugin}/ --format=zip -o ${plugin}-${version}.zip 4 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | **Please, do not create duplicate issues** 2 | 3 | 4 | ### Actual behaviour 5 | 6 | 7 | ### Expected behaviour 8 | 9 | 10 | ### Steps to reproduce 11 | 12 | 13 | ### Configuration 14 | 15 | - Plugin version: 16 | - Kanboard version: 17 | - Database type and version: 18 | - PHP version: 19 | - OS: 20 | - Browser: 21 | 22 | -------------------------------------------------------------------------------- /Schema/Sqlite.php: -------------------------------------------------------------------------------- 1 | exec('CREATE TABLE IF NOT EXISTS object_storage ( 12 | "object_key" VARCHAR(255) PRIMARY KEY, 13 | "object_data" BYTEA NOT NULL 14 | )'); 15 | } 16 | -------------------------------------------------------------------------------- /Schema/Postgres.php: -------------------------------------------------------------------------------- 1 | exec('CREATE TABLE IF NOT EXISTS object_storage ( 12 | "object_key" VARCHAR(255) PRIMARY KEY, 13 | "object_data" BYTEA NOT NULL 14 | )'); 15 | } 16 | -------------------------------------------------------------------------------- /Schema/Mysql.php: -------------------------------------------------------------------------------- 1 | exec('CREATE TABLE IF NOT EXISTS `object_storage` ( 12 | `object_key` VARCHAR(255) NOT NULL, 13 | `object_data` LONGBLOB NOT NULL, 14 | PRIMARY KEY (`object_key`) 15 | ) ENGINE=InnoDB 16 | '); 17 | } 18 | -------------------------------------------------------------------------------- /Test/PluginTest.php: -------------------------------------------------------------------------------- 1 | container); 14 | $this->assertSame(null, $plugin->initialize()); 15 | $this->assertNotEmpty($plugin->getPluginName()); 16 | $this->assertNotEmpty($plugin->getPluginDescription()); 17 | $this->assertNotEmpty($plugin->getPluginAuthor()); 18 | $this->assertNotEmpty($plugin->getPluginVersion()); 19 | $this->assertNotEmpty($plugin->getPluginHomepage()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Plugin.php: -------------------------------------------------------------------------------- 1 | container['objectStorage'] = function() { 13 | return new DatabaseObjectStorage($this->db); 14 | }; 15 | 16 | $this->cli->add(new MigrateCommand($this->container)); 17 | } 18 | 19 | public function getPluginName() 20 | { 21 | return 'Database Storage'; 22 | } 23 | 24 | public function getPluginDescription() 25 | { 26 | return 'Use the database to store attachments'; 27 | } 28 | 29 | public function getPluginAuthor() 30 | { 31 | return 'Frédéric Guillot'; 32 | } 33 | 34 | public function getPluginVersion() 35 | { 36 | return '1.0.2'; 37 | } 38 | 39 | public function getPluginHomepage() 40 | { 41 | return 'https://github.com/kanboard/plugin-database-storage'; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Frédéric Guillot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Database Object Storage 2 | ======================= 3 | 4 | This plugin stores uploaded files into the database instead of using the local filesystem. 5 | 6 | Author 7 | ------ 8 | 9 | - Frederic Guillot 10 | - License MIT 11 | 12 | Requirements 13 | ------------ 14 | 15 | - PHP >= 5.6 16 | - Kanboard >= 1.2.1 17 | - Postgres is recommended 18 | - Mysql or Sqlite 19 | 20 | Why I should store files in the database? 21 | ----------------------------------------- 22 | 23 | Storing files in the database doesn't fit all usages. 24 | Obviously, the database size will increase over the time if you store large files. 25 | 26 | The main benefit of doing this is to simplify backups. 27 | Everything is in a central location and nothing is stored on the frontend servers. 28 | PostgreSQL is preferred because streaming files is supported. 29 | 30 | Migrating old files to the database 31 | ----------------------------------- 32 | 33 | ```bash 34 | ./cli db-storage:migrate 35 | ``` 36 | 37 | Notes 38 | ----- 39 | 40 | - Run the command `VACUUM` to free up disk space used by removed files 41 | - With Mysql, you may need to change the value of `max_allowed_packet`, the default is 1MB 42 | -------------------------------------------------------------------------------- /Command/MigrateCommand.php: -------------------------------------------------------------------------------- 1 | setName('db-storage:migrate') 20 | ->setDescription('Migrate local files to the database'); 21 | } 22 | 23 | protected function execute(InputInterface $input, OutputInterface $output) 24 | { 25 | if (! file_exists(FILES_DIR)) { 26 | $output->writeln('Directory not found: ' . FILES_DIR . ''); 27 | } else { 28 | $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(FILES_DIR), RecursiveIteratorIterator::SELF_FIRST); 29 | $storage = new DatabaseObjectStorage($this->container['db']);; 30 | 31 | foreach($files as $file) { 32 | $fileName = $file->getFilename(); 33 | if ($fileName[0] !== '.' && ! $file->isDir()) { 34 | $key = substr($file->getRealPath(), strlen(FILES_DIR) + 1); 35 | 36 | if (! $this->fileExists($storage, $key)) { 37 | $output->writeln('Migrating ' . $fileName . ''); 38 | $blob = $this->readFile($file->getRealPath()); 39 | $storage->put($key, $blob); 40 | } 41 | } 42 | } 43 | } 44 | } 45 | 46 | protected function fileExists(DatabaseObjectStorage $storage, $key) 47 | { 48 | try { 49 | $storage->get($key); 50 | return true; 51 | } catch (ObjectStorageException $e) {} 52 | 53 | return false; 54 | } 55 | 56 | protected function readFile($filename) 57 | { 58 | $handle = fopen($filename, 'rb'); 59 | $contents = fread($handle, filesize($filename)); 60 | fclose($handle); 61 | return $contents; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | Sqlite: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Kanboard repo 12 | uses: actions/checkout@v2 13 | with: 14 | repository: kanboard/kanboard 15 | - name: Checkout Plugin repo 16 | uses: actions/checkout@v2 17 | with: 18 | path: plugins/DatabaseStorage 19 | - name: Install dependencies 20 | run: composer install --prefer-dist --no-progress --no-suggest 21 | - name: Unit tests with Sqlite 22 | run: ./vendor/bin/phpunit -c tests/units.sqlite.xml plugins/DatabaseStorage/Test/ 23 | 24 | Postgres: 25 | runs-on: ubuntu-latest 26 | services: 27 | postgres: 28 | image: postgres:9.4 29 | env: 30 | POSTGRES_USER: postgres 31 | POSTGRES_PASSWORD: postgres 32 | POSTGRES_DB: kanboard_unit_test 33 | ports: 34 | - 5432:5432 35 | options: >- 36 | --health-cmd pg_isready 37 | --health-interval 10s 38 | --health-timeout 5s 39 | --health-retries 5 40 | steps: 41 | - name: Checkout Kanboard repo 42 | uses: actions/checkout@v2 43 | with: 44 | repository: kanboard/kanboard 45 | - name: Checkout Plugin repo 46 | uses: actions/checkout@v2 47 | with: 48 | path: plugins/DatabaseStorage 49 | - name: Install dependencies 50 | run: composer install --prefer-dist --no-progress --no-suggest 51 | - name: Unit tests with Postgres 52 | run: ./vendor/bin/phpunit -c tests/units.postgres.xml plugins/DatabaseStorage/Test/ 53 | env: 54 | DB_HOSTNAME: 127.0.0.1 55 | DB_PORT: ${{ job.services.postgres.ports[5432] }} 56 | 57 | MySQL: 58 | runs-on: ubuntu-latest 59 | services: 60 | mysql: 61 | image: mysql:5.7 62 | env: 63 | MYSQL_ROOT_PASSWORD: "kanboard" 64 | MYSQL_DATABASE: "kanboard_unit_test" 65 | MYSQL_USER: "kanboard" 66 | MYSQL_PASSWORD: "kanboard" 67 | ports: 68 | - 3306:3306 69 | options: >- 70 | --health-cmd="mysqladmin ping" 71 | --health-interval 10s 72 | --health-timeout 5s 73 | --health-retries 10 74 | steps: 75 | - name: Checkout Kanboard repo 76 | uses: actions/checkout@v2 77 | with: 78 | repository: kanboard/kanboard 79 | - name: Checkout Plugin repo 80 | uses: actions/checkout@v2 81 | with: 82 | path: plugins/DatabaseStorage 83 | - name: Install dependencies 84 | run: composer install --prefer-dist --no-progress --no-suggest 85 | - name: Unit tests with MariaDB 86 | run: ./vendor/bin/phpunit -c tests/units.mysql.xml plugins/DatabaseStorage/Test/ 87 | env: 88 | DB_HOSTNAME: 127.0.0.1 89 | DB_USERNAME: kanboard 90 | DB_PASSWORD: kanboard 91 | DB_NAME: kanboard_unit_test 92 | DB_PORT: ${{ job.services.mysql.ports[3306] }} 93 | -------------------------------------------------------------------------------- /DatabaseObjectStorage.php: -------------------------------------------------------------------------------- 1 | db = $db; 33 | } 34 | 35 | /** 36 | * Fetch object contents 37 | * 38 | * @access public 39 | * @throws ObjectStorageException 40 | * @param string $key 41 | * @return string 42 | */ 43 | public function get($key) 44 | { 45 | $contents = $this->db 46 | ->largeObject(self::TABLE)->eq('object_key', $key) 47 | ->findOneColumnAsString('object_data'); 48 | 49 | if ($contents === false) { 50 | throw new ObjectStorageException('Object not found: '.$key); 51 | } 52 | 53 | return $contents; 54 | } 55 | 56 | /** 57 | * Save object 58 | * 59 | * @access public 60 | * @throws ObjectStorageException 61 | * @param string $key 62 | * @param string $blob 63 | */ 64 | public function put($key, &$blob) 65 | { 66 | $result = $this->db->largeObject(self::TABLE) 67 | ->insertFromString('object_data', $blob, array('object_key' => $key)); 68 | 69 | if ($result === false) { 70 | throw new ObjectStorageException('Unable to save object: '.$key); 71 | } 72 | } 73 | 74 | /** 75 | * Output directly object content 76 | * 77 | * @access public 78 | * @param string $key 79 | */ 80 | public function output($key) 81 | { 82 | $fd = $this->db->largeObject(self::TABLE)->eq('object_key', $key)->findOneColumnAsStream('object_data'); 83 | 84 | if (is_string($fd)) { 85 | echo $fd; 86 | } else { 87 | fpassthru($fd); 88 | } 89 | } 90 | 91 | /** 92 | * Move local file to object storage 93 | * 94 | * @access public 95 | * @throws ObjectStorageException 96 | * @param string $src_filename 97 | * @param string $key 98 | * @return boolean 99 | */ 100 | public function moveFile($src_filename, $key) 101 | { 102 | $result = $this->db->largeObject(self::TABLE) 103 | ->insertFromFile('object_data', $src_filename, array('object_key' => $key)); 104 | 105 | if ($result === false) { 106 | throw new ObjectStorageException('Unable to move this file: '.$src_filename); 107 | } 108 | 109 | return true; 110 | } 111 | 112 | /** 113 | * Move uploaded file to object storage 114 | * 115 | * @access public 116 | * @param string $filename 117 | * @param string $key 118 | * @return boolean 119 | */ 120 | public function moveUploadedFile($filename, $key) 121 | { 122 | return $this->moveFile($filename, $key); 123 | } 124 | 125 | /** 126 | * Remove object 127 | * 128 | * @access public 129 | * @param string $key 130 | * @return boolean 131 | */ 132 | public function remove($key) 133 | { 134 | return $this->db->table(self::TABLE)->eq('object_key', $key)->remove(); 135 | } 136 | } 137 | --------------------------------------------------------------------------------