From fc658708728385220172384ad8a174f779518ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Thu, 28 Aug 2025 19:02:56 +0200 Subject: [PATCH] Image upload --- assets/app.js | 1 + assets/controllers/image_upload_controller.js | 118 ++++++++++++ assets/styles/form.css | 4 +- assets/styles/modal.css | 33 ++++ composer.json | 1 + composer.lock | 173 +++++++++++++++++- src/Controller/ArticleController.php | 49 ++--- src/Controller/ImageUploadController.php | 139 ++++++++++++++ src/Service/CacheService.php | 66 ------- templates/pages/editor.html.twig | 40 ++++ 10 files changed, 517 insertions(+), 107 deletions(-) create mode 100644 assets/controllers/image_upload_controller.js create mode 100644 assets/styles/modal.css create mode 100644 src/Controller/ImageUploadController.php delete mode 100644 src/Service/CacheService.php diff --git a/assets/app.js b/assets/app.js index 196585b..6645057 100644 --- a/assets/app.js +++ b/assets/app.js @@ -18,6 +18,7 @@ import './styles/notice.css'; import './styles/spinner.css'; import './styles/a2hs.css'; import './styles/analytics.css'; +import './styles/modal.css'; console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); diff --git a/assets/controllers/image_upload_controller.js b/assets/controllers/image_upload_controller.js new file mode 100644 index 0000000..f9d2e4f --- /dev/null +++ b/assets/controllers/image_upload_controller.js @@ -0,0 +1,118 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ["dialog", "dropArea", "fileInput", "progress", "error", "provider"]; + + // Unicode-safe base64 encoder + base64Encode(str) { + try { + return btoa(unescape(encodeURIComponent(str))); + } catch (_) { + return btoa(str); + } + } + + openDialog() { + this.dialogTarget.style.display = ''; + this.errorTarget.textContent = ''; + this.progressTarget.style.display = 'none'; + } + + closeDialog() { + this.dialogTarget.style.display = 'none'; + this.errorTarget.textContent = ''; + this.progressTarget.style.display = 'none'; + } + + connect() { + this.dropAreaTarget.addEventListener('click', () => this.fileInputTarget.click()); + this.fileInputTarget.addEventListener('change', (e) => this.handleFile(e.target.files[0])); + this.dropAreaTarget.addEventListener('dragover', (e) => { + e.preventDefault(); + this.dropAreaTarget.classList.add('dragover'); + }); + this.dropAreaTarget.addEventListener('dragleave', (e) => { + e.preventDefault(); + this.dropAreaTarget.classList.remove('dragover'); + }); + this.dropAreaTarget.addEventListener('drop', (e) => { + e.preventDefault(); + this.dropAreaTarget.classList.remove('dragover'); + if (e.dataTransfer.files.length > 0) { + this.handleFile(e.dataTransfer.files[0]); + } + }); + } + + async handleFile(file) { + if (!file) return; + this.errorTarget.textContent = ''; + this.progressTarget.style.display = ''; + this.progressTarget.textContent = 'Preparing upload...'; + try { + // NIP98: get signed HTTP Auth event from window.nostr + if (!window.nostr || !window.nostr.signEvent) { + this.errorTarget.textContent = 'Nostr extension not found.'; + return; + } + // Determine provider + const provider = this.providerTarget.value; + + // Map provider -> upstream endpoint used for signing the NIP-98 event + const upstreamMap = { + nostrbuild: 'https://nostr.build/nip96/upload', + nostrcheck: 'https://nostrcheck.me/api/v2/media', + sovbit: 'https://files.sovbit.host/api/v2/media', + }; + const upstreamEndpoint = upstreamMap[provider] || upstreamMap['nostrcheck']; + + // Backend proxy endpoint to avoid third-party CORS + const proxyEndpoint = `/api/image-upload/${provider}`; + + const event = { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["u", upstreamEndpoint], + ["method", "POST"] + ], + content: "" + }; + const signed = await window.nostr.signEvent(event); + const signedJson = JSON.stringify(signed); + const authHeader = 'Nostr ' + this.base64Encode(signedJson); + // Prepare form data + const formData = new FormData(); + formData.append('uploadtype', 'media'); + formData.append('file', file); + this.progressTarget.textContent = 'Uploading...'; + // Upload to backend proxy + const response = await fetch(proxyEndpoint, { + method: 'POST', + headers: { + 'Authorization': authHeader + }, + body: formData + }); + const result = await response.json().catch(() => ({})); + if (!response.ok || result.status !== 'success' || !result.url) { + this.errorTarget.textContent = result.message || `Upload failed (HTTP ${response.status})`; + return; + } + this.setImageField(result.url); + this.progressTarget.textContent = 'Upload successful!'; + setTimeout(() => this.closeDialog(), 1000); + } catch (e) { + this.errorTarget.textContent = 'Upload error: ' + (e.message || e); + } + } + + setImageField(url) { + // Find the image input in the form and set its value + const imageInput = document.querySelector('input[name$="[image]"]'); + if (imageInput) { + imageInput.value = url; + imageInput.dispatchEvent(new Event('input', { bubbles: true })); + } + } +} diff --git a/assets/styles/form.css b/assets/styles/form.css index c4dde5b..0f38c60 100644 --- a/assets/styles/form.css +++ b/assets/styles/form.css @@ -15,7 +15,7 @@ input { clear: both; } -input, textarea { +input, textarea, select { background-color: var(--color-bg); color: var(--color-text); border: 2px solid var(--color-border); @@ -23,7 +23,7 @@ input, textarea { border-radius: 0; /* Sharp edges */ } -input:focus, textarea:focus { +input:focus, textarea:focus, select:focus { border-color: var(--color-primary); outline: none; } diff --git a/assets/styles/modal.css b/assets/styles/modal.css new file mode 100644 index 0000000..758f5e2 --- /dev/null +++ b/assets/styles/modal.css @@ -0,0 +1,33 @@ +.iu-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; +} +.iu-modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: #fff; + width: min(600px, 90vw); + max-height: 85vh; + overflow: auto; + padding: 1rem 1.25rem; + z-index: 1000; +} +.iu-modal .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: .5rem; + margin-bottom: .5rem; +} +.iu-modal .close { + appearance: none; + border: 0; + background: transparent; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; +} diff --git a/composer.json b/composer.json index 60fbb0f..e9aa294 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "symfony/http-foundation": "7.1.*", "symfony/intl": "7.1.*", "symfony/mercure-bundle": "^0.3.9", + "symfony/mime": "7.1.*", "symfony/property-access": "7.1.*", "symfony/property-info": "7.1.*", "symfony/runtime": "7.1.*", diff --git a/composer.lock b/composer.lock index 988eb43..3b0f124 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b86dce5335224284e826d5e102c0bc65", + "content-hash": "8172177fac811888db46cc398b805356", "packages": [ { "name": "bacon/bacon-qr-code", @@ -7218,6 +7218,90 @@ ], "time": "2024-05-31T09:07:18+00:00" }, + { + "name": "symfony/mime", + "version": "v7.1.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "c252e20d1179dd35a5bfdb4a61a2084387ce97f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/c252e20d1179dd35a5bfdb4a61a2084387ce97f4", + "reference": "c252e20d1179dd35a5bfdb4a61a2084387ce97f4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.1.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-27T10:57:12+00:00" + }, { "name": "symfony/options-resolver", "version": "v7.1.9", @@ -7519,6 +7603,93 @@ ], "time": "2024-12-21T18:38:29+00:00" }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, { "name": "symfony/polyfill-intl-normalizer", "version": "v1.32.0", diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index ac2825f..baca93a 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -122,59 +122,33 @@ class ArticleController extends AbstractController /** * Create new article - * @throws InvalidArgumentException * @throws \Exception */ #[Route('/article-editor/create', name: 'editor-create')] #[Route('/article-editor/edit/{id}', name: 'editor-edit')] - public function newArticle(Request $request, EntityManagerInterface $entityManager, CacheItemPoolInterface $articlesCache, - WorkflowInterface $articlePublishingWorkflow, Article $article = null): Response + public function newArticle(Request $request, EntityManagerInterface $entityManager, $id = null): Response { - if (!$article) { + if (!$id) { $article = new Article(); $article->setKind(KindsEnum::LONGFORM); $article->setCreatedAt(new \DateTimeImmutable()); $formAction = $this->generateUrl('editor-create'); } else { - $formAction = $this->generateUrl('editor-edit', ['id' => $article->getId()]); + $formAction = $this->generateUrl('editor-edit', ['id' => $id]); + $repository = $entityManager->getRepository(Article::class); + $article = $repository->find($id); } - $form = $this->createForm(EditorType::class, $article, ['action' => $formAction]); - $form->handleRequest($request); - - // Step 3: Check if the form is submitted and valid - if ($form->isSubmitted() && $form->isValid()) { + if ($article->getPubkey() === null) { $user = $this->getUser(); $key = new Key(); $currentPubkey = $key->convertToHex($user->getUserIdentifier()); - - if ($article->getPubkey() === null) { - $article->setPubkey($currentPubkey); - } - - // Check which button was clicked - if ($form->getClickedButton() === $form->get('actions')->get('submit')) { - // Save button was clicked, handle the "Publish" action - $this->addFlash('success', 'Product published!'); - } elseif ($form->getClickedButton() === $form->get('actions')->get('draft')) { - // Save and Publish button was clicked, handle the "Draft" action - $this->addFlash('success', 'Product saved as draft!'); - } elseif ($form->getClickedButton() === $form->get('actions')->get('preview')) { - // Preview button was clicked, handle the "Preview" action - // construct slug from title and save to tags - $slugger = new AsciiSlugger(); - $slug = $slugger->slug($article->getTitle())->lower(); - $article->setSig(''); // clear the sig - $article->setSlug($slug); - $cacheKey = 'article_' . $currentPubkey . '_' . $article->getSlug(); - $cacheItem = $articlesCache->getItem($cacheKey); - $cacheItem->set($article); - $articlesCache->save($cacheItem); - - return $this->redirectToRoute('article-preview', ['d' => $article->getSlug()]); - } + $article->setPubkey($currentPubkey); } + $form = $this->createForm(EditorType::class, $article, ['action' => $formAction]); + $form->handleRequest($request); + // load template with content editor return $this->render('pages/editor.html.twig', [ 'article' => $article, @@ -191,7 +165,6 @@ class ArticleController extends AbstractController Request $request, EntityManagerInterface $entityManager, NostrClient $nostrClient, - WorkflowInterface $articlePublishingWorkflow, CsrfTokenManagerInterface $csrfTokenManager ): JsonResponse { try { @@ -269,7 +242,7 @@ class ArticleController extends AbstractController if ($user && method_exists($user, 'getRelays') && $user->getRelays()) { foreach ($user->getRelays() as $relayArr) { if (isset($relayArr[1]) && isset($relayArr[2]) && $relayArr[2] === 'write') { - // $relays[] = $relayArr[1]; + $relays[] = $relayArr[1]; } } } diff --git a/src/Controller/ImageUploadController.php b/src/Controller/ImageUploadController.php new file mode 100644 index 0000000..c48955d --- /dev/null +++ b/src/Controller/ImageUploadController.php @@ -0,0 +1,139 @@ + 'https://nostr.build/nip96/upload', + 'nostrcheck' => 'https://nostrcheck.me/api/v2/media', + 'sovbit' => 'https://files.sovbit.host/api/v2/media', + ]; + + if (!isset($endpoints[$provider])) { + return new JsonResponse(['status' => 'error', 'message' => 'Unsupported provider'], 400); + } + + $authHeader = $request->headers->get('Authorization'); + if (!$authHeader || !str_starts_with($authHeader, 'Nostr ')) { + return new JsonResponse(['status' => 'error', 'message' => 'Missing or invalid Authorization header'], 401); + } + + $file = $request->files->get('file'); + if (!$file) { + return new JsonResponse(['status' => 'error', 'message' => 'Missing file'], 400); + } + + $endpoint = $endpoints[$provider]; + + try { + $boundary = bin2hex(random_bytes(16)); + + $fields = [ + 'uploadtype' => 'media', + ]; + + $body = ''; + foreach ($fields as $name => $value) { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n\r\n"; + $body .= $value . "\r\n"; + } + + $fileContent = file_get_contents($file->getPathname()); + if ($fileContent === false) { + return new JsonResponse(['status' => 'error', 'message' => 'Failed to read uploaded file'], 500); + } + + $filename = $file->getClientOriginalName() ?: ('upload_' . date('Ymd_His')); + $mimeType = $file->getMimeType() ?: 'application/octet-stream'; + + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n"; + $body .= "Content-Type: {$mimeType}\r\n\r\n"; + $body .= $fileContent . "\r\n"; + $body .= "--{$boundary}--\r\n"; + + $headers = [ + 'Authorization: ' . $authHeader, + 'Content-Type: multipart/form-data; boundary=' . $boundary, + ]; + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => $body, + 'ignore_errors' => true, + 'timeout' => 30, + ], + ]); + + $responseBody = @file_get_contents($endpoint, false, $context); + if ($responseBody === false) { + return new JsonResponse(['status' => 'error', 'message' => 'Upstream request failed'], 502); + } + + $statusCode = 200; + if (isset($http_response_header) && is_array($http_response_header)) { + foreach ($http_response_header as $hdr) { + if (preg_match('#^HTTP/\\S+\\s+(\\d{3})#', $hdr, $m)) { + $statusCode = (int) $m[1]; + break; + } + } + } + + $json = json_decode($responseBody, true); + if (!is_array($json)) { + return new JsonResponse(['status' => 'error', 'message' => 'Invalid JSON from provider', 'raw' => $responseBody], 502); + } + + $isSuccess = (($json['status'] ?? null) === 'success') || (($json['success'] ?? null) === true) || ($statusCode >= 200 && $statusCode < 300); + + $imageUrl = null; + // Common locations: data.url, url, result.url, nip94_event.tags -> ['url', '...'] + if (isset($json['data']['url']) && is_string($json['data']['url'])) { + $imageUrl = $json['data']['url']; + } elseif (isset($json['url']) && is_string($json['url'])) { + $imageUrl = $json['url']; + } elseif (isset($json['result']['url']) && is_string($json['result']['url'])) { + $imageUrl = $json['result']['url']; + } + + if (!$imageUrl) { + $tags = $json['nip94_event']['tags'] ?? null; + if (is_array($tags)) { + foreach ($tags as $tag) { + if (is_array($tag) && ($tag[0] ?? null) === 'url' && isset($tag[1])) { + $imageUrl = $tag[1]; + break; + } + } + } + } + + if ($isSuccess && $imageUrl) { + return new JsonResponse(['status' => 'success', 'url' => $imageUrl]); + } + + $message = $json['message'] ?? $json['error'] ?? $json['msg'] ?? 'Upload failed'; + return new JsonResponse(['status' => 'error', 'message' => $message, 'provider' => $provider, 'raw' => $json], $statusCode >= 400 ? $statusCode : 502); + } catch (\Throwable $e) { + return new JsonResponse([ + 'status' => 'error', + 'message' => 'Proxy error: ' . $e->getMessage(), + ], 500); + } + } +} + diff --git a/src/Service/CacheService.php b/src/Service/CacheService.php deleted file mode 100644 index 346bd7c..0000000 --- a/src/Service/CacheService.php +++ /dev/null @@ -1,66 +0,0 @@ -cache->get($cacheKey, function (ItemInterface $item) use ($npub, $cacheKey) { - $item->expiresAfter(3600); // 1 hour, adjust as needed - try { - $meta = $this->nostrClient->getNpubMetadata($npub); - $this->logger->info('Metadata:', ['meta' => json_encode($meta)]); - return json_decode($meta->content); - } catch (\Exception $e) { - $this->logger->error('Error getting user data.', ['exception' => $e]); - throw new MetadataRetrievalException('Failed to retrieve metadata', 0, $e); - } - }); - } catch (\Exception|InvalidArgumentException $e) { - $this->logger->error('Error getting user data.', ['exception' => $e]); - $content = new \stdClass(); - $content->name = substr($npub, 0, 8) . '…' . substr($npub, -4); - return $content; - } - } - - public function getRelays($npub) - { - $cacheKey = '3_' . $npub; - try { - return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) { - $item->expiresAfter(3600); // 1 hour - try { - return $this->nostrClient->getNpubRelays($npub); - } catch (\Exception $e) { - $this->logger->error('Error getting relays.', ['exception' => $e]); - return []; - } - }); - } catch (InvalidArgumentException $e) { - $this->logger->error('Error getting relay data.', ['exception' => $e]); - return []; - } - } -} diff --git a/templates/pages/editor.html.twig b/templates/pages/editor.html.twig index bbb3185..403da1a 100644 --- a/templates/pages/editor.html.twig +++ b/templates/pages/editor.html.twig @@ -27,6 +27,46 @@ {{ form_row(form.summary) }} {{ form_row(form.content) }} {{ form_row(form.image) }} + +
+ + +
+
+
+ + +
+
+
+ + {{ form_row(form.topics) }}