diff --git a/api/v1/index.js b/api/v1/index.js
new file mode 100644
index 0000000..c61d247
--- /dev/null
+++ b/api/v1/index.js
@@ -0,0 +1,23 @@
+/*
+Copyright 2022 Yarmo Mackenbach
+
+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
+
+http://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 express from 'express'
+const router = express.Router()
+
+router.get('*', (req, res) => {
+ return res.status(501).send('Proxy v1 API endpoint is no longer supported, please migrate to proxy v2 API endpoint')
+})
+
+export default router
diff --git a/api/v2/index.js b/api/v2/index.js
new file mode 100644
index 0000000..3a22084
--- /dev/null
+++ b/api/v2/index.js
@@ -0,0 +1,39 @@
+/*
+Copyright (C) 2022 Yarmo Mackenbach
+
+This program is free software: you can redistribute it and/or modify it under
+the terms of the GNU Affero General Public License as published by the Free
+Software Foundation, either version 3 of the License, or (at your option)
+any later version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+details.
+
+You should have received a copy of the GNU Affero General Public License along
+with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If your software can interact with users remotely through a computer network,
+you should also make sure that it provides a way for users to get its source.
+For example, if your program is a web application, its interface could display
+a "Source" link that leads users to an archive of the code. There are many
+ways you could offer source, and different solutions will be better for different
+programs; see section 13 for the specific requirements.
+
+You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary. For
+more information on this, and how to apply and follow the GNU AGPL, see .
+*/
+import express from 'express'
+import keyoxideProfileApiRouter from './keyoxide_profile.js'
+import proxyGetApiRouter from './proxy_get.js'
+
+const router = express.Router()
+
+router.use('/profile', keyoxideProfileApiRouter)
+router.use('/get', proxyGetApiRouter)
+
+export default router
diff --git a/api/v2/keyoxide_profile.js b/api/v2/keyoxide_profile.js
new file mode 100644
index 0000000..1cd3454
--- /dev/null
+++ b/api/v2/keyoxide_profile.js
@@ -0,0 +1,390 @@
+/*
+Copyright (C) 2022 Yarmo Mackenbach
+
+This program is free software: you can redistribute it and/or modify it under
+the terms of the GNU Affero General Public License as published by the Free
+Software Foundation, either version 3 of the License, or (at your option)
+any later version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+details.
+
+You should have received a copy of the GNU Affero General Public License along
+with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If your software can interact with users remotely through a computer network,
+you should also make sure that it provides a way for users to get its source.
+For example, if your program is a web application, its interface could display
+a "Source" link that leads users to an archive of the code. There are many
+ways you could offer source, and different solutions will be better for different
+programs; see section 13 for the specific requirements.
+
+You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary. For
+more information on this, and how to apply and follow the GNU AGPL, see .
+*/
+import express from 'express'
+import { check, validationResult } from 'express-validator'
+import Ajv from 'ajv'
+import { generateWKDProfile, generateHKPProfile } from '../../server/index.js'
+import 'dotenv/config.js'
+
+const router = express.Router()
+const ajv = new Ajv({coerceTypes: true})
+
+const apiProfileSchema = {
+ type: "object",
+ properties: {
+ keyData: {
+ type: "object",
+ properties: {
+ fingerprint: {
+ type: "string"
+ },
+ openpgp4fpr: {
+ type: "string"
+ },
+ users: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ userData: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ name: { type: "string" },
+ email: { type: "string" },
+ comment: { type: "string" },
+ isPrimary: { type: "boolean" },
+ isRevoked: { type: "boolean" },
+ }
+ },
+ claims: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ claimVersion: { type: "integer" },
+ uri: { type: "string" },
+ fingerprint: { type: "string" },
+ status: { type: "string" },
+ matches: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ serviceProvider: {
+ type: "object",
+ properties: {
+ type: { type: "string" },
+ name: { type: "string" },
+ }
+ },
+ match: {
+ type: "object",
+ properties: {
+ regularExpression: { type: "object" },
+ isAmbiguous: { type: "boolean" },
+ }
+ },
+ profile: {
+ type: "object",
+ properties: {
+ display: { type: "string" },
+ uri: { type: "string" },
+ qr: { type: "string" },
+ }
+ },
+ proof: {
+ type: "object",
+ properties: {
+ uri: { type: "string" },
+ request: {
+ type: "object",
+ properties: {
+ fetcher: { type: "string" },
+ access: { type: "string" },
+ format: { type: "string" },
+ data: { type: "object" },
+ }
+ },
+ }
+ },
+ claim: {
+ type: "object",
+ properties: {
+ format: { type: "string" },
+ relation: { type: "string" },
+ path: {
+ type: "array",
+ items: {
+ type: "string"
+ }
+ },
+ }
+ },
+ }
+ }
+ },
+ verification: {
+ type: "object"
+ },
+ summary: {
+ type: "object",
+ properties: {
+ profileName: { type: "string" },
+ profileURL: { type: "string" },
+ serviceProviderName: { type: "string" },
+ isVerificationDone: { type: "boolean" },
+ isVerified: { type: "boolean" },
+ }
+ }
+ }
+ }
+ },
+ }
+ }
+ },
+ primaryUserIndex: {
+ type: "integer"
+ },
+ key: {
+ type: "object",
+ properties: {
+ data: { type: "object" },
+ fetchMethod: { type: "string" },
+ uri: { type: "string" },
+ }
+ },
+ },
+ },
+ keyoxide: {
+ type: "object",
+ properties: {
+ url: { type: "string" },
+ }
+ },
+ extra: {
+ type: "object",
+ properties: {
+ avatarURL: { type: "string" },
+ }
+ },
+ errors: {
+ type: "array"
+ },
+ },
+ required: ["keyData", "keyoxide", "extra", "errors"],
+ additionalProperties: false
+}
+
+const apiProfileValidate = ajv.compile(apiProfileSchema)
+
+const doVerification = async (data) => {
+ let promises = []
+ let results = []
+ let verificationOptions = {
+ proxy: {
+ hostname: process.env.PROXY_HOSTNAME,
+ policy: (process.env.PROXY_HOSTNAME != "") ? 'adaptive' : 'never'
+ }
+ }
+
+ for (let iUser = 0; iUser < data.keyData.users.length; iUser++) {
+ const user = data.keyData.users[iUser]
+
+ for (let iClaim = 0; iClaim < user.claims.length; iClaim++) {
+ const claim = user.claims[iClaim]
+
+ promises.push(
+ new Promise(async (resolve, reject) => {
+ await claim.verify(verificationOptions)
+ results.push([iUser, iClaim, claim])
+ resolve()
+ })
+ )
+ }
+ }
+ await Promise.all(promises)
+
+ results.forEach(result => {
+ data.keyData.users[result[0]].claims[result[1]] = result[2]
+ })
+
+ return data
+}
+
+const sanitize = (data) => {
+ let results = []
+
+ const dataClone = JSON.parse(JSON.stringify(data))
+
+ for (let iUser = 0; iUser < dataClone.keyData.users.length; iUser++) {
+ const user = dataClone.keyData.users[iUser]
+
+ for (let iClaim = 0; iClaim < user.claims.length; iClaim++) {
+ const claim = user.claims[iClaim]
+
+ // TODO Fix upstream
+ for (let iMatch = 0; iMatch < claim.matches.length; iMatch++) {
+ const match = claim.matches[iMatch];
+ if (Array.isArray(match.claim)) {
+ match.claim = match.claim[0]
+ }
+ }
+ // TODO Fix upstream
+ if (!claim.verification) {
+ claim.verification = {}
+ }
+ // TODO Fix upstream
+ claim.matches.forEach(match => {
+ match.proof.request.access = ['generic', 'nocors', 'granted', 'server'][match.proof.request.access]
+ match.claim.format = ['uri', 'fingerprint', 'message'][match.claim.format]
+ match.claim.relation = ['contains', 'equals', 'oneof'][match.claim.relation]
+ })
+
+ data.keyData.users[iUser].claims[iClaim] = claim
+ }
+ }
+
+ const valid = apiProfileValidate(data)
+ if (!valid) {
+ throw new Error(`Profile data sanitization error`)
+ }
+
+ return data
+}
+
+const addSummaryToClaims = (data) => {
+ // To be removed when data is added by DOIP library
+ for (let userIndex = 0; userIndex < data.keyData.users.length; userIndex++) {
+ const user = data.keyData.users[userIndex]
+
+ for (let claimIndex = 0; claimIndex < user.claims.length; claimIndex++) {
+ const claim = user.claims[claimIndex]
+
+ const isVerificationDone = claim.status === "verified"
+ const isVerified = isVerificationDone ? claim.verification.result : false
+ const isAmbiguous = isVerified
+ ? false
+ : claim.matches.length > 1 || claim.matches[0].match.isAmbiguous
+
+ data.keyData.users[userIndex].claims[claimIndex].summary = {
+ profileName: !isAmbiguous ? claim.matches[0].profile.display : claim.uri,
+ profileURL: !isAmbiguous ? claim.matches[0].profile.uri : "",
+ serviceProviderName: !isAmbiguous ? claim.matches[0].serviceprovider.name : "",
+ isVerificationDone: isVerificationDone,
+ isVerified: isVerified,
+ }
+ }
+ }
+
+ return data
+}
+
+router.get('/fetch',
+ check('query').exists(),
+ check('protocol').optional().toLowerCase().isIn(["hkp", "wkd"]),
+ check('doVerification').default(false).isBoolean().toBoolean(),
+ check('returnPublicKey').default(false).isBoolean().toBoolean(),
+ async (req, res) => {
+ const valRes = validationResult(req);
+ if (!valRes.isEmpty()) {
+ res.status(400).send(valRes)
+ return
+ }
+
+ // Generate profile
+ let data
+ switch (req.query.protocol) {
+ case 'wkd':
+ data = await generateWKDProfile(req.query.query)
+ break;
+ case 'hkp':
+ data = await generateHKPProfile(req.query.query)
+ break;
+ default:
+ if (req.query.query.includes('@')) {
+ data = await generateWKDProfile(req.query.query)
+ } else {
+ data = await generateHKPProfile(req.query.query)
+ }
+ break;
+ }
+
+ if (data.errors.length > 0) {
+ delete data.key
+ res.status(500).send(data)
+ }
+
+ // Return public key
+ if (req.query.returnPublicKey) {
+ data.keyData.key.data = data.key.publicKey
+ }
+ delete data.key
+
+ // Do verification
+ if (req.query.doVerification) {
+ data = await doVerification(data)
+ }
+
+ try {
+ // Sanitize JSON
+ data = sanitize(data)
+ } catch (error) {
+ data.keyData = {}
+ data.extra = {}
+ data.errors = [error.message]
+ }
+
+ // Add missing data
+ data = addSummaryToClaims(data)
+
+ let statusCode = 200
+ if (data.errors.length > 0) {
+ statusCode = 500
+ }
+
+ res.status(statusCode).send(data)
+ }
+)
+
+router.get('/verify',
+ check('data').exists().isJSON(),
+ async (req, res) => {
+ const valRes = validationResult(req)
+ if (!valRes.isEmpty()) {
+ res.status(400).send(valRes)
+ return
+ }
+
+ // Do verification
+ data = await doVerification(req.query.data)
+
+ try {
+ // Sanitize JSON
+ data = sanitize(data);
+ } catch (error) {
+ data.keyData = {}
+ data.extra = {}
+ data.errors = [error.message]
+ }
+
+ // Add missing data
+ data = addSummaryToClaims(data)
+
+ let statusCode = 200
+ if (data.errors.length > 0) {
+ statusCode = 500
+ }
+
+ res.status(statusCode).send(data)
+ }
+)
+
+export default router
diff --git a/api/v2/proxy_get.js b/api/v2/proxy_get.js
new file mode 100644
index 0000000..df28273
--- /dev/null
+++ b/api/v2/proxy_get.js
@@ -0,0 +1,289 @@
+/*
+Copyright (C) 2022 Yarmo Mackenbach
+
+This program is free software: you can redistribute it and/or modify it under
+the terms of the GNU Affero General Public License as published by the Free
+Software Foundation, either version 3 of the License, or (at your option)
+any later version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+details.
+
+You should have received a copy of the GNU Affero General Public License along
+with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If your software can interact with users remotely through a computer network,
+you should also make sure that it provides a way for users to get its source.
+For example, if your program is a web application, its interface could display
+a "Source" link that leads users to an archive of the code. There are many
+ways you could offer source, and different solutions will be better for different
+programs; see section 13 for the specific requirements.
+
+You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary. For
+more information on this, and how to apply and follow the GNU AGPL, see .
+*/
+import express from 'express'
+import { query, validationResult } from 'express-validator'
+import { fetcher, enums as E } from 'doipjs'
+import 'dotenv/config.js'
+
+const router = express.Router()
+
+const opts = {
+ claims: {
+ activitypub: {
+ url: process.env.ACTIVITYPUB_URL || null,
+ privateKey: process.env.ACTIVITYPUB_PRIVATE_KEY || null
+ },
+ irc: {
+ nick: process.env.IRC_NICK || null
+ },
+ matrix: {
+ instance: process.env.MATRIX_INSTANCE || null,
+ accessToken: process.env.MATRIX_ACCESS_TOKEN || null
+ },
+ telegram: {
+ token: process.env.TELEGRAM_TOKEN || null
+ },
+ twitter: {
+ bearerToken: process.env.TWITTER_BEARER_TOKEN || null
+ },
+ xmpp: {
+ service: process.env.XMPP_SERVICE || null,
+ username: process.env.XMPP_USERNAME || null,
+ password: process.env.XMPP_PASSWORD || null
+ }
+ }
+}
+
+// Root route
+router.get('/', async (req, res) => {
+ return res.status(400).json({ errors: 'Invalid endpoint' })
+})
+
+// HTTP route
+router.get(
+ '/http',
+ query('url').isURL(),
+ query('format').isIn([E.ProofFormat.JSON, E.ProofFormat.TEXT]),
+ (req, res) => {
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() })
+ }
+
+ fetcher.http
+ .fn(req.query, opts)
+ .then((result) => {
+ switch (req.query.format) {
+ case E.ProofFormat.JSON:
+ return res.status(200).json(result)
+
+ case E.ProofFormat.TEXT:
+ return res.status(200).send(result)
+ }
+ })
+ .catch((err) => {
+ return res.status(400).json({ errors: err.message ? err.message : err })
+ })
+ }
+)
+
+// DNS route
+router.get('/dns', query('domain').isFQDN(), (req, res) => {
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() })
+ }
+
+ fetcher.dns
+ .fn(req.query, opts)
+ .then((data) => {
+ return res.status(200).send(data)
+ })
+ .catch((err) => {
+ return res.status(400).json({ errors: err.message ? err.message : err })
+ })
+})
+
+// XMPP route
+router.get(
+ '/xmpp',
+ query('id').isEmail(),
+ query('field').isIn([
+ 'fn',
+ 'number',
+ 'userid',
+ 'url',
+ 'bday',
+ 'nickname',
+ 'note',
+ 'desc'
+ ]),
+ async (req, res) => {
+ if (
+ !opts.claims.xmpp.service ||
+ !opts.claims.xmpp.username ||
+ !opts.claims.xmpp.password
+ ) {
+ return res.status(501).json({ errors: 'XMPP not enabled on server' })
+ }
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() })
+ }
+
+ fetcher.xmpp
+ .fn(req.query, opts)
+ .then((data) => {
+ return res.status(200).send(data)
+ })
+ .catch((err) => {
+ return res.status(400).json({ errors: err.message ? err.message : err })
+ })
+ }
+)
+
+// Twitter route
+router.get('/twitter', query('tweetId').isInt(), async (req, res) => {
+ if (!opts.claims.twitter.bearerToken) {
+ return res.status(501).json({ errors: 'Twitter not enabled on server' })
+ }
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() })
+ }
+
+ fetcher.twitter
+ .fn(req.query, opts)
+ .then((data) => {
+ return res.status(200).send(data)
+ })
+ .catch((err) => {
+ return res.status(400).json({ errors: err.message ? err.message : err })
+ })
+})
+
+// Matrix route
+router.get(
+ '/matrix',
+ query('roomId').isString(),
+ query('eventId').isString(),
+ async (req, res) => {
+ if (!opts.claims.matrix.instance || !opts.claims.matrix.accessToken) {
+ return res.status(501).json({ errors: 'Matrix not enabled on server' })
+ }
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() })
+ }
+
+ fetcher.matrix
+ .fn(req.query, opts)
+ .then((data) => {
+ return res.status(200).send(data)
+ })
+ .catch((err) => {
+ return res.status(400).json({ errors: err.message ? err.message : err })
+ })
+ }
+)
+
+// Telegram route
+router.get(
+ '/telegram',
+ query('user').isString(),
+ query('chat').isString(),
+ async (req, res) => {
+ if (!opts.claims.telegram.token) {
+ return res.status(501).json({ errors: 'Telegram not enabled on server' })
+ }
+
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() })
+ }
+
+ fetcher.telegram
+ .fn(req.query, opts)
+ .then((data) => {
+ return res.status(200).send(data)
+ })
+ .catch((err) => {
+ return res.status(400).json({ errors: err.message ? err.message : err })
+ })
+ }
+)
+
+// IRC route
+router.get('/irc', query('nick').isString(), async (req, res) => {
+ if (!opts.claims.irc.nick) {
+ return res.status(501).json({ errors: 'IRC not enabled on server' })
+ }
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() })
+ }
+
+ fetcher.irc
+ .fn(req.query, opts)
+ .then((data) => {
+ return res.status(200).send(data)
+ })
+ .catch((err) => {
+ return res.status(400).json({ errors: err.message ? err.message : err })
+ })
+})
+
+// Gitlab route
+router.get(
+ '/gitlab',
+ query('domain').isFQDN(),
+ query('username').isString(),
+ async (req, res) => {
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() })
+ }
+
+ fetcher.http
+ .fn({
+ url: `https://${req.query.domain}/api/v4/projects/${req.query.username}%2Fgitlab_proof`,
+ format: 'json'
+ }, opts)
+ .then((data) => {
+ return res.status(200).send(data)
+ })
+ .catch((err) => {
+ return res.status(400).json({ errors: err.message ? err.message : err })
+ })
+ }
+)
+
+// ActivityPub route
+router.get(
+ '/activitypub',
+ query('url').isURL(),
+ async (req, res) => {
+ const errors = validationResult(req)
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() })
+ }
+
+ fetcher.activitypub
+ .fn(req.query, opts)
+ .then((data) => {
+ return res.status(200).send(data)
+ })
+ .catch((err) => {
+ return res.status(400).json({ errors: err.message ? err.message : err })
+ })
+ }
+)
+
+export default router
diff --git a/routes/api.js b/routes/api.js
index 1aa2e42..2a6aa3c 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -29,9 +29,13 @@ more information on this, and how to apply and follow the GNU AGPL, see