2023-01-20 17:48:34 +01:00
|
|
|
import {Metadata, Review} from "./Review";
|
2023-01-21 22:09:48 +01:00
|
|
|
import jwkToPem from "jwk-to-pem"
|
2023-01-20 17:48:34 +01:00
|
|
|
import axios from "axios";
|
2023-01-21 22:09:48 +01:00
|
|
|
import {SignJWT} from "jose"
|
2023-01-20 17:48:34 +01:00
|
|
|
import {JWTPayload} from "jose/dist/types/types";
|
|
|
|
|
|
|
|
export interface QueryParameters {
|
|
|
|
/**
|
|
|
|
* Search for reviews that have this string in `sub` or `opinion` field.
|
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
q?: string,
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Search for review with this `signature` value.
|
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
signature?: string
|
2023-01-20 17:48:34 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Reviews by issuer with the following PEM public key.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
kid?: string
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Reviews issued at this UNIX time.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
iat?: number
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Reviews with UNIX timestamp greater than this.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
gt_iat?: number
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Reviews of the given subject URI.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
sub?: string
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Reviews with the given rating.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
rating?: number
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Reviews with the given opinion.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
opinion?: string
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Maximum number of reviews to be returned.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
limit?: number
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Get only reviews with opinion text.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
opinionated?: boolean
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Include reviews of example subjects.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
examples?: boolean
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Include aggregate information about review issuers.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
issuers?: boolean
|
2023-01-20 17:48:34 +01:00
|
|
|
/**
|
|
|
|
* Include aggregate information about reviews of returned reviews.
|
2023-01-21 22:09:48 +01:00
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
maresi_subjects?: boolean
|
2023-01-20 17:48:34 +01:00
|
|
|
|
2024-12-31 20:22:28 +01:00
|
|
|
/**
|
|
|
|
* Only used for 'geo:'-queries
|
|
|
|
* uncertainty in meters
|
|
|
|
*/
|
|
|
|
u?: number
|
|
|
|
|
2023-01-20 17:48:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export class MangroveReviews {
|
|
|
|
/** The API of the server used for https://mangrove.reviews */
|
|
|
|
public static readonly ORIGINAL_API = 'https://api.mangrove.reviews'
|
|
|
|
private static readonly PRIVATE_KEY_METADATA = 'Mangrove private key'
|
|
|
|
|
|
|
|
/** Assembles JWT from base payload, mutates the payload as needed.
|
|
|
|
* @param keypair - WebCrypto keypair, can be generated with `generateKeypair`.
|
|
|
|
* @param {Payload} payload - Base {@link Payload} to be cleaned, it will be mutated.
|
|
|
|
* @returns {string} Mangrove Review encoded as JWT.
|
|
|
|
*/
|
2023-01-21 22:09:48 +01:00
|
|
|
public static async signReview(keypair: CryptoKeyPair, payload: Review): Promise<string> {
|
2023-01-20 17:48:34 +01:00
|
|
|
payload = MangroveReviews.cleanPayload(payload)
|
|
|
|
const algo = 'ES256'
|
|
|
|
const kid = await MangroveReviews.publicToPem(keypair.publicKey)
|
2023-01-21 22:42:58 +01:00
|
|
|
const jwk = JSON.stringify(await crypto.subtle.exportKey('jwk', keypair.publicKey))
|
2023-01-21 22:09:48 +01:00
|
|
|
return await new SignJWT(<JWTPayload>payload)
|
2023-01-20 17:48:34 +01:00
|
|
|
.setProtectedHeader({
|
|
|
|
alg: algo,
|
|
|
|
kid,
|
2024-12-31 20:22:28 +01:00
|
|
|
jwk: <any>jwk,
|
2023-01-20 17:48:34 +01:00
|
|
|
enc: "utf-8"
|
|
|
|
})
|
2023-01-21 22:09:48 +01:00
|
|
|
.sign(keypair.privateKey);
|
2023-01-20 17:48:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Submit a signed review to be stored in the database.
|
|
|
|
* @param {string} jwt Signed review in JWT format.
|
|
|
|
* @param {string} [api=ORIGINAL_API] API endpoint used to fetch the data.
|
|
|
|
* @returns {Promise} Resolves to "true" in case of successful insertion or rejects with errors.
|
|
|
|
*/
|
2023-01-21 21:28:22 +01:00
|
|
|
public static submitReview(jwt: string, api: string = MangroveReviews.ORIGINAL_API) {
|
2023-01-20 17:48:34 +01:00
|
|
|
return axios.put(`${api}/submit/${jwt}`)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Composition of `signReview` and `submitReview`.
|
|
|
|
* @param keypair WebCrypto keypair, can be generated with `generateKeypair`.
|
|
|
|
* @param {Payload} payload Base {@link Payload} to be cleaned, it will be mutated.
|
|
|
|
* @param {string} [api=ORIGINAL_API] - API endpoint used to fetch the data.
|
|
|
|
*/
|
2023-01-21 22:09:48 +01:00
|
|
|
static async signAndSubmitReview(keypair: CryptoKeyPair, payload: Review, api: string = MangroveReviews.ORIGINAL_API) {
|
2023-01-20 17:48:34 +01:00
|
|
|
const jwt = await MangroveReviews.signReview(keypair, payload)
|
|
|
|
return MangroveReviews.submitReview(jwt, api)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Retrieve reviews which fulfill the query.
|
|
|
|
* @param {QueryParameters} query Query to be passed to API, see the API documentation for examples.
|
|
|
|
|
|
|
|
* @param api The api-endpoint to query; default: mangrove.reviews
|
|
|
|
*/
|
2023-01-21 22:09:48 +01:00
|
|
|
public static async getReviews(query: QueryParameters, api = MangroveReviews.ORIGINAL_API):
|
|
|
|
Promise<{
|
|
|
|
/** A list of reviews satisfying the query.*/
|
|
|
|
reviews: {
|
|
|
|
signature: string,
|
|
|
|
jwt: string,
|
|
|
|
kid: string,
|
|
|
|
payload: Review,
|
|
|
|
scheme: "geo" | string
|
|
|
|
}[],
|
2023-01-21 21:28:22 +01:00
|
|
|
/** A map from Review identifiers (urn:maresi:<signature>) to information about the reviews of that review. */
|
|
|
|
maresi_subjects?: any[],
|
|
|
|
issuers?: any[]
|
|
|
|
}> {
|
2024-12-31 20:22:28 +01:00
|
|
|
if (query.sub?.startsWith("geo:")) {
|
|
|
|
const parts = []
|
|
|
|
if (query.q) {
|
|
|
|
parts.push("q=" + encodeURIComponent(query.q))
|
|
|
|
delete query.q
|
|
|
|
}
|
|
|
|
if (query.u) {
|
|
|
|
parts.push("u=" + query.u)
|
|
|
|
delete query.u
|
|
|
|
}
|
|
|
|
if (parts.length > 0) {
|
|
|
|
query.sub = query.sub + "?" + parts.join("&")
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
2023-01-20 17:48:34 +01:00
|
|
|
const {data} = await axios.get(`${api}/reviews`, {
|
|
|
|
params: query,
|
|
|
|
headers: {'Content-Type': 'application/json'}
|
|
|
|
})
|
|
|
|
return data
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get aggregate information about the review subject.
|
|
|
|
* @param {string} uri URI of the review subject.
|
|
|
|
* @param {string} [api=ORIGINAL_API] API endpoint used to fetch the data.
|
|
|
|
*/
|
|
|
|
public static getSubject(uri: string, api = MangroveReviews.ORIGINAL_API) {
|
|
|
|
return axios.get(`${api}/subject/${encodeURIComponent(uri)}`).then(({data}) => data)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get aggregate information about the reviewer.
|
|
|
|
* @param {string} pem - Reviewer public key in PEM format.
|
|
|
|
* @param {string} [api=ORIGINAL_API] - API endpoint used to fetch the data.
|
|
|
|
*/
|
2023-01-21 22:09:48 +01:00
|
|
|
public static getIssuer(pem: string, api = MangroveReviews.ORIGINAL_API) {
|
2023-01-20 17:48:34 +01:00
|
|
|
return axios.get(`${api}/issuer/${encodeURIComponent(pem)}`).then(({data}) => data)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Retrieve aggregates for multiple subjects or issuers.
|
|
|
|
* @param {Object} query Batch query listing identifiers to use for fetching.
|
|
|
|
* @param {string[]} [query.subs] A list of subject URIs to get aggregates for.
|
|
|
|
* @param {string[]} [query.pems] A list of issuer PEM public keys to get aggregates for.
|
|
|
|
* @param {string} [api=ORIGINAL_API] - API endpoint used to fetch the data.
|
|
|
|
*/
|
2023-01-21 22:09:48 +01:00
|
|
|
public static batchAggregate(query: { subs?: string[], pems?: string[] }, api = MangroveReviews.ORIGINAL_API):
|
|
|
|
null |
|
|
|
|
Promise<{
|
|
|
|
"issuers": Record<string, { count: number, neutrality: number }>,
|
|
|
|
"subjects": Record<string, {
|
|
|
|
"confirmed_count": number,
|
|
|
|
"count": number,
|
|
|
|
"opinion_count": number,
|
|
|
|
"positive_count": NamedNodeMap,
|
|
|
|
"quality": number,
|
|
|
|
"sub": string
|
|
|
|
}>
|
|
|
|
}> {
|
2023-01-20 17:48:34 +01:00
|
|
|
if (!query.pems && !query.subs) {
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
return axios.post(`${api}/batch`, query).then(({data}) => data)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Generate a new user identity, which can be used for signing reviews and stored for later.
|
|
|
|
* @returns ECDSA
|
|
|
|
* [WebCrypto](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)
|
|
|
|
* key pair with `privateKey` and `publicKey`
|
|
|
|
*/
|
|
|
|
public static generateKeypair(): Promise<CryptoKeyPair> {
|
|
|
|
return crypto.subtle
|
|
|
|
.generateKey(
|
|
|
|
{
|
|
|
|
name: 'ECDSA',
|
|
|
|
namedCurve: 'P-256'
|
|
|
|
},
|
|
|
|
true,
|
|
|
|
['sign', 'verify']
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Come back from JWK representation to representation which allows for signing.
|
|
|
|
* Import keys which were exported with `keypairToJwk`.
|
|
|
|
* @param jwk - Private JSON Web Key (JWK) to be converted in to a WebCrypto keypair.
|
|
|
|
*/
|
2023-01-21 22:09:48 +01:00
|
|
|
public static async jwkToKeypair(jwk: JsonWebKey & {metadata: string} ) {
|
2023-01-20 17:48:34 +01:00
|
|
|
// Do not mutate the argument.
|
|
|
|
let key = {...jwk}
|
|
|
|
if (!key || key.metadata !== MangroveReviews.PRIVATE_KEY_METADATA) {
|
|
|
|
throw new Error(
|
|
|
|
`does not contain the required metadata field "${MangroveReviews.PRIVATE_KEY_METADATA}"`
|
|
|
|
)
|
|
|
|
}
|
|
|
|
const sk = await crypto.subtle.importKey(
|
|
|
|
'jwk',
|
|
|
|
key,
|
|
|
|
{
|
|
|
|
name: 'ECDSA',
|
|
|
|
namedCurve: 'P-256'
|
|
|
|
},
|
|
|
|
true,
|
|
|
|
['sign']
|
|
|
|
)
|
|
|
|
delete key.d
|
|
|
|
delete key.dp
|
|
|
|
delete key.dq
|
|
|
|
delete key.q
|
|
|
|
delete key.qi
|
|
|
|
key.key_ops = ['verify']
|
|
|
|
const pk = await crypto.subtle.importKey(
|
|
|
|
'jwk',
|
|
|
|
key,
|
|
|
|
{
|
|
|
|
name: 'ECDSA',
|
|
|
|
namedCurve: 'P-256'
|
|
|
|
},
|
|
|
|
true,
|
|
|
|
['verify']
|
|
|
|
)
|
|
|
|
return {privateKey: sk, publicKey: pk}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Exports a keypair to JSON Web Key (JWK) of the private key.
|
|
|
|
* JWK is a format which can be then used to stringify and store.
|
|
|
|
* You can later import it back with `jwkToKeypair`.
|
|
|
|
* @param keypair - WebCrypto key pair, can be generate with `generateKeypair`.
|
|
|
|
*/
|
2023-01-21 22:09:48 +01:00
|
|
|
public static async keypairToJwk(keypair: CryptoKeyPair) {
|
2023-01-20 17:48:34 +01:00
|
|
|
const s = await crypto.subtle.exportKey('jwk', keypair.privateKey)
|
|
|
|
s["metadata"] = MangroveReviews.PRIVATE_KEY_METADATA
|
|
|
|
return s
|
|
|
|
}
|
|
|
|
|
2023-01-21 22:09:48 +01:00
|
|
|
public static u8aToString(buf: ArrayBuffer): string {
|
2023-01-20 17:48:34 +01:00
|
|
|
return new TextDecoder().decode(buf);
|
|
|
|
//return String.fromCharCode.apply(null, new Uint8Array(buf))
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get PEM represenation of the user "password".
|
|
|
|
* @param key - Private WebCrypto key to be exported.
|
|
|
|
*/
|
2023-01-21 22:09:48 +01:00
|
|
|
public static async privateToPem(key: CryptoKey) {
|
2023-01-20 17:48:34 +01:00
|
|
|
try {
|
|
|
|
const exported: ArrayBuffer = await crypto.subtle.exportKey('pkcs8', key)
|
2023-01-21 22:09:48 +01:00
|
|
|
const exportedAsBase64 = btoa(String.fromCharCode(...new Uint8Array(exported)));
|
2023-01-20 17:48:34 +01:00
|
|
|
return `-----BEGIN PRIVATE KEY-----\n${exportedAsBase64}\n-----END PRIVATE KEY-----`
|
|
|
|
} catch {
|
|
|
|
// Workaround for Firefox webcrypto not working.
|
2023-01-21 22:09:48 +01:00
|
|
|
const exported: JsonWebKey = await crypto.subtle.exportKey('jwk', key)
|
|
|
|
return jwkToPem(<any> exported, {private: true})
|
2023-01-20 17:48:34 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get PEM representation of public reviewer identity.
|
|
|
|
* This format can be found in the `kid` field of a Mangrove Review Header.
|
|
|
|
* @param key - Public WebCrypto key to be exported.
|
|
|
|
*/
|
|
|
|
public static async publicToPem(key: CryptoKey): Promise<string> {
|
|
|
|
const exported: ArrayBuffer = await crypto.subtle.exportKey('spki', key)
|
2023-01-21 22:09:48 +01:00
|
|
|
const exportedAsBase64 = btoa(String.fromCharCode(...new Uint8Array(exported)));
|
2023-01-21 21:28:22 +01:00
|
|
|
|
2023-01-20 17:48:34 +01:00
|
|
|
// Do not add new lines so that its copyable from plain string representation.
|
|
|
|
return `-----BEGIN PUBLIC KEY-----${exportedAsBase64}-----END PUBLIC KEY-----`
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check and fill in the review payload so that its ready for signing.
|
|
|
|
* See the [Mangrove Review Standard](https://mangrove.reviews/standard)
|
|
|
|
* for more details.
|
|
|
|
* Has to include at least `sub` and `rating` or `opinion`.
|
|
|
|
* @param {Payload} payload Base {@link Payload} to be cleaned, it will be mutated.
|
|
|
|
* @returns {Payload} Payload ready to sign - the same as param 'PayLoad'.
|
|
|
|
*/
|
|
|
|
private static cleanPayload(payload: Review): Review {
|
|
|
|
if (!payload.sub) throw 'Payload must include subject URI in `sub` field.'
|
|
|
|
if (!payload.rating && !payload.opinion) throw 'Payload must include either rating or opinion.'
|
|
|
|
if (payload.rating !== undefined) {
|
|
|
|
if (payload.rating < 0 || payload.rating > 100) throw 'Rating must be in the range from 0 to 100.'
|
|
|
|
}
|
|
|
|
payload.iat = Math.floor(Date.now() / 1000)
|
|
|
|
if (payload.rating === null) delete payload.rating
|
|
|
|
if (!payload.opinion) delete payload.opinion
|
|
|
|
if (!payload.images || !payload.images.length) delete payload.images
|
|
|
|
const meta: Metadata = {client_id: window.location.href, ...payload.metadata}
|
|
|
|
for (const key in meta) {
|
|
|
|
const value = meta[key]
|
|
|
|
if (value === null || value === false) {
|
|
|
|
delete meta[key]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
payload.metadata = meta
|
|
|
|
return payload
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|