Add logo upload functionality for branded QR codes
Torey Heinz
committed Aug 24, 2025
commit a9e219f062b628ba73b123b63f0a579715a88f31
Showing 9
changed files with
818 additions
and 25 deletions
frontend/src/components/EditEntryModal.vue
+144
-0
| @@ | @@ -0,0 +1,144 @@ |
| + | <template> |
| + | <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" @click.self="$emit('close')"> |
| + | <div class="bg-white rounded-2xl shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"> |
| + | <div class="flex justify-between items-start mb-6"> |
| + | <h3 class="text-xl font-semibold text-gray-900">Edit Link</h3> |
| + | <button |
| + | @click="$emit('close')" |
| + | class="text-gray-400 hover:text-gray-600 transition" |
| + | > |
| + | <XIcon class="h-6 w-6" /> |
| + | </button> |
| + | </div> |
| + | |
| + | <form @submit.prevent="handleSubmit" class="space-y-4"> |
| + | <div class="grid md:grid-cols-2 gap-4"> |
| + | <div> |
| + | <label class="block text-sm font-medium text-gray-700 mb-1"> |
| + | Name |
| + | </label> |
| + | <input |
| + | v-model="formData.name" |
| + | type="text" |
| + | required |
| + | class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" |
| + | placeholder="My awesome link" |
| + | /> |
| + | </div> |
| + | |
| + | <div> |
| + | <label class="block text-sm font-medium text-gray-700 mb-1"> |
| + | Slug (cannot be changed) |
| + | </label> |
| + | <input |
| + | :value="entry.slug" |
| + | type="text" |
| + | disabled |
| + | class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500" |
| + | /> |
| + | </div> |
| + | </div> |
| + | |
| + | <div> |
| + | <label class="block text-sm font-medium text-gray-700 mb-1"> |
| + | Destination URL |
| + | </label> |
| + | <input |
| + | v-model="formData.url" |
| + | type="url" |
| + | required |
| + | class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" |
| + | placeholder="https://example.com" |
| + | /> |
| + | </div> |
| + | |
| + | <!-- Logo Management --> |
| + | <div class="border-t pt-4"> |
| + | <h4 class="text-sm font-medium text-gray-700 mb-3">Logo for QR Code</h4> |
| + | <LogoUploader |
| + | :entry="entry" |
| + | @update="handleLogoUpdate" |
| + | /> |
| + | </div> |
| + | |
| + | <div class="flex justify-end space-x-3 pt-4 border-t"> |
| + | <button |
| + | type="button" |
| + | @click="$emit('close')" |
| + | class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition" |
| + | > |
| + | Cancel |
| + | </button> |
| + | <button |
| + | type="submit" |
| + | :disabled="saving" |
| + | class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition disabled:opacity-50" |
| + | > |
| + | {{ saving ? 'Saving...' : 'Save Changes' }} |
| + | </button> |
| + | </div> |
| + | </form> |
| + | </div> |
| + | </div> |
| + | </template> |
| + | |
| + | <script setup> |
| + | import { ref, reactive, onMounted } from 'vue' |
| + | import { X as XIcon } from 'lucide-vue-next' |
| + | import { useEntriesStore } from '../stores/entries' |
| + | import LogoUploader from './LogoUploader.vue' |
| + | |
| + | const props = defineProps({ |
| + | entry: { |
| + | type: Object, |
| + | required: true |
| + | } |
| + | }) |
| + | |
| + | const emit = defineEmits(['close', 'update']) |
| + | |
| + | const entriesStore = useEntriesStore() |
| + | const saving = ref(false) |
| + | |
| + | const formData = reactive({ |
| + | name: '', |
| + | url: '' |
| + | }) |
| + | |
| + | onMounted(() => { |
| + | // Initialize form with current values |
| + | formData.name = props.entry.name |
| + | formData.url = props.entry.original_url || props.entry.url |
| + | }) |
| + | |
| + | async function handleSubmit() { |
| + | saving.value = true |
| + | try { |
| + | await entriesStore.updateEntry(props.entry.id, { |
| + | name: formData.name, |
| + | url: formData.url |
| + | }) |
| + | |
| + | // Update local entry |
| + | const updatedEntry = { |
| + | ...props.entry, |
| + | name: formData.name, |
| + | original_url: formData.url |
| + | } |
| + | |
| + | emit('update', updatedEntry) |
| + | emit('close') |
| + | |
| + | alert('Link updated successfully!') |
| + | } catch (error) { |
| + | alert(error.response?.data?.error || 'Failed to update link') |
| + | } finally { |
| + | saving.value = false |
| + | } |
| + | } |
| + | |
| + | function handleLogoUpdate(updatedEntry) { |
| + | // Update the entry with new logo information |
| + | emit('update', updatedEntry) |
| + | } |
| + | </script> |
| \ No newline at end of file | |
frontend/src/components/LogoUploader.vue
+182
-0
| @@ | @@ -0,0 +1,182 @@ |
| + | <template> |
| + | <div class="space-y-4"> |
| + | <div v-if="entry.logo_url" class="space-y-3"> |
| + | <div class="flex items-center space-x-4"> |
| + | <img |
| + | :src="getLogoUrl(entry.logo_url)" |
| + | alt="Logo" |
| + | class="w-20 h-20 object-contain border rounded-lg p-2" |
| + | /> |
| + | <div class="flex-1"> |
| + | <p class="text-sm text-gray-600 mb-2">Current logo</p> |
| + | <button |
| + | @click="deleteLogo" |
| + | :disabled="deleting" |
| + | class="text-red-600 hover:text-red-700 text-sm font-medium" |
| + | > |
| + | {{ deleting ? 'Deleting...' : 'Remove Logo' }} |
| + | </button> |
| + | </div> |
| + | </div> |
| + | </div> |
| + | |
| + | <div> |
| + | <label class="block text-sm font-medium text-gray-700 mb-1"> |
| + | {{ entry.logo_url ? 'Replace Logo' : 'Add Logo' }} |
| + | </label> |
| + | <input |
| + | type="file" |
| + | ref="fileInput" |
| + | accept="image/png,image/jpeg,image/svg+xml,image/webp" |
| + | @change="handleFileSelect" |
| + | class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" |
| + | /> |
| + | <p class="text-xs text-gray-500 mt-1"> |
| + | PNG, JPG, SVG, or WebP. Max 5MB. |
| + | </p> |
| + | </div> |
| + | |
| + | <div v-if="selectedFile" class="flex justify-end space-x-3"> |
| + | <button |
| + | @click="cancelUpload" |
| + | class="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition" |
| + | > |
| + | Cancel |
| + | </button> |
| + | <button |
| + | @click="uploadLogo" |
| + | :disabled="uploading" |
| + | class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition disabled:opacity-50" |
| + | > |
| + | {{ uploading ? 'Uploading...' : 'Upload Logo' }} |
| + | </button> |
| + | </div> |
| + | </div> |
| + | </template> |
| + | |
| + | <script setup> |
| + | import { ref } from 'vue' |
| + | import { useAuthStore } from '../stores/auth' |
| + | |
| + | const props = defineProps({ |
| + | entry: { |
| + | type: Object, |
| + | required: true |
| + | } |
| + | }) |
| + | |
| + | const emit = defineEmits(['update']) |
| + | |
| + | const authStore = useAuthStore() |
| + | const fileInput = ref(null) |
| + | const selectedFile = ref(null) |
| + | const uploading = ref(false) |
| + | const deleting = ref(false) |
| + | |
| + | function getLogoUrl(logoUrl) { |
| + | // If it's a relative URL, prepend the backend URL |
| + | if (logoUrl?.startsWith('/')) { |
| + | return `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8787'}${logoUrl}` |
| + | } |
| + | return logoUrl |
| + | } |
| + | |
| + | function handleFileSelect(event) { |
| + | const file = event.target.files[0] |
| + | if (file) { |
| + | // Validate file |
| + | const maxSize = 5 * 1024 * 1024 // 5MB |
| + | if (file.size > maxSize) { |
| + | alert('File too large. Maximum size: 5MB') |
| + | event.target.value = '' |
| + | return |
| + | } |
| + | |
| + | const allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'] |
| + | if (!allowedTypes.includes(file.type)) { |
| + | alert('Invalid file type. Allowed: PNG, JPG, SVG, WebP') |
| + | event.target.value = '' |
| + | return |
| + | } |
| + | |
| + | selectedFile.value = file |
| + | } |
| + | } |
| + | |
| + | function cancelUpload() { |
| + | selectedFile.value = null |
| + | if (fileInput.value) { |
| + | fileInput.value.value = '' |
| + | } |
| + | } |
| + | |
| + | async function uploadLogo() { |
| + | if (!selectedFile.value) return |
| + | |
| + | uploading.value = true |
| + | const formData = new FormData() |
| + | formData.append('logo', selectedFile.value) |
| + | formData.append('entryId', props.entry.id) |
| + | |
| + | try { |
| + | const response = await fetch('/api/logo/upload', { |
| + | method: 'POST', |
| + | headers: { |
| + | 'Authorization': `Bearer ${authStore.token}` |
| + | }, |
| + | body: formData |
| + | }) |
| + | |
| + | if (!response.ok) { |
| + | const error = await response.json() |
| + | throw new Error(error.error || 'Failed to upload logo') |
| + | } |
| + | |
| + | const data = await response.json() |
| + | |
| + | // Emit update event with new logo URL |
| + | emit('update', { ...props.entry, logo_url: data.logoUrl }) |
| + | |
| + | // Reset |
| + | selectedFile.value = null |
| + | if (fileInput.value) { |
| + | fileInput.value.value = '' |
| + | } |
| + | |
| + | alert('Logo uploaded successfully!') |
| + | } catch (error) { |
| + | alert(error.message || 'Failed to upload logo') |
| + | } finally { |
| + | uploading.value = false |
| + | } |
| + | } |
| + | |
| + | async function deleteLogo() { |
| + | if (!confirm('Are you sure you want to remove the logo?')) return |
| + | |
| + | deleting.value = true |
| + | |
| + | try { |
| + | const response = await fetch(`/api/logo/delete/${props.entry.id}`, { |
| + | method: 'DELETE', |
| + | headers: { |
| + | 'Authorization': `Bearer ${authStore.token}` |
| + | } |
| + | }) |
| + | |
| + | if (!response.ok) { |
| + | const error = await response.json() |
| + | throw new Error(error.error || 'Failed to delete logo') |
| + | } |
| + | |
| + | // Emit update event with no logo |
| + | emit('update', { ...props.entry, logo_url: null }) |
| + | |
| + | alert('Logo removed successfully!') |
| + | } catch (error) { |
| + | alert(error.message || 'Failed to delete logo') |
| + | } finally { |
| + | deleting.value = false |
| + | } |
| + | } |
| + | </script> |
| \ No newline at end of file | |
frontend/src/components/QRCodeModal.vue
+11
-1
| @@ | @@ -73,7 +73,9 @@ const shortUrl = computed(() => { |
| onMounted(async () => { | |
| try { | |
| - | qrCodeUrl.value = await generateQRCodeWithLogo(shortUrl.value, props.entry.logo_url) |
| + | // Get full logo URL if logo exists |
| + | const logoUrl = props.entry.logo_url ? getFullLogoUrl(props.entry.logo_url) : null |
| + | qrCodeUrl.value = await generateQRCodeWithLogo(shortUrl.value, logoUrl) |
| } catch (error) { | |
| console.error('Failed to generate QR code:', error) | |
| // Try without logo as fallback | |
| @@ | @@ -88,6 +90,14 @@ onMounted(async () => { |
| } | |
| }) | |
| + | function getFullLogoUrl(logoUrl) { |
| + | // If it's a relative URL starting with /api/logo, prepend the backend URL |
| + | if (logoUrl?.startsWith('/api/logo')) { |
| + | return `${import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8787'}${logoUrl}` |
| + | } |
| + | return logoUrl |
| + | } |
| + | |
| function downloadQR() { | |
| const link = document.createElement('a') | |
| link.download = `qr-${props.entry.slug}.png` | |
frontend/src/services/qrcode.js
+41
-22
| @@ | @@ -25,6 +25,8 @@ export async function generateQRCode(text, options = {}) { |
| } | |
| export async function generateQRCodeWithLogo(text, logoUrl, options = {}) { | |
| + | console.log('Generating QR code with logo:', logoUrl) |
| + | |
| const canvas = document.createElement('canvas') | |
| const ctx = canvas.getContext('2d') | |
| @@ | @@ -46,31 +48,48 @@ export async function generateQRCodeWithLogo(text, logoUrl, options = {}) { |
| ctx.drawImage(qrImage, 0, 0, canvas.width, canvas.height) | |
| if (logoUrl) { | |
| - | // Add logo |
| - | const logo = new Image() |
| - | logo.crossOrigin = 'anonymous' |
| - | logo.src = logoUrl |
| - | |
| - | logo.onload = () => { |
| - | // Calculate logo size (max 30% of QR code) |
| - | const logoSize = Math.floor(canvas.width * 0.3) |
| - | const logoX = (canvas.width - logoSize) / 2 |
| - | const logoY = (canvas.height - logoSize) / 2 |
| - | |
| - | // Draw white background for logo |
| - | ctx.fillStyle = 'white' |
| - | ctx.fillRect(logoX - 10, logoY - 10, logoSize + 20, logoSize + 20) |
| + | // Fetch logo as blob to avoid CORS issues |
| + | fetch(logoUrl) |
| + | .then(response => { |
| + | if (!response.ok) throw new Error('Failed to fetch logo') |
| + | return response.blob() |
| + | }) |
| + | .then(blob => { |
| + | const logo = new Image() |
| + | const objectUrl = URL.createObjectURL(blob) |
| + | |
| + | logo.onload = () => { |
| + | // Calculate logo size (max 25% of QR code for better scanning) |
| + | const logoSize = Math.floor(canvas.width * 0.25) |
| + | const logoX = (canvas.width - logoSize) / 2 |
| + | const logoY = (canvas.height - logoSize) / 2 |
| - | // Draw logo |
| - | ctx.drawImage(logo, logoX, logoY, logoSize, logoSize) |
| + | // Draw white background with rounded corners |
| + | ctx.fillStyle = 'white' |
| + | const padding = 8 |
| + | ctx.fillRect(logoX - padding, logoY - padding, logoSize + padding * 2, logoSize + padding * 2) |
| - | resolve(canvas.toDataURL()) |
| - | } |
| + | // Draw logo |
| + | ctx.drawImage(logo, logoX, logoY, logoSize, logoSize) |
| + | |
| + | // Clean up |
| + | URL.revokeObjectURL(objectUrl) |
| + | |
| + | resolve(canvas.toDataURL()) |
| + | } |
| - | logo.onerror = () => { |
| - | // If logo fails to load, return QR without logo |
| - | resolve(qrDataUrl) |
| - | } |
| + | logo.onerror = () => { |
| + | console.error('Failed to load logo image') |
| + | URL.revokeObjectURL(objectUrl) |
| + | resolve(qrDataUrl) |
| + | } |
| + | |
| + | logo.src = objectUrl |
| + | }) |
| + | .catch(error => { |
| + | console.error('Failed to fetch logo:', error) |
| + | resolve(qrDataUrl) |
| + | }) |
| } else { | |
| resolve(qrDataUrl) | |
| } | |
frontend/src/views/Dashboard.vue
+122
-2
| @@ | @@ -70,6 +70,22 @@ |
| /> | |
| </div> | |
| + | <div> |
| + | <label class="block text-sm font-medium text-gray-700 mb-1"> |
| + | Logo for QR Code (optional) |
| + | </label> |
| + | <input |
| + | type="file" |
| + | ref="logoFileInput" |
| + | accept="image/png,image/jpeg,image/svg+xml,image/webp" |
| + | @change="handleLogoSelect" |
| + | class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" |
| + | /> |
| + | <p class="text-xs text-gray-500 mt-1"> |
| + | PNG, JPG, SVG, or WebP. Max 5MB. Will be embedded in QR code center. |
| + | </p> |
| + | </div> |
| + | |
| <div class="flex justify-end"> | |
| <button | |
| type="submit" | |
| @@ | @@ -142,6 +158,13 @@ |
| > | |
| <ChartBarIcon class="h-5 w-5" /> | |
| </router-link> | |
| + | <button |
| + | @click="editEntry(entry)" |
| + | class="p-2 text-gray-400 hover:text-gray-600 transition" |
| + | title="Edit" |
| + | > |
| + | <EditIcon class="h-5 w-5" /> |
| + | </button> |
| <button | |
| @click="deleteEntry(entry.id)" | |
| class="p-2 text-gray-400 hover:text-red-600 transition" | |
| @@ | @@ -162,6 +185,14 @@ |
| :entry="selectedEntry" | |
| @close="selectedEntry = null" | |
| /> | |
| + | |
| + | <!-- Edit Entry Modal --> |
| + | <EditEntryModal |
| + | v-if="editingEntry" |
| + | :entry="editingEntry" |
| + | @close="editingEntry = null" |
| + | @update="handleEntryUpdate" |
| + | /> |
| </div> | |
| </template> | |
| @@ | @@ -175,11 +206,13 @@ import { |
| Copy as CopyIcon, | |
| QrCode as QrCodeIcon, | |
| BarChart3 as ChartBarIcon, | |
| - | Trash2 as TrashIcon |
| + | Trash2 as TrashIcon, |
| + | Edit as EditIcon |
| } from 'lucide-vue-next' | |
| import { useAuthStore } from '../stores/auth' | |
| import { useEntriesStore } from '../stores/entries' | |
| import QRCodeModal from '../components/QRCodeModal.vue' | |
| + | import EditEntryModal from '../components/EditEntryModal.vue' |
| const router = useRouter() | |
| const authStore = useAuthStore() | |
| @@ | @@ -188,6 +221,9 @@ const entriesStore = useEntriesStore() |
| const loading = ref(true) | |
| const creating = ref(false) | |
| const selectedEntry = ref(null) | |
| + | const editingEntry = ref(null) |
| + | const logoFileInput = ref(null) |
| + | const selectedLogo = ref(null) |
| const newEntry = ref({ | |
| name: '', | |
| @@ | @@ -206,8 +242,20 @@ onMounted(async () => { |
| async function handleCreate() { | |
| creating.value = true | |
| try { | |
| - | await entriesStore.createEntry(newEntry.value) |
| + | // First create the entry |
| + | const entry = await entriesStore.createEntry(newEntry.value) |
| + | |
| + | // Then upload logo if selected |
| + | if (selectedLogo.value && entry) { |
| + | await uploadLogo(entry.id) |
| + | } |
| + | |
| + | // Reset form |
| newEntry.value = { name: '', url: '', customSlug: '' } | |
| + | selectedLogo.value = null |
| + | if (logoFileInput.value) { |
| + | logoFileInput.value.value = '' |
| + | } |
| } catch (error) { | |
| alert(error.response?.data?.error || 'Failed to create link') | |
| } finally { | |
| @@ | @@ -215,6 +263,61 @@ async function handleCreate() { |
| } | |
| } | |
| + | function handleLogoSelect(event) { |
| + | const file = event.target.files[0] |
| + | if (file) { |
| + | // Validate file |
| + | const maxSize = 5 * 1024 * 1024 // 5MB |
| + | if (file.size > maxSize) { |
| + | alert('File too large. Maximum size: 5MB') |
| + | event.target.value = '' |
| + | return |
| + | } |
| + | |
| + | const allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'] |
| + | if (!allowedTypes.includes(file.type)) { |
| + | alert('Invalid file type. Allowed: PNG, JPG, SVG, WebP') |
| + | event.target.value = '' |
| + | return |
| + | } |
| + | |
| + | selectedLogo.value = file |
| + | } |
| + | } |
| + | |
| + | async function uploadLogo(entryId) { |
| + | if (!selectedLogo.value) return |
| + | |
| + | const formData = new FormData() |
| + | formData.append('logo', selectedLogo.value) |
| + | formData.append('entryId', entryId) |
| + | |
| + | try { |
| + | const response = await fetch('/api/logo/upload', { |
| + | method: 'POST', |
| + | headers: { |
| + | 'Authorization': `Bearer ${authStore.token}` |
| + | }, |
| + | body: formData |
| + | }) |
| + | |
| + | if (!response.ok) { |
| + | throw new Error('Failed to upload logo') |
| + | } |
| + | |
| + | const data = await response.json() |
| + | |
| + | // Update the entry in the store with the logo URL |
| + | const entryIndex = entriesStore.entries.findIndex(e => e.id === entryId) |
| + | if (entryIndex !== -1) { |
| + | entriesStore.entries[entryIndex].logo_url = data.logoUrl |
| + | } |
| + | } catch (error) { |
| + | console.error('Logo upload error:', error) |
| + | // Don't throw - logo upload failure shouldn't break the flow |
| + | } |
| + | } |
| + | |
| async function deleteEntry(id) { | |
| if (confirm('Are you sure you want to delete this link?')) { | |
| try { | |
| @@ | @@ -244,6 +347,23 @@ function showQRCode(entry) { |
| selectedEntry.value = entry | |
| } | |
| + | function editEntry(entry) { |
| + | editingEntry.value = entry |
| + | } |
| + | |
| + | function handleEntryUpdate(updatedEntry) { |
| + | // Update the entry in the store |
| + | const index = entriesStore.entries.findIndex(e => e.id === updatedEntry.id) |
| + | if (index !== -1) { |
| + | entriesStore.entries[index] = updatedEntry |
| + | } |
| + | |
| + | // Update selected entry if it's the same one |
| + | if (selectedEntry.value?.id === updatedEntry.id) { |
| + | selectedEntry.value = updatedEntry |
| + | } |
| + | } |
| + | |
| function formatDate(dateString) { | |
| const date = new Date(dateString) | |
| const now = new Date() | |
src/index.js
+4
-0
| @@ | @@ -2,6 +2,7 @@ import { Router } from './router'; |
| import { handleRedirect } from './routes/redirect'; | |
| import { authRoutes } from './routes/auth'; | |
| import { apiRoutes } from './routes/api'; | |
| + | import { logoRoutes } from './routes/logo'; |
| import { applyMiddleware } from './middleware'; | |
| import { handleError } from './utils/errors'; | |
| import { addCorsHeaders } from './middleware/cors'; | |
| @@ | @@ -37,6 +38,9 @@ export default { |
| // API routes | |
| router.all('/api/auth/*', authRoutes); | |
| + | router.all('/api/logo/*', (request, env, ctx) => |
| + | applyMiddleware(request, env, ctx, logoRoutes) |
| + | ); |
| router.all('/api/*', (request, env, ctx) => | |
| applyMiddleware(request, env, ctx, apiRoutes) | |
| ); | |
storage.js b/src/lib/storage.js
+123
-0
| @@ | @@ -0,0 +1,123 @@ |
| + | export class Storage { |
| + | constructor(r2Bucket) { |
| + | this.bucket = r2Bucket; |
| + | } |
| + | |
| + | /** |
| + | * Upload a logo image to R2 |
| + | * @param {string} key - The storage key (e.g., 'logos/user-id/filename.png') |
| + | * @param {ArrayBuffer|ReadableStream} data - The image data |
| + | * @param {string} contentType - The MIME type of the image |
| + | * @returns {Promise<{key: string, url: string}>} |
| + | */ |
| + | async uploadLogo(key, data, contentType) { |
| + | try { |
| + | const object = await this.bucket.put(key, data, { |
| + | httpMetadata: { |
| + | contentType: contentType, |
| + | cacheControl: 'public, max-age=31536000', // Cache for 1 year |
| + | } |
| + | }); |
| + | |
| + | if (!object) { |
| + | throw new Error('Failed to upload logo'); |
| + | } |
| + | |
| + | // Return the key for later retrieval |
| + | // In production, you might want to use a CDN URL here |
| + | return { |
| + | key: key, |
| + | etag: object.etag, |
| + | uploaded: object.uploaded |
| + | }; |
| + | } catch (error) { |
| + | console.error('Logo upload error:', error); |
| + | throw new Error('Failed to upload logo to storage'); |
| + | } |
| + | } |
| + | |
| + | /** |
| + | * Get a logo from R2 |
| + | * @param {string} key - The storage key |
| + | * @returns {Promise<Response>} |
| + | */ |
| + | async getLogo(key) { |
| + | try { |
| + | const object = await this.bucket.get(key); |
| + | |
| + | if (!object) { |
| + | return null; |
| + | } |
| + | |
| + | return new Response(object.body, { |
| + | headers: { |
| + | 'Content-Type': object.httpMetadata?.contentType || 'image/png', |
| + | 'Cache-Control': 'public, max-age=31536000', |
| + | 'ETag': object.etag |
| + | } |
| + | }); |
| + | } catch (error) { |
| + | console.error('Logo fetch error:', error); |
| + | return null; |
| + | } |
| + | } |
| + | |
| + | /** |
| + | * Delete a logo from R2 |
| + | * @param {string} key - The storage key |
| + | * @returns {Promise<void>} |
| + | */ |
| + | async deleteLogo(key) { |
| + | try { |
| + | await this.bucket.delete(key); |
| + | } catch (error) { |
| + | console.error('Logo deletion error:', error); |
| + | // Don't throw - deletion failures shouldn't break the flow |
| + | } |
| + | } |
| + | |
| + | /** |
| + | * Generate a unique storage key for a logo |
| + | * @param {string} userId - The user ID |
| + | * @param {string} entryId - The entry ID |
| + | * @param {string} filename - Original filename |
| + | * @returns {string} |
| + | */ |
| + | static generateLogoKey(userId, entryId, filename) { |
| + | // Extract extension from filename |
| + | const ext = filename.split('.').pop().toLowerCase(); |
| + | |
| + | // Validate extension |
| + | const allowedExtensions = ['png', 'jpg', 'jpeg', 'svg', 'webp']; |
| + | if (!allowedExtensions.includes(ext)) { |
| + | throw new Error('Invalid file type. Allowed: PNG, JPG, SVG, WebP'); |
| + | } |
| + | |
| + | // Generate unique key: logos/userId/entryId.extension |
| + | return `logos/${userId}/${entryId}.${ext}`; |
| + | } |
| + | |
| + | /** |
| + | * Validate image file |
| + | * @param {File|ArrayBuffer} file - The image file |
| + | * @param {number} maxSize - Maximum size in bytes (default: 5MB) |
| + | * @returns {boolean} |
| + | */ |
| + | static validateImage(file, maxSize = 5 * 1024 * 1024) { |
| + | // Check size |
| + | const size = file.size || file.byteLength; |
| + | if (size > maxSize) { |
| + | throw new Error(`File too large. Maximum size: ${maxSize / 1024 / 1024}MB`); |
| + | } |
| + | |
| + | // Check MIME type if available |
| + | if (file.type) { |
| + | const allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']; |
| + | if (!allowedTypes.includes(file.type)) { |
| + | throw new Error('Invalid file type. Allowed: PNG, JPG, SVG, WebP'); |
| + | } |
| + | } |
| + | |
| + | return true; |
| + | } |
| + | } |
| \ No newline at end of file | |
src/middleware/auth.js
+5
-0
| @@ | @@ -5,6 +5,11 @@ export async function verifyAuth(request, env) { |
| const url = new URL(request.url); | |
| const publicPaths = ['/health', '/api/health', '/api/auth/request', '/api/auth/verify']; | |
| + | // Skip auth for logo serving (public endpoint) |
| + | if (url.pathname.startsWith('/api/logo/get/')) { |
| + | return null; |
| + | } |
| + | |
| // Skip auth for non-API paths (like redirects) or public API paths | |
| if (!url.pathname.startsWith('/api') || publicPaths.includes(url.pathname)) { | |
| return null; | |
src/routes/logo.js
+186
-0
| @@ | @@ -0,0 +1,186 @@ |
| + | import { Storage } from '../lib/storage'; |
| + | import { Database } from '../lib/db'; |
| + | import { AuthError, ValidationError, NotFoundError } from '../utils/errors'; |
| + | |
| + | export async function logoRoutes(request, env, ctx) { |
| + | const url = new URL(request.url); |
| + | const path = url.pathname; |
| + | |
| + | // Upload logo for an entry |
| + | if (path === '/api/logo/upload' && request.method === 'POST') { |
| + | return uploadLogo(request, env); |
| + | } |
| + | |
| + | // Get logo by key (public endpoint for serving logos) |
| + | if (path.startsWith('/api/logo/get/') && request.method === 'GET') { |
| + | const key = path.replace('/api/logo/get/', ''); |
| + | return getLogo(key, env); |
| + | } |
| + | |
| + | // Delete logo for an entry |
| + | if (path.startsWith('/api/logo/delete/') && request.method === 'DELETE') { |
| + | const entryId = path.replace('/api/logo/delete/', ''); |
| + | return deleteLogo(request, env, entryId); |
| + | } |
| + | |
| + | return new Response('Not Found', { status: 404 }); |
| + | } |
| + | |
| + | async function uploadLogo(request, env) { |
| + | try { |
| + | // Check authentication |
| + | if (!request.user) { |
| + | throw new AuthError('Authentication required'); |
| + | } |
| + | |
| + | // Parse multipart form data |
| + | const contentType = request.headers.get('content-type') || ''; |
| + | |
| + | if (!contentType.includes('multipart/form-data')) { |
| + | throw new ValidationError('Content-Type must be multipart/form-data'); |
| + | } |
| + | |
| + | const formData = await request.formData(); |
| + | const file = formData.get('logo'); |
| + | const entryId = formData.get('entryId'); |
| + | |
| + | console.log('Upload request - File:', file?.name, 'Size:', file?.size, 'Type:', file?.type); |
| + | |
| + | if (!file || !entryId) { |
| + | throw new ValidationError('Logo file and entry ID are required'); |
| + | } |
| + | |
| + | // Validate file |
| + | Storage.validateImage(file); |
| + | |
| + | // Verify entry ownership |
| + | const db = new Database(env.DB); |
| + | const entry = await db.getEntryById(entryId); |
| + | |
| + | if (!entry) { |
| + | throw new NotFoundError('Entry not found'); |
| + | } |
| + | |
| + | if (entry.user_id !== request.user.id) { |
| + | throw new AuthError('You do not have permission to update this entry'); |
| + | } |
| + | |
| + | // Delete old logo if exists |
| + | if (entry.logo_url) { |
| + | const storage = new Storage(env.STORAGE); |
| + | const oldKey = entry.logo_url.replace('/api/logo/get/', ''); |
| + | await storage.deleteLogo(oldKey); |
| + | } |
| + | |
| + | // Generate storage key |
| + | const key = Storage.generateLogoKey(request.user.id, entryId, file.name); |
| + | console.log('Generated storage key:', key); |
| + | |
| + | // Upload to R2 |
| + | const storage = new Storage(env.STORAGE); |
| + | const arrayBuffer = await file.arrayBuffer(); |
| + | console.log('Uploading to R2, size:', arrayBuffer.byteLength); |
| + | |
| + | const result = await storage.uploadLogo(key, arrayBuffer, file.type); |
| + | console.log('Upload result:', result); |
| + | |
| + | // Update database with logo URL |
| + | const logoUrl = `/api/logo/get/${key}`; |
| + | await db.updateEntry(entryId, { logoUrl }); |
| + | |
| + | // Clear cache |
| + | if (env.CACHE) { |
| + | await env.CACHE.delete(`entry:${entry.slug}`); |
| + | await env.CACHE.delete(`user-entries:${request.user.id}`); |
| + | } |
| + | |
| + | return new Response(JSON.stringify({ |
| + | success: true, |
| + | logoUrl: logoUrl, |
| + | key: result.key |
| + | }), { |
| + | status: 200, |
| + | headers: { 'Content-Type': 'application/json' } |
| + | }); |
| + | } catch (error) { |
| + | console.error('Logo upload error:', error); |
| + | return new Response(JSON.stringify({ |
| + | error: error.message || 'Failed to upload logo' |
| + | }), { |
| + | status: error.status || 500, |
| + | headers: { 'Content-Type': 'application/json' } |
| + | }); |
| + | } |
| + | } |
| + | |
| + | async function getLogo(key, env) { |
| + | try { |
| + | console.log('Fetching logo with key:', key); |
| + | const storage = new Storage(env.STORAGE); |
| + | const response = await storage.getLogo(key); |
| + | |
| + | if (!response) { |
| + | console.log('Logo not found in R2:', key); |
| + | return new Response('Logo not found', { status: 404 }); |
| + | } |
| + | |
| + | console.log('Logo found, returning response'); |
| + | return response; |
| + | } catch (error) { |
| + | console.error('Logo fetch error:', error); |
| + | return new Response('Failed to fetch logo', { status: 500 }); |
| + | } |
| + | } |
| + | |
| + | async function deleteLogo(request, env, entryId) { |
| + | try { |
| + | // Check authentication |
| + | if (!request.user) { |
| + | throw new AuthError('Authentication required'); |
| + | } |
| + | |
| + | // Verify entry ownership |
| + | const db = new Database(env.DB); |
| + | const entry = await db.getEntryById(entryId); |
| + | |
| + | if (!entry) { |
| + | throw new NotFoundError('Entry not found'); |
| + | } |
| + | |
| + | if (entry.user_id !== request.user.id) { |
| + | throw new AuthError('You do not have permission to update this entry'); |
| + | } |
| + | |
| + | // Delete logo from R2 |
| + | if (entry.logo_url) { |
| + | const storage = new Storage(env.STORAGE); |
| + | const key = entry.logo_url.replace('/api/logo/get/', ''); |
| + | await storage.deleteLogo(key); |
| + | |
| + | // Update database |
| + | await db.updateEntry(entryId, { logoUrl: null }); |
| + | |
| + | // Clear cache |
| + | if (env.CACHE) { |
| + | await env.CACHE.delete(`entry:${entry.slug}`); |
| + | await env.CACHE.delete(`user-entries:${request.user.id}`); |
| + | } |
| + | } |
| + | |
| + | return new Response(JSON.stringify({ |
| + | success: true, |
| + | message: 'Logo deleted successfully' |
| + | }), { |
| + | status: 200, |
| + | headers: { 'Content-Type': 'application/json' } |
| + | }); |
| + | } catch (error) { |
| + | console.error('Logo deletion error:', error); |
| + | return new Response(JSON.stringify({ |
| + | error: error.message || 'Failed to delete logo' |
| + | }), { |
| + | status: error.status || 500, |
| + | headers: { 'Content-Type': 'application/json' } |
| + | }); |
| + | } |
| + | } |
| \ No newline at end of file | |