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
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