Skip to content

Commit 09f6c23

Browse files
authored
Merge pull request #2842 from bcgov/DEP-261-object-storage-normalize-urls
DEP-261: update object storage service to normalize URLs to keys
2 parents b568692 + a705720 commit 09f6c23

15 files changed

Lines changed: 515 additions & 14 deletions

File tree

CHANGELOG.MD

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## April 30, 2026
2+
3+
- **Feature** Updated all S3 uploaded objects to dynamically generate document URLs from the bucket and object key, rather than storing the full URL in the database. [🎟️ DEP-261](https://citz-gdx.atlassian.net/browse/DEP-261)
4+
- Updated the API to generate S3 object URLs on-the-fly using the bucket name and object key, rather than storing the full URL in the database. This allows for more flexibility in changing storage configurations in the future without needing to update existing database records.
5+
- Updated relevant API endpoints to reflect this change, including those for retrieving engagement details, survey details, and any other endpoints that return data with S3 object references.
6+
- Added basic unit tests for S3 service and URL generation test to ensure that URLs are generated correctly.
7+
18
## April 22, 2026
29

310
- **Bugfix** Move confirmation logic out of subscribe widget [🎟️ DEP-243](https://citz-gdx.atlassian.net/browse/DEP-243)
@@ -6,7 +13,7 @@
613
- Updated notify-api sample.env with all needed values (many were previously missing)
714
- Updated notify-api make scripts to remove update from install commands, which was breaking functionality
815
- Removed subscribe widget as an option from widget selector and removed associated tables via migration
9-
- This completes the work for [🎟️ DEP-241](https://citz-gdx.atlassian.net/browse/DEP-241)
16+
- This completes the work for [🎟️ DEP-241](https://citz-gdx.atlassian.net/browse/DEP-241)
1017
- Added Keycloak environment variable fallbacks to the API that are compatible with Vault secret injection.
1118

1219
## April 20, 2026
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Normalize object storage URLs to object keys.
2+
3+
Previously, widget_image.image_url, widget_documents.url (for uploaded files),
4+
and tenant.hero_image_url stored full S3 URLs
5+
(e.g. https://host/bucket/my-file.png). Going forward only the object key
6+
(e.g. my-file.png) is stored; URLs are constructed dynamically at read time
7+
using the S3_BUCKET / S3_HOST environment variables.
8+
9+
This migration strips the bucket+host prefix from any rows that still contain
10+
a full URL, leaving only the object key. The downgrade puts them back using
11+
the values of those env vars at the time it is run.
12+
13+
Revision ID: 8e50e47b7d32
14+
Revises: d756d0fe942c
15+
Create Date: 2026-04-29 00:00:00.000000
16+
"""
17+
import os
18+
19+
from alembic import op
20+
import sqlalchemy as sa
21+
22+
# revision identifiers, used by Alembic.
23+
revision = '8e50e47b7d32'
24+
down_revision = 'd756d0fe942c'
25+
branch_labels = None
26+
depends_on = None
27+
28+
29+
def _url_prefix() -> str | None:
30+
"""Return the full URL prefix that was previously prepended to object keys.
31+
32+
Returns None when S3_HOST or S3_BUCKET are not configured, in which case
33+
nothing needs to be migrated (no full URLs could have been stored).
34+
"""
35+
host = os.getenv('S3_HOST')
36+
bucket = os.getenv('S3_BUCKET')
37+
if not host or not bucket:
38+
return None
39+
return f'https://{host}/{bucket}/'
40+
41+
42+
def upgrade():
43+
prefix = _url_prefix()
44+
if not prefix:
45+
return
46+
47+
conn = op.get_bind()
48+
49+
# ---- widget_image.image_url ----------------------------------------
50+
conn.execute(
51+
sa.text(
52+
"""
53+
UPDATE widget_image
54+
SET image_url = SUBSTRING(image_url FROM :prefix_len)
55+
WHERE image_url LIKE :prefix_pattern
56+
"""
57+
),
58+
{
59+
'prefix_len': len(prefix) + 1, # 1-based SUBSTRING index
60+
'prefix_pattern': prefix + '%',
61+
},
62+
)
63+
64+
# ---- widget_documents.url (uploaded files only) --------------------
65+
conn.execute(
66+
sa.text(
67+
"""
68+
UPDATE widget_documents
69+
SET url = SUBSTRING(url FROM :prefix_len)
70+
WHERE is_uploaded = TRUE
71+
AND url LIKE :prefix_pattern
72+
"""
73+
),
74+
{
75+
'prefix_len': len(prefix) + 1,
76+
'prefix_pattern': prefix + '%',
77+
},
78+
)
79+
80+
# ---- tenant.hero_image_url -----------------------------------------
81+
conn.execute(
82+
sa.text(
83+
"""
84+
UPDATE tenant
85+
SET hero_image_url = SUBSTRING(hero_image_url FROM :prefix_len)
86+
WHERE hero_image_url LIKE :prefix_pattern
87+
"""
88+
),
89+
{
90+
'prefix_len': len(prefix) + 1,
91+
'prefix_pattern': prefix + '%',
92+
},
93+
)
94+
95+
96+
def downgrade():
97+
prefix = _url_prefix()
98+
if not prefix:
99+
return
100+
101+
conn = op.get_bind()
102+
103+
# Prepend the prefix back to any rows that do NOT already start with https://
104+
conn.execute(
105+
sa.text(
106+
"""
107+
UPDATE widget_image
108+
SET image_url = :prefix || image_url
109+
WHERE image_url IS NOT NULL
110+
AND image_url NOT LIKE 'https://%'
111+
AND image_url <> ''
112+
"""
113+
),
114+
{'prefix': prefix},
115+
)
116+
117+
conn.execute(
118+
sa.text(
119+
"""
120+
UPDATE widget_documents
121+
SET url = :prefix || url
122+
WHERE is_uploaded = TRUE
123+
AND url IS NOT NULL
124+
AND url NOT LIKE 'https://%'
125+
AND url <> ''
126+
"""
127+
),
128+
{'prefix': prefix},
129+
)
130+
131+
conn.execute(
132+
sa.text(
133+
"""
134+
UPDATE tenant
135+
SET hero_image_url = :prefix || hero_image_url
136+
WHERE hero_image_url IS NOT NULL
137+
AND hero_image_url NOT LIKE 'https://%'
138+
AND hero_image_url <> ''
139+
"""
140+
),
141+
{'prefix': prefix},
142+
)

api/src/api/schemas/tenant.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Tenant schema class."""
22

3-
from marshmallow import EXCLUDE, Schema, fields
3+
from marshmallow import EXCLUDE, Schema, fields, post_dump
4+
5+
from api.services.object_storage_service import ObjectStorageService
46

57

68
class TenantSchema(Schema):
79
"""Schema for tenant."""
810

11+
_object_storage = ObjectStorageService()
12+
913
class Meta: # pylint: disable=too-few-public-methods
1014
"""Exclude unknown fields in the deserialized output."""
1115

@@ -20,3 +24,18 @@ class Meta: # pylint: disable=too-few-public-methods
2024
contact_email = fields.Str(data_key='contact_email')
2125
hero_image_credit = fields.Str(data_key='hero_image_credit')
2226
hero_image_description = fields.Str(data_key='hero_image_description')
27+
28+
@post_dump(pass_collection=True)
29+
def _attach_hero_image_url(self, data, many, **kwargs):
30+
if not many:
31+
hero_image_url = data.get('hero_image_url')
32+
if hero_image_url:
33+
data['hero_image_url'] = self._object_storage.get_url(hero_image_url)
34+
return data
35+
36+
for item in data:
37+
hero_image_url = item.get('hero_image_url')
38+
if hero_image_url:
39+
item['hero_image_url'] = self._object_storage.get_url(hero_image_url)
40+
41+
return data
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
11
"""Widget schema class."""
2+
from marshmallow import post_dump
23

34
from api.models.widget_documents import WidgetDocuments as WidgetDocumentModel
5+
from api.services.object_storage_service import ObjectStorageService
46

57
from .base_schema import BaseSchema
68

79

810
class WidgetDocumentsSchema(BaseSchema):
911
"""Widget Documents schema."""
1012

13+
_object_storage = ObjectStorageService()
14+
1115
class Meta(BaseSchema.Meta): # pylint: disable=too-few-public-methods
1216
"""Exclude unknown fields in the deserialized output."""
1317

1418
model = WidgetDocumentModel
1519
include_fk = True
1620
fields = ('id', 'title', 'type', 'parent_document_id', 'url', 'sort_index', 'is_uploaded')
21+
22+
@post_dump(pass_collection=True)
23+
def _attach_document_url(self, data, many, **kwargs):
24+
if not many:
25+
if data.get('is_uploaded') and data.get('url'):
26+
data['url'] = self._object_storage.get_url(data.get('url'))
27+
return data
28+
29+
for item in data:
30+
if item.get('is_uploaded') and item.get('url'):
31+
item['url'] = self._object_storage.get_url(item.get('url'))
32+
33+
return data

api/src/api/schemas/widget_image.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,19 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
"""Widget image schema definition."""
15+
from marshmallow import post_dump
1516

1617
from api.models.widget_image import WidgetImage as WidgetImageModel
18+
from api.services.object_storage_service import ObjectStorageService
1719

1820
from .base_schema import BaseSchema
1921

2022

2123
class WidgetImageSchema(BaseSchema):
2224
"""This is the schema for the image model."""
2325

26+
_object_storage = ObjectStorageService()
27+
2428
class Meta(BaseSchema.Meta): # pylint: disable=too-few-public-methods
2529
"""Images all of the Widget Image fields to a default schema."""
2630

@@ -34,3 +38,18 @@ class Meta(BaseSchema.Meta): # pylint: disable=too-few-public-methods
3438
'alt_text',
3539
'description',
3640
)
41+
42+
@post_dump(pass_collection=True)
43+
def _attach_image_url(self, data, many, **kwargs):
44+
if not many:
45+
image_url = data.get('image_url')
46+
if image_url:
47+
data['image_url'] = self._object_storage.get_url(image_url)
48+
return data
49+
50+
for item in data:
51+
image_url = item.get('image_url')
52+
if image_url:
53+
item['image_url'] = self._object_storage.get_url(image_url)
54+
55+
return data

api/src/api/services/object_storage_service.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import uuid
44
from typing import List
5+
from urllib.parse import urlparse
56

67
import requests
78
from aws_requests_auth.aws_auth import AWSRequestsAuth
@@ -28,10 +29,28 @@ def __init__(self):
2829

2930
def get_url(self, filename: str):
3031
"""Get the object url."""
31-
if (not self.s3_auth.aws_host or not self.s3_bucket or not filename):
32+
object_key = self.get_object_key(filename)
33+
if (not self.s3_auth.aws_host or not self.s3_bucket or not object_key):
3234
return ''
3335

34-
return f'https://{self.s3_auth.aws_host}/{self.s3_bucket}/{filename}'
36+
return f'https://{self.s3_auth.aws_host}/{self.s3_bucket}/{object_key}'
37+
38+
def get_object_key(self, filename: str):
39+
"""Extract the object key from a stored filename/path/url."""
40+
if not filename:
41+
return ''
42+
43+
parsed = urlparse(filename)
44+
if parsed.scheme and parsed.netloc:
45+
path = parsed.path.lstrip('/')
46+
if not path:
47+
return ''
48+
bucket_prefix = f'{self.s3_bucket}/' if self.s3_bucket else ''
49+
if bucket_prefix and path.startswith(bucket_prefix):
50+
return path[len(bucket_prefix):]
51+
return path
52+
53+
return filename.lstrip('/')
3554

3655
def get_auth_headers(self, documents: List[Document]):
3756
"""Get the S3 auth headers for the provided documents."""

api/src/api/services/tenant_service.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from api.models.tenant import Tenant as TenantModel
77
from api.schemas.tenant import TenantSchema
88
from api.services import authorization
9+
from api.services.object_storage_service import ObjectStorageService
910
from api.utils.roles import Role
1011
from ..utils.cache import cache
1112

@@ -15,6 +16,14 @@
1516
class TenantService:
1617
"""Tenant management service."""
1718

19+
_object_storage = ObjectStorageService()
20+
21+
@classmethod
22+
def _normalize_hero_image_url(cls, data: dict):
23+
if 'hero_image_url' in data:
24+
data['hero_image_url'] = cls._object_storage.get_object_key(data.get('hero_image_url'))
25+
return data
26+
1827
@classmethod
1928
def build_all_tenant_cache(cls):
2029
"""Build cache for all tenant values."""
@@ -47,7 +56,8 @@ def create(cls, data: dict):
4756
Role.SUPER_ADMIN.value,
4857
)
4958
authorization.check_auth(one_of_roles=one_of_roles)
50-
tenant = TenantModel(**data)
59+
normalized_data = cls._normalize_hero_image_url(dict(data))
60+
tenant = TenantModel(**normalized_data)
5161
try:
5262
tenant.save()
5363
except SQLAlchemyError as e:
@@ -66,7 +76,7 @@ def update(cls, tenant_id: str, data: dict):
6676
if not tenant:
6777
raise ValueError(NOT_FOUND_MSG, cls, tenant_id)
6878
try:
69-
tenant.update(data)
79+
tenant.update(cls._normalize_hero_image_url(dict(data)))
7080
except SQLAlchemyError as e:
7181
current_app.logger.error('Error updating tenant {}', e)
7282
raise ValueError('Error updating tenant.') from e

0 commit comments

Comments
 (0)