10 changed files with 517 additions and 107 deletions
@ -0,0 +1,118 @@
@@ -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 })); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -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; |
||||
} |
||||
@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
|
||||
<?php |
||||
|
||||
namespace App\Controller; |
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
|
||||
class ImageUploadController extends AbstractController |
||||
{ |
||||
#[Route('/api/image-upload/{provider}', name: 'api_image_upload', methods: ['POST'])] |
||||
public function proxyUpload(Request $request, string $provider): JsonResponse |
||||
{ |
||||
$provider = strtolower($provider); |
||||
$endpoints = [ |
||||
'nostrbuild' => '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); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -1,66 +0,0 @@
@@ -1,66 +0,0 @@
|
||||
<?php |
||||
|
||||
namespace App\Service; |
||||
|
||||
use Psr\Cache\InvalidArgumentException; |
||||
use Psr\Log\LoggerInterface; |
||||
use Symfony\Contracts\Cache\CacheInterface; |
||||
use Symfony\Contracts\Cache\ItemInterface; |
||||
|
||||
readonly class CacheService |
||||
{ |
||||
|
||||
public function __construct( |
||||
private NostrClient $nostrClient, |
||||
private CacheInterface $cache, |
||||
private LoggerInterface $logger |
||||
) |
||||
{ |
||||
} |
||||
|
||||
/** |
||||
* @param string $npub |
||||
* @return \stdClass |
||||
*/ |
||||
public function getMetadata(string $npub): \stdClass |
||||
{ |
||||
$cacheKey = '0_' . $npub; |
||||
try { |
||||
return $this->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 []; |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue