feat: api token migration, new ACP tokens list, token creation

This commit is contained in:
Julian Lam
2023-05-02 11:11:05 -04:00
parent 7b8bffd763
commit e4888dea17
14 changed files with 233 additions and 120 deletions

View File

@@ -20,6 +20,8 @@ const translator = require('../translator');
const sockets = require('../socket.io');
const utils = require('../utils');
const api = require('.');
const usersAPI = module.exports;
const hasAdminPrivilege = async (uid, privilege) => {
@@ -313,19 +315,7 @@ usersAPI.generateToken = async (caller, { uid, description }) => {
throw new Error('[[error:invalid-uid]]');
}
const settings = await meta.settings.get('core.api');
settings.tokens = settings.tokens || [];
const newToken = {
token: utils.generateUUID(),
uid: caller.uid,
description: description || '',
timestamp: Date.now(),
};
settings.tokens.push(newToken);
await meta.settings.set('core.api', settings);
return newToken;
return await api.utils.tokens.generate({ uid, description });
};
usersAPI.deleteToken = async (caller, { uid, token }) => {
@@ -334,15 +324,8 @@ usersAPI.deleteToken = async (caller, { uid, token }) => {
throw new Error('[[error:invalid-uid]]');
}
const settings = await meta.settings.get('core.api');
const beforeLen = settings.tokens.length;
settings.tokens = settings.tokens.filter(tokenObj => tokenObj.token !== token);
if (beforeLen !== settings.tokens.length) {
await meta.settings.set('core.api', settings);
return true;
}
return false;
await api.utils.tokens.delete(token);
return true;
};
const getSessionAsync = util.promisify((sid, callback) => {

View File

@@ -34,16 +34,16 @@ utils.tokens.get = async (tokens) => {
]);
tokenObjs.forEach((tokenObj, idx) => {
tokenObj.token = tokens[idx];
tokenObj.lastSeen = lastSeen[idx];
tokenObj.lastSeenISO = new Date(lastSeen[idx]).toISOString();
tokenObj.timestampISO = new Date(parseInt(tokenObj.timestamp, 10)).toISOString();
});
return singular ? tokenObjs[0] : tokenObjs;
};
utils.tokens.generate = async ({ uid, description }) => {
const token = srcUtils.generateUUID();
const timestamp = Date.now();
if (parseInt(uid, 10) !== 0) {
const uidExists = await user.exists(uid);
if (!uidExists) {
@@ -51,6 +51,17 @@ utils.tokens.generate = async ({ uid, description }) => {
}
}
const token = srcUtils.generateUUID();
const timestamp = Date.now();
return utils.tokens.add({ token, uid, description, timestamp });
};
utils.tokens.add = async ({ token, uid, description = '', timestamp = Date.now() }) => {
if (!token || uid === undefined) {
throw new Error('[[error:invalid-data]]');
}
await Promise.all([
db.setObject(`token:${token}`, { uid, description, timestamp }),
db.sortedSetAdd(`tokens:createtime`, timestamp, token),
@@ -60,8 +71,11 @@ utils.tokens.generate = async ({ uid, description }) => {
return token;
};
utils.tokens.update = async (token, { description }) => {
await db.setObject(`token:${token}`, { description });
utils.tokens.update = async (token, { uid, description }) => {
await Promise.all([
db.setObject(`token:${token}`, { uid, description }),
db.sortedSetAdd(`tokens:uid`, uid, token),
]);
return await utils.tokens.get(token);
};

View File

@@ -111,14 +111,6 @@ settingsController.social = async function (req, res) {
};
settingsController.api = async (req, res) => {
const { tokens } = await meta.settings.get('core.api');
const scores = await api.utils.tokens.getLastSeen(tokens.map(t => t.token));
const [lastSeen, lastSeenISO] = tokens.reduce((memo, cur, idx) => {
memo[0][cur.token] = scores[idx];
memo[1][cur.token] = new Date(scores[idx]).toISOString();
return memo;
}, [{}, {}]);
res.render('admin/settings/api', { lastSeen, lastSeenISO });
const tokens = await api.utils.tokens.list();
res.render('admin/settings/api', { tokens });
};

View File

@@ -28,3 +28,21 @@ Admin.getAnalyticsData = async (req, res) => {
units: req.query.units,
}));
};
Admin.generateToken = async (req, res) => {
const { uid, description } = req.body;
helpers.formatApiResponse(200, res, await api.utils.tokens.generate({ uid, description }));
};
Admin.updateToken = async (req, res) => {
// todo: token rolling via req.body
const { uid, description } = req.body;
const { token } = req.params;
helpers.formatApiResponse(200, res, await api.utils.tokens.update(token, { uid, description }));
};
Admin.deleteToken = async (req, res) => {
const { token } = req.params;
helpers.formatApiResponse(200, res, await api.utils.tokens.delete(token));
};

View File

@@ -15,5 +15,9 @@ module.exports = function () {
setupApiRoute(router, 'get', '/analytics', [...middlewares], controllers.write.admin.getAnalyticsKeys);
setupApiRoute(router, 'get', '/analytics/:set', [...middlewares], controllers.write.admin.getAnalyticsData);
setupApiRoute(router, 'post', '/tokens', [...middlewares], controllers.write.admin.generateToken);
setupApiRoute(router, 'put', '/tokens/:token', [...middlewares], controllers.write.admin.updateToken);
setupApiRoute(router, 'delete', '/tokens/:token', [...middlewares], controllers.write.admin.deleteToken);
return router;
};

View File

@@ -0,0 +1,38 @@
'use strict';
const assert = require('assert');
const winston = require('winston');
const db = require('../../database');
const meta = require('../../meta');
const api = require('../../api');
module.exports = {
name: 'Migrate tokens away from sorted-list implementation',
timestamp: Date.UTC(2023, 4, 2),
method: async () => {
const { tokens = [] } = await meta.settings.get('core.api');
await Promise.all(tokens.map(async (tokenObj) => {
const { token, uid, description } = tokenObj;
await api.utils.tokens.add({ token, uid, description });
}));
// Validate
const oldCount = await db.sortedSetCard('settings:core.api:sorted-list:tokens');
const newCount = await db.sortedSetCard('tokens:createtime');
try {
if (oldCount > 0) {
assert.strictEqual(oldCount, newCount);
}
// Delete old tokens
await meta.settings.set('core.api', {
tokens: [],
});
await db.delete('settings:core.api:sorted-lists');
} catch (e) {
winston.warn('Old token count does not match migrated tokens count, leaving old tokens behind.');
}
},
};

View File

@@ -1,21 +0,0 @@
<li data-type="item" class="list-group-item">
<div class="row">
<div class="col-9">
<span class="badge bg-primary">{{{ if uid }}}uid {uid}{{{ else }}}master{{{ end }}}</span>
{{{ if token }}}<input class="form-control-plaintext" type="text" readonly="readonly" value="{token}" size="32" />{{{ else }}}<em class="text-warning">[[admin/settings/api:token-on-save]]</em>{{{ end }}}<br />
<p>
{{{ if description }}}
{description}
{{{ else }}}
<em>[[admin/settings/api:no-description]]</em>
{{{ end }}}
<br />
<small class="text-info">{./lastSeen}</small>
</p>
</div>
<div class="col-3 text-end">
<button type="button" data-type="edit" class="btn btn-info">Edit</button>
<button type="button" data-type="remove" class="btn btn-danger">Delete</button>
</div>
</div>
</li>

View File

@@ -1,15 +1,13 @@
<form>
<input type="hidden" name="token" />
<input type="hidden" name="timestamp" />
<form role="form">
<div class="mb-3">
<label class="form-label" for="uid">[[admin/settings/api:uid]]</label>
<input type="text" inputmode="numeric" pattern="\d+" name="uid" class="form-control" placeholder="1" />
<input type="text" inputmode="numeric" pattern="\d+" name="uid" class="form-control" placeholder="0" value="{./uid}" />
<p class="form-text">
[[admin/settings/api:uid-help-text]]
</p>
</div>
<div class="mb-3">
<label class="form-label" for="description">[[admin/settings/api:description]]</label>
<input type="text" name="description" class="form-control" placeholder="Description" />
<input type="text" name="description" class="form-control" placeholder="Description" value="{./description}" />
</div>
</form>

View File

@@ -1,14 +1,64 @@
<form role="form" class="core-api-settings">
<p class="lead">[[admin/settings/api:lead-text]]</p>
<p>[[admin/settings/api:intro]]</p>
<p>
<a href="https://docs.nodebb.org/api">
<p class="text-danger">[[admin/settings/api:warning]]</p>
<p class="d-flex align-items-center">
<a class="flex-grow-1" href="https://docs.nodebb.org/api">
<i class="fa fa-external-link"></i>
[[admin/settings/api:docs]]
</a>
<button type="button" class="btn btn-primary float-end" data-action="create">
<i class="fa fa-plus"></i>
[[admin/settings/api:create-token]]
</button>
</p>
<hr />
<table class="table mb-5" data-component="acp/tokens">
<thead>
<tr>
<th>[[admin/settings/api:token]]</th>
<th>[[admin/settings/api:description]]</th>
<th>[[admin/settings/api:uid]]</th>
<th>[[admin/settings/api:last-seen]]</th>
<th>[[admin/settings/api:created]]</th>
</tr>
</thead>
<tbody>
{{{ each tokens }}}
<tr>
<td>
<button type="button" class="btn btn-link" data-action="copy" data-clipboard-text="{./token}"><i class="fa fa-fw fa-clipboard" aria-hidden="true"></i></button>
<div class="vr me-3" aria-hidden="true"></div>
<span class="user-select-all">{./token}</span>
</td>
<td class="align-middle">
{{{ if ./description }}}
{./description}
{{{ else }}}
<em class="text-secondary">[[admin/settings/api:no-description]]</em>
{{{ end }}}
</td>
<td class="align-middle">
{{{ if (./uid == "0") }}}
<em>[[admin/settings/api:master-token]]</em>
{{{ else }}}
{./uid}
{{{ end }}}
</td>
<td class="align-middle">
{{{ if ./lastSeen }}}
<span class="timeago" title="{./lastSeenISO}"></span>
{{{ else }}}
<em class="text-secondary">[[admin/settings/api:last-seen-never]]</em>
{{{ end }}}
</td>
<td class="align-middle">
<span class="timeago" title="{./timestampISO}"></span>
</td>
</tr>
{{{ end }}}
</tbody>
</table>
<div class="row mb-4">
<div class="col-sm-2 col-12 settings-header">[[admin/settings/api:settings]]</div>
@@ -20,17 +70,6 @@
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-sm-2 col-12 settings-header">[[admin/settings/api:tokens]]</div>
<div class="col-sm-10 col-12">
<div class="form-group" data-type="sorted-list" data-sorted-list="tokens" data-item-template="admin/partials/api/sorted-list/item" data-form-template="admin/partials/api/sorted-list/form">
<input type="hidden" name="tokens">
<ul data-type="list" class="list-group mb-3"></ul>
<button type="button" data-type="add" class="btn btn-info">Create Token</button>
</div>
</div>
</div>
</form>
<!-- IMPORT admin/partials/save_button.tpl -->