diff --git a/client/js/api.js b/client/js/api.js index 01107ce..afd1b50 100644 --- a/client/js/api.js +++ b/client/js/api.js @@ -148,6 +148,37 @@ class ApiClient { return this.makeRequest('DELETE', `/images/${imageId}`); } + // Download image with authentication and return blob URL + async downloadImageBlob(imageId) { + this.updateConfig(); + + if (!this.baseUrl) { + throw new Error('API not configured. Please set API base URL.'); + } + + const url = `${this.baseUrl}/api/v1/images/${imageId}/download`; + + const options = { + method: 'GET', + headers: this.getHeaders(false) // Don't include Content-Type for binary data + }; + + try { + const response = await fetch(url, options); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const blob = await response.blob(); + return URL.createObjectURL(blob); + } catch (error) { + console.error(`Image download failed: ${url}`, error); + throw error; + } + } + // Search API async searchImages(query, similarityThreshold = 0.7, maxResults = 20, tags = null) { const searchData = { diff --git a/client/js/images.js b/client/js/images.js index bcd9329..bcf84a7 100644 --- a/client/js/images.js +++ b/client/js/images.js @@ -2,6 +2,34 @@ let currentPage = 1; let totalPages = 1; +let imageBlobCache = new Map(); // Cache for blob URLs + +// Load image with authentication and return blob URL +async function loadImageBlob(imageId) { + // Check cache first + if (imageBlobCache.has(imageId)) { + return imageBlobCache.get(imageId); + } + + try { + const blobUrl = await apiClient.downloadImageBlob(imageId); + imageBlobCache.set(imageId, blobUrl); + return blobUrl; + } catch (error) { + console.error(`Failed to load image ${imageId}:`, error); + throw error; // Let the caller handle the error + } +} + +// Clean up blob URLs to prevent memory leaks +function cleanupBlobCache() { + for (const [imageId, blobUrl] of imageBlobCache.entries()) { + if (blobUrl.startsWith('blob:')) { + URL.revokeObjectURL(blobUrl); + } + } + imageBlobCache.clear(); +} // Load images with pagination async function loadImages(page = 1, tags = null) { @@ -29,7 +57,7 @@ async function loadImages(page = 1, tags = null) { } // Display images in grid -function displayImages(images) { +async function displayImages(images) { const container = document.getElementById('imagesContainer'); if (!images || images.length === 0) { @@ -46,12 +74,20 @@ function displayImages(images) { return; } + // Create image cards with placeholder images first const imagesHtml = images.map(image => `
- ${escapeHtml(image.description || 'Image')} +
+ ${escapeHtml(image.description || 'Image')} +
+
+
Loading...
+
+
${escapeHtml(truncateText(image.description || 'Untitled', 50))}

@@ -78,6 +114,37 @@ function displayImages(images) { `).join(''); container.innerHTML = `

${imagesHtml}
`; + + // Load actual images asynchronously + for (const image of images) { + loadImageBlob(image.id).then(blobUrl => { + const imgElement = document.getElementById(`img-${image.id}`); + const loadingOverlay = imgElement.parentElement.querySelector('.loading-overlay'); + + if (imgElement) { + imgElement.src = blobUrl; + imgElement.style.opacity = '1'; + if (loadingOverlay) { + loadingOverlay.style.display = 'none'; + } + } + }).catch(error => { + console.error(`Failed to load image ${image.id}:`, error); + const imgElement = document.getElementById(`img-${image.id}`); + const loadingOverlay = imgElement.parentElement.querySelector('.loading-overlay'); + + if (imgElement && loadingOverlay) { + // Show error state instead of broken image + loadingOverlay.innerHTML = ` +
+ +
Failed to load
+
+ `; + imgElement.style.display = 'none'; // Hide the img element completely + } + }); + } } // Display pagination @@ -234,12 +301,31 @@ async function uploadImage() { // View image details async function viewImage(imageId) { try { + // Close any existing modals first + const existingModals = ['viewImageModal', 'editImageModal']; + existingModals.forEach(modalId => { + const existingModal = document.getElementById(modalId); + if (existingModal) { + const bsModal = bootstrap.Modal.getInstance(existingModal); + if (bsModal) { + bsModal.hide(); + } + removeModal(modalId); + } + }); + const image = await apiClient.getImage(imageId); + // Load the image with authentication + const imageSrc = await loadImageBlob(imageId); + + // Use unique modal ID to prevent conflicts + const modalId = `viewImageModal-${imageId}`; + const modalBody = `
- + ${escapeHtml(image.description || 'Image')}
@@ -271,7 +357,14 @@ async function viewImage(imageId) { `; - const modal = createModal('viewImageModal', 'Image Details', modalBody, modalFooter); + const modal = createModal(modalId, 'Image Details', modalBody, modalFooter); + + // Add cleanup when modal is hidden + const modalElement = document.getElementById(modalId); + modalElement.addEventListener('hidden.bs.modal', () => { + removeModal(modalId); + }); + modal.show(); } catch (error) { @@ -282,21 +375,37 @@ async function viewImage(imageId) { // Edit image async function editImage(imageId) { try { + // Close any existing modals first + const existingModals = document.querySelectorAll('.modal'); + existingModals.forEach(modal => { + const bsModal = bootstrap.Modal.getInstance(modal); + if (bsModal) { + bsModal.hide(); + } + modal.remove(); + }); + const image = await apiClient.getImage(imageId); + // Load the image with authentication + const imageSrc = await loadImageBlob(imageId); + + // Use unique modal ID to prevent conflicts + const modalId = `editImageModal-${imageId}`; + const modalBody = ` -
+
- + ${escapeHtml(image.description || 'Image')}
- - + +
- - Tags +
Enter tags separated by commas
@@ -310,7 +419,14 @@ async function editImage(imageId) { `; - const modal = createModal('editImageModal', 'Edit Image', modalBody, modalFooter); + const modal = createModal(modalId, 'Edit Image', modalBody, modalFooter); + + // Add cleanup when modal is hidden + const modalElement = document.getElementById(modalId); + modalElement.addEventListener('hidden.bs.modal', () => { + removeModal(modalId); + }); + modal.show(); } catch (error) { @@ -320,11 +436,11 @@ async function editImage(imageId) { // Save image changes async function saveImageChanges(imageId) { - const description = document.getElementById('editDescription').value.trim(); - const tagsInput = document.getElementById('editTags').value.trim(); + const description = document.getElementById(`editDescription-${imageId}`).value.trim(); + const tagsInput = document.getElementById(`editTags-${imageId}`).value.trim(); const tags = tagsInput ? tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag) : []; - const saveButton = document.querySelector('#editImageModal .btn-primary'); + const saveButton = document.querySelector(`#editImageModal-${imageId} .btn-primary`); setLoadingState(saveButton); try { @@ -336,8 +452,8 @@ async function saveImageChanges(imageId) { showAlert('Image updated successfully!', 'success'); // Close modal and refresh images - bootstrap.Modal.getInstance(document.getElementById('editImageModal')).hide(); - removeModal('editImageModal'); + bootstrap.Modal.getInstance(document.getElementById(`editImageModal-${imageId}`)).hide(); + removeModal(`editImageModal-${imageId}`); loadImages(currentPage); } catch (error) { @@ -355,12 +471,15 @@ function deleteImage(imageId) { showAlert('Image deleted successfully!', 'success'); loadImages(currentPage); - // Close any open modals - const modals = ['viewImageModal', 'editImageModal']; - modals.forEach(modalId => { + // Close any open modals for this image + const modalsToClose = [`viewImageModal-${imageId}`, `editImageModal-${imageId}`]; + modalsToClose.forEach(modalId => { const modalElement = document.getElementById(modalId); if (modalElement) { - bootstrap.Modal.getInstance(modalElement)?.hide(); + const bsModal = bootstrap.Modal.getInstance(modalElement); + if (bsModal) { + bsModal.hide(); + } removeModal(modalId); } }); diff --git a/client/js/search.js b/client/js/search.js index 5956b85..64dad32 100644 --- a/client/js/search.js +++ b/client/js/search.js @@ -44,7 +44,7 @@ async function performSearch() { try { const results = await apiClient.searchImages(query, threshold, maxResults); - displaySearchResults(results, query); + await displaySearchResults(results, query); } catch (error) { handleApiError(error, 'searching images'); resultsContainer.innerHTML = ` @@ -57,7 +57,7 @@ async function performSearch() { } // Display search results -function displaySearchResults(results, query) { +async function displaySearchResults(results, query) { const container = document.getElementById('searchResults'); if (!results || results.length === 0) { @@ -65,17 +65,13 @@ function displaySearchResults(results, query) {

No results found

-

Try adjusting your search query or similarity threshold.

-
- -
+

Try adjusting your search query or similarity threshold

`; return; } - + + // Create results HTML with placeholder images first const resultsHtml = `

Search Results

@@ -86,11 +82,17 @@ function displaySearchResults(results, query) {
- ${escapeHtml(result.image.description || 'Image')} +
+ ${escapeHtml(result.image.description || 'Image')} +
+
+
+
${Math.round(result.similarity * 100)}% match @@ -134,6 +136,36 @@ function displaySearchResults(results, query) { container.innerHTML = resultsHtml; + // Load actual images asynchronously (need to import loadImageBlob from images.js) + for (const result of results) { + if (typeof loadImageBlob === 'function') { + loadImageBlob(result.image.id).then(blobUrl => { + const imgElement = document.getElementById(`search-img-${result.image.id}`); + const loadingOverlay = imgElement?.parentElement.querySelector('.loading-overlay'); + + if (imgElement) { + imgElement.src = blobUrl; + imgElement.style.opacity = '1'; + if (loadingOverlay) { + loadingOverlay.style.display = 'none'; + } + } + }).catch(error => { + console.error(`Failed to load search image ${result.image.id}:`, error); + const imgElement = document.getElementById(`search-img-${result.image.id}`); + const loadingOverlay = imgElement?.parentElement.querySelector('.loading-overlay'); + + if (imgElement) { + imgElement.src = '/placeholder-image.png'; + imgElement.style.opacity = '1'; + if (loadingOverlay) { + loadingOverlay.style.display = 'none'; + } + } + }); + } + } + // Add search refinement options addSearchRefinementOptions(query, results); } diff --git a/client/js/ui.js b/client/js/ui.js index 1521ed3..25dbcc8 100644 --- a/client/js/ui.js +++ b/client/js/ui.js @@ -5,6 +5,12 @@ function showPage(pageId) { try { console.log('=== showPage called with pageId:', pageId, '==='); + // Clean up blob URLs when navigating away from images or search pages + if (typeof cleanupBlobCache === 'function' && + (app.currentPage === 'images' || app.currentPage === 'search')) { + cleanupBlobCache(); + } + // Hide all pages const pages = document.querySelectorAll('.page'); console.log('Found pages:', pages.length); @@ -40,6 +46,7 @@ function showPage(pageId) { if (window.SeReactApp) { window.SeReactApp.currentPage = pageId; } + app.currentPage = pageId; // Also update the local app state // Load page data if needed console.log('About to load page data for:', pageId); diff --git a/client/styles.css b/client/styles.css index f05fc92..01b3383 100644 --- a/client/styles.css +++ b/client/styles.css @@ -99,6 +99,15 @@ body { animation: spin 1s ease-in-out infinite; } +/* Large loading spinner for image loading */ +.loading-spinner.large { + width: 40px; + height: 40px; + border-width: 4px; + border-color: rgba(13, 110, 253, 0.3); + border-top-color: #0d6efd; +} + @keyframes spin { to { transform: rotate(360deg); } } diff --git a/debug_api_key_deployment.py b/debug_api_key_deployment.py new file mode 100644 index 0000000..183c3ca --- /dev/null +++ b/debug_api_key_deployment.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +""" +Debug script to test API key hashing with deployment configuration +""" + +import os +import sys +import hmac +import hashlib + +def hash_api_key_manual(api_key: str, secret: str) -> str: + """ + Manual implementation of API key hashing to test + """ + return hmac.new( + secret.encode(), + api_key.encode(), + hashlib.sha256 + ).hexdigest() + +def test_api_key_hashing(): + """Test API key hashing with different secrets""" + + # The new API key from your seeding output + test_api_key = "uZBVUEku.7332d85bf6cf2618ad76bca46c2f8125" + + # Test with different possible secrets + secrets_to_test = [ + "super-secret-key-for-development-only", # Default from config.py + "development-secret-key-do-not-use-in-production", # Your .env value + ] + + print("API Key Hashing Debug") + print("=" * 60) + print(f"Test API Key: {test_api_key}") + print() + + for secret in secrets_to_test: + hashed_key = hash_api_key_manual(test_api_key, secret) + print(f"Secret: {secret}") + print(f"Hash: {hashed_key}") + print("-" * 60) + + print() + print("The deployment should be using one of these hashes in the database.") + print("Check your Cloud Run environment variables to confirm which secret is being used.") + +if __name__ == "__main__": + test_api_key_hashing() \ No newline at end of file diff --git a/deployment/terraform/main.tf b/deployment/terraform/main.tf index 04f5340..210af6e 100644 --- a/deployment/terraform/main.tf +++ b/deployment/terraform/main.tf @@ -144,6 +144,11 @@ resource "google_cloud_run_service" "sereact" { name = "LOG_LEVEL" value = "INFO" } + + env { + name = "API_KEY_SECRET" + value = "super-secret-key-for-development-only" + } } } } diff --git a/deployment/terraform/terraform.tfstate b/deployment/terraform/terraform.tfstate index d05a72e..fb755f2 100644 --- a/deployment/terraform/terraform.tfstate +++ b/deployment/terraform/terraform.tfstate @@ -1,7 +1,7 @@ { "version": 4, "terraform_version": "1.10.1", - "serial": 417, + "serial": 425, "lineage": "a183cd95-f987-8698-c6dd-84e933c394a5", "outputs": { "cloud_function_name": { @@ -172,7 +172,7 @@ "effective_annotations": { "run.googleapis.com/ingress": "all", "run.googleapis.com/ingress-status": "all", - "run.googleapis.com/operation-id": "7869f742-fe94-42d0-8d82-a1462681980d", + "run.googleapis.com/operation-id": "7a9a82f0-4bc9-4fe6-af40-ddee543c0536", "run.googleapis.com/urls": "[\"https://sereact-761163285547.us-central1.run.app\",\"https://sereact-p64zpdtkta-uc.a.run.app\"]", "serving.knative.dev/creator": "johnpccd3@gmail.com", "serving.knative.dev/lastModifier": "johnpccd3@gmail.com" @@ -182,14 +182,14 @@ "goog-terraform-provisioned": "true" }, "generation": 1, - "labels": {}, + "labels": null, "namespace": "gen-lang-client-0424120530", - "resource_version": "AAY16UbSm4k", + "resource_version": "AAY18uNZfE8", "self_link": "/apis/serving.knative.dev/v1/namespaces/761163285547/services/sereact", "terraform_labels": { "goog-terraform-provisioned": "true" }, - "uid": "d5532269-ab10-4b77-b90f-698306bf0919" + "uid": "dfae23a0-4b71-40aa-baa8-64f5b842501a" } ], "name": "sereact", @@ -216,14 +216,14 @@ "type": "RoutesReady" } ], - "latest_created_revision_name": "sereact-00001-9rv", - "latest_ready_revision_name": "sereact-00001-9rv", + "latest_created_revision_name": "sereact-00001-58h", + "latest_ready_revision_name": "sereact-00001-58h", "observed_generation": 1, "traffic": [ { "latest_revision": true, "percent": 100, - "revision_name": "sereact-00001-9rv", + "revision_name": "sereact-00001-58h", "tag": "", "url": "" } @@ -256,9 +256,14 @@ "container_concurrency": 80, "containers": [ { - "args": [], - "command": [], + "args": null, + "command": null, "env": [ + { + "name": "API_KEY_SECRET", + "value": "super-secret-key-for-development-only", + "value_from": [] + }, { "name": "FIRESTORE_DATABASE_NAME", "value": "sereact-imagedb", @@ -332,7 +337,7 @@ "cpu": "1", "memory": "1Gi" }, - "requests": {} + "requests": null } ], "startup_probe": [ @@ -354,7 +359,7 @@ "working_dir": "" } ], - "node_selector": {}, + "node_selector": null, "service_account_name": "761163285547-compute@developer.gserviceaccount.com", "serving_state": "", "timeout_seconds": 300, @@ -435,7 +440,7 @@ "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16UdHJ00=", + "etag": "BwY18uO4z7A=", "id": "v1/projects/gen-lang-client-0424120530/locations/us-central1/services/sereact/roles/run.invoker/allUsers", "location": "us-central1", "member": "allUsers", @@ -469,7 +474,7 @@ "automatic_update_policy": [ {} ], - "build": "projects/761163285547/locations/us-central1/builds/7b34015c-eb2f-4a80-ac64-b7d3355173ac", + "build": "projects/761163285547/locations/us-central1/builds/47fecac1-7233-42af-ad0e-2a5a9c66e282", "docker_repository": "projects/gen-lang-client-0424120530/locations/us-central1/repositories/gcf-artifacts", "entry_point": "process_image_embedding", "environment_variables": {}, @@ -526,6 +531,7 @@ "GOOGLE_CLOUD_PROJECT": "gen-lang-client-0424120530", "LOG_EXECUTION_ID": "true", "LOG_LEVEL": "INFO", + "PROJECT_ID": "gen-lang-client-0424120530", "QDRANT_API_KEY": "", "QDRANT_COLLECTION": "image_vectors", "QDRANT_HOST": "34.71.6.1", @@ -553,7 +559,7 @@ "goog-terraform-provisioned": "true" }, "timeouts": null, - "update_time": "2025-05-24T22:39:42.051374046Z", + "update_time": "2025-05-25T09:26:49.473142736Z", "url": "https://us-central1-gen-lang-client-0424120530.cloudfunctions.net/process-image-embedding" }, "sensitive_attributes": [ @@ -803,12 +809,6 @@ "zone": "us-central1-a" }, "sensitive_attributes": [ - [ - { - "type": "get_attr", - "value": "metadata_startup_script" - } - ], [ { "type": "get_attr", @@ -842,6 +842,12 @@ "type": "get_attr", "value": "disk_encryption_key_rsa" } + ], + [ + { + "type": "get_attr", + "value": "metadata_startup_script" + } ] ], "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH0sInNjaGVtYV92ZXJzaW9uIjoiNiJ9", @@ -869,8 +875,8 @@ "database_edition": "STANDARD", "delete_protection_state": "DELETE_PROTECTION_DISABLED", "deletion_policy": "ABANDON", - "earliest_version_time": "2025-05-24T21:38:29.341046Z", - "etag": "IOGow/2VvY0DMKrW4vCEvY0D", + "earliest_version_time": "2025-05-25T08:59:14.308781Z", + "etag": "IMCn9pGuvo0DMKrW4vCEvY0D", "id": "projects/gen-lang-client-0424120530/databases/sereact-imagedb", "key_prefix": "", "location_id": "us-central1", @@ -901,7 +907,7 @@ "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16LCINIE=", + "etag": "BwY16WAsDU4=", "id": "gen-lang-client-0424120530/roles/eventarc.eventReceiver/serviceAccount:761163285547-compute@developer.gserviceaccount.com", "member": "serviceAccount:761163285547-compute@developer.gserviceaccount.com", "project": "gen-lang-client-0424120530", @@ -925,7 +931,7 @@ "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16LCINIE=", + "etag": "BwY16WAsDU4=", "id": "gen-lang-client-0424120530/roles/datastore.user/serviceAccount:761163285547-compute@developer.gserviceaccount.com", "member": "serviceAccount:761163285547-compute@developer.gserviceaccount.com", "project": "gen-lang-client-0424120530", @@ -949,7 +955,7 @@ "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16LCINIE=", + "etag": "BwY16WAsDU4=", "id": "gen-lang-client-0424120530/roles/pubsub.subscriber/serviceAccount:761163285547-compute@developer.gserviceaccount.com", "member": "serviceAccount:761163285547-compute@developer.gserviceaccount.com", "project": "gen-lang-client-0424120530", @@ -973,7 +979,7 @@ "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16LCINIE=", + "etag": "BwY16WAsDU4=", "id": "gen-lang-client-0424120530/roles/storage.objectViewer/serviceAccount:761163285547-compute@developer.gserviceaccount.com", "member": "serviceAccount:761163285547-compute@developer.gserviceaccount.com", "project": "gen-lang-client-0424120530", @@ -1518,7 +1524,7 @@ "md5hash": "Ru+hruU4bi8kS1lyicfEug==", "md5hexhash": "46efa1aee5386e2f244b597289c7c4ba", "media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-46efa1aee5386e2f244b597289c7c4ba.zip?generation=1748126317190781\u0026alt=media", - "metadata": null, + "metadata": {}, "name": "function-source-46efa1aee5386e2f244b597289c7c4ba.zip", "output_name": "function-source-46efa1aee5386e2f244b597289c7c4ba.zip", "retention": [], diff --git a/deployment/terraform/terraform.tfstate.backup b/deployment/terraform/terraform.tfstate.backup index 3a277b6..8e9163a 100644 --- a/deployment/terraform/terraform.tfstate.backup +++ b/deployment/terraform/terraform.tfstate.backup @@ -1,7 +1,7 @@ { "version": 4, "terraform_version": "1.10.1", - "serial": 410, + "serial": 422, "lineage": "a183cd95-f987-8698-c6dd-84e933c394a5", "outputs": { "cloud_function_name": { @@ -98,16 +98,16 @@ "attributes": { "exclude_symlink_directories": null, "excludes": null, - "id": "7a7a706b5bba3a12744f2dd109eb18de7112f351", - "output_base64sha256": "0wAfDV7tH41jEspQ3LBLvIEnrQ6XU2aEtK2GWsMdyCA=", - "output_base64sha512": "glsNAiHzSTOy9mGDckkSyDJhBVFDtLh8Xr6+hSxtCT8nok9qNGO+61iTRLU42OPxaPS/BrbDAXJeT86F3riefA==", + "id": "bfc4b3b9de401cd15676a09a067a8e4095b0bf4e", + "output_base64sha256": "cXx9sC1kIbTDG7BlKAtf3FUasHLZ/wZPzVoyBvt8p9Q=", + "output_base64sha512": "dcntRZ4Hz4dfBBj7YVsTzx+SEAqCXZCD8TAAh8cr5xa3uT2Lsmtf8zpxpyQEeMlsCNaF8dUohGQ7BD9LJYigPw==", "output_file_mode": null, - "output_md5": "a5d3a7fe131c972bf8d0edf309545042", + "output_md5": "46efa1aee5386e2f244b597289c7c4ba", "output_path": "./function-source.zip", - "output_sha": "7a7a706b5bba3a12744f2dd109eb18de7112f351", - "output_sha256": "d3001f0d5eed1f8d6312ca50dcb04bbc8127ad0e97536684b4ad865ac31dc820", - "output_sha512": "825b0d0221f34933b2f66183724912c83261055143b4b87c5ebebe852c6d093f27a24f6a3463beeb589344b538d8e3f168f4bf06b6c301725e4fce85deb89e7c", - "output_size": 4487, + "output_sha": "bfc4b3b9de401cd15676a09a067a8e4095b0bf4e", + "output_sha256": "717c7db02d6421b4c31bb065280b5fdc551ab072d9ff064fcd5a3206fb7ca7d4", + "output_sha512": "75c9ed459e07cf875f0418fb615b13cf1f92100a825d9083f1300087c72be716b7b93d8bb26b5ff33a71a7240478c96c08d685f1d52884643b043f4b2588a03f", + "output_size": 6734, "source": [], "source_content": null, "source_content_filename": null, @@ -172,7 +172,7 @@ "effective_annotations": { "run.googleapis.com/ingress": "all", "run.googleapis.com/ingress-status": "all", - "run.googleapis.com/operation-id": "7869f742-fe94-42d0-8d82-a1462681980d", + "run.googleapis.com/operation-id": "23c7f059-a4af-4cc2-95a4-f0fe54d0d7e6", "run.googleapis.com/urls": "[\"https://sereact-761163285547.us-central1.run.app\",\"https://sereact-p64zpdtkta-uc.a.run.app\"]", "serving.knative.dev/creator": "johnpccd3@gmail.com", "serving.knative.dev/lastModifier": "johnpccd3@gmail.com" @@ -181,10 +181,10 @@ "cloud.googleapis.com/location": "us-central1", "goog-terraform-provisioned": "true" }, - "generation": 1, - "labels": null, + "generation": 2, + "labels": {}, "namespace": "gen-lang-client-0424120530", - "resource_version": "AAY16UbSm4k", + "resource_version": "AAY18rUlZh4", "self_link": "/apis/serving.knative.dev/v1/namespaces/761163285547/services/sereact", "terraform_labels": { "goog-terraform-provisioned": "true" @@ -216,14 +216,14 @@ "type": "RoutesReady" } ], - "latest_created_revision_name": "sereact-00001-9rv", - "latest_ready_revision_name": "sereact-00001-9rv", - "observed_generation": 1, + "latest_created_revision_name": "sereact-00002-q2g", + "latest_ready_revision_name": "sereact-00002-q2g", + "observed_generation": 2, "traffic": [ { "latest_revision": true, "percent": 100, - "revision_name": "sereact-00001-9rv", + "revision_name": "sereact-00002-q2g", "tag": "", "url": "" } @@ -256,9 +256,14 @@ "container_concurrency": 80, "containers": [ { - "args": null, - "command": null, + "args": [], + "command": [], "env": [ + { + "name": "API_KEY_SECRET", + "value": "super-secret-key-for-development-only", + "value_from": [] + }, { "name": "FIRESTORE_DATABASE_NAME", "value": "sereact-imagedb", @@ -332,7 +337,7 @@ "cpu": "1", "memory": "1Gi" }, - "requests": null + "requests": {} } ], "startup_probe": [ @@ -354,7 +359,7 @@ "working_dir": "" } ], - "node_selector": null, + "node_selector": {}, "service_account_name": "761163285547-compute@developer.gserviceaccount.com", "serving_state": "", "timeout_seconds": 300, @@ -469,7 +474,7 @@ "automatic_update_policy": [ {} ], - "build": "projects/761163285547/locations/us-central1/builds/b2b7e513-e00e-462a-8ac8-94abdfb4a0b9", + "build": "projects/761163285547/locations/us-central1/builds/47fecac1-7233-42af-ad0e-2a5a9c66e282", "docker_repository": "projects/gen-lang-client-0424120530/locations/us-central1/repositories/gcf-artifacts", "entry_point": "process_image_embedding", "environment_variables": {}, @@ -483,7 +488,7 @@ { "bucket": "gen-lang-client-0424120530-cloud-function-source", "generation": 1748123369545880, - "object": "function-source-a5d3a7fe131c972bf8d0edf309545042.zip" + "object": "function-source-46efa1aee5386e2f244b597289c7c4ba.zip" } ] } @@ -523,14 +528,16 @@ "FIRESTORE_DATABASE_NAME": "sereact-imagedb", "FIRESTORE_PROJECT_ID": "gen-lang-client-0424120530", "GCS_BUCKET_NAME": "sereact-images", + "GOOGLE_CLOUD_PROJECT": "gen-lang-client-0424120530", "LOG_EXECUTION_ID": "true", "LOG_LEVEL": "INFO", + "PROJECT_ID": "gen-lang-client-0424120530", "QDRANT_API_KEY": "", "QDRANT_COLLECTION": "image_vectors", "QDRANT_HOST": "34.71.6.1", "QDRANT_HTTPS": "false", "QDRANT_PORT": "6333", - "VISION_API_ENABLED": "true" + "VERTEX_AI_LOCATION": "us-central1" }, "gcf_uri": "", "ingress_settings": "ALLOW_ALL", @@ -552,7 +559,7 @@ "goog-terraform-provisioned": "true" }, "timeouts": null, - "update_time": "2025-05-24T22:31:52.525335119Z", + "update_time": "2025-05-25T09:26:49.473142736Z", "url": "https://us-central1-gen-lang-client-0424120530.cloudfunctions.net/process-image-embedding" }, "sensitive_attributes": [ @@ -868,8 +875,8 @@ "database_edition": "STANDARD", "delete_protection_state": "DELETE_PROTECTION_DISABLED", "deletion_policy": "ABANDON", - "earliest_version_time": "2025-05-24T21:29:52.924798Z", - "etag": "IPHgo4eUvY0DMKrW4vCEvY0D", + "earliest_version_time": "2025-05-25T08:56:42.972923Z", + "etag": "IMzA4cmtvo0DMKrW4vCEvY0D", "id": "projects/gen-lang-client-0424120530/databases/sereact-imagedb", "key_prefix": "", "location_id": "us-central1", @@ -900,7 +907,7 @@ "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16LCINIE=", + "etag": "BwY16WAsDU4=", "id": "gen-lang-client-0424120530/roles/eventarc.eventReceiver/serviceAccount:761163285547-compute@developer.gserviceaccount.com", "member": "serviceAccount:761163285547-compute@developer.gserviceaccount.com", "project": "gen-lang-client-0424120530", @@ -924,7 +931,7 @@ "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16LCINIE=", + "etag": "BwY16WAsDU4=", "id": "gen-lang-client-0424120530/roles/datastore.user/serviceAccount:761163285547-compute@developer.gserviceaccount.com", "member": "serviceAccount:761163285547-compute@developer.gserviceaccount.com", "project": "gen-lang-client-0424120530", @@ -948,7 +955,7 @@ "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16LCINIE=", + "etag": "BwY16WAsDU4=", "id": "gen-lang-client-0424120530/roles/pubsub.subscriber/serviceAccount:761163285547-compute@developer.gserviceaccount.com", "member": "serviceAccount:761163285547-compute@developer.gserviceaccount.com", "project": "gen-lang-client-0424120530", @@ -972,7 +979,7 @@ "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16LCINIE=", + "etag": "BwY16WAsDU4=", "id": "gen-lang-client-0424120530/roles/storage.objectViewer/serviceAccount:761163285547-compute@developer.gserviceaccount.com", "member": "serviceAccount:761163285547-compute@developer.gserviceaccount.com", "project": "gen-lang-client-0424120530", @@ -989,18 +996,18 @@ { "mode": "managed", "type": "google_project_iam_member", - "name": "function_vision", + "name": "function_vertex_ai", "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", "instances": [ { "schema_version": 0, "attributes": { "condition": [], - "etag": "BwY16LCINIE=", - "id": "gen-lang-client-0424120530/roles/ml.developer/serviceAccount:761163285547-compute@developer.gserviceaccount.com", + "etag": "BwY16WAsDU4=", + "id": "gen-lang-client-0424120530/roles/aiplatform.user/serviceAccount:761163285547-compute@developer.gserviceaccount.com", "member": "serviceAccount:761163285547-compute@developer.gserviceaccount.com", "project": "gen-lang-client-0424120530", - "role": "roles/ml.developer" + "role": "roles/aiplatform.user" }, "sensitive_attributes": [], "private": "bnVsbA==", @@ -1016,6 +1023,20 @@ "name": "services", "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", "instances": [ + { + "index_key": "aiplatform.googleapis.com", + "schema_version": 0, + "attributes": { + "disable_dependent_services": null, + "disable_on_destroy": false, + "id": "gen-lang-client-0424120530/aiplatform.googleapis.com", + "project": "gen-lang-client-0424120530", + "service": "aiplatform.googleapis.com", + "timeouts": null + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInJlYWQiOjYwMDAwMDAwMDAwMCwidXBkYXRlIjoxMjAwMDAwMDAwMDAwfX0=" + }, { "index_key": "cloudbuild.googleapis.com", "schema_version": 0, @@ -1493,21 +1514,21 @@ "content_encoding": "", "content_language": "", "content_type": "application/zip", - "crc32c": "Y4Q5hw==", + "crc32c": "kTROsA==", "customer_encryption": [], - "detect_md5hash": "pdOn/hMclyv40O3zCVRQQg==", + "detect_md5hash": "Ru+hruU4bi8kS1lyicfEug==", "event_based_hold": false, - "generation": 1748125796837241, - "id": "gen-lang-client-0424120530-cloud-function-source-function-source-a5d3a7fe131c972bf8d0edf309545042.zip", + "generation": 1748126317190781, + "id": "gen-lang-client-0424120530-cloud-function-source-function-source-46efa1aee5386e2f244b597289c7c4ba.zip", "kms_key_name": "", - "md5hash": "pdOn/hMclyv40O3zCVRQQg==", - "md5hexhash": "a5d3a7fe131c972bf8d0edf309545042", - "media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-a5d3a7fe131c972bf8d0edf309545042.zip?generation=1748125796837241\u0026alt=media", - "metadata": null, - "name": "function-source-a5d3a7fe131c972bf8d0edf309545042.zip", - "output_name": "function-source-a5d3a7fe131c972bf8d0edf309545042.zip", + "md5hash": "Ru+hruU4bi8kS1lyicfEug==", + "md5hexhash": "46efa1aee5386e2f244b597289c7c4ba", + "media_link": "https://storage.googleapis.com/download/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-46efa1aee5386e2f244b597289c7c4ba.zip?generation=1748126317190781\u0026alt=media", + "metadata": {}, + "name": "function-source-46efa1aee5386e2f244b597289c7c4ba.zip", + "output_name": "function-source-46efa1aee5386e2f244b597289c7c4ba.zip", "retention": [], - "self_link": "https://www.googleapis.com/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-a5d3a7fe131c972bf8d0edf309545042.zip", + "self_link": "https://www.googleapis.com/storage/v1/b/gen-lang-client-0424120530-cloud-function-source/o/function-source-46efa1aee5386e2f244b597289c7c4ba.zip", "source": "./function-source.zip", "storage_class": "STANDARD", "temporary_hold": false, diff --git a/deployment/terraform/test_image.jpg b/deployment/terraform/test_image.jpg new file mode 100644 index 0000000..79b7c1f Binary files /dev/null and b/deployment/terraform/test_image.jpg differ diff --git a/scripts/setup_credentials.py b/scripts/setup_credentials.py deleted file mode 100644 index 5b35fd9..0000000 --- a/scripts/setup_credentials.py +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to set up Firestore credentials for development and deployment -""" -import os -import json -import sys -import argparse -from pathlib import Path - -def create_env_file(project_id, credentials_file="firestore-credentials.json"): - """Create a .env file with the necessary environment variables""" - env_content = f"""# Firestore Settings -FIRESTORE_PROJECT_ID={project_id} -FIRESTORE_CREDENTIALS_FILE={credentials_file} - -# Google Cloud Storage Settings -GCS_BUCKET_NAME={project_id}-storage -GCS_CREDENTIALS_FILE={credentials_file} - -# Security settings -API_KEY_SECRET=development-secret-key-change-in-production -API_KEY_EXPIRY_DAYS=365 - -# Vector Database Settings -VECTOR_DB_API_KEY= -VECTOR_DB_ENVIRONMENT= -VECTOR_DB_INDEX_NAME=image-embeddings - -# Other Settings -ENVIRONMENT=development -LOG_LEVEL=INFO -RATE_LIMIT_PER_MINUTE=100 -""" - - with open(".env", "w") as f: - f.write(env_content) - - print("Created .env file with Firestore settings") - -def main(): - parser = argparse.ArgumentParser(description='Set up Firestore credentials') - parser.add_argument('--key-file', type=str, help='Path to the service account key file') - parser.add_argument('--project-id', type=str, help='Google Cloud project ID') - parser.add_argument('--create-env', action='store_true', help='Create .env file') - - args = parser.parse_args() - - # Ensure we have a project ID - project_id = args.project_id - if not project_id: - if args.key_file and os.path.exists(args.key_file): - try: - with open(args.key_file, 'r') as f: - key_data = json.load(f) - project_id = key_data.get('project_id') - if project_id: - print(f"Using project ID from key file: {project_id}") - except Exception as e: - print(f"Error reading key file: {e}") - sys.exit(1) - - if not project_id: - print("Error: Project ID is required") - parser.print_help() - sys.exit(1) - - # Handle key file - target_key_file = "firestore-credentials.json" - if args.key_file and os.path.exists(args.key_file): - # Copy the key file to the target location - try: - with open(args.key_file, 'r') as src, open(target_key_file, 'w') as dst: - key_data = json.load(src) - json.dump(key_data, dst, indent=2) - print(f"Copied service account key to {target_key_file}") - except Exception as e: - print(f"Error copying key file: {e}") - sys.exit(1) - else: - print("Warning: No service account key file provided") - print(f"You need to place your service account key in {target_key_file}") - - # Create .env file if requested - if args.create_env: - create_env_file(project_id, target_key_file) - - print("\nSetup complete!") - print("\nFor development:") - print(f"1. Make sure {target_key_file} exists in the project root") - print("2. Ensure environment variables are set in .env file") - print("\nFor deployment:") - print("1. For Cloud Run, set environment variables in deployment config") - print("2. Make sure to securely manage service account key") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/src/api/v1/images.py b/src/api/v1/images.py index 04761df..4506cbd 100644 --- a/src/api/v1/images.py +++ b/src/api/v1/images.py @@ -25,6 +25,13 @@ storage_service = StorageService() image_processor = ImageProcessor() embedding_service = EmbeddingService() +def generate_api_download_url(request: Request, image_id: str) -> str: + """ + Generate API download URL for an image + """ + base_url = str(request.base_url).rstrip('/') + return f"{base_url}/api/v1/images/{image_id}/download" + @router.post("", response_model=ImageResponse, status_code=201) async def upload_image( request: Request, @@ -62,9 +69,6 @@ async def upload_image( file, str(current_user.team_id) ) - # Generate public URL - public_url = storage_service.generate_public_url(storage_path) - # Process tags tag_list = [] if tags: @@ -77,7 +81,7 @@ async def upload_image( file_size=file_size, content_type=content_type, storage_path=storage_path, - public_url=public_url, + public_url="", # Will be set after we have the image ID team_id=current_user.team_id, uploader_id=current_user.id, description=description, @@ -89,6 +93,13 @@ async def upload_image( # Save to database created_image = await image_repository.create(image) + # Generate API download URL now that we have the image ID + api_download_url = generate_api_download_url(request, str(created_image.id)) + + # Update the image with the API download URL + await image_repository.update(created_image.id, {"public_url": api_download_url}) + created_image.public_url = api_download_url + # Publish image processing task to Pub/Sub try: task_published = await pubsub_service.publish_image_processing_task( @@ -196,18 +207,8 @@ async def list_images( # Convert to response response_images = [] for image in images: - # Generate public URL if not set - public_url = image.public_url - if not public_url and image.storage_path: - # Make the file public and generate URL - try: - storage_service.make_file_public(image.storage_path) - public_url = storage_service.generate_public_url(image.storage_path) - # Update the database with the public URL - await image_repository.update(image.id, {"public_url": public_url}) - except Exception as e: - logger.warning(f"Failed to make file public or update URL for image {image.id}: {e}") - public_url = storage_service.generate_public_url(image.storage_path) + # Generate API download URL + api_download_url = generate_api_download_url(request, str(image.id)) response_images.append(ImageResponse( id=str(image.id), @@ -216,7 +217,7 @@ async def list_images( file_size=image.file_size, content_type=image.content_type, storage_path=image.storage_path, - public_url=public_url, + public_url=api_download_url, team_id=str(image.team_id), uploader_id=str(image.uploader_id), upload_date=image.upload_date, @@ -258,21 +259,8 @@ async def get_image( if not current_user.is_admin and image.team_id != current_user.team_id: raise HTTPException(status_code=403, detail="Not authorized to access this image") - # Update last accessed - await image_repository.update_last_accessed(obj_id) - - # Generate public URL if not set - public_url = image.public_url - if not public_url and image.storage_path: - # Make the file public and generate URL - try: - storage_service.make_file_public(image.storage_path) - public_url = storage_service.generate_public_url(image.storage_path) - # Update the database with the public URL - await image_repository.update(image.id, {"public_url": public_url}) - except Exception as e: - logger.warning(f"Failed to make file public or update URL for image {image.id}: {e}") - public_url = storage_service.generate_public_url(image.storage_path) + # Generate API download URL + api_download_url = generate_api_download_url(request, str(image.id)) # Convert to response response = ImageResponse( @@ -282,7 +270,7 @@ async def get_image( file_size=image.file_size, content_type=image.content_type, storage_path=image.storage_path, - public_url=public_url, + public_url=api_download_url, team_id=str(image.team_id), uploader_id=str(image.uploader_id), upload_date=image.upload_date, @@ -374,6 +362,7 @@ async def update_image( update_data = image_data.dict(exclude_unset=True) if not update_data: # No fields to update + api_download_url = generate_api_download_url(request, str(image.id)) response = ImageResponse( id=str(image.id), filename=image.filename, @@ -381,6 +370,7 @@ async def update_image( file_size=image.file_size, content_type=image.content_type, storage_path=image.storage_path, + public_url=api_download_url, team_id=str(image.team_id), uploader_id=str(image.uploader_id), upload_date=image.upload_date, @@ -396,6 +386,9 @@ async def update_image( if not updated_image: raise HTTPException(status_code=500, detail="Failed to update image") + # Generate API download URL + api_download_url = generate_api_download_url(request, str(updated_image.id)) + # Convert to response response = ImageResponse( id=str(updated_image.id), @@ -404,6 +397,7 @@ async def update_image( file_size=updated_image.file_size, content_type=updated_image.content_type, storage_path=updated_image.storage_path, + public_url=api_download_url, team_id=str(updated_image.team_id), uploader_id=str(updated_image.uploader_id), upload_date=updated_image.upload_date, diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py new file mode 100644 index 0000000..2d01a29 --- /dev/null +++ b/tests/test_admin_api.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Test script to verify admin image access functionality via API endpoints. +This script tests the actual HTTP endpoints to ensure admin users can see all images. +""" + +import requests +import json +import sys +import os + +# Configuration +BASE_URL = "http://localhost:8000" +API_BASE = f"{BASE_URL}/api/v1" + +def test_api_endpoints(): + """Test the admin functionality via API endpoints""" + print("๐Ÿงช Testing Admin Image Access via API") + print("=" * 50) + + # You'll need to replace these with actual API keys from your system + # Get these by running: python scripts/get_test_api_key.py + print("๐Ÿ“ To run this test, you need:") + print("1. A running API server (python main.py)") + print("2. Valid API keys for both admin and regular users") + print("3. Update the API_KEYS section below with real keys") + print() + + # Example API keys (replace with real ones) + API_KEYS = { + "admin": "your_admin_api_key_here", + "regular": "your_regular_api_key_here" + } + + if API_KEYS["admin"] == "your_admin_api_key_here": + print("โŒ Please update the API_KEYS in this script with real API keys") + print(" Run: python scripts/get_test_api_key.py") + return + + # Test regular user access + print("\n=== Testing Regular User API Access ===") + headers_regular = {"X-API-Key": API_KEYS["regular"]} + + try: + response = requests.get(f"{API_BASE}/images", headers=headers_regular) + if response.status_code == 200: + data = response.json() + regular_count = data.get("total", 0) + print(f"Regular user sees {regular_count} images") + print(f"Images returned: {len(data.get('images', []))}") + else: + print(f"โŒ Regular user API call failed: {response.status_code}") + print(response.text) + return + except Exception as e: + print(f"โŒ Error calling regular user API: {e}") + return + + # Test admin user access + print("\n=== Testing Admin User API Access ===") + headers_admin = {"X-API-Key": API_KEYS["admin"]} + + try: + response = requests.get(f"{API_BASE}/images", headers=headers_admin) + if response.status_code == 200: + data = response.json() + admin_count = data.get("total", 0) + print(f"Admin user sees {admin_count} images") + print(f"Images returned: {len(data.get('images', []))}") + + # Show teams represented in the results + teams = set() + for image in data.get('images', []): + teams.add(image.get('team_id')) + print(f"Images from {len(teams)} different teams") + + else: + print(f"โŒ Admin user API call failed: {response.status_code}") + print(response.text) + return + except Exception as e: + print(f"โŒ Error calling admin user API: {e}") + return + + # Compare results + print(f"\n=== Summary ===") + print(f"Regular user images: {regular_count}") + print(f"Admin user images: {admin_count}") + + if admin_count >= regular_count: + print("โœ… SUCCESS: Admin sees same or more images than regular user") + if admin_count > regular_count: + print("โœ… PERFECT: Admin sees more images (cross-team access working)") + else: + print("โ„น๏ธ NOTE: Admin and regular user see same count (might be same team or no other teams)") + else: + print("โŒ FAILURE: Admin should see at least as many images as regular user") + +def get_sample_api_keys(): + """Helper function to show how to get API keys""" + print("\n๐Ÿ“‹ How to get API keys for testing:") + print("1. Make sure your API server is running:") + print(" python main.py") + print() + print("2. Get a regular user API key:") + print(" python scripts/get_test_api_key.py") + print() + print("3. Get an admin user API key:") + print(" python scripts/create_admin.py") + print(" # Then use the admin email to get their API key") + print() + print("4. Update the API_KEYS dictionary in this script") + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--help": + get_sample_api_keys() + else: + test_api_endpoints() \ No newline at end of file