Upgrade API endpoints, add proxy API

This commit is contained in:
Yarmo Mackenbach 2022-11-14 18:53:18 +01:00
parent d6b9d5bec9
commit 50b3254c7d
No known key found for this signature in database
GPG key ID: 37367F4AF4087AD1
5 changed files with 745 additions and 0 deletions

23
api/v1/index.js Normal file
View file

@ -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

39
api/v2/index.js Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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 <https://www.gnu.org/licenses/>.
*/
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

390
api/v2/keyoxide_profile.js Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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 <https://www.gnu.org/licenses/>.
*/
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

289
api/v2/proxy_get.js Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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 <https://www.gnu.org/licenses/>.
*/
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

View file

@ -29,9 +29,13 @@ more information on this, and how to apply and follow the GNU AGPL, see <https:/
*/
import express from 'express'
import apiRouter0 from '../api/v0/index.js'
import apiRouter1 from '../api/v1/index.js'
import apiRouter2 from '../api/v2/index.js'
const router = express.Router()
router.use('/0', apiRouter0)
router.use('/1', apiRouter1)
router.use('/2', apiRouter2)
export default router