feat: send a resource to your own Nextcloud, as the rendered file
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A .pptx landing in someone's own storage is worth having; a markdown blob is not, and it is not what they would have downloaded. So this renders through exactly the path the download uses — stored deck, its figures, the chosen theme — and PUTs the bytes. The file never travels through the browser. Offered only when a Nextcloud is connected: an action that always fails is worse than one that is not offered. An article offers Word, a deck PowerPoint, and asking for slides from an article is refused with the reason. Putting a file in Nextcloud now lives in src/utils/nextcloudFiles.js. Two callers want it and neither should grow its own copy of the WebDAV dance — make the dated folder a segment at a time, PUT, migrate a legacy plaintext token — because it reaches into storage that is not ours and a second slightly different copy is how the two drift. It also replaces a route importing another route. Also: a model that leaves the roster now leaves every list that names it. clinical_assistant.allowed_models and the image roster are advisory copies of the roster, and a stale id there was invisible until someone asked a clinical question and the request failed at the gateway. Removing or disabling a model prunes it; clearing the roster clears them. Re-enabling deliberately does not re-allow it — that is a separate decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
025290d64a
commit
24c8d71b7e
7 changed files with 327 additions and 0 deletions
|
|
@ -151,6 +151,7 @@
|
|||
var themeRow = document.getElementById('mr-theme-row');
|
||||
var themes = Array.isArray(data.themes) ? data.themes : [];
|
||||
themeCatalogue = themes;
|
||||
nextcloudConnected = Boolean(data.nextcloudConnected);
|
||||
if (themeSelect && themes.length) {
|
||||
var chosenTheme = themeSelect.value;
|
||||
themeSelect.textContent = '';
|
||||
|
|
@ -359,6 +360,7 @@
|
|||
// renders — data-image-thumb is what asks hydrateImage for the small copy.
|
||||
// The catalogue, once, shared by the generate form and every library row.
|
||||
var themeCatalogue = [];
|
||||
var nextcloudConnected = false;
|
||||
var imagesLoaded = false;
|
||||
var nextImagesBefore = null;
|
||||
|
||||
|
|
@ -807,6 +809,21 @@
|
|||
wrap.appendChild(theme);
|
||||
}
|
||||
|
||||
// Send the rendered file to the owner's own Nextcloud. Offered only when
|
||||
// there is a Nextcloud to send it to.
|
||||
if (nextcloudConnected) {
|
||||
var cloud = document.createElement('button');
|
||||
cloud.className = 'btn-sm btn-ghost';
|
||||
cloud.type = 'button';
|
||||
cloud.dataset.nextcloud = String(row.id);
|
||||
cloud.dataset.format = row.kind === 'article' ? 'docx' : 'pptx';
|
||||
cloud.title = 'Save to my Nextcloud as ' + cloud.dataset.format.toUpperCase();
|
||||
var cloudIcon = document.createElement('i');
|
||||
cloudIcon.className = 'fas fa-cloud-arrow-up';
|
||||
cloud.appendChild(cloudIcon);
|
||||
wrap.appendChild(cloud);
|
||||
}
|
||||
|
||||
var del = document.createElement('button');
|
||||
del.className = 'btn-sm btn-ghost';
|
||||
del.type = 'button';
|
||||
|
|
@ -848,7 +865,34 @@
|
|||
.finally(function () { select.disabled = false; });
|
||||
}
|
||||
|
||||
// Rendered server-side and pushed straight to their storage — the file never
|
||||
// travels through this browser, which is the point: it is a copy in their own
|
||||
// Nextcloud, not a download they then have to file away.
|
||||
function sendToNextcloud(id, format, btn) {
|
||||
var icon = btn.querySelector('i');
|
||||
var was = icon ? icon.className : '';
|
||||
btn.disabled = true;
|
||||
if (icon) icon.className = 'fas fa-spinner fa-spin';
|
||||
fetch('/api/my-resources/' + encodeURIComponent(id) + '/to-nextcloud', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ format: format })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.success) throw new Error(data.error || 'Could not send it');
|
||||
if (icon) icon.className = 'fas fa-cloud-arrow-up';
|
||||
if (typeof showToast === 'function') showToast('Saved to your Nextcloud: ' + data.path, 'success');
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (icon) icon.className = was;
|
||||
if (typeof showToast === 'function') showToast(err.message, 'error');
|
||||
})
|
||||
.finally(function () { btn.disabled = false; });
|
||||
}
|
||||
|
||||
function onRowClick(event) {
|
||||
var cloud = event.target.closest && event.target.closest('[data-nextcloud]');
|
||||
if (cloud) return sendToNextcloud(cloud.dataset.nextcloud, cloud.dataset.format, cloud);
|
||||
|
||||
var download = event.target.closest && event.target.closest('[data-download]');
|
||||
if (download) return downloadResource(download.dataset.download, download.dataset.format, download);
|
||||
|
||||
|
|
|
|||
|
|
@ -400,6 +400,9 @@ router.put('/config/models/toggle', async function(req, res) {
|
|||
}
|
||||
|
||||
await db.setSetting('models.disabled', JSON.stringify(disabled));
|
||||
// Disabling is as final as removing, from a caller's point of view: the
|
||||
// model stops being selectable, so it stops being allowed.
|
||||
if (!enabled) await forgetModelEverywhere(modelId);
|
||||
await require('../utils/models').reconcileDefaultModel(db);
|
||||
logger.audit(req.user.id, 'admin_model_toggle', (enabled ? 'Enabled' : 'Disabled') + ' model: ' + modelId, req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
|
|
@ -454,6 +457,29 @@ router.post('/config/models/custom', async function(req, res) {
|
|||
});
|
||||
|
||||
// ── DELETE custom model ───────────────────────────────────────────────────
|
||||
// A model that leaves the roster must leave every list that names it. Otherwise
|
||||
// clinical_assistant.allowed_models keeps offering an id the gateway no longer
|
||||
// has, and the only sign is a failed request at the moment someone asks a
|
||||
// clinical question. The lists are advisory copies of the roster; the roster is
|
||||
// the fact.
|
||||
async function forgetModelEverywhere(ids) {
|
||||
var gone = (Array.isArray(ids) ? ids : [ids]).filter(Boolean);
|
||||
if (!gone.length) return;
|
||||
for (var key of ['clinical_assistant.allowed_models',
|
||||
'clinical_assistant.allowed_image_models',
|
||||
'clinical_assistant.image_model_roster']) {
|
||||
var current = String(await db.getSetting(key, '') || '');
|
||||
if (!current) continue;
|
||||
var kept = current.split(',').map(function (id) { return id.trim(); })
|
||||
.filter(function (id) { return id && gone.indexOf(id) === -1; });
|
||||
if (kept.join(',') !== current) await db.setSetting(key, kept.join(','));
|
||||
}
|
||||
// A default that no longer exists is reconciled on the next read, but
|
||||
// clearing it here means the admin panel never shows it as current.
|
||||
var current = String(await db.getSetting('models.default', '') || '');
|
||||
if (current && gone.indexOf(current) !== -1) await db.setSetting('models.default', '');
|
||||
}
|
||||
|
||||
router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
|
||||
try {
|
||||
var modelId = req.params.modelId;
|
||||
|
|
@ -463,6 +489,7 @@ router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
|
|||
if (!Array.isArray(custom)) throw new Error('Invalid custom model settings');
|
||||
custom = custom.filter(function(m) { return m.id !== modelId; });
|
||||
await db.setSetting('models.custom', JSON.stringify(custom));
|
||||
await forgetModelEverywhere(modelId);
|
||||
await require('../utils/models').reconcileDefaultModel(db);
|
||||
logger.audit(req.user.id, 'admin_model_delete', 'Removed custom model: ' + modelId, req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
|
|
@ -475,6 +502,12 @@ router.post('/config/models/clear-all', async function(req, res) {
|
|||
await db.setSetting('models.custom', '[]');
|
||||
await db.setSetting('models.disabled', '[]');
|
||||
await db.setSetting('models.default', '');
|
||||
// Nothing is on the roster any more, so nothing may remain allowed.
|
||||
for (var key of ['clinical_assistant.allowed_models',
|
||||
'clinical_assistant.allowed_image_models',
|
||||
'clinical_assistant.image_model_roster']) {
|
||||
await db.setSetting(key, '');
|
||||
}
|
||||
logger.audit(req.user.id, 'admin_models_clear_all', 'Cleared all custom models and disabled list', req, { category: 'admin' });
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ var resourceImages = require('../utils/resourceImages');
|
|||
var deckSchema = require('../utils/deckSchema');
|
||||
var deckBuild = require('../utils/deckBuild');
|
||||
var deckReview = require('../utils/deckReview');
|
||||
var nextcloudFiles = require('../utils/nextcloudFiles');
|
||||
// The diagnostics below are the record of what a generation or a modification
|
||||
// actually did. console goes to the container's stdout, which is destroyed every
|
||||
// time the container is recreated — so the one question these exist to answer,
|
||||
|
|
@ -215,6 +216,11 @@ router.get('/my-resources/options', async function (req, res) {
|
|||
models: models.allowed,
|
||||
defaultModel: models.configured,
|
||||
imagesAvailable: Boolean(await db.getSetting('clinical_assistant.image_model', '')),
|
||||
// Whether this person has somewhere to send a file. The button is hidden
|
||||
// rather than shown-and-refused: an action that always fails is worse
|
||||
// than an action that is not offered.
|
||||
nextcloudConnected: Boolean((await db.get(
|
||||
'SELECT nextcloud_url FROM users WHERE id = ?', [req.user.id]) || {}).nextcloud_url),
|
||||
themes: deckSchema.themes().map(function (t) {
|
||||
return { id: t.id, name: t.name, description: t.description || '' };
|
||||
}),
|
||||
|
|
@ -487,6 +493,54 @@ router.post('/my-resources/generate', async function (req, res) {
|
|||
}
|
||||
});
|
||||
|
||||
// ── Send a resource to Nextcloud ────────────────────────────
|
||||
// The rendered file, not the markdown. A .pptx landing in someone's own storage
|
||||
// is the thing worth having; a text blob is not, and it is not what they would
|
||||
// have downloaded.
|
||||
//
|
||||
// Rendered by exactly the path the download uses, so what lands in Nextcloud is
|
||||
// byte-for-byte what the browser would have saved.
|
||||
router.post('/my-resources/:id/to-nextcloud', async function (req, res) {
|
||||
var fsp = require('fs/promises');
|
||||
var os = require('os');
|
||||
var pathMod = require('path');
|
||||
var workdir = null;
|
||||
try {
|
||||
var format = String(req.body.format || 'pptx');
|
||||
if (!documentExport.isSupported(format)) return res.status(400).json({ error: 'Unsupported format' });
|
||||
|
||||
var row = await db.get(
|
||||
'SELECT id, title, kind, markdown, image_ids, deck FROM user_resources WHERE id = ? AND user_id = ?',
|
||||
[parseInt(req.params.id, 10), req.user.id]
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
||||
if (row.kind === 'article' && format === 'pptx') {
|
||||
return res.status(400).json({ error: 'An article has no slides. Send it as Word or PDF.' });
|
||||
}
|
||||
|
||||
workdir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'nc-'));
|
||||
var figures = await collectFigures(row.image_ids, req.user, workdir);
|
||||
var deck = row.deck ? (typeof row.deck === 'string' ? JSON.parse(row.deck) : row.deck) : null;
|
||||
var bytes = await documentExport.render(row.markdown, row.kind, format, {
|
||||
images: figures, figureIds: figureIdList(row.image_ids), deck: deck
|
||||
});
|
||||
|
||||
var saved = await nextcloudFiles.send(req.user.id,
|
||||
documentExport.filename(row.title, format), bytes, documentExport.mimeFor(format));
|
||||
|
||||
logger.info('[my-resources] sent to Nextcloud', { id: row.id, format: format });
|
||||
res.json({ success: true, path: saved });
|
||||
} catch (err) {
|
||||
// A Nextcloud that is not connected, or a password that no longer works, is
|
||||
// the caller's to fix and is worth saying plainly.
|
||||
if (err.statusCode) return res.status(err.statusCode).json({ error: err.message });
|
||||
logger.warn('[my-resources] to-nextcloud', { error: err.message });
|
||||
res.status(502).json({ error: 'Could not send it to Nextcloud. Check the connection in Settings.' });
|
||||
} finally {
|
||||
if (workdir) await require('fs/promises').rm(workdir, { recursive: true, force: true }).catch(function () {});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Theme previews ──────────────────────────────────────────
|
||||
// One representative slide per theme, rendered by the renderer itself and
|
||||
// cached. Drawn rather than mocked up: a hand-made swatch drifts the moment a
|
||||
|
|
|
|||
82
src/utils/nextcloudFiles.js
Normal file
82
src/utils/nextcloudFiles.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// ============================================================
|
||||
// NEXTCLOUD FILES
|
||||
// ============================================================
|
||||
// Putting a file in someone's own Nextcloud, in one place.
|
||||
//
|
||||
// Two callers want this: the clinical tabs send a note as text, My Resources
|
||||
// sends a rendered deck or document as bytes. Neither should grow its own copy
|
||||
// of the WebDAV dance — make the dated folder, PUT the file, migrate a legacy
|
||||
// token — because this reaches into storage that is not ours and a second
|
||||
// slightly-different copy is how the two drift.
|
||||
//
|
||||
// A route importing another route is what this replaces.
|
||||
var axios = require('axios');
|
||||
var db = require('../db/database');
|
||||
var cryptoUtil = require('./crypto');
|
||||
var { assertSafeHttpsUrl } = require('./urlSafety');
|
||||
|
||||
function davRoot(url, username) {
|
||||
return String(url).replace(/\/+$/, '') + '/remote.php/dav/files/' + encodeURIComponent(username);
|
||||
}
|
||||
|
||||
function refuse(statusCode, message) {
|
||||
return Object.assign(new Error(message), { statusCode: statusCode });
|
||||
}
|
||||
|
||||
// The connection, the decrypted password, and a dated folder that exists.
|
||||
async function target(userId) {
|
||||
var user = await db.get(
|
||||
'SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder FROM users WHERE id = ?',
|
||||
[userId]);
|
||||
if (!user || !user.nextcloud_url) throw refuse(400, 'Nextcloud is not connected. Connect it in Settings.');
|
||||
await assertSafeHttpsUrl(user.nextcloud_url, 'Nextcloud URL');
|
||||
|
||||
var password;
|
||||
try { password = cryptoUtil.decryptString(user.nextcloud_token); }
|
||||
catch (e) { throw refuse(400, 'Nextcloud credentials are invalid. Reconnect in Settings.'); }
|
||||
|
||||
var base = user.nextcloud_folder || '/PediatricScribe';
|
||||
var folder = base + '/' + new Date().toISOString().split('T')[0];
|
||||
var root = davRoot(user.nextcloud_url, user.nextcloud_user);
|
||||
|
||||
// One MKCOL per segment: WebDAV will not create a tree in a single call, and
|
||||
// a folder that already exists answers 405, which is success here.
|
||||
var walked = '';
|
||||
for (var part of folder.split('/').filter(Boolean)) {
|
||||
walked += '/' + part;
|
||||
try {
|
||||
await axios({ method: 'MKCOL', url: root + walked + '/',
|
||||
auth: { username: user.nextcloud_user, password: password },
|
||||
timeout: 15000, maxRedirects: 0 });
|
||||
} catch (e) { /* already there, or the PUT below will report the real fault */ }
|
||||
}
|
||||
return { user: user, password: password, root: root, folder: folder };
|
||||
}
|
||||
|
||||
/** Put bytes in the owner's Nextcloud. Returns the path it landed at. */
|
||||
async function send(userId, name, bytes, contentType) {
|
||||
var place = await target(userId);
|
||||
// The name reaches a filesystem, so it is reduced to characters that cannot
|
||||
// traverse or confuse one.
|
||||
var safe = String(name || 'resource').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'resource';
|
||||
|
||||
await axios({
|
||||
method: 'PUT',
|
||||
url: place.root + place.folder + '/' + safe,
|
||||
data: bytes,
|
||||
auth: { username: place.user.nextcloud_user, password: place.password },
|
||||
headers: { 'Content-Type': contentType || 'application/octet-stream' },
|
||||
timeout: 60000,
|
||||
maxRedirects: 0,
|
||||
maxBodyLength: Infinity
|
||||
});
|
||||
|
||||
// Migrate a legacy plaintext token to encrypted form on first successful use.
|
||||
if (!cryptoUtil.isEncrypted(place.user.nextcloud_token)) {
|
||||
db.run('UPDATE users SET nextcloud_token = ? WHERE id = ?',
|
||||
[cryptoUtil.encryptString(place.password), userId]).catch(function () {});
|
||||
}
|
||||
return place.folder + '/' + safe;
|
||||
}
|
||||
|
||||
module.exports = { send, target, davRoot };
|
||||
46
test/model-roster-pruning.test.js
Normal file
46
test/model-roster-pruning.test.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// A model that leaves the roster has to leave every list that names it. The
|
||||
// allowed lists are advisory copies of the roster; the roster is the fact. A
|
||||
// stale id there is invisible until someone asks a clinical question and the
|
||||
// request fails at the gateway.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/adminConfig.js'), 'utf8');
|
||||
|
||||
test('one helper owns forgetting a model, and covers every list that names one', () => {
|
||||
assert.match(route, /async function forgetModelEverywhere\(ids\)/);
|
||||
for (const key of ['clinical_assistant.allowed_models',
|
||||
'clinical_assistant.allowed_image_models',
|
||||
'clinical_assistant.image_model_roster']) {
|
||||
assert.ok(route.includes("'" + key + "'"), key + ' is not pruned');
|
||||
}
|
||||
// And a default pointing at a model that has gone.
|
||||
assert.match(route, /gone\.indexOf\(current\) !== -1\) await db\.setSetting\('models\.default', ''\)/);
|
||||
});
|
||||
|
||||
test('removing a custom model prunes it', () => {
|
||||
const handler = route.slice(route.indexOf("router.delete('/config/models/custom"));
|
||||
assert.match(handler.slice(0, 900), /await forgetModelEverywhere\(modelId\)/);
|
||||
});
|
||||
|
||||
test('disabling a model prunes it too — it stops being selectable either way', () => {
|
||||
const handler = route.slice(route.indexOf("router.put('/config/models/toggle"));
|
||||
assert.match(handler.slice(0, 1600), /if \(!enabled\) await forgetModelEverywhere\(modelId\)/);
|
||||
// Re-enabling must NOT silently re-allow it: that is a separate decision.
|
||||
assert.doesNotMatch(handler.slice(0, 1600), /if \(enabled\) await forgetModelEverywhere/);
|
||||
});
|
||||
|
||||
test('clearing every model clears every allowed list', () => {
|
||||
const handler = route.slice(route.indexOf("router.post('/config/models/clear-all"));
|
||||
assert.match(handler.slice(0, 900), /await db\.setSetting\(key, ''\)/);
|
||||
assert.match(handler.slice(0, 900), /Nothing is on the roster any more/);
|
||||
});
|
||||
|
||||
test('a list is only written when it actually changed', () => {
|
||||
// Avoids a settings write, an updated_at bump and a cache refresh per removal
|
||||
// when nothing referenced the model.
|
||||
const fn = route.slice(route.indexOf('async function forgetModelEverywhere'));
|
||||
assert.match(fn.slice(0, 1200), /if \(kept\.join\(','\) !== current\)/);
|
||||
});
|
||||
|
|
@ -78,6 +78,7 @@ function router(t, overrides = {}) {
|
|||
'../utils/generatedImages': { service: () => ({ get: async () => null, asset: async () => null }), workflows: ['my_resources'] },
|
||||
'../utils/learningRetrieval': { retrieve: async () => ({ context: '', sources: [], reason: null }) },
|
||||
'../utils/logger': quiet,
|
||||
'../utils/nextcloudFiles': { send: async () => '/PediatricScribe/2026-01-01/x.pptx' },
|
||||
'../utils/metrics': { resourceRefines: { inc() {} }, resourceVocabularyGaps: { inc() {} } },
|
||||
'../utils/pubmedSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' },
|
||||
'../utils/webSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' },
|
||||
|
|
|
|||
67
test/resource-to-nextcloud.test.js
Normal file
67
test/resource-to-nextcloud.test.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Sending a resource to Nextcloud pushes the rendered file, not the markdown.
|
||||
// A .pptx landing in someone's own storage is the thing worth having; a text
|
||||
// blob is not, and it is not what they would have downloaded.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
|
||||
const route = read('src/routes/myResources.js');
|
||||
const util = read('src/utils/nextcloudFiles.js');
|
||||
const ui = read('public/js/myResources.js');
|
||||
|
||||
test('the file is rendered by the same path a download uses', () => {
|
||||
const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'"));
|
||||
assert.match(handler.slice(0, 2200), /documentExport\.render\(row\.markdown, row\.kind, format/);
|
||||
assert.match(handler.slice(0, 2200), /deck: deck/, 'the stored deck, so it renders as designed');
|
||||
assert.match(handler.slice(0, 2200), /collectFigures\(row\.image_ids/, 'with its figures');
|
||||
// Never the markdown on its own.
|
||||
assert.doesNotMatch(handler.slice(0, 2200), /send\([^)]*row\.markdown/);
|
||||
});
|
||||
|
||||
test('it is scoped to the owner and refuses a format the resource cannot be', () => {
|
||||
const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'"));
|
||||
assert.match(handler.slice(0, 2200), /WHERE id = \? AND user_id = \?/);
|
||||
assert.match(handler.slice(0, 2200), /An article has no slides/);
|
||||
assert.match(handler.slice(0, 2200), /documentExport\.isSupported\(format\)/);
|
||||
});
|
||||
|
||||
test('a disconnected Nextcloud says so, rather than failing as a server error', () => {
|
||||
assert.match(util, /Nextcloud is not connected\. Connect it in Settings\./);
|
||||
assert.match(util, /Nextcloud credentials are invalid\. Reconnect in Settings\./);
|
||||
// Those carry a status the route passes through instead of flattening to 502.
|
||||
const handler = route.slice(route.indexOf("router.post('/my-resources/:id/to-nextcloud'"));
|
||||
assert.match(handler.slice(0, 2400), /if \(err\.statusCode\) return res\.status\(err\.statusCode\)/);
|
||||
});
|
||||
|
||||
test('one module knows how to put a file in Nextcloud', () => {
|
||||
// A route importing another route is what this replaced.
|
||||
assert.match(route, /require\('\.\.\/utils\/nextcloudFiles'\)/);
|
||||
assert.doesNotMatch(route, /require\('\.\/nextcloud'\)/);
|
||||
assert.match(util, /async function send\(userId, name, bytes, contentType\)/);
|
||||
});
|
||||
|
||||
test('the filename cannot traverse or confuse a filesystem', () => {
|
||||
assert.match(util, /replace\(\/\[\^a-zA-Z0-9\._-\]\/g, '_'\)/);
|
||||
assert.match(util, /\.slice\(0, 120\) \|\| 'resource'/, 'and cannot end up empty');
|
||||
});
|
||||
|
||||
test('the folder tree is made a segment at a time, and an existing one is not an error', () => {
|
||||
// WebDAV will not create a tree in one call; MKCOL on an existing folder is 405.
|
||||
assert.match(util, /for \(var part of folder\.split\('\/'\)\.filter\(Boolean\)\)/);
|
||||
assert.match(util, /already there, or the PUT below will report the real fault/);
|
||||
});
|
||||
|
||||
test('the button appears only when there is a Nextcloud to send to', () => {
|
||||
assert.match(route, /nextcloudConnected: Boolean/);
|
||||
assert.match(ui, /if \(nextcloudConnected\) \{/);
|
||||
assert.match(ui, /data-nextcloud|dataset\.nextcloud/);
|
||||
// An article offers Word, a deck offers PowerPoint.
|
||||
assert.match(ui, /row\.kind === 'article' \? 'docx' : 'pptx'/);
|
||||
});
|
||||
|
||||
test('a legacy plaintext token is encrypted on first successful use', () => {
|
||||
assert.match(util, /if \(!cryptoUtil\.isEncrypted\(place\.user\.nextcloud_token\)\)/);
|
||||
assert.match(util, /cryptoUtil\.encryptString\(place\.password\)/);
|
||||
});
|
||||
Loading…
Reference in a new issue