diff options
author | Carl Schwan <carl@carlschwan.eu> | 2022-08-09 15:38:26 +0200 |
---|---|---|
committer | Carl Schwan <carl@carlschwan.eu> | 2022-08-09 15:38:26 +0200 |
commit | 9f49b1465765b7d9ddc72a5240bba4b9c5187c11 (patch) | |
tree | f0309bcc18fe127e37a41f8828d408781d7d08fe | |
parent | e821566ecaba11b8bfe07ba54efa05d2b96b8a67 (diff) |
Add media api
See https://docs.joinmastodon.org/methods/statuses/media/
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
-rw-r--r-- | appinfo/routes.php | 4 | ||||
-rw-r--r-- | lib/Controller/MediaApiController.php | 112 | ||||
-rw-r--r-- | lib/Entity/MediaAttachment.php | 17 | ||||
-rw-r--r-- | src/components/Composer/Composer.vue | 7 | ||||
-rw-r--r-- | src/components/Composer/PreviewGrid.vue | 14 | ||||
-rw-r--r-- | src/components/Composer/PreviewGridItem.vue | 19 | ||||
-rw-r--r-- | src/store/timeline.js | 43 |
7 files changed, 184 insertions, 32 deletions
diff --git a/appinfo/routes.php b/appinfo/routes.php index dd1655e8..b845fa82 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -81,6 +81,10 @@ return [ ['name' => 'Api#savedSearches', 'url' => '/api/saved_searches/list.json', 'verb' => 'GET'], ['name' => 'Api#timelines', 'url' => '/api/v1/timelines/{timeline}/', 'verb' => 'GET'], ['name' => 'Api#notifications', 'url' => '/api/v1/notifications', 'verb' => 'GET'], + ['name' => 'MediaApi#uploadMedia', 'url' => '/api/v1/media', 'verb' => 'POST'], + ['name' => 'MediaApi#updateMedia', 'url' => '/api/v1/media/{id}', 'verb' => 'PUT'], + ['name' => 'MediaApi#deleteMedia', 'url' => '/api/v1/media/{id}', 'verb' => 'DELETE'], + ['name' => 'MediaApi#getMedia', 'url' => '/media/{shortcode}.{extension}', 'verb' => 'GET'], // Api for local front-end // TODO: front-end should be using the new ApiController diff --git a/lib/Controller/MediaApiController.php b/lib/Controller/MediaApiController.php index 4598a505..962918a0 100644 --- a/lib/Controller/MediaApiController.php +++ b/lib/Controller/MediaApiController.php @@ -10,19 +10,22 @@ namespace OCA\Social\Controller; use OCA\Social\Entity\MediaAttachment; use OCA\Social\Service\AccountFinder; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\DataDownloadResponse; +use OCP\AppFramework\Http\NotFoundResponse; use OCP\DB\ORM\IEntityManager; use OCP\Files\IAppData; use OCP\Files\NotFoundException; use OCP\IL10N; use OCP\AppFramework\Controller; -use OCP\AppFramework\Http\DataResponse; use OCP\Files\IMimeTypeDetector; use OCP\Image; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserSession; use OCP\Util; +use Psr\Log\LoggerInterface; class MediaApiController extends Controller { @@ -34,6 +37,18 @@ class MediaApiController extends Controller { private IEntityManager $entityManager; private IURLGenerator $generator; + public const IMAGE_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/x-xbitmap', + 'image/x-ms-bmp', + 'image/bmp', + 'image/svg+xml', + 'image/webp', + ]; + public function __construct( string $appName, IRequest $request, @@ -43,7 +58,8 @@ class MediaApiController extends Controller { IUserSession $userSession, AccountFinder $accountFinder, IEntityManager $entityManager, - IURLGenerator $generator + IURLGenerator $generator, + LoggerInterface $logger ) { parent::__construct($appName, $request); $this->l10n = $l10n; @@ -53,6 +69,7 @@ class MediaApiController extends Controller { $this->accountFinder = $accountFinder; $this->entityManager = $entityManager; $this->generator = $generator; + $this->logger = $logger; } /** @@ -60,7 +77,7 @@ class MediaApiController extends Controller { * * @NoAdminRequired */ - public function uploadMedia(string $description, string $focus = ''): DataResponse { + public function uploadMedia(?string $description, ?string $focus = ''): DataResponse { try { $file = $this->getUploadedFile('file'); if (!isset($file['tmp_name'], $file['name'], $file['type'])) { @@ -90,10 +107,10 @@ class MediaApiController extends Controller { "aspect" => $image->width() / $image->height(), ]; - $attachment = new MediaAttachment(); + $attachment = MediaAttachment::create(); $attachment->setMimetype($file['type']); $attachment->setAccount($account); - $attachment->setDescription($description); + $attachment->setDescription($description ?? ''); $attachment->setMeta($meta); $this->entityManager->persist($attachment); $this->entityManager->flush(); @@ -103,10 +120,39 @@ class MediaApiController extends Controller { } catch (NotFoundException $e) { $folder = $this->appData->newFolder('media-attachments'); } + assert($attachment->getId() !== ''); $folder->newFile($attachment->getId(), $image->data()); return new DataResponse($attachment->toMastodonApi($this->generator)); } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new DataResponse([ + "error" => "Validation failed: File content type is invalid, File is invalid", + ], 500); + } + } + + /** + * @NoAdminRequired + */ + public function updateMedia(string $id, ?string $description, ?string $focus = ''): Response { + try { + $account = $this->accountFinder->getCurrentAccount($this->userSession->getUser()); + $attachementRepository = $this->entityManager->getRepository(MediaAttachment::class); + $attachement = $attachementRepository->findOneBy([ + 'id' => $id, + ]); + if ($attachement->getAccount()->getId() !== $account->getId()) { + throw new NotFoundResponse(); + } + + $attachement->setDescription($description ?? ''); + $this->entityManager->persist($attachement); + $this->entityManager->flush(); + + return new DataResponse($attachement->toMastodonApi($this->generator)); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); return new DataResponse([ "error" => "Validation failed: File content type is invalid, File is invalid", ], 500); @@ -151,4 +197,60 @@ class MediaApiController extends Controller { } return $file; } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function getMedia(string $shortcode, string $extension): DataDownloadResponse { + try { + $folder = $this->appData->getFolder('media-attachments'); + } catch (NotFoundException $e) { + $folder = $this->appData->newFolder('media-attachments'); + } + $attachementRepository = $this->entityManager->getRepository(MediaAttachment::class); + $attachement = $attachementRepository->findOneBy([ + 'shortcode' => $shortcode, + ]); + $file = $folder->getFile($attachement->getId()); + return new DataDownloadResponse( + $file->getContent(), + (string) Http::STATUS_OK, + $this->getSecureMimeType($file->getMimeType()) + ); + } + + /** + * @NoAdminRequired + */ + public function deleteMedia(string $id): DataResponse { + try { + $folder = $this->appData->getFolder('media-attachments'); + } catch (NotFoundException $e) { + $folder = $this->appData->newFolder('media-attachments'); + } + $attachementRepository = $this->entityManager->getRepository(MediaAttachment::class); + $attachement = $attachementRepository->findOneBy([ + 'id' => $id, + ]); + $file = $folder->getFile($attachement->getId()); + $file->delete(); + $this->entityManager->remove($attachement); + $this->entityManager->flush(); + return new DataResponse(['removed']); + } + + /** + * Allow all supported mimetypes + * Use mimetype detector for the other ones + * + * @param string $mimetype + * @return string + */ + private function getSecureMimeType(string $mimetype): string { + if (in_array($mimetype, self::IMAGE_MIME_TYPES)) { + return $mimetype; + } + return $this->mimeTypeDetector->getSecureMimeType($mimetype); + } } diff --git a/lib/Entity/MediaAttachment.php b/lib/Entity/MediaAttachment.php index c56eea20..cdb67ede 100644 --- a/lib/Entity/MediaAttachment.php +++ b/lib/Entity/MediaAttachment.php @@ -46,7 +46,7 @@ class MediaAttachment { * @ORM\Column(type="bigint") * @ORM\GeneratedValue */ - private string $id = '-1'; + private ?string $id = '-1'; /** * @ORM\ManyToOne @@ -81,7 +81,7 @@ class MediaAttachment { /** * @ORM\Column(type="text") */ - private ?string $description = null; + private string $description = ''; /** * @ORM\Column @@ -101,18 +101,27 @@ class MediaAttachment { /** * @ORM\Column */ - private ?string $blurhash = null; + private string $blurhash = ''; public function __construct() { $this->updatedAt = new \DateTime(); $this->createdAt = new \DateTime(); + $this->meta = []; + } + + static public function create(): self { + $attachement = new MediaAttachment(); + $length = 14; + $length = ($length < 4) ? 4 : $length; + $attachement->setShortcode(bin2hex(random_bytes(($length - ($length % 2)) / 2))); + return $attachement; } public function getId(): string { return $this->id; } - public function setId(string $id): void { + public function setId(?string $id): void { $this->id = $id; } diff --git a/src/components/Composer/Composer.vue b/src/components/Composer/Composer.vue index a8887ecb..543c95d9 100644 --- a/src/components/Composer/Composer.vue +++ b/src/components/Composer/Composer.vue @@ -381,13 +381,6 @@ export default { const formData = new FormData() formData.append('file', event.target.files[0]) this.$store.dispatch('uploadAttachement', formData) - - const previewUrl = URL.createObjectURL(event.target.files[0]) - this.previewUrls.push({ - description: '', - url: previewUrl, - result: event.target.files[0], - }) }, removeAttachment(idx) { this.previewUrls.splice(idx, 1) diff --git a/src/components/Composer/PreviewGrid.vue b/src/components/Composer/PreviewGrid.vue index 77e134c9..6b3f03a2 100644 --- a/src/components/Composer/PreviewGrid.vue +++ b/src/components/Composer/PreviewGrid.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later </div> </div> <div class="preview-grid"> - <PreviewGridItem v-for="(item, index) in miniatures" :key="index" :preview="item" :index="index" @delete="deletePreview" /> + <PreviewGridItem v-for="(item, index) in draft.attachements" :key="index" :preview="item" :index="index" /> </div> </div> </template> @@ -27,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later <script> import PreviewGridItem from './PreviewGridItem' import FileUpload from 'vue-material-design-icons/FileUpload' +import { mapState } from 'vuex' export default { name: 'PreviewGrid', @@ -34,6 +35,11 @@ export default { PreviewGridItem, FileUpload, }, + computed: { + ...mapState({ + 'draft': state => state.timeline.draft, + }), + }, props: { uploadProgress: { type: Number, @@ -48,12 +54,6 @@ export default { required: true, }, }, - methods: { - deletePreview(index) { - console.debug("rjeoijreo") - this.miniatures.splice(index, 1) - } - }, } </script> diff --git a/src/components/Composer/PreviewGridItem.vue b/src/components/Composer/PreviewGridItem.vue index 94233059..1e2d922a 100644 --- a/src/components/Composer/PreviewGridItem.vue +++ b/src/components/Composer/PreviewGridItem.vue @@ -2,7 +2,7 @@ <div class="preview-item-wrapper"> <div class="preview-item" :style="backgroundStyle"> <div class="preview-item__actions"> - <Button type="tertiary-no-background" @click="$emit('delete', index)"> + <Button type="tertiary-no-background" @click="deletePreview"> <template #icon> <Close :size="16" fillColor="white" /> </template> @@ -25,7 +25,7 @@ <label :for="`image-description-${index}`"> {{ t('social', 'Describe for the visually impaired') }} </label> - <textarea :id="`image-description-${index}`" v-model="preview.description"> + <textarea :id="`image-description-${index}`" v-model="internalDescription"> </textarea> <Button type="primary" @click="closeModal">{{ t('social', 'Close') }}</Button> </div> @@ -51,14 +51,27 @@ export default { data() { return { modal: false, + internalDescription: '', } }, + mounted() { + this.internalDescription = this.preview.description + }, methods: { + deletePreview() { + this.$store.dispatch('deleteAttachement', { + id: this.preview.id, + }) + }, showModal() { this.modal = true }, closeModal() { this.modal = false + this.$store.dispatch('updateAttachement', { + id: this.preview.id, + description: this.internalDescription, + }) } }, props: { @@ -74,7 +87,7 @@ export default { computed: { backgroundStyle() { return { - backgroundImage: `url("${this.preview.url}")`, + backgroundImage: `url("${this.preview.preview_url}")`, } }, }, diff --git a/src/store/timeline.js b/src/store/timeline.js index 7b96af7f..9f8c0d49 100644 --- a/src/store/timeline.js +++ b/src/store/timeline.js @@ -53,7 +53,9 @@ const state = { * @member {boolean} */ composerDisplayStatus: false, - draft: null, + draft: { + attachements: [] + }, } const mutations = { addToTimeline(state, data) { @@ -112,7 +114,22 @@ const mutations = { if (typeof parentAnnounce.id !== 'undefined') { Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object].object.action.values, 'boosted', false) } - } + }, + addAttachement(state, {id, description, url, preview_url}) { + state.draft.attachements.push({id, description, url, preview_url}) + }, + updateAttachement(state, {id, description, url, preview_url}) { + const index = state.draft.attachements.findIndex(item => { + return id === item.id + }) + state.draft.attachements.splice(index, 1, {id, description, url, preview_url}) + }, + deleteAttachement(state, {id}) { + const index = state.draft.attachements.findIndex(item => { + return id === item.id + }) + state.draft.attachements.splice(index, 1) + }, } const getters = { getComposerDisplayStatus(state) { @@ -146,7 +163,7 @@ const actions = { context.commit('setAccount', account) }, async uploadAttachement(context, formData) { - const res = await axios.post(generateUrl('apps/social/api/v1/media', formData, { + const res = await axios.post(generateUrl('apps/social/api/v1/media'), formData, { headers: { 'Content-Type': 'multipart/form-data' }, @@ -158,9 +175,23 @@ const actions = { preview_url: res.data.preview_url, }) }, - async uploadAttachement() { - - }, + async updateAttachement(context, {id, description}) { + const res = await axios.put(generateUrl('apps/social/api/v1/media/' + id), { + description, + }) + context.commit('updateAttachement', { + id: res.data.id, + description: res.data.description, + url: res.data.url, + preview_url: res.data.preview_url, + }) + }, + async deleteAttachement(context, {id}) { + const res = await axios.delete(generateUrl('apps/social/api/v1/media/' + id)) + context.commit('deleteAttachement', { + id: res.data.id, + }) + }, post(context, post) { return new Promise((resolve, reject) => { axios.post(generateUrl('apps/social/api/v1/post'), { data: post }).then((response) => { |