Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add persistence for trails #334

Merged
merged 1 commit into from
Aug 9, 2023
Merged
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
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