Skip to content
36 changes: 36 additions & 0 deletions functions/src/clean-temp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Copyright 2026 The Ground Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { getStorageBucket } from './common/context';
import { TEMP_MAX_AGE_MS, TEMP_PREFIX } from './common/temp-storage';

/**
* Deletes temporary files older than MAX_AGE_MS from the temp/ prefix in the
* default storage bucket.
*/
export async function cleanTempHandler() {
const bucket = getStorageBucket();
const [files] = await bucket.getFiles({ prefix: TEMP_PREFIX });
const cutoff = Date.now() - TEMP_MAX_AGE_MS;
const deletions = files
.filter(f => {
const updated = f.metadata?.updated;
return updated && new Date(updated).getTime() < cutoff;
})
.map(f => f.delete());
await Promise.all(deletions);
console.log(`Deleted ${deletions.length} expired temp file(s).`);
}
25 changes: 25 additions & 0 deletions functions/src/common/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { Datastore } from './datastore';
import { MailService } from './mail-service';
import { getApp, initializeApp } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
import { randomUUID } from 'crypto';

let datastore: Datastore | undefined;
let mailService: MailService | undefined;
Expand Down Expand Up @@ -49,3 +51,26 @@ export async function getMailService(): Promise<MailService | undefined> {
export function resetDatastore() {
datastore = undefined;
}

export function getStorageBucket() {
initializeFirebaseApp();
return getStorage().bucket();
}

/**
* Sets a Firebase Storage download token on the given file and returns a
* download URL that does not require IAM signing permissions.
*/
export async function getFirebaseDownloadUrl(file: {
name: string;
bucket: { name: string };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setMetadata: (metadata: any) => Promise<unknown>;
}): Promise<string> {
const token = randomUUID();
await file.setMetadata({
metadata: { firebaseStorageDownloadTokens: token },
});
const encoded = encodeURIComponent(file.name);
return `https://firebasestorage.googleapis.com/v0/b/${file.bucket.name}/o/${encoded}?alt=media&token=${token}`;
}
22 changes: 22 additions & 0 deletions functions/src/common/temp-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Copyright 2026 The Ground Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export const TEMP_PREFIX = 'temp/';
export const TEMP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour

export function getTempFilePath(userId: string, filename: string): string {
return `${TEMP_PREFIX}${userId}/${filename}`;
}
48 changes: 39 additions & 9 deletions functions/src/export-csv.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ import {
createResponseSpy,
} from './testing/http-test-helpers';
import { DecodedIdToken } from 'firebase-admin/auth';
import { StatusCodes } from 'http-status-codes';
import { SURVEY_ORGANIZER_ROLE } from './common/auth';
import { getDatastore, resetDatastore } from './common/context';
import * as context from './common/context';
import { PassThrough } from 'stream';
import { Firestore, QueryDocumentSnapshot } from 'firebase-admin/firestore';
import { exportCsvHandler } from './export-csv';
import { registry } from '@ground/lib';
Expand Down Expand Up @@ -94,6 +95,10 @@ async function* fetchLoisSubmissionsFromMock(

describe('exportCsv()', () => {
let mockFirestore: Firestore;
let storageChunks: string[];
let mockFile: jasmine.SpyObj<any>;
const FIREBASE_DOWNLOAD_URL_PREFIX =
'https://firebasestorage.googleapis.com/v0/b/test-bucket/o/';
const email = 'somebody@test.it';
const userId = 'user5000';
const survey = {
Expand Down Expand Up @@ -342,6 +347,26 @@ describe('exportCsv()', () => {
beforeEach(() => {
mockFirestore = createMockFirestore();
stubAdminApi(mockFirestore);
storageChunks = [];
const writeStream = new PassThrough();
writeStream.on('data', (chunk: Buffer) =>
storageChunks.push(chunk.toString())
);
mockFile = jasmine.createSpyObj('file', [
'createWriteStream',
'setMetadata',
]);
mockFile.createWriteStream.and.returnValue(writeStream);
mockFile.setMetadata.and.resolveTo([{}]);
Object.defineProperty(mockFile, 'name', {
value: 'temp/user5000/job.csv',
});
Object.defineProperty(mockFile, 'bucket', {
value: { name: 'test-bucket' },
});
const mockBucket = jasmine.createSpyObj('bucket', ['file']);
mockBucket.file.and.returnValue(mockFile);
spyOn(context, 'getStorageBucket').and.returnValue(mockBucket);
spyOn(getDatastore(), 'fetchPartialLocationsOfInterest').and.callFake(
(surveyId: string, jobId: string) => {
const emptyQuery: any = {
Expand Down Expand Up @@ -402,20 +427,25 @@ describe('exportCsv()', () => {
job: jobId,
},
});
const chunks: string[] = [];
const res = createResponseSpy(chunks);
const res = createResponseSpy();

// Run export CSV handler.
await exportCsvHandler(req, res, { email } as DecodedIdToken);

// Check post-conditions.
expect(res.status).toHaveBeenCalledOnceWith(StatusCodes.OK);
expect(res.type).toHaveBeenCalledOnceWith('text/csv');
expect(res.setHeader).toHaveBeenCalledOnceWith(
'Content-Disposition',
`attachment; filename=${expectedFilename}`
expect(res.redirect as jasmine.Spy).toHaveBeenCalledTimes(1);
const redirectUrl: string = (
res.redirect as jasmine.Spy
).calls.mostRecent().args[0];
expect(redirectUrl).toContain(FIREBASE_DOWNLOAD_URL_PREFIX);
expect(mockFile.createWriteStream).toHaveBeenCalledWith(
jasmine.objectContaining({
metadata: jasmine.objectContaining({
contentDisposition: `attachment; filename=${expectedFilename}`,
}),
})
);
const output = chunks.join('').trim();
const output = storageChunks.join('').trim();
const lines = output.split('\n');
expect(lines).toEqual(expectedCsv);
})
Expand Down
31 changes: 23 additions & 8 deletions functions/src/export-csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import * as csv from '@fast-csv/format';
import { canExport, hasOrganizerRole } from './common/auth';
import { isAccessibleLoi } from './common/utils';
import { geojsonToWKT } from '@terraformer/wkt';
import { getDatastore } from './common/context';
import {
getDatastore,
getFirebaseDownloadUrl,
getStorageBucket,
} from './common/context';
import { getTempFilePath } from './common/temp-storage';
import { DecodedIdToken } from 'firebase-admin/auth';
import { QueryDocumentSnapshot } from 'firebase-admin/firestore';
import { StatusCodes } from 'http-status-codes';
Expand Down Expand Up @@ -103,11 +108,15 @@ export async function exportCsvHandler(

const headers = getHeaders(tasks, loiProperties);

res.type('text/csv');
res.setHeader(
'Content-Disposition',
'attachment; filename=' + getFileName(jobName)
);
const fileName = getFileName(jobName);
const bucket = getStorageBucket();
const file = bucket.file(getTempFilePath(userId, `${Date.now()}.csv`));
const writeStream = file.createWriteStream({
metadata: {
contentType: 'text/csv',
contentDisposition: `attachment; filename=${fileName}`,
},
});

const csvStream = csv.format({
delimiter: ',',
Expand All @@ -116,7 +125,7 @@ export async function exportCsvHandler(
includeEndRowDelimiter: true, // Add \n to last row in CSV
quote: false,
});
csvStream.pipe(res);
csvStream.pipe(writeStream);

const rows = await db.fetchLoisSubmissions(
surveyId,
Expand All @@ -142,8 +151,14 @@ export async function exportCsvHandler(
}
}

res.status(StatusCodes.OK);
csvStream.end();

await new Promise<void>((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});

res.redirect(await getFirebaseDownloadUrl(file));
}

function getHeaders(tasks: Pb.ITask[], loiProperties: Set<string>): string[] {
Expand Down
48 changes: 39 additions & 9 deletions functions/src/export-geojson.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ import {
createResponseSpy,
} from './testing/http-test-helpers';
import { DecodedIdToken } from 'firebase-admin/auth';
import { StatusCodes } from 'http-status-codes';
import { DATA_COLLECTOR_ROLE } from './common/auth';
import { resetDatastore } from './common/context';
import * as context from './common/context';
import { PassThrough } from 'stream';
import { Firestore } from 'firebase-admin/firestore';
import { exportGeojsonHandler } from './export-geojson';
import { registry } from '@ground/lib';
Expand All @@ -45,6 +46,10 @@ const op = registry.getFieldIds(Pb.Task.MultipleChoiceQuestion.Option);

describe('export()', () => {
let mockFirestore: Firestore;
let storageChunks: string[];
let mockFile: jasmine.SpyObj<any>;
const FIREBASE_DOWNLOAD_URL_PREFIX =
'https://firebasestorage.googleapis.com/v0/b/test-bucket/o/';
const email = 'somebody@test.it';
const userId = 'user5000';
const survey = {
Expand Down Expand Up @@ -174,6 +179,26 @@ describe('export()', () => {
beforeEach(() => {
mockFirestore = createMockFirestore();
stubAdminApi(mockFirestore);
storageChunks = [];
const writeStream = new PassThrough();
writeStream.on('data', (chunk: Buffer) =>
storageChunks.push(chunk.toString())
);
mockFile = jasmine.createSpyObj('file', [
'createWriteStream',
'setMetadata',
]);
mockFile.createWriteStream.and.returnValue(writeStream);
mockFile.setMetadata.and.resolveTo([{}]);
Object.defineProperty(mockFile, 'name', {
value: 'temp/user5000/job.geojson',
});
Object.defineProperty(mockFile, 'bucket', {
value: { name: 'test-bucket' },
});
const mockBucket = jasmine.createSpyObj('bucket', ['file']);
mockBucket.file.and.returnValue(mockFile);
spyOn(context, 'getStorageBucket').and.returnValue(mockBucket);
});

afterEach(() => {
Expand All @@ -200,8 +225,7 @@ describe('export()', () => {
job: jobId,
},
});
const chunks: string[] = [];
const res = createResponseSpy(chunks);
const res = createResponseSpy();

// Run export handler.
await exportGeojsonHandler(req, res, {
Expand All @@ -210,13 +234,19 @@ describe('export()', () => {
} as DecodedIdToken);

// Check post-conditions.
expect(res.status).toHaveBeenCalledOnceWith(StatusCodes.OK);
expect(res.type).toHaveBeenCalledOnceWith('application/json');
expect(res.setHeader).toHaveBeenCalledOnceWith(
'Content-Disposition',
`attachment; filename=${expectedFilename}`
expect(res.redirect as jasmine.Spy).toHaveBeenCalledTimes(1);
const redirectUrl: string = (
res.redirect as jasmine.Spy
).calls.mostRecent().args[0];
expect(redirectUrl).toContain(FIREBASE_DOWNLOAD_URL_PREFIX);
expect(mockFile.createWriteStream).toHaveBeenCalledWith(
jasmine.objectContaining({
metadata: jasmine.objectContaining({
contentDisposition: `attachment; filename=${expectedFilename}`,
}),
})
);
const output = JSON.parse(chunks.join(''));
const output = JSON.parse(storageChunks.join(''));
expect(output).toEqual(expectedGeojson);
expect(JSON.stringify(output)).toEqual(JSON.stringify(expectedGeojson));
})
Expand Down
39 changes: 27 additions & 12 deletions functions/src/export-geojson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
import { Request } from 'firebase-functions/v2/https';
import type { Response } from 'express';
import { canExport, hasOrganizerRole } from './common/auth';
import { getDatastore } from './common/context';
import {
getDatastore,
getFirebaseDownloadUrl,
getStorageBucket,
} from './common/context';
import { getTempFilePath } from './common/temp-storage';
import { isAccessibleLoi } from './common/utils';
import { DecodedIdToken } from 'firebase-admin/auth';
import { StatusCodes } from 'http-status-codes';
Expand Down Expand Up @@ -79,15 +84,18 @@ export async function exportGeojsonHandler(

const ownerIdFilter = canViewAll ? null : userId;

res.type('application/json');
res.setHeader(
'Content-Disposition',
'attachment; filename=' + getFileName(jobName)
);
res.status(StatusCodes.OK);
const fileName = getFileName(jobName);
const bucket = getStorageBucket();
const file = bucket.file(getTempFilePath(userId, `${Date.now()}.geojson`));
const writeStream = file.createWriteStream({
metadata: {
contentType: 'application/json',
contentDisposition: `attachment; filename=${fileName}`,
},
});

// Write opening of FeatureCollection manually
res.write('{\n "type": "FeatureCollection",\n "features": [\n');
writeStream.write('{\n "type": "FeatureCollection",\n "features": [\n');

// Fetch all locations of interest
const rows = await db.fetchLocationsOfInterest(surveyId, jobId);
Expand All @@ -103,22 +111,29 @@ export async function exportGeojsonHandler(

// Manually write the separator comma before each feature except the first one.
if (!first) {
res.write(',\n');
writeStream.write(',\n');
} else {
first = false;
}

// Use JSON.stringify to convert the feature object to a string and write it.
res.write(JSON.stringify(feature, null, 2));
writeStream.write(JSON.stringify(feature, null, 2));
}
} catch (e) {
console.debug('Skipping row', e);
}
}

// Close the FeatureCollection after the loop completes.
res.write('\n ]\n}');
res.end();
writeStream.write('\n ]\n}');
writeStream.end();

await new Promise<void>((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});

res.redirect(await getFirebaseDownloadUrl(file));
}

function buildFeature(loi: Pb.LocationOfInterest) {
Expand Down
Loading
Loading