summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorCarl Schwan <carl@carlschwan.eu>2022-08-09 15:38:26 +0200
committerCarl Schwan <carl@carlschwan.eu>2022-08-09 15:38:26 +0200
commit9f49b1465765b7d9ddc72a5240bba4b9c5187c11 (patch)
treef0309bcc18fe127e37a41f8828d408781d7d08fe
parente821566ecaba11b8bfe07ba54efa05d2b96b8a67 (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.php4
-rw-r--r--lib/Controller/MediaApiController.php112
-rw-r--r--lib/Entity/MediaAttachment.php17
-rw-r--r--src/components/Composer/Composer.vue7
-rw-r--r--src/components/Composer/PreviewGrid.vue14
-rw-r--r--src/components/Composer/PreviewGridItem.vue19
-rw-r--r--src/store/timeline.js43
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) => {