Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ CHANGELOG.md
release-please-config.json
.release-please-manifest.json

# Azure Function (separate deployment)
api

# Docker
docker-compose.yml
Dockerfile
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/deploy-function.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Deploy Download Function

on:
push:
branches: [main]
paths: [api/**]
workflow_dispatch:

permissions:
id-token: write
contents: read

env:
AZURE_FUNCTIONAPP_NAME: bcit-tlu-opendata-dl
PYTHON_VERSION: "3.12"

jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Install dependencies
run: pip install -r api/requirements.txt --target api/.python_packages/lib/site-packages

- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- uses: azure/functions-action@v1
with:
app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }}
package: api
scm-do-build-during-deployment: false
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Azure Functions local settings (contains secrets for local dev)
api/local.settings.json
api/.python_packages
api/__pycache__
8 changes: 8 additions & 0 deletions api/.funcignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.git*
.vscode
__azurite_db*
__pycache__
.python_packages
local.settings.json
test
.venv
80 changes: 80 additions & 0 deletions api/function_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Azure Function — authenticated dataset download via SAS redirect.
#
# Sits behind Entra ID Easy Auth. Unauthenticated requests are
# redirected to Microsoft SSO automatically by the platform.
# Authenticated requests receive a short-lived SAS URL (302 redirect)
# that grants read-only access to the requested blob.

import datetime
import logging
import os

import azure.functions as func
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobSasPermissions, BlobServiceClient, generate_blob_sas

app = func.FunctionApp()

STORAGE_ACCOUNT = os.environ["STORAGE_ACCOUNT_NAME"]
CONTAINER = os.environ.get("CONTAINER_NAME", "datasets-latest")
SAS_TTL_MINUTES = int(os.environ.get("SAS_TTL_MINUTES", "5"))

ALLOWED_FILES: set[str] = {
"audiovideoprocessed.zip",
"contentobjects.zip",
"discussionsforum.zip",
"gradeobjects.zip",
"organizationalunits.zip",
"quizobjects.zip",
"releaseconditionsobjects.zip",
"roledetails.zip",
"all-datasets.zip",
}


@app.function_name("download")
@app.route(route="download", methods=["GET"])
def download(req: func.HttpRequest) -> func.HttpResponse:
file_name = req.params.get("file")
if not file_name or file_name not in ALLOWED_FILES:
return func.HttpResponse(
"Invalid or missing 'file' parameter. "
f"Allowed values: {', '.join(sorted(ALLOWED_FILES))}",
status_code=400,
)

try:
credential = DefaultAzureCredential()
blob_service = BlobServiceClient(
f"https://{STORAGE_ACCOUNT}.blob.core.windows.net",
credential=credential,
)

now = datetime.datetime.now(datetime.timezone.utc)
delegation_key = blob_service.get_user_delegation_key(
key_start_time=now,
key_expiry_time=now + datetime.timedelta(hours=1),
)

sas_token = generate_blob_sas(
account_name=STORAGE_ACCOUNT,
container_name=CONTAINER,
blob_name=file_name,
user_delegation_key=delegation_key,
permission=BlobSasPermissions(read=True),
expiry=now + datetime.timedelta(minutes=SAS_TTL_MINUTES),
content_disposition=f"attachment; filename={file_name}",
)

sas_url = (
f"https://{STORAGE_ACCOUNT}.blob.core.windows.net"
f"/{CONTAINER}/{file_name}?{sas_token}"
)
return func.HttpResponse(status_code=302, headers={"Location": sas_url})

except Exception:
logging.exception("SAS URL generation failed")
return func.HttpResponse(
"Failed to generate download link. Please try again later.",
status_code=500,
)
15 changes: 15 additions & 0 deletions api/host.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
},
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
3 changes: 3 additions & 0 deletions api/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
azure-functions>=1.21.0,<2
azure-identity>=1.17.0,<2
azure-storage-blob>=12.23.0,<13
Loading