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