Skip to content

Commit

Permalink
new: Upload images to the server with TinyMCE
Browse files Browse the repository at this point in the history
  • Loading branch information
marien-probesys committed Aug 25, 2023
2 parents 4fdd67f + 633e8f8 commit 14f17fe
Show file tree
Hide file tree
Showing 22 changed files with 1,329 additions and 0 deletions.
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
LDAP_ENABLED=true
APP_UPLOADS_DIRECTORY=/tmp/bileto/uploads

KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
/public/bundles/
/public/dev_assets/*
/public/manifest.dev.json
/uploads/
/var/
/vendor/
/node_modules/
Expand Down
78 changes: 78 additions & 0 deletions assets/javascripts/controllers/tinymce_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
static get values () {
return {
uploadUrl: String,
uploadCsrf: String,
};
}

connect () {
const colorScheme = this.colorScheme;

Expand Down Expand Up @@ -34,6 +41,9 @@ export default class extends Controller {
link_assume_external_targets: true,
link_target_list: false,
auto_focus: autofocus,
images_upload_handler: this.imagesUploader.bind(this),
relative_urls: false,
remove_script_host: false,
};

window.tinymce.init(configuration);
Expand All @@ -55,4 +65,72 @@ export default class extends Controller {
}
return colorScheme;
}

imagesUploader (blobInfo, progress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', this.uploadUrlValue);

xhr.upload.onprogress = (e) => {
progress(e.loaded / e.total * 100);
};

xhr.onload = () => {
if (xhr.status === 401) {
// eslint-disable-next-line prefer-promise-reject-errors
reject({ message: 'You are not authorized to upload files.', remove: true });
return;
}

let json;
try {
json = JSON.parse(xhr.responseText);
} catch (e) {
console.error('Bad JSON from server: ' + xhr.responseText);

// eslint-disable-next-line prefer-promise-reject-errors
reject({ message: 'Bad response from the server.', remove: true });
return;
}

if (
json == null ||
(typeof json !== 'object') ||
(json.error == null && json.urlShow == null)
) {
console.error('Bad JSON from server: ' + xhr.responseText);

// eslint-disable-next-line prefer-promise-reject-errors
reject({ message: 'Bad response from the server.', remove: true });
return;
}

if (json.error) {
if (json.description) {
console.error('Unexpected error from server: ' + json.description);
}

// eslint-disable-next-line prefer-promise-reject-errors
reject({ message: json.error, remove: true });
return;
}

resolve(json.urlShow);
};

xhr.onerror = () => {
console.error('Unexpected error from server: error code ' + xhr.status);

// eslint-disable-next-line prefer-promise-reject-errors
reject({ message: 'Bad response from the server.', remove: true });
};

const formData = new FormData();
formData.append('document', blobInfo.blob(), blobInfo.filename());
formData.append('_csrf_token', this.uploadCsrfValue);

xhr.send(formData);
});
}
}
1 change: 1 addition & 0 deletions assets/stylesheets/components/images.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@

img {
max-width: 100%;
height: auto;
}
2 changes: 2 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
app.default_uploads_directory: '%kernel.project_dir%/uploads'
app.uploads_directory: '%env(default:app.default_uploads_directory:APP_UPLOADS_DIRECTORY)%'

services:
# default configuration for services in *this* file
Expand Down
1 change: 1 addition & 0 deletions docs/developers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Dedicated to the maintainers:

Architecture:

- [Document upload](/docs/developers/document-upload.md)
- [Email collector](/docs/developers/email-collector.md)
- [GreenMail](/docs/developers/greenmail.md)
- [Roles & permissions](/docs/developers/roles.md)
Expand Down
44 changes: 44 additions & 0 deletions docs/developers/document-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Document upload

Bileto allows users to upload documents.
At the moment, documents can only be uploaded with messages.
The decision was made to name all the objects (i.e. controller, service and entity) to reflect this fact.

## The `MessageDocuments` controller

The controller dedicated to the upload of documents is [`MessageDocumentsController`](/src/Controller/MessageDocumentsController.php).

It provides two endpoints:

- `create` allows to upload the document to the server;
- `show` allows to download the document from the server.

The user can upload a document if he's allowed to create messages in any organization.

The document can be downloaded if:

- the user has uploaded the file himself;
- or if he has access to the message related to the document.

## The `MessageDocumentStorage` service

The [`MessageDocumentStorage` service](/src/Service/MessageDocumentStorage.php) establishes the relationship between the file system (where the files are stored) and the database (to associate the file with a message).

It allows to:

- store a file in its correct location and its associated [`MessageDocument`](/src/Entity/MessageDocument.php) to be returned;
- get the size and read the file related to a given `MessageDocument`.

## Documents on the filesystem

Finally, the documents are stored somewhere on the file system.
The location is determined by the `APP_UPLOADS_DIRECTORY` environment variable (see [`env.sample`](/env.sample)).

Then, the sha256 hash of the file is used to get the file name (the extension is the one corresponding to the mime type of the file).
The file is placed in two levels of subdirectories.
The subdirectories are named after the first four characters of the file hash.

For example, considering the JPEG file hash `bd62115afd16249cff5bd9418b3c4fab3a9a254ebcf0e695cb3c14b92d7827f1`, the corresponding file will be saved as `$APP_UPLOADS_DIRECTORY/bd/62/bd62115afd16249cff5bd9418b3c4fab3a9a254ebcf0e695cb3c14b92d7827f1.jpg`.

Using the hash as the file name avoids duplicating the same file more than once.
Storing the files in subdirectories allows a large number of files to be stored while maintaining good performance.
4 changes: 4 additions & 0 deletions env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ APP_ENV=prod
# Generate a token with the command: php bin/console app:secret
APP_SECRET=change-me

# The path to the upload directory.
# Default is uploads/ in the root directory.
# APP_UPLOADS_DIRECTORY=/path/to/uploads

#################################
# Configuration of the database #
#################################
Expand Down
129 changes: 129 additions & 0 deletions migrations/Version20230817141440CreateMessageDocument.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

// This file is part of Bileto.
// Copyright 2022-2023 Probesys
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Platforms\MariaDBPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20230817141440CreateMessageDocument extends AbstractMigration
{
public function getDescription(): string
{
return 'Create the message_document table.';
}

public function up(Schema $schema): void
{
$dbPlatform = $this->connection->getDatabasePlatform();
if ($dbPlatform instanceof PostgreSQLPlatform) {
$this->addSql('CREATE SEQUENCE message_document_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql(<<<SQL
CREATE TABLE message_document (
id INT NOT NULL,
created_by_id INT NOT NULL,
updated_by_id INT DEFAULT NULL,
message_id INT DEFAULT NULL,
uid VARCHAR(20) NOT NULL,
created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITH TIME ZONE NOT NULL,
name VARCHAR(255) NOT NULL,
filename VARCHAR(255) NOT NULL,
mimetype VARCHAR(100) NOT NULL,
hash VARCHAR(255) NOT NULL,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX UNIQ_D14F4E67539B0606 ON message_document (uid)');
$this->addSql('CREATE INDEX IDX_D14F4E67B03A8386 ON message_document (created_by_id)');
$this->addSql('CREATE INDEX IDX_D14F4E67896DBBDE ON message_document (updated_by_id)');
$this->addSql('CREATE INDEX IDX_D14F4E67537A1329 ON message_document (message_id)');
$this->addSql('COMMENT ON COLUMN message_document.created_at IS \'(DC2Type:datetimetz_immutable)\'');
$this->addSql('COMMENT ON COLUMN message_document.updated_at IS \'(DC2Type:datetimetz_immutable)\'');
$this->addSql(<<<SQL
ALTER TABLE message_document
ADD CONSTRAINT FK_D14F4E67B03A8386
FOREIGN KEY (created_by_id)
REFERENCES "users" (id)
NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<SQL
ALTER TABLE message_document
ADD CONSTRAINT FK_D14F4E67896DBBDE
FOREIGN KEY (updated_by_id)
REFERENCES "users" (id)
NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<SQL
ALTER TABLE message_document
ADD CONSTRAINT FK_D14F4E67537A1329
FOREIGN KEY (message_id)
REFERENCES message (id)
NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
} elseif ($dbPlatform instanceof MariaDBPlatform) {
$this->addSql(<<<SQL
CREATE TABLE message_document (
id INT AUTO_INCREMENT NOT NULL,
created_by_id INT NOT NULL,
updated_by_id INT DEFAULT NULL,
message_id INT DEFAULT NULL,
uid VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetimetz_immutable)',
updated_at DATETIME NOT NULL COMMENT '(DC2Type:datetimetz_immutable)',
name VARCHAR(255) NOT NULL,
filename VARCHAR(255) NOT NULL,
mimetype VARCHAR(100) NOT NULL,
hash VARCHAR(255) NOT NULL,
UNIQUE INDEX UNIQ_D14F4E67539B0606 (uid),
INDEX IDX_D14F4E67B03A8386 (created_by_id),
INDEX IDX_D14F4E67896DBBDE (updated_by_id),
INDEX IDX_D14F4E67537A1329 (message_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<SQL
ALTER TABLE message_document
ADD CONSTRAINT FK_D14F4E67B03A8386
FOREIGN KEY (created_by_id)
REFERENCES `users` (id)
SQL);
$this->addSql(<<<SQL
ALTER TABLE message_document
ADD CONSTRAINT FK_D14F4E67896DBBDE
FOREIGN KEY (updated_by_id)
REFERENCES `users` (id)
SQL);
$this->addSql(<<<SQL
ALTER TABLE message_document
ADD CONSTRAINT FK_D14F4E67537A1329
FOREIGN KEY (message_id)
REFERENCES message (id)
SQL);
}
}

public function down(Schema $schema): void
{
$dbPlatform = $this->connection->getDatabasePlatform();
if ($dbPlatform instanceof PostgreSQLPlatform) {
$this->addSql('DROP SEQUENCE message_document_id_seq CASCADE');
$this->addSql('ALTER TABLE message_document DROP CONSTRAINT FK_D14F4E67B03A8386');
$this->addSql('ALTER TABLE message_document DROP CONSTRAINT FK_D14F4E67896DBBDE');
$this->addSql('ALTER TABLE message_document DROP CONSTRAINT FK_D14F4E67537A1329');
$this->addSql('DROP TABLE message_document');
} elseif ($dbPlatform instanceof MariaDBPlatform) {
$this->addSql('ALTER TABLE message_document DROP FOREIGN KEY FK_D14F4E67B03A8386');
$this->addSql('ALTER TABLE message_document DROP FOREIGN KEY FK_D14F4E67896DBBDE');
$this->addSql('ALTER TABLE message_document DROP FOREIGN KEY FK_D14F4E67537A1329');
$this->addSql('DROP TABLE message_document');
}
}
}
Loading

0 comments on commit 14f17fe

Please sign in to comment.