Skip to content

Commit

Permalink
feat: add persistence for trails
Browse files Browse the repository at this point in the history
Using Cloud Storage with an index in Firestore.
  • Loading branch information
th0rgall committed Aug 9, 2023
1 parent 6c3cb2b commit 5cf8f87
Show file tree
Hide file tree
Showing 17 changed files with 381 additions and 72 deletions.
7 changes: 7 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,5 +167,12 @@ firebase deploy --only functions:createStripeCustomer,functions:createOrRetrieve
See [the docs](https://firebase.google.com/docs/rules/manage-deploy#deploy_your_updates).

```
# For Firestore
firebase deploy --only firestore:rules
# For Storage
firebase deploy --only storage
# Both
firebase deploy --only firestore:rules,storage
```
32 changes: 28 additions & 4 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ service cloud.firestore {
return request.auth.token.admin;
}

function isMember() {
return get(/databases/$(database)/documents/users/$(requesterId())).data.superfan == true;
}

function requesterId() {
return request.auth.uid;
}
Expand Down Expand Up @@ -90,13 +94,34 @@ service cloud.firestore {
&& !request.resource.data.diff(resource.data).affectedKeys().hasAny(['stripeCustomerId', 'stripeSubscription', 'sendgridId', 'oldEmail', 'newEmail'])
}

function validateTrailState(trail) {
return isNonEmptyString(trail.originalFileName)
&& isNonEmptyString(trail.md5Hash)
&& trail.visible is bool
}

function isValidTrailAccess(userId) {
// Only superfans (members) can access trails
return isSignedIn() && isOwner(userId) && isMember()
}

match /users-private/{userId} {
// users-private documents are always created by firebase-admin.
allow read: if isOwner(userId);
allow update:
if isSignedIn() &&
isOwner(userId) &&
validateUserPrivateState(request.resource.data)
match /trails/{trailId} {
allow read: if isValidTrailAccess(userId);
allow create: if isValidTrailAccess(userId)
&& validateTrailState(incomingData())
allow update: if isValidTrailAccess(userId)
// Only the visibility should be updateable by the user, once the trail is created.
&& incomingData().diff(existingData()).affectedKeys().hasOnly(['visible'])
&& incomingData().visible is bool;
allow delete: if isValidTrailAccess(userId);
}
}

// Garden functions
Expand Down Expand Up @@ -251,7 +276,7 @@ service cloud.firestore {
// The first user in the participating `users` array must be the chat creator (first message sender)
&& incomingData().users[0] == requesterId()
// Only superfans (members) can create chats
&& get(/databases/$(database)/documents/users/$(requesterId())).data.superfan == true
&& isMember()
// Both participants exist
&& userExists(incomingData().users[0])
&& userExists(incomingData().users[1])
Expand Down Expand Up @@ -299,9 +324,7 @@ service cloud.firestore {
}
}

match /tmp-users/{userId} {
allow read, write: if false
}
// Stats

match /stats/{type} {
allow read: if isSignedIn() && isAdmin();
Expand All @@ -315,5 +338,6 @@ service cloud.firestore {
match /stats/campsites {
allow read: if true;
}

}
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@tmcw/togeojson": "^5.5.0",
"@turf/turf": "^6.5.0",
"@types/lodash-es": "^4.17.7",
"@types/md5": "^2.3.2",
"@types/nprogress": "^0.2.0",
"@types/smoothscroll-polyfill": "^0.3.1",
"@typescript-eslint/eslint-plugin": "^5.27.0",
Expand All @@ -51,6 +52,7 @@
"iso-639-1": "^2.1.9",
"lodash-es": "^4.17.21",
"maplibre-gl": "^1",
"md5": "^2.3.0",
"nprogress": "^0.2.0",
"prettier": "^2.6.2",
"prettier-plugin-svelte": "^2.10.1",
Expand Down
1 change: 1 addition & 0 deletions src/lib/api/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export const STATS = 'stats';
export const USERS = 'users';
export const USERS_PRIVATE = 'users-private';
export const MESSAGES = 'messages';
export const TRAILS = 'trails';
151 changes: 151 additions & 0 deletions src/lib/api/trail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { deleteObject, getDownloadURL, ref, uploadString } from 'firebase/storage';
import { db, storage } from './firebase';
import { getUser } from '$lib/stores/auth';
import type GeoJSON from 'geojson';
import {
CollectionReference,
DocumentReference,
collection,
deleteDoc,
doc,
onSnapshot,
query,
setDoc,
updateDoc
} from 'firebase/firestore';
import { TRAILS, USERS_PRIVATE } from './collections';
import type { FirebaseTrail, LocalTrail } from '$lib/types/Trail';
import md5 from 'md5';
import {
addFileDataLayers,
findFileDataLayer,
findFileDataLayerByMD5Hash,
removeFileDataLayers,
updateFileDataLayers
} from '$lib/stores/file';

const getFileRef = (fileId: string) => ref(storage(), `trails/${getUser().uid}/${fileId}`);

export const createTrailObserver = () => {
const q = query(
collection(db(), USERS_PRIVATE, getUser().id, TRAILS) as CollectionReference<FirebaseTrail>
);

return onSnapshot(q, async (querySnapshot) => {
const changes = querySnapshot.docChanges();
changes.map(async (change) => {
const trail: LocalTrail = {
id: change.doc.id,
animate: false,
...change.doc.data()
};
if (change.type === 'added') {
const existingLocalLayer = findFileDataLayer(trail.id);
if (!existingLocalLayer) {
// Sync a *remote* addition to the local cache
// Locally added files are added before they are uploaded (see `createTrail()`).
//
// Download the trail file
const ref = getFileRef(trail.id);
const geoJson = await getDownloadURL(ref)
.then((url) => fetch(url))
.then((r) => r.json());
addFileDataLayers({
...trail,
geoJson
});
}
} else if (change.type === 'removed') {
// Sync the deletion to the local cache
const existingLocalLayer = findFileDataLayer(trail.id);
if (existingLocalLayer) {
removeFileDataLayers(trail.id);
}
} else if (change.type === 'modified') {
// For now, only the visiblity can be modified
updateFileDataLayers(trail.id, trail);
}
});
});
};

export const createTrail = async ({
/**
* Original file name
*/
name,
geoJson
}: {
name: string;
geoJson: GeoJSON.FeatureCollection | GeoJSON.Feature;
}) => {
const uid = getUser().id;

// Calculate the file's MD5 checksum
const jsonString = JSON.stringify(geoJson);
const md5Hash = md5(jsonString);

// Check if the file already exists
const existingFile = findFileDataLayerByMD5Hash(md5Hash);
if (existingFile) {
console.error('This file already exists');
return;
}

// First, create a local Firestore doc reference explicitly, so it's ID can be used
const docRef = doc(
collection(db(), USERS_PRIVATE, uid, TRAILS)
) as DocumentReference<FirebaseTrail>;

// Immediately show the file locally
addFileDataLayers({
id: docRef.id,
originalFileName: name,
geoJson,
md5Hash,
visible: true,
animate: true
});

// Next, upload the file for persistence
const fileRef = getFileRef(docRef.id);
await uploadString(fileRef, jsonString, undefined, {
// TODO: should we rename extensions to .geojson?
contentType: 'application/geo+json',
customMetadata: {
originalFileName: name
}
});

// Set the remote document content
await setDoc(docRef, {
originalFileName: name,
// Default
md5Hash,
visible: true
});
};

export const toggleTrailVisibility = async (id: string) => {
const existingFile = findFileDataLayer(id);
if (!existingFile) {
console.error('The visibility of this trail can not be changed');
return;
}
const ref = doc(db(), USERS_PRIVATE, getUser().id, TRAILS, id);
await updateDoc(ref, { visible: !existingFile.visible });
};

export const deleteTrail = async (id: string) => {
const existingFile = findFileDataLayer(id);
if (!existingFile) {
console.error('This trail does not exist');
return;
}

const ref = getFileRef(id);
// Delete the storage object
await deleteObject(ref);
// Delete the metadata
await deleteDoc(doc(db(), USERS_PRIVATE, getUser().id, TRAILS, id));
};
17 changes: 5 additions & 12 deletions src/lib/components/LayersAndTools/TrailsTool.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@
import { LabeledCheckbox, MultiActionLabel, Button, Icon } from '$lib/components/UI';
import { cyclistIcon, hikerIcon, routesIcon } from '$lib/images/icons';
import {
fileDataLayers,
removeFileDataLayers,
toggleVisibilityFileDataLayers
} from '$lib/stores/file';
import { fileDataLayers } from '$lib/stores/file';
import { cleanName } from '$lib/util/slugify';
import { onDestroy } from 'svelte';
import { deleteTrail, toggleTrailVisibility } from '$lib/api/trail';
export let showHiking: boolean;
export let showCycling: boolean;
Expand Down Expand Up @@ -41,14 +38,10 @@
<MultiActionLabel
icon={routesIcon}
name={layer.id}
label={cleanName(layer.name)}
label={cleanName(layer.originalFileName)}
checked={layer.visible}
on:change={() => {
toggleVisibilityFileDataLayers(layer.id);
}}
on:secondary={() => {
removeFileDataLayers(layer.id);
}}
on:change={() => toggleTrailVisibility(layer.id)}
on:secondary={() => deleteTrail(layer.id)}
/>
{/each}
</div>
Expand Down
7 changes: 2 additions & 5 deletions src/lib/components/Map/FileTrailModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import { keyboardEvent } from '$lib/stores/keyboardEvent';
import { VALID_FILETYPE_EXTENSIONS } from '$lib/constants';
import { getFileExtension } from '$lib/util';
import { addFileDataLayers } from '$lib/stores/file';
import Icon from '$lib/components/UI/Icon.svelte';
import { crossIcon, uploadCloudIcon } from '$lib/images/icons';
import Text from '$lib/components/UI/Text.svelte';
Expand All @@ -14,6 +13,7 @@
import notification from '$lib/stores/notification';
import trackEvent from '$lib/util/track-plausible';
import { PlausibleEvent } from '$lib/types/Plausible';
import { createTrail } from '$lib/api/trail';
export let show = false;
let files: File[] = [];
Expand Down Expand Up @@ -43,10 +43,7 @@
try {
const geoJson = await fileToGeoJson(file);
addFileDataLayers({
name: file.name,
geoJson: geoJson
});
createTrail({ name: file.name, geoJson });
return true;
} catch (error) {
notification.warning('Error while processing file', 5000);
Expand Down
38 changes: 22 additions & 16 deletions src/lib/components/Map/FileTrails.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import bbox from '@turf/bbox';
import key from './mapbox-context.js';
import { ZOOM_LEVELS } from '$lib/constants.js';
import type { FileDataLayer } from '$lib/types/DataLayer';
type SourceData =
| string
Expand All @@ -24,23 +25,27 @@
}
};
const addTrail = (geoJson: SourceData, id: string) => {
try {
const bboxBounds = <[number, number, number, number]>bbox(geoJson).slice(0, 4);
map.fitBounds(bboxBounds, {
padding: {
top: 150,
bottom: 150,
left: 50,
right: 50
},
maxZoom: ZOOM_LEVELS.ROAD,
linear: true
});
} catch (error) {
console.error(error);
const addTrail = ({ geoJson, id, animate }: FileDataLayer) => {
// Zoom & pan the map to show the trail, but only if we added the route locally.
if (animate) {
try {
const bboxBounds = <[number, number, number, number]>bbox(geoJson).slice(0, 4);
map.fitBounds(bboxBounds, {
padding: {
top: 150,
bottom: 150,
left: 50,
right: 50
},
maxZoom: ZOOM_LEVELS.ROAD,
linear: true
});
} catch (error) {
console.error(error);
}
}
// Add/update layer data
if (map.getSource(id)) {
map.getSource(id).setData(geoJson);
} else {
Expand All @@ -50,6 +55,7 @@
});
}
// Add a presentation if not existing yet
if (!map.getLayer(id)) {
map.addLayer({
id,
Expand Down Expand Up @@ -142,7 +148,7 @@
// Add new layers
idsToAdd.map((id) => {
const fileDataLayer = fileDataLayers.find((fileDataLayer) => fileDataLayer.id === id);
if (fileDataLayer) addTrail(fileDataLayer.geoJson, id);
if (fileDataLayer) addTrail(fileDataLayer);
});
// Remove old layers
Expand Down
Loading

0 comments on commit 5cf8f87

Please sign in to comment.