MapComplete/src/Logic/Web/MangroveReviews.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

374 lines
13 KiB
TypeScript
Raw Normal View History

2023-09-28 23:50:27 +02:00
import { ImmutableStore, Store, UIEventSource } from "../UIEventSource"
import { MangroveReviews, Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils"
import { Feature, Position } from "geojson"
import { GeoOperations } from "../GeoOperations"
2024-03-28 02:23:13 +01:00
import ScriptUtils from "../../../scripts/ScriptUtils"
2020-12-08 23:44:34 +01:00
export class MangroveIdentity {
2024-04-13 02:40:21 +02:00
private readonly keypair: UIEventSource<CryptoKeyPair> = new UIEventSource<CryptoKeyPair>(
undefined
)
2024-02-20 16:53:26 +01:00
/**
* Same as the one in the user settings
*/
public readonly mangroveIdentity: UIEventSource<string>
2024-04-02 13:32:19 +02:00
private readonly key_id: UIEventSource<string> = new UIEventSource<string>(undefined)
2024-02-20 16:53:26 +01:00
private readonly _mangroveIdentityCreationDate: UIEventSource<string>
2020-12-08 23:44:34 +01:00
2024-04-13 02:40:21 +02:00
constructor(
mangroveIdentity: UIEventSource<string>,
mangroveIdentityCreationDate: UIEventSource<string>
) {
this.mangroveIdentity = mangroveIdentity
2024-02-20 16:53:26 +01:00
this._mangroveIdentityCreationDate = mangroveIdentityCreationDate
mangroveIdentity.addCallbackAndRunD(async (data) => {
2024-04-13 02:40:21 +02:00
await this.setKeypair(data)
2023-09-28 23:50:27 +02:00
})
2020-12-08 23:44:34 +01:00
}
2024-04-13 02:40:21 +02:00
private async setKeypair(data: string) {
console.log("Setting keypair from", data)
2024-04-02 13:32:19 +02:00
const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data))
this.keypair.setData(keypair)
const pem = await MangroveReviews.publicToPem(keypair.publicKey)
this.key_id.setData(pem)
}
2020-12-08 23:44:34 +01:00
/**
* Creates an identity if none exists already.
* Is written into the UIEventsource, which was passed into the constructor
* @constructor
*/
2024-02-20 16:53:26 +01:00
private async CreateIdentity(): Promise<void> {
2023-09-28 23:50:27 +02:00
const keypair = await MangroveReviews.generateKeypair()
const jwk = await MangroveReviews.keypairToJwk(keypair)
2024-02-20 16:53:26 +01:00
if ((this.mangroveIdentity.data ?? "") !== "") {
// Identity has been loaded via osmPreferences by now - we don't overwrite
2023-09-28 23:50:27 +02:00
return
2020-12-08 23:44:34 +01:00
}
2024-04-02 13:32:19 +02:00
this.keypair.setData(keypair)
const pem = await MangroveReviews.publicToPem(keypair.publicKey)
this.key_id.setData(pem)
2024-02-20 16:53:26 +01:00
this.mangroveIdentity.setData(JSON.stringify(jwk))
this._mangroveIdentityCreationDate.setData(new Date().toISOString())
2020-12-08 23:44:34 +01:00
}
/**
* Only called to create a review.
*/
async getKeypair(): Promise<CryptoKeyPair> {
2024-04-02 13:32:19 +02:00
if (this.keypair.data === undefined) {
// We want to create a review, but it seems like no key has been setup at this moment
// We create the key
try {
if (!Utils.runningFromConsole && (this.mangroveIdentity.data ?? "") === "") {
2024-02-20 16:53:26 +01:00
await this.CreateIdentity()
}
} catch (e) {
console.error("Could not create identity: ", e)
}
}
return this.keypair.data
}
getKeyId(): Store<string> {
return this.key_id
}
2024-04-13 02:40:21 +02:00
private geoReviewsById: Store<(Review & { kid: string; signature: string })[]> = undefined
2024-03-28 02:23:13 +01:00
2024-04-13 02:40:21 +02:00
public getGeoReviews(): Store<(Review & { kid: string; signature: string })[] | undefined> {
2024-03-28 02:23:13 +01:00
if (!this.geoReviewsById) {
const all = this.getAllReviews()
2024-04-13 02:40:21 +02:00
this.geoReviewsById = this.getAllReviews().mapD((reviews) =>
reviews.filter((review) => {
2024-04-02 13:32:19 +02:00
try {
const subjectUrl = new URL(review.sub)
return subjectUrl.protocol === "geo:"
} catch (e) {
2024-03-28 02:23:13 +01:00
return false
}
2024-04-13 02:40:21 +02:00
})
)
2024-03-28 02:23:13 +01:00
}
return this.geoReviewsById
}
2024-02-20 13:33:38 +01:00
private allReviewsById: UIEventSource<(Review & { kid: string; signature: string })[]> =
undefined
/**
* Gets all reviews that are made for the current identity.
2024-03-28 02:23:13 +01:00
* The returned store will contain `undefined` if still loading
*/
2024-03-28 02:23:13 +01:00
public getAllReviews(): Store<(Review & { kid: string; signature: string })[] | undefined> {
2024-02-20 13:33:38 +01:00
if (this.allReviewsById !== undefined) {
return this.allReviewsById
}
2024-03-28 02:23:13 +01:00
this.allReviewsById = new UIEventSource(undefined)
this.key_id.map(async (pem) => {
2024-02-20 13:33:38 +01:00
if (pem === undefined) {
return []
}
2024-03-28 02:23:13 +01:00
const allReviews = await MangroveReviews.getReviews({
2024-04-13 02:40:21 +02:00
kid: pem,
})
2024-03-28 02:23:13 +01:00
this.allReviewsById.setData(
allReviews.reviews.map((r) => ({
...r,
2024-04-13 02:40:21 +02:00
...r.payload,
2024-03-28 02:23:13 +01:00
}))
)
})
return this.allReviewsById
}
2024-02-20 13:33:38 +01:00
addReview(review: Review & { kid; signature }) {
this.allReviewsById?.setData(this.allReviewsById?.data?.concat([review]))
}
2020-12-08 23:44:34 +01:00
}
2020-12-07 03:02:50 +01:00
/**
* Tracks all reviews of a given feature, allows to create a new review
*/
export default class FeatureReviews {
/**
* See https://gitlab.com/open-reviews/mangrove/-/blob/master/servers/reviewer/src/review.rs#L269 and https://github.com/pietervdvn/MapComplete/issues/1775
*/
public static readonly REVIEW_OPINION_MAX_LENGTH = 1000
2023-09-28 23:50:27 +02:00
private static readonly _featureReviewsCache: Record<string, FeatureReviews> = {}
public readonly subjectUri: Store<string>
public readonly average: Store<number | null>
private readonly _reviews: UIEventSource<
(Review & { kid: string; signature: string; madeByLoggedInUser: Store<boolean> })[]
> = new UIEventSource([])
public readonly reviews: Store<(Review & { madeByLoggedInUser: Store<boolean> })[]> =
2023-09-28 23:50:27 +02:00
this._reviews
private readonly _lat: number
private readonly _lon: number
private readonly _uncertainty: number
private readonly _name: Store<string>
private readonly _identity: MangroveIdentity
2024-04-02 13:32:19 +02:00
private readonly _testmode: Store<boolean>
2022-09-08 21:40:48 +02:00
2021-04-23 17:22:01 +02:00
private constructor(
2023-03-28 05:13:48 +02:00
feature: Feature,
tagsSource: UIEventSource<Record<string, string>>,
2024-02-20 16:53:26 +01:00
mangroveIdentity: MangroveIdentity,
options?: {
nameKey?: "name" | string
fallbackName?: string
uncertaintyRadius?: number
2024-04-02 13:32:19 +02:00
},
testmode?: Store<boolean>
2021-04-23 17:22:01 +02:00
) {
const centerLonLat = GeoOperations.centerpointCoordinates(feature)
2023-09-28 23:50:27 +02:00
;[this._lon, this._lat] = centerLonLat
2024-02-20 16:53:26 +01:00
this._identity = mangroveIdentity
2024-04-02 13:32:19 +02:00
this._testmode = testmode ?? new ImmutableStore(false)
2023-09-28 23:50:27 +02:00
const nameKey = options?.nameKey ?? "name"
if (feature.geometry.type === "Point") {
2023-09-28 23:50:27 +02:00
this._uncertainty = options?.uncertaintyRadius ?? 10
} else {
2023-09-28 23:50:27 +02:00
let coordss: Position[][]
if (feature.geometry.type === "LineString") {
2023-09-28 23:50:27 +02:00
coordss = [feature.geometry.coordinates]
} else if (
feature.geometry.type === "MultiLineString" ||
feature.geometry.type === "Polygon"
) {
2023-09-28 23:50:27 +02:00
coordss = feature.geometry.coordinates
}
2023-09-28 23:50:27 +02:00
let maxDistance = 0
2023-02-06 00:30:50 +01:00
for (const coords of coordss) {
for (const coord of coords) {
maxDistance = Math.max(
maxDistance,
GeoOperations.distanceBetween(centerLonLat, coord)
2023-09-28 23:50:27 +02:00
)
2023-02-06 00:30:50 +01:00
}
}
2023-09-28 23:50:27 +02:00
this._uncertainty = options?.uncertaintyRadius ?? maxDistance
2021-04-23 17:22:01 +02:00
}
2023-09-28 23:50:27 +02:00
this._name = tagsSource.map((tags) => tags[nameKey] ?? options?.fallbackName)
2023-09-28 23:50:27 +02:00
this.subjectUri = this.ConstructSubjectUri()
2023-09-28 23:50:27 +02:00
const self = this
this.subjectUri.addCallbackAndRunD(async (sub) => {
2023-09-28 23:50:27 +02:00
const reviews = await MangroveReviews.getReviews({ sub })
self.addReviews(reviews.reviews)
})
/* We also construct all subject queries _without_ encoding the name to work around a previous bug
* See https://github.com/giggls/opencampsitemap/issues/30
*/
this.ConstructSubjectUri(true).addCallbackAndRunD(async (sub) => {
try {
2023-09-28 23:50:27 +02:00
const reviews = await MangroveReviews.getReviews({ sub })
self.addReviews(reviews.reviews)
} catch (e) {
2023-09-28 23:50:27 +02:00
console.log("Could not fetch reviews for partially incorrect query ", sub)
}
2023-09-28 23:50:27 +02:00
})
this.average = this._reviews.map((reviews) => {
2023-09-28 04:02:42 +02:00
if (!reviews) {
2023-09-28 23:50:27 +02:00
return null
2023-09-28 04:02:42 +02:00
}
2023-09-28 23:50:27 +02:00
if (reviews.length === 0) {
2023-09-28 04:02:42 +02:00
return null
}
2023-09-28 23:50:27 +02:00
let sum = 0
let count = 0
2023-09-28 04:02:42 +02:00
for (const review of reviews) {
if (review.rating !== undefined) {
2023-09-28 23:50:27 +02:00
count++
sum += review.rating
2023-09-28 04:02:42 +02:00
}
}
return Math.round(sum / count)
2023-09-28 23:50:27 +02:00
})
2021-04-23 17:22:01 +02:00
}
2020-12-08 23:44:34 +01:00
/**
* Construct a featureReviewsFor or fetches it from the cache
*/
public static construct(
2023-03-28 05:13:48 +02:00
feature: Feature,
tagsSource: UIEventSource<Record<string, string>>,
2024-04-02 13:32:19 +02:00
mangroveIdentity: MangroveIdentity,
options: {
nameKey?: "name" | string
fallbackName?: string
uncertaintyRadius?: number
2024-04-02 13:32:19 +02:00
},
testmode: Store<boolean>
): FeatureReviews {
2023-09-28 23:50:27 +02:00
const key = feature.properties.id
const cached = FeatureReviews._featureReviewsCache[key]
2021-04-23 17:22:01 +02:00
if (cached !== undefined) {
2023-09-28 23:50:27 +02:00
return cached
2020-12-08 23:44:34 +01:00
}
2024-04-13 02:40:21 +02:00
const featureReviews = new FeatureReviews(
feature,
tagsSource,
mangroveIdentity,
options,
testmode
)
2023-09-28 23:50:27 +02:00
FeatureReviews._featureReviewsCache[key] = featureReviews
return featureReviews
2020-12-07 03:02:50 +01:00
}
2020-12-08 23:44:34 +01:00
/**
* The given review is uploaded to mangrove.reviews and added to the list of known reviews
2020-12-08 23:44:34 +01:00
*/
public async createReview(review: Omit<Review, "sub">): Promise<void> {
2024-02-20 13:33:38 +01:00
if (
review.opinion !== undefined &&
review.opinion.length > FeatureReviews.REVIEW_OPINION_MAX_LENGTH
) {
throw (
"Opinion too long, should be at most " +
FeatureReviews.REVIEW_OPINION_MAX_LENGTH +
" characters long"
)
}
const r: Review = {
sub: this.subjectUri.data,
2024-04-13 02:40:21 +02:00
...review,
2023-09-28 23:50:27 +02:00
}
const keypair: CryptoKeyPair = await this._identity.getKeypair()
2023-09-28 23:50:27 +02:00
const jwt = await MangroveReviews.signReview(keypair, r)
const kid = await MangroveReviews.publicToPem(keypair.publicKey)
2024-04-02 13:32:19 +02:00
if (!this._testmode.data) {
await MangroveReviews.submitReview(jwt)
} else {
console.log("Testmode enabled - not uploading review")
await Utils.waitFor(1000)
}
const reviewWithKid = {
...r,
kid,
signature: jwt,
2024-04-13 02:40:21 +02:00
madeByLoggedInUser: new ImmutableStore(true),
}
2024-02-20 13:33:38 +01:00
this._reviews.data.push(reviewWithKid)
2023-09-28 23:50:27 +02:00
this._reviews.ping()
this._identity.addReview(reviewWithKid)
2020-12-08 23:44:34 +01:00
}
2020-12-07 03:02:50 +01:00
/**
* Adds given reviews to the 'reviews'-UI-eventsource
* @param reviews
* @private
2020-12-07 03:02:50 +01:00
*/
private addReviews(reviews: { payload: Review; kid: string; signature: string }[]) {
2023-09-28 23:50:27 +02:00
const self = this
const alreadyKnown = new Set(self._reviews.data.map((r) => r.rating + " " + r.opinion))
2023-09-28 23:50:27 +02:00
let hasNew = false
for (const reviewData of reviews) {
2023-09-28 23:50:27 +02:00
const review = reviewData.payload
try {
2023-09-28 23:50:27 +02:00
const url = new URL(review.sub)
if (url.protocol === "geo:") {
const coordinate = <[number, number]>(
url.pathname.split(",").map((n) => Number(n))
2023-09-28 23:50:27 +02:00
)
const distance = GeoOperations.distanceBetween(
[this._lat, this._lon],
coordinate
2023-09-28 23:50:27 +02:00
)
if (distance > this._uncertainty) {
2023-09-28 23:50:27 +02:00
continue
}
}
} catch (e) {
2023-09-28 23:50:27 +02:00
console.warn(e)
}
2020-12-08 23:44:34 +01:00
2023-09-28 23:50:27 +02:00
const key = review.rating + " " + review.opinion
if (alreadyKnown.has(key)) {
2023-09-28 23:50:27 +02:00
continue
}
self._reviews.data.push({
...review,
kid: reviewData.kid,
signature: reviewData.signature,
madeByLoggedInUser: this._identity.getKeyId().map((user_key_id) => {
2023-09-28 23:50:27 +02:00
return reviewData.kid === user_key_id
2024-04-13 02:40:21 +02:00
}),
2023-09-28 23:50:27 +02:00
})
hasNew = true
2020-12-08 23:44:34 +01:00
}
if (hasNew) {
2023-09-28 04:02:42 +02:00
self._reviews.data.sort((a, b) => b.iat - a.iat) // Sort with most recent first
2023-09-28 23:50:27 +02:00
self._reviews.ping()
2020-12-11 15:27:52 +01:00
}
}
/**
* Gets an URI which represents the item in a mangrove-compatible way
*
* See https://mangrove.reviews/standard#mangrove-core-uri-schemes
* @constructor
*/
private ConstructSubjectUri(dontEncodeName: boolean = false): Store<string> {
// https://www.rfc-editor.org/rfc/rfc5870#section-3.4.2
// `u` stands for `uncertainty`, https://www.rfc-editor.org/rfc/rfc5870#section-3.4.3
2023-09-28 23:50:27 +02:00
const self = this
2024-04-13 02:40:21 +02:00
return this._name.map(function (name) {
2024-01-01 20:20:21 +01:00
let uri = `geo:${self._lat},${self._lon}?u=${Math.round(self._uncertainty)}`
if (name) {
2023-09-28 23:50:27 +02:00
uri += "&q=" + (dontEncodeName ? name : encodeURIComponent(name))
2020-12-11 15:27:52 +01:00
}
2023-09-28 23:50:27 +02:00
return uri
})
2020-12-07 03:02:50 +01:00
}
}