10 changed files with 517 additions and 107 deletions
@ -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 @@ |
|||||||
|
.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 @@ |
|||||||
|
<?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 @@ |
|||||||
<?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