Files
occweb/js/index.js
T
dillard 138ee88486 Rename app id from occweb to extended_occweb, prep for App Store
Nextcloud's app id "occweb" is effectively unavailable for a new store
listing: the original Adphi/occweb still owns that App Store slot
(stale, v0.2.3), and el-choco/occweb_v2 has since published a
same-lineage rewrite under its own id. Rather than fight over the
"occweb" name, publish this fork under its own id, matching the
<name> this app has always had: "Extended OCC Web".

Renames every place the literal app id is used: info.xml <id> and the
auto-generated navigation <route>, script()/style() registrations in
templates/index.php, the TemplateResponse app name in
OccController::index(), the /apps/... base URL in js/index.js, and the
'app' log context tag in DbController.php. The GitHub repo name and
its <website>/<bugs>/<repository>/<screenshot> links are unaffected -
those identify the repo, not the Nextcloud app.

Docs (README, INSTALL-AIO.md, install.sh) updated to clone into and
reference an "extended_occweb" directory, since Nextcloud requires the
app folder name to match its id - a plain `git clone` of this repo no
longer produces a correctly-named install directory on its own.

Version bump to 0.4.0 given the scope (breaking change for anyone who
already deployed under the old "occweb" id - requires disable+remove
of the old app and a fresh enable under the new id, not an in-place
upgrade).
2026-09-26 12:51:21 +02:00

419 lines
17 KiB
JavaScript

(function (OC, window, $, undefined) {
'use strict';
$(function() {
// Nextcloud core only auto-attaches the CSRF requesttoken header to ajax
// calls made through its own globally-loaded jQuery. Since NC34 that
// global jQuery is no longer guaranteed to be the one still active by
// the time our own calls fire (other apps loading their own jQuery can
// replace window.jQuery afterwards - see js/jquery.js), ajaxSetup() here
// isn't reliable; every $.ajax() call below sets the header explicitly.
function scrollToBottom(){
var html = $('html');
html.scrollTop(html.prop('scrollHeight'));
}
var baseUrl = OC.generateUrl('/apps/extended_occweb');
// Current terminal mode: 'occ' (regular occ commands) or 'sql'
// (running arbitrary SQL queries via /db/query).
var mode = 'occ';
var OCC_PROMPT = 'occ $ ';
var SQL_PROMPT = '[[;#ff5555;]sql]# ';
// Splits a batch of queries on ";" while respecting single AND double
// quotes (incl. '' / "" as an escaped quote inside a string/identifier),
// so that a ";" inside a string literal or "quoted identifier" doesn't
// break the split. Mirrors splitStatements() on the backend.
function splitStatements(sql) {
var statements = [];
var current = '';
var quoteChar = null;
for (var i = 0; i < sql.length; i++) {
var ch = sql[i];
if (quoteChar !== null) {
if (ch === quoteChar) {
if (sql[i + 1] === quoteChar) {
current += quoteChar + quoteChar;
i++;
continue;
}
quoteChar = null;
}
current += ch;
continue;
}
if (ch === "'" || ch === '"') {
quoteChar = ch;
current += ch;
continue;
}
if (ch === ';') {
statements.push(current.trim());
current = '';
continue;
}
current += ch;
}
if (current.trim() !== '') {
statements.push(current.trim());
}
return statements.filter(function (s) { return s !== ''; });
}
// Quick client-side check for DELETE/UPDATE — purely for UX (to avoid
// an extra round trip to the server). The backend always makes the
// final call (requiresConfirmation); this is just a hint.
function scriptNeedsConfirmation(sql) {
return splitStatements(sql).some(function (part) {
var normalized = part.replace(/^(\s*--[^\n]*\n)*\s*/, '');
return /^(DELETE|UPDATE)\b/i.test(normalized);
});
}
function escapeSqlString(value) {
return String(value).replace(/'/g, "''");
}
// Ready-made SQL script templates. build() returns the script text,
// which is inserted into the command line via term.set_command() —
// nothing runs automatically, the user reviews it themselves and
// presses Enter (which then triggers the normal DELETE confirmation).
var TEMPLATES = {
'delete-user': {
args: ['uid'],
description: 'Completely delete a local user (oc_preferences, oc_group_user, oc_ldap_user_mapping, oc_users)',
build: function (uid) {
// The value is substituted directly into each query (rather than
// via SET+current_setting) — this keeps the script working even
// if the user copies/runs only some of the lines individually.
var v = escapeSqlString(uid);
return [
"SELECT * FROM oc_users WHERE uid = '" + v + "'",
"SELECT * FROM oc_preferences WHERE userid = '" + v + "'",
"DELETE FROM oc_preferences WHERE userid = '" + v + "'",
"DELETE FROM oc_group_user WHERE uid = '" + v + "'",
"DELETE FROM oc_ldap_user_mapping WHERE owncloud_name = '" + v + "'",
"DELETE FROM oc_users WHERE uid = '" + v + "'"
].join(';\n') + ';';
}
},
'list-user': {
args: ['uid'],
description: "View a user's data only, without deleting anything (oc_users, oc_preferences, oc_group_user, oc_ldap_user_mapping)",
build: function (uid) {
var v = escapeSqlString(uid);
return [
"SELECT * FROM oc_users WHERE uid = '" + v + "'",
"SELECT * FROM oc_preferences WHERE userid = '" + v + "'",
"SELECT * FROM oc_group_user WHERE uid = '" + v + "'",
"SELECT * FROM oc_ldap_user_mapping WHERE owncloud_name = '" + v + "'"
].join(';\n') + ';';
}
},
'rename-user': {
args: ['old_uid', 'new_uid'],
description: "WARNING: not an official Nextcloud operation. Renames the uid only in the core tables — doesn't cover everything, requires manual extra steps (see the warning in the script itself)",
build: function (oldUid, newUid) {
var o = escapeSqlString(oldUid);
var n = escapeSqlString(newUid);
// The warning consists only of single-line "--" comments with no
// ";" inside them, so splitStatements() (mirrored on the backend
// and here) won't split them into separate statements, and
// stripLeadingComments() on the backend will strip this block
// before determining the type of the very first query (SELECT).
var warning =
"-- WARNING rename is NOT an officially supported Nextcloud operation\n" +
"-- This only updates core tables below - it does NOT cover app-specific\n" +
"-- tables (Talk, Calendar, Contacts, Mail, two-factor, WebAuthn, etc.)\n" +
"-- After running this you must ALSO, with the web server stopped or in\n" +
"-- maintenance mode, rename the data directory on disk (data/" + o + " -> data/" + n + ")\n" +
"-- and then run: occ files:scan --all\n" +
"-- Back up the database first and test on a non-critical account\n";
var statements = [
"SELECT uid FROM oc_users WHERE uid = '" + o + "'",
"SELECT uid FROM oc_users WHERE uid = '" + n + "'",
"UPDATE oc_users SET uid = '" + n + "' WHERE uid = '" + o + "'",
"UPDATE oc_preferences SET userid = '" + n + "' WHERE userid = '" + o + "'",
"UPDATE oc_group_user SET uid = '" + n + "' WHERE uid = '" + o + "'",
"UPDATE oc_group_admin SET uid = '" + n + "' WHERE uid = '" + o + "'",
"UPDATE oc_ldap_user_mapping SET owncloud_name = '" + n + "' WHERE owncloud_name = '" + o + "'",
"UPDATE oc_share SET uid_owner = '" + n + "' WHERE uid_owner = '" + o + "'",
"UPDATE oc_share SET uid_initiator = '" + n + "' WHERE uid_initiator = '" + o + "'",
"UPDATE oc_share SET share_with = '" + n + "' WHERE share_with = '" + o + "' AND share_type = 0",
"UPDATE oc_mounts SET user_id = '" + n + "' WHERE user_id = '" + o + "'",
"UPDATE oc_storages SET id = 'home::" + n + "' WHERE id = 'home::" + o + "'",
"SELECT uid FROM oc_users WHERE uid = '" + n + "'"
];
return warning + statements.join(';\n') + ';';
}
}
};
function listTemplates(term) {
term.echo('[[;yellow;]Available templates:]');
Object.keys(TEMPLATES).forEach(function (name) {
var t = TEMPLATES[name];
var usage = name + ' ' + t.args.map(function (a) { return '<' + a + '>'; }).join(' ');
term.echo('[[;#009ae3;] template ' + usage + ']');
term.echo(' ' + t.description);
});
term.echo('[[;gray;]Fills the command line — review it, then press Enter to run.]');
}
function useTemplate(term, name, args) {
var t = TEMPLATES[name];
if (!t) {
term.echo('[[;#ff5555;]Unknown template: ]' + $.terminal.escape_formatting(name || '') + '. Type "templates" to list available ones.');
return;
}
if (args.length < t.args.length) {
var usage = name + ' ' + t.args.map(function (a) { return '<' + a + '>'; }).join(' ');
term.echo('[[;#ff5555;]Missing arguments. Usage: ]template ' + usage);
return;
}
var sql = t.build.apply(null, args);
term.set_command(sql);
term.echo('[[;yellow;]Template inserted into the command line — review it, then press Enter to run.]');
}
// Tabular rendering of SELECT results, psql-style.
function renderTable(term, rows) {
if (!rows || !rows.length) {
return;
}
var columns = Object.keys(rows[0]);
function formatCell(v) {
if (v === null || v === undefined) {
// An explicit label rather than an empty string — otherwise NULL
// would be indistinguishable from an actual empty string '' in
// the table output.
return '[NULL]';
}
if (typeof v === 'object') {
return JSON.stringify(v);
}
return String(v).replace(/\r?\n/g, '\\n');
}
function pad(str, width) {
str = String(str);
var diff = width - str.length;
return diff > 0 ? str + new Array(diff + 1).join(' ') : str;
}
var widths = columns.map(function (col) {
return rows.reduce(function (max, row) {
return Math.max(max, formatCell(row[col]).length);
}, col.length);
});
function formatRow(cells) {
return cells.map(function (cell, i) {
return ' ' + pad(cell, widths[i]) + ' ';
}).join('|');
}
var lines = [];
lines.push(formatRow(columns));
lines.push(widths.map(function (w) { return new Array(w + 3).join('-'); }).join('+'));
rows.forEach(function (row) {
lines.push(formatRow(columns.map(function (col) { return formatCell(row[col]); })));
});
term.echo($.terminal.escape_formatting(lines.join('\n')));
}
function renderSqlResponse(term, response) {
if (!response) {
term.echo('[[;#ff5555;]Empty response from server]');
return;
}
if (response.success === false) {
term.echo('[[;#ff5555;]Error: ]' + $.terminal.escape_formatting(response.error || 'unknown error'));
return;
}
var results = response.results || [];
if (!results.length) {
term.echo('[[;yellow;]No statements were executed]');
return;
}
results.forEach(function (r) {
term.echo('[[;#009ae3;]> ]' + $.terminal.escape_formatting(r.query || ''));
if (r.type === 'error') {
term.echo('[[;#ff5555;] Error: ]' + $.terminal.escape_formatting(r.error || 'unknown error'));
} else if (r.type === 'select') {
term.echo('[[;gray;] ' + r.count + ' row(s)]');
if (r.count > 0) {
renderTable(term, r.data);
}
if (r.truncated) {
term.echo('[[;yellow;] Result truncated — showing only the first ' + r.count + ' rows, add LIMIT to see more precisely.]');
}
} else if (r.type === 'set') {
term.echo('[[;green;] OK (session variable set)]');
} else if (r.type === 'delete') {
term.echo('[[;#ff9900;] DELETED ' + r.affected_rows + ' row(s)]');
} else if (r.type === 'update') {
term.echo('[[;#ff9900;] UPDATED ' + r.affected_rows + ' row(s)]');
} else {
term.echo('[[;green;] OK, ' + r.affected_rows + ' row(s) affected]');
}
});
if (response.rolledBack) {
term.echo('[[;#ff5555;]Batch failed partway through — all statements in this batch were rolled back.]');
} else if (response.rollbackFailed) {
term.echo('[[;#ff0000;]' + $.terminal.escape_formatting(response.warning || 'Batch failed and the rollback itself failed — earlier statements may have been permanently applied. Check manually.') + ']');
}
}
function enterSqlMode(term) {
mode = 'sql';
term.set_prompt(SQL_PROMPT);
term.echo('[[;yellow;]Switched to SQL mode. Admin only — statements run directly against the database.]');
term.echo('[[;gray;]Separate statements with ";". Shift+Enter for a new line, Enter to run. Type "occ" to go back.]');
term.echo('[[;gray;]Type "templates" to list ready-made scripts (e.g. deleting a user).]');
}
function exitSqlMode(term) {
mode = 'occ';
term.set_prompt(OCC_PROMPT);
term.echo('[[;yellow;]Switched back to OCC mode.]');
}
// Timeout for large/long-running batches (e.g. a DELETE on a big table
// without an index): without it, a hung query would silently leave the
// terminal blocked (term.pause()) forever if the server never responds.
var SQL_REQUEST_TIMEOUT_MS = 120000;
function sendSqlQuery(term, sql, confirmed) {
term.pause();
$.ajax({
url: baseUrl + '/db/query',
type: 'POST',
contentType: 'application/json',
timeout: SQL_REQUEST_TIMEOUT_MS,
headers: { requesttoken: OC.requestToken },
data: JSON.stringify({ sql: sql, confirm: !!confirmed })
}).done(function (response) {
if (response && response.requiresConfirmation) {
term.resume();
askDeleteConfirmation(term, sql, response.error);
return;
}
renderSqlResponse(term, response);
term.resume();
}).fail(function (xhr, status) {
if (status === 'timeout') {
term.echo('[[;#ff5555;]Request timed out after ' + (SQL_REQUEST_TIMEOUT_MS / 1000) + 's — the query may still be running on the server, check occ/DB logs before retrying.]');
} else {
term.echo('[[;#ff5555;]Request failed: ]' + $.terminal.escape_formatting(xhr.status + ' ' + xhr.statusText));
}
term.resume();
});
}
function askDeleteConfirmation(term, sql, message) {
var prompt = '[[;#ff5555;]' + (message || 'This script contains DELETE/UPDATE statement(s).') + ' Type "yes" to run it: ]';
term.read(prompt).then(function (answer) {
if ((answer || '').trim().toLowerCase() === 'yes') {
sendSqlQuery(term, sql, true);
} else {
term.echo('[[;yellow;]Cancelled — nothing was executed.]');
}
}, function () {
term.echo('[[;yellow;]Cancelled — nothing was executed.]');
});
}
$.ajax({ url: baseUrl + '/cmd', headers: { requesttoken: OC.requestToken } }).done(function(response){
$('#app-content').terminal(function(command, term) {
if (mode === 'sql') {
var trimmed = command.trim();
if (trimmed === 'occ') {
exitSqlMode(term);
return;
}
if (trimmed === 'c') {
term.clear();
return;
}
if (trimmed === 'exit') {
exitSqlMode(term);
term.reset();
return;
}
if (trimmed === 'templates') {
listTemplates(term);
return;
}
if (/^template(\s|$)/i.test(trimmed)) {
var parts = trimmed.split(/\s+/);
useTemplate(term, parts[1], parts.slice(2));
return;
}
if (!trimmed) {
return;
}
if (scriptNeedsConfirmation(command)) {
askDeleteConfirmation(term, command);
} else {
sendSqlQuery(term, command, false);
}
return;
}
switch (command) {
case "c":
this.clear();
break;
case "exit":
this.reset();
break;
case "sql":
enterSqlMode(term);
break;
default:
var occCommand = {
command: command
};
term.pause();
$.ajax({
url: baseUrl + '/cmd',
type: 'POST',
contentType: 'application/json',
headers: { requesttoken: OC.requestToken },
data: JSON.stringify(occCommand)
}).done(function (response) {
term.echo('\n' + response).resume();
}).fail(function (response, code) {
term.echo('\n' + response).resume();
});
}
}, {
greetings: function (callback) {
callback('[[;green;]' + new Date().toString().slice(0, 24) + "]\n\nPress [[;#ff5e99;]Enter] for more information on [[;#009ae3;]occ] commands.\nType [[;#ff5e99;]sql] to switch to SQL query mode.\n")
},
name: 'occ',
prompt: OCC_PROMPT,
completion: response,
keydown: function (e) {
// Shift+Enter inserts a newline instead of running the command,
// letting you type multi-line SQL scripts in sql mode.
if (e.shiftKey && e.key === 'Enter') {
this.insert('\n');
return false;
}
},
onResize: function(){
scrollToBottom()
}
});
});
$('html').keypress(function(){
scrollToBottom()
})
});
})(OC, window, jQuery);