Implement /api/v1/apps Mastodon API.

This commit is contained in:
Andrew Pietila 2023-09-27 07:03:36 -05:00
commit 339cbeb297
9 changed files with 2497 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
brainz-social.db
node_modules/

72
app.js Normal file
View file

@ -0,0 +1,72 @@
'use strict';
const express = require('express');
const glob = require('glob');
const log4js = require('log4js');
const { match: createPathMatch } = require('path-to-regexp');
var bodyParser = require('body-parser');
(async () => {
const app = express();
app.use(bodyParser.json({ type: 'application/*+json',
verify: function (req, _res, buf, _encoding) {
req.rawBody = buf;
}
}));
app.use(bodyParser.json({
verify: function (req, _res, buf, _encoding) {
req.rawBody = buf;
}
}));
app.use(bodyParser.urlencoded({
extended: false,
verify: function (req, _res, buf, _encoding) {
req.rawBody = buf;
}
}));
const routes = await glob('**/*.js', {
cwd: './routes',
dot: true,
});
const pathMatches = [];
app.use((req, _res, next) => {
const requestUrl = new URL(req.url, 'https://example.com/');
let candidateUrl = '';
let secondCandidateUrl = '';
for ( const pathMatch in pathMatches ) {
if ( pathMatches[pathMatch](requestUrl.pathname) ) {
// If we get an exact match, we don't need to process further.
return next();
} else if ( requestUrl.pathname.endsWith('/') && pathMatches[pathMatch](`${requestUrl.pathname}index`) ) {
// If we end with a /, and the index path matches, lets do the index path, but prioritize the non-index path.
const secondRequestUrl = new URL(requestUrl);
secondRequestUrl.pathname = `${requestUrl.pathname}index`;
candidateUrl = secondRequestUrl.toString().substring(19);
} else if ( pathMatches[pathMatch](`${requestUrl.pathname}/index`) ) {
// If we don't end with a /, and the /index path matches, lets do the /index path, but prioritize paths checked previously.
const secondRequestUrl = new URL(requestUrl);
secondRequestUrl.pathname = `${requestUrl.pathname}/index`;
secondCandidateUrl = secondRequestUrl.toString().substring(19);
}
}
if ( candidateUrl !== '' ) {
req.url = candidateUrl;
return next();
}
if ( secondCandidateUrl !== '' ) {
req.url = secondCandidateUrl;
return next();
}
return next();
} );
for ( const routeScript in routes ) {
const route = routes[routeScript].replace(/\.js$/, '');
console.log(route);
pathMatches.push( createPathMatch(`/${route}`));
const routeObj = require(`./routes/${route}`);
if ( routeObj.route ) {
routeObj.route(app.route(`/${route}`));
}
}
app.listen(process.env.PORT || 3000);
})();

19
database-handler.js Normal file
View file

@ -0,0 +1,19 @@
const db = require('better-sqlite3')('brainz-social.db');
db.pragma('journal_mode = WAL');
export default {
db,
getConfig: (key) => {
const row = db.prepare('SELECT * FROM config WHERE key = ?').get(key);
return row.value;
},
setConfig: (key, value) => {
db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES(?, ?);").run(key, value);
},
createApplication: (client_name, redirect_uri, scopes, website, client_id, client_secret) => {
db.prepare("INSERT INTO applications (client_name, redirect_uri, scopes, website, client_id, client_secret) VALUES (?, ?, ?, ?, ?, ?);").run(client_name, redirect_uri, scopes, website, client_id, client_secret);
}
};

6
database.json Normal file
View file

@ -0,0 +1,6 @@
{
"dev": {
"driver": "sqlite3",
"filename": "brainz-social.db"
}
}

View file

@ -0,0 +1,43 @@
'use strict';
const async = require('async');
var dbm;
var type;
var seed;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
};
exports.up = function(db, callback) {
async.series([
db.createTable.bind(db, 'applications', {
id: { type: 'int', primaryKey: true, autoIncrement: true },
client_name: 'string',
redirect_uri: 'string',
scopes: 'string',
website: 'string',
client_id: { type: 'string', unique: true },
client_secret: 'string'
}),
db.addIndex.bind(db, 'applications', 'clientIdIndex', ['client_id'], true)
], callback);
};
exports.down = function(db, callback) {
async.series([
db.removeIndex.bind(db, 'applications', 'clientIdIndex'),
db.dropTable.bind(db, 'applications')
], callback);
};
exports._meta = {
"version": 1
};

View file

@ -0,0 +1,31 @@
'use strict';
var dbm;
var type;
var seed;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
};
exports.up = function(db) {
return db.createTable('config', {
id: { type: 'int', primaryKey: true, autoIncrement: true },
key: { type: 'string', unique: true },
value: 'string'
});
};
exports.down = function(db) {
return db.dropTable('config');
};
exports._meta = {
"version": 1
};

2219
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

17
package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "brainz-social",
"version": "0.0.1",
"description": "Brainz Social",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Andrew Pietila <a.pietila@protonmail.com>",
"license": "WTFPL",
"dependencies": {
"better-sqlite3": "^8.6.0",
"db-migrate": "^0.11.14",
"db-migrate-sqlite3": "^0.5.0",
"web-push": "^3.6.6"
}
}

88
routes/api/v1/apps.js Normal file
View file

@ -0,0 +1,88 @@
const database_handler = require("../../../database-handler");
const webpush = require("web-push");
export default {
route: ((routeObj) => {
routeObj.post((req, res) => {
var client_name, redirect_uris, scopes, website;
if ( !req.body ) {
res.status(422);
res.json({error: "Validation failed, unparsable or no body."});
return;
}
if ( !req.body.client_name ) {
res.status(422);
res.json({error: "Validation failed, no client_name."});
return;
}
client_name = req.body.client_name;
if ( !req.body.redirect_uris ) {
res.status(422);
res.json({error: "Validation failed, no redirect_uris."})
return;
}
redirect_uris = req.body.redirect_uris;
if ( redirect_uris !== "urn:ietf:wg:oauth:2.0:oob" ) {
var url;
try {
url = new URL(redirect_uris);
} catch (e) {
res.status(422);
res.json({error: "Validation failed, redirect_uris not parsable as absolute URL."});
return;
}
if ( url.schema.toLower === "http" ) {
res.status(422);
res.json({error: "Validation failed, http redirect_uris not allowed."});
}
}
scopes = "read";
if ( req.body.scopes ) {
scopes = req.body.scopes;
}
website = null;
if ( req.body.website ) {
website = req.body.website;
}
var client_id, client_secret, vapid_key;
// Not that anything is implemented yet regarding Web Push/VAPID/whatever,
// this is a required item per the Mastodon API documentation.
vapid_key = database_handler.getConfig("vapid_key_public");
if ( !vapid_key ) {
const vapidKeys = webpush.generateVAPIDKeys();
database_handler.setConfig("vapid_key_public", vapidKeys.publicKey);
database_handler.setConfig("vapid_key_private", vapidKeys.privateKey);
vapid_key = vapidKeys.publicKey;
}
client_id = crypto.randomBytes(32).toString('base64');
client_secret = crypto.randomBytes(32).toString('base64');
database_handler.createApplication(client_name, redirect_uris, scopes, website, client_id, client_secret);
res.status(200);
res.json({
name: client_name,
website,
vapid_key,
client_id,
client_secret
});
return;
})
})
}