kopia lustrzana https://github.com/nextcloud/social
Add media api
See https://docs.joinmastodon.org/methods/statuses/media/ Signed-off-by: Carl Schwan <carl@carlschwan.eu>pull/1439/head
rodzic
e821566eca
commit
9f49b14657
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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}")`,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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) => {
|
||||
|
|
Ładowanie…
Reference in New Issue