Sanitized public release v1.0.3

Source-Tag: v1.0.3
Manifest-SHA256: b7b09bf48f9e092ed8ad82f99c0319d6c3837d0a1549bd0cc4a374276c0f3896
This commit is contained in:
NIA Sanitized Release Publisher
2026-07-20 22:35:36 +00:00
commit 7141326ea0
41 changed files with 6397 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
<section class="neo-colour-settings" ng-show="neoSettingsTab === 'colours'" role="tabpanel" aria-label="Colours">
<div class="neo-settings-toolbar">
<p>These values are read from and saved to WeeChat. Role colours update <code>irc.color.nick_prefixes</code>.</p>
<button type="button" class="btn btn-default" ng-click="loadNeoWeeChatColours()" ng-disabled="neoWeeChatColours.loading">Refresh</button>
</div>
<p class="status" ng-bind="neoWeeChatColours.status"></p>
<h5>Chat</h5>
<div class="neo-colour-grid">
<label ng-repeat="item in neoWeeChatColours.items"><span ng-bind="item.label"></span><select class="form-control" ng-model="neoWeeChatColours.values[item.name]" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoWeeChatColour(item.name)"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.values[item.name])}" title="{{neoWeeChatColours.values[item.name]}}"></i><code ng-bind="item.name"></code></label>
</div>
<h5>Nickname roles</h5>
<div class="neo-colour-grid">
<label><span>Operator</span><select class="form-control" ng-model="neoWeeChatColours.prefixes.o" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('o')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes.o)}" title="{{neoWeeChatColours.prefixes.o}}"></i><code>mode o</code></label>
<label><span>Half-operator</span><select class="form-control" ng-model="neoWeeChatColours.prefixes.h" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('h')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes.h)}" title="{{neoWeeChatColours.prefixes.h}}"></i><code>mode h</code></label>
<label><span>Voice</span><select class="form-control" ng-model="neoWeeChatColours.prefixes.v" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('v')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes.v)}" title="{{neoWeeChatColours.prefixes.v}}"></i><code>mode v</code></label>
<label><span>Other prefixes</span><select class="form-control" ng-model="neoWeeChatColours.prefixes['*']" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('*')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes['*'])}" title="{{neoWeeChatColours.prefixes['*']}}"></i><code>fallback *</code></label>
</div>
<h5>Channel nickname palette</h5>
<div class="neo-palette-setting"><input class="form-control" type="text" ng-model="neoWeeChatColours.values['weechat.color.chat_nick_colors']" ng-change="updateNeoPalettePreview()"><button type="button" class="btn btn-primary" ng-click="setNeoWeeChatColour('weechat.color.chat_nick_colors')">Save palette</button><div class="neo-palette-preview" aria-label="Nickname palette preview"><i ng-repeat="colour in neoWeeChatColours.palettePreview track by $index" ng-style="{'background-color': neoWeeChatColourPreview(colour)}" title="{{colour}}"></i></div><code>weechat.color.chat_nick_colors</code></div>
</section>
+102
View File
@@ -0,0 +1,102 @@
var neoColourOptions = [
{name: 'weechat.color.chat_nick_self', label: 'Your nickname', variable: '--neorelay-nick-self'},
{name: 'weechat.color.chat_nick', label: 'Regular nickname fallback', variable: '--neorelay-nick'},
{name: 'weechat.color.chat_nick_other', label: 'Other private nicknames', variable: '--neorelay-nick-other'},
{name: 'weechat.color.nicklist_away', label: 'Away / AFK', variable: '--neorelay-nick-away'},
{name: 'weechat.color.chat_highlight', label: 'Highlight foreground', variable: '--neorelay-chat-highlight'},
{name: 'weechat.color.chat_highlight_bg', label: 'Highlight background', variable: '--neorelay-chat-highlight-bg'},
{name: 'weechat.color.chat_time', label: 'Timestamp', variable: '--neorelay-chat-time'}
];
var neoPrefixOption = 'irc.color.nick_prefixes';
var neoPaletteOption = 'weechat.color.chat_nick_colors';
$scope.neoWeeChatColours = {loading: false, status: '', items: neoColourOptions, rows: {}, values: {}, prefixes: {}, prefixRaw: '', palettePreview: []};
$scope.neoWeeChatColourPreview = neoResolveWeeChatColour;
$scope.updateNeoPalettePreview = function() {
$scope.neoWeeChatColours.palettePreview = String($scope.neoWeeChatColours.values[neoPaletteOption] || '').split(',').map(function(value) { return value.trim(); }).filter(neoValidWeeChatColour);
};
function neoParsePrefixes(value) {
var result = {};
String(value || '').split(';').forEach(function(entry) {
var match = entry.match(/^([A-Za-z*]):([^;]+)$/);
if (match) result[match[1]] = match[2].replace(/^[*!/_|]+/, '');
});
return result;
}
function neoApplyRelayColours() {
neoColourOptions.forEach(function(item) {
var value = $scope.neoWeeChatColours.values[item.name];
document.documentElement.style.setProperty(item.variable, neoResolveWeeChatColour(value));
});
var prefixes = $scope.neoWeeChatColours.prefixes;
document.documentElement.style.setProperty('--neorelay-nick-op', neoResolveWeeChatColour(prefixes.o || prefixes.a || prefixes.q || prefixes['*']));
document.documentElement.style.setProperty('--neorelay-nick-halfop', neoResolveWeeChatColour(prefixes.h || prefixes['*']));
document.documentElement.style.setProperty('--neorelay-nick-voice', neoResolveWeeChatColour(prefixes.v || prefixes['*']));
}
$scope.loadNeoWeeChatColours = function() {
if (!$rootScope.connected || $scope.neoWeeChatColours.loading) return;
$scope.neoWeeChatColours.loading = true;
$scope.neoWeeChatColours.status = 'Loading WeeChat colours...';
var names = neoColourOptions.map(function(item) { return item.name; }).concat([neoPrefixOption, neoPaletteOption]);
Promise.all(names.map(function(name) { return connection.requestInfolist('option', 0, name); })).then(function(results) {
$scope.$applyAsync(function() {
results.forEach(function(rows) {
if (!rows[0]) return;
var row = neoNormalizeOption(rows[0]);
neoLoadedOptions[row.name] = row;
$scope.neoWeeChatColours.rows[row.name] = row;
$scope.neoWeeChatColours.values[row.name] = row.value;
});
$scope.neoWeeChatColours.prefixRaw = $scope.neoWeeChatColours.values[neoPrefixOption] || '';
$scope.neoWeeChatColours.prefixes = neoParsePrefixes($scope.neoWeeChatColours.prefixRaw);
$scope.updateNeoPalettePreview();
neoApplyRelayColours();
$scope.neoWeeChatColours.loading = false;
$scope.neoWeeChatColours.status = 'Loaded from WeeChat.';
});
}, function() {
$scope.$applyAsync(function() {
$scope.neoWeeChatColours.loading = false;
$scope.neoWeeChatColours.status = 'Unable to load WeeChat colours.';
});
});
};
$scope.setNeoWeeChatColour = function(name) {
var row = $scope.neoWeeChatColours.rows[name];
var value = $scope.neoWeeChatColours.values[name];
neoWriteOption(row, value, function(updated) {
$scope.neoWeeChatColours.values[name] = updated.value;
if (name === neoPaletteOption) $scope.updateNeoPalettePreview();
neoApplyRelayColours();
$scope.neoWeeChatColours.status = 'Saved ' + name + ' to WeeChat.';
}, function(message) { $scope.neoWeeChatColours.status = message; });
};
$scope.setNeoNickPrefixColour = function(mode) {
var row = $scope.neoWeeChatColours.rows[neoPrefixOption];
if (!row || !neoValidWeeChatColour($scope.neoWeeChatColours.prefixes[mode])) return;
var found = false;
var entries = String($scope.neoWeeChatColours.prefixRaw || '').split(';').filter(Boolean).map(function(entry) {
if (entry.slice(0, 2) === mode + ':') {
found = true;
return mode + ':' + $scope.neoWeeChatColours.prefixes[mode];
}
return entry;
});
if (!found) entries.push(mode + ':' + $scope.neoWeeChatColours.prefixes[mode]);
var value = entries.join(';');
neoWriteOption(row, value, function(updated) {
$scope.neoWeeChatColours.prefixRaw = updated.value;
$scope.neoWeeChatColours.values[neoPrefixOption] = updated.value;
$scope.neoWeeChatColours.prefixes = neoParsePrefixes(updated.value);
neoApplyRelayColours();
$scope.neoWeeChatColours.status = 'Saved IRC prefix colours to WeeChat.';
}, function(message) { $scope.neoWeeChatColours.status = message; });
};
$scope.$watch(function() { return $rootScope.connected; }, function(connected) {
if (connected) $timeout($scope.loadNeoWeeChatColours, 250);
});
+9
View File
@@ -0,0 +1,9 @@
<div class="command-menu" ng-if="commandMenuOpen" role="listbox" id="{{inputId}}-command-menu">
<button type="button" class="command-menu-item" ng-repeat="item in commandMenuMatches track by item.command" ng-class="{selected: $index === commandMenuIndex}" id="{{inputId}}-command-{{$index}}" role="option" aria-selected="{{$index === commandMenuIndex}}" ng-mousedown="$event.preventDefault()" ng-mouseenter="commandMenuIndex = $index" ng-click="selectCommand(item)">
<span class="command-menu-name">/{{item.command}}</span>
<span class="command-menu-args" ng-bind="item.args"></span>
<span class="command-menu-description" ng-bind="item.description"></span>
</button>
<div class="command-menu-empty" ng-if="commandMenuLoading">Loading commands...</div>
<div class="command-menu-empty" ng-if="!commandMenuLoading && !commandMenuMatches.length">No matching commands</div>
</div>
+146
View File
@@ -0,0 +1,146 @@
var commandHooks = null;
var commandHooksRequest = null;
var commandMenuGeneration = 0;
var suppressCommandMenuKeypress = false;
var serverCommandFallbacks = [
{command:'defcon',args:'[<level>]',description:'Set or show Ergo emergency restrictions',insertion:'/quote DEFCON',serverCommand:true},
{command:'deoper',args:'',description:'Remove IRC operator privileges',insertion:'/quote DEOPER',serverCommand:true},
{command:'dline',args:'[<duration>] <ip>/<net> [<reason>] || LIST',description:'Add or list IP/network bans',insertion:'/quote DLINE',serverCommand:true},
{command:'kline',args:'[<duration>] <mask> [<reason>] || LIST',description:'Add or list client-mask bans',insertion:'/quote KLINE',serverCommand:true},
{command:'uban',args:'ADD|DEL|LIST|INFO <target>',description:'Manage unified bans',insertion:'/quote UBAN',serverCommand:true},
{command:'undline',args:'<ip>/<net>',description:'Remove an IP/network ban',insertion:'/quote UNDLINE',serverCommand:true},
{command:'unkline',args:'<mask>',description:'Remove a client-mask ban',insertion:'/quote UNKLINE',serverCommand:true}
];
var mergeCommandHooks = function(hooks) {
var unique = Object.create(null);
var hasOwn = Object.prototype.hasOwnProperty;
hooks.forEach(function(hook) {
var key = String(hook.command || '').toLowerCase();
if (key && (!hasOwn.call(unique, key) || hook.priority > unique[key].priority)) {
unique[key] = Object.assign({}, hook, {command:key});
}
});
serverCommandFallbacks.forEach(function(hook) {
if (!hasOwn.call(unique, hook.command)) unique[hook.command] = hook;
});
return Object.keys(unique).map(function(key){return unique[key];}).sort(function(left,right){return left.command.localeCompare(right.command);});
};
$scope.commandMenuOpen = false;
$scope.commandMenuLoading = false;
$scope.commandMenuMatches = [];
$scope.commandMenuIndex = 0;
var closeCommandMenu = function() {
$scope.commandMenuOpen = false;
$scope.commandMenuLoading = false;
$scope.commandMenuMatches = [];
$scope.commandMenuIndex = 0;
};
var updateCommandMenu = function() {
var match = /^\/([^\s]*)$/.exec($scope.command || '');
if (!match) {
closeCommandMenu();
return;
}
$scope.commandMenuOpen = true;
$scope.commandMenuIndex = 0;
if (commandHooks === null) {
$scope.commandMenuLoading = true;
if (commandHooksRequest === null) {
var requestGeneration = commandMenuGeneration;
commandHooksRequest = connection.requestCommandHooks().then(function(hooks) {
if (requestGeneration !== commandMenuGeneration) {
return;
}
commandHooks = mergeCommandHooks(hooks);
commandHooksRequest = null;
updateCommandMenu();
}, function() {
if (requestGeneration !== commandMenuGeneration) {
return;
}
commandHooks = mergeCommandHooks([]);
commandHooksRequest = null;
updateCommandMenu();
});
}
return;
}
var prefix = match[1].toLowerCase();
var activeBuffer = models.getActiveBuffer();
var isIrcBuffer = activeBuffer && activeBuffer.plugin === 'irc';
$scope.commandMenuLoading = false;
$scope.commandMenuMatches = commandHooks.filter(function(hook) {
return (!hook.serverCommand || isIrcBuffer) && hook.command.indexOf(prefix) === 0;
});
};
$scope.selectCommand = function(item) {
var suffix = item.args ? ' ' : '';
$scope.command = (item.insertion || ('/' + item.command.toLowerCase())) + suffix;
closeCommandMenu();
setTimeout(function() {
var inputNode = $scope.getInputNode();
inputNode.focus();
inputNode.setSelectionRange($scope.command.length, $scope.command.length);
}, 0);
};
var scrollCommandMenuSelection = function() {
setTimeout(function() {
var selected = document.getElementById(
$scope.inputId + '-command-' + $scope.commandMenuIndex
);
if (selected) {
selected.scrollIntoView({block: 'nearest'});
}
}, 0);
};
$scope.handleCommandMenuKey = function(event, code, inputNode) {
if (suppressCommandMenuKeypress && event.type === 'keypress' &&
(code === 9 || code === 13)) {
event.preventDefault();
return true;
}
if (!$scope.commandMenuOpen || event.type !== 'keydown' ||
document.activeElement !== inputNode || event.altKey ||
event.ctrlKey || event.metaKey) {
return false;
}
var count = $scope.commandMenuMatches.length;
if ((code === 38 || code === 40) && count > 0) {
var direction = code === 38 ? -1 : 1;
$scope.commandMenuIndex = ($scope.commandMenuIndex + direction + count) % count;
scrollCommandMenuSelection();
event.preventDefault();
return true;
}
if ((code === 9 || code === 13) && count > 0 && !event.shiftKey) {
$scope.selectCommand($scope.commandMenuMatches[$scope.commandMenuIndex]);
suppressCommandMenuKeypress = true;
setTimeout(function() { suppressCommandMenuKeypress = false; }, 0);
event.preventDefault();
return true;
}
if (code === 27) {
closeCommandMenu();
event.preventDefault();
return true;
}
return false;
};
var removeCommandMenuDisconnect = $rootScope.$on('relayDisconnect', function() {
commandMenuGeneration++;
commandHooks = null;
commandHooksRequest = null;
closeCommandMenu();
});
$scope.$on('$destroy', removeCommandMenuDisconnect);
+36
View File
@@ -0,0 +1,36 @@
<div class="panel connection-panel" data-state="active">
<div class="panel-collapse collapse">
<div class="panel-body">
<form class="form-signin" role="form">
<div class="relay-terminal">
<div class="relay-terminal-bar"><span></span><span></span><span></span><b>relay@{{RELAY_HOST}}:~</b></div>
<div class="relay-terminal-body">
<p><span class="relay-prompt">&gt;&gt;</span> relay config --show</p>
<dl>
<div><dt>HOSTNAME</dt><dd>{{RELAY_HOST}}</dd></div>
<div><dt>PORT</dt><dd>443</dd></div>
<div><dt>PATH</dt><dd>/weechat</dd></div>
<div><dt>TRANSPORT</dt><dd>WSS / TLS</dd></div>
</dl>
<div class="relay-password-line">
<label for="password"><span class="relay-prompt">&gt;&gt;</span> password</label>
<span class="relay-password-field">
<span class="relay-password-mask" ng-if="password" ng-cloak>{{ maskPassword(password) }}</span>
<span class="relay-password-placeholder" ng-if="!password">[ enter relay password ]</span>
<input type="password" id="password" ng-model="password" autocomplete="current-password" aria-label="WeeChat relay password">
</span>
</div>
<p class="relay-password-error" ng-show="passwordError" ng-cloak><span class="relay-prompt">!!</span> authentication rejected</p>
<p class="relay-input-line"><span class="relay-prompt">&gt;&gt;</span> <span class="relay-cursor" aria-hidden="true">_</span></p>
</div>
</div>
<div class="connection-options">
<label><input type="checkbox" ng-model="settings.tls" disabled> TLS encryption</label>
<label><input type="checkbox" ng-model="settings.savepassword"> Save password in this browser</label>
<label><input type="checkbox" ng-model="settings.autoconnect"> Automatically connect</label>
</div>
<button class="btn btn-lg btn-primary relay-connect" ng-click="connect()" ng-cloak>{{ connectbutton }} <i ng-class="connectbuttonicon" class="glyphicon"></i></button>
</form>
</div>
</div>
</div>
+3
View File
@@ -0,0 +1,3 @@
<div class="neo-context-menu" ng-if="menu.visible" ng-style="menu.position" role="menu" aria-label="{{menu.label}}">
<button type="button" role="menuitem" ng-repeat="item in menu.actions track by item.id" ng-class="{destructive:item.destructive}" ng-click="runContextAction(item)" ng-bind="item.label"></button>
</div>
+95
View File
@@ -0,0 +1,95 @@
'use strict';
(function() {
var weechat = angular.module('weechat');
weechat.factory('neoContextMenu', ['$rootScope', 'connection', 'models', function($rootScope, connection, models) {
var menu = {visible: false, actions: [], position: {}, label: ''};
var send = function(buffer, command) { connection.sendMessageToBuffer(buffer.id, command); };
var action = function(id, label, run, destructive) { return {id: id, label: label, run: run, destructive: Boolean(destructive)}; };
var safeNick = function(value) { return /^[A-Za-z0-9[\]\\`_^{|}-]+$/.test(value || ''); };
function bufferActions(buffer) {
var actions = [];
var core = buffer.fullName === 'core.weechat';
if (buffer.type === 'server' && buffer.plugin === 'irc') {
actions.push(action('edit-server', 'Edit server settings', function() { $rootScope.$broadcast('neoServerManagerOpen', buffer.server || buffer.shortName); }));
actions.push(action('connect', 'Connect', function() { send(buffer, '/connect ' + buffer.server); }));
actions.push(action('disconnect', 'Disconnect', function() { send(buffer, '/disconnect ' + buffer.server); }, true));
}
if (buffer.type === 'channel' && buffer.plugin === 'irc') {
actions.push(action('mute', 'Mute activity', function() { send(buffer, '/buffer notify none'); }));
actions.push(action('unmute', 'Unmute activity', function() { send(buffer, '/buffer notify all'); }));
actions.push(action('part', 'Part channel', function() { send(buffer, '/part'); }, true));
actions.push(action('rejoin', 'Rejoin channel', function() { send(buffer, '/join'); }));
actions.push(action('autojoin-add', 'Add to auto-join', function() { send(buffer, '/autojoin add'); }));
actions.push(action('autojoin-del', 'Remove from auto-join', function() { send(buffer, '/autojoin del'); }));
}
if (/neodrop_manager/.test(buffer.fullName)) {
actions.push(action('drops-list', 'Refresh drops', function() { send(buffer, '/drops list'); }));
actions.push(action('drops-help', 'NeoDrop help', function() { send(buffer, '/drops help'); }));
}
if (/neoguard/.test(buffer.fullName)) {
actions.push(action('guard-status', 'Refresh NeoGuard', function() { send(buffer, '/neoguard status'); }));
actions.push(action('guard-help', 'NeoGuard help', function() { send(buffer, '/neoguard'); }));
}
actions.push(action('clear', 'Clear buffer', function() { send(buffer, '/buffer clear'); }));
if (!core) actions.push(action('close', 'Close buffer', function() {
if (window.confirm('Close ' + (buffer.shortName || buffer.fullName) + '?')) send(buffer, '/buffer close');
}, true));
return actions;
}
function nickActions(target) {
var nick = target.nick.name;
var buffer = target.buffer;
if (!safeNick(nick) || !buffer || buffer.plugin !== 'irc') return [];
var actions = [
action('dm', 'Direct message', function() {
var prefix = buffer.fullName.substring(0, buffer.fullName.lastIndexOf('.') + 1);
if (!models.setActiveBuffer(prefix + nick, 'fullName')) { models.outgoingQueries.push(nick); send(buffer, '/query -noswitch ' + nick); }
}),
action('whois', 'Whois', function() { send(buffer, '/whois ' + nick); })
];
if (buffer.type === 'channel') {
actions.push(action('op', 'Give operator', function() { send(buffer, '/mode +o ' + nick); }));
actions.push(action('halfop', 'Give half-operator', function() { send(buffer, '/mode +h ' + nick); }));
actions.push(action('voice', 'Give voice', function() { send(buffer, '/mode +v ' + nick); }));
actions.push(action('kick', 'Kick', function() { if (window.confirm('Kick ' + nick + '?')) send(buffer, '/kick ' + nick); }, true));
actions.push(action('ban', 'Ban', function() { if (window.confirm('Ban ' + nick + '?')) send(buffer, '/ban ' + nick); }, true));
}
return actions;
}
return {
menu: menu,
open: function(event, target) {
var actions = target.kind === 'nick' ? nickActions(target) : bufferActions(target.buffer);
if (!actions.length) return;
menu.actions = actions;
menu.label = target.kind === 'nick' ? 'Actions for ' + target.nick.name : 'Buffer actions';
menu.position = {left: Math.min(event.clientX, window.innerWidth - 230) + 'px', top: Math.min(event.clientY, window.innerHeight - 360) + 'px'};
menu.visible = true;
},
close: function() { menu.visible = false; },
run: function(item) { menu.visible = false; item.run(); }
};
}]);
weechat.directive('neoContext', ['neoContextMenu', function(neoContextMenu) {
return {restrict: 'A', link: function(scope, element, attrs) {
var open = function(event) { event.preventDefault(); event.stopPropagation(); scope.$apply(function() { neoContextMenu.open(event, scope.$eval(attrs.neoContext)); }); };
element.on('contextmenu', open);
scope.$on('$destroy', function() { element.off('contextmenu', open); });
}};
}]);
weechat.directive('neoContextMenuView', ['neoContextMenu', '$document', function(neoContextMenu, $document) {
return {restrict: 'E', templateUrl: 'directives/context-menu.html', link: function(scope) {
scope.menu = neoContextMenu.menu;
scope.runContextAction = neoContextMenu.run;
var close = function(event) { if (scope.menu.visible && !event.target.closest('.neo-context-menu')) scope.$apply(neoContextMenu.close); };
$document.on('mousedown', close);
scope.$on('$destroy', function() { $document.off('mousedown', close); });
}};
}]);
}());
+910
View File
@@ -0,0 +1,910 @@
@font-face {
font-family: "OpenDyslexic";
src: url("../assets/fonts/OpenDyslexic-Regular.woff2") format("woff2");
font-style: normal;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "OpenDyslexic";
src: url("../assets/fonts/OpenDyslexic-Italic.woff2") format("woff2");
font-style: italic;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "OpenDyslexic";
src: url("../assets/fonts/OpenDyslexic-Bold.woff2") format("woff2");
font-style: normal;
font-weight: 700;
font-display: swap;
}
@font-face {
font-family: "OpenDyslexic";
src: url("../assets/fonts/OpenDyslexic-BoldItalic.woff2") format("woff2");
font-style: italic;
font-weight: 700;
font-display: swap;
}
:root {
--neorelay-void: #07030d;
--neorelay-ink: #0d0618;
--neorelay-panel: #150a24;
--neorelay-panel-raised: #211034;
--neorelay-violet: #9d5cff;
--neorelay-purple: #6f2cff;
--neorelay-pink: #ff65c8;
--neorelay-cyan: #55e7ff;
--neorelay-acid: #b9ff4f;
--neorelay-text: #f1eaff;
--neorelay-muted: #a99abd;
--neorelay-nick: #55e7ff;
--neorelay-nick-other: #55e7ff;
--neorelay-nick-self: #ff65c8;
--neorelay-nick-op: #ff65c8;
--neorelay-nick-halfop: #55e7ff;
--neorelay-nick-voice: #b9ff4f;
--neorelay-nick-away: #a99abd;
--neorelay-chat-highlight: #ffff55;
--neorelay-chat-highlight-bg: #aa00aa;
--neorelay-chat-time: #f1eaff;
--neorelay-line: rgba(157, 92, 255, 0.35);
--neorelay-sidebar-preferred-width: 140px;
--neorelay-sidebar-width: clamp(120px, var(--neorelay-sidebar-preferred-width), min(480px, 40vw));
--neorelay-page-gutter: 5px;
--neo-preferred-font: "OpenDyslexic", "Atkinson Hyperlegible", system-ui, sans-serif;
--neo-preferred-font-size: 14px;
}
html,
body {
background: var(--neorelay-void) !important;
color: var(--neorelay-text) !important;
font-family: "OpenDyslexic", "Atkinson Hyperlegible", system-ui, sans-serif;
}
body {
background-image:
linear-gradient(rgba(157, 92, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(157, 92, 255, 0.035) 1px, transparent 1px),
radial-gradient(circle at 75% 5%, rgba(111, 44, 255, 0.2), transparent 34%) !important;
background-size: 32px 32px, 32px 32px, auto !important;
}
.favorite-font {
font-family: "OpenDyslexic", "Atkinson Hyperlegible", monospace !important;
}
body.neo-preferred-font {
font-family: var(--neo-preferred-font) !important;
font-size: var(--neo-preferred-font-size) !important;
}
body.neo-preferred-font .content :not(.glyphicon):not(.neo-font-option),
body.neo-preferred-font .gb-modal :not(.glyphicon):not(.neo-font-option),
body.neo-preferred-font .neo-context-menu :not(.glyphicon):not(.neo-font-option),
body.neo-preferred-font .neo-server-manager :not(.glyphicon):not(.neo-font-option) {
font-family: inherit !important;
font-size: inherit !important;
}
a,
.panel-title,
.panel-title a {
color: var(--neorelay-cyan);
}
a:hover,
a:focus {
color: var(--neorelay-acid);
}
/* Logged-out identity */
.login-shell {
position: relative;
width: min(1120px, calc(100% - 40px));
margin: 0 auto;
padding: 54px 0 72px;
}
.login-shell::before {
content: "{{RELAY_GATEWAY_LABEL}}";
display: block;
margin-bottom: 12px;
color: var(--neorelay-muted);
font: 700 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.24em;
}
.login-hero {
display: grid;
grid-template-columns: minmax(250px, 0.78fr) minmax(360px, 1.35fr);
min-height: 390px;
margin-bottom: 28px;
overflow: hidden;
border-top: 1px solid var(--neorelay-violet);
border-bottom: 1px solid var(--neorelay-line);
background:
linear-gradient(115deg, rgba(22, 9, 40, 0.96), rgba(9, 3, 17, 0.78)),
linear-gradient(90deg, transparent 49.8%, rgba(85, 231, 255, 0.12) 50%, transparent 50.2%);
box-shadow: 20px 20px 0 rgba(72, 24, 122, 0.12);
}
.login-portrait {
position: relative;
min-height: 390px;
overflow: hidden;
border-right: 1px solid var(--neorelay-line);
}
.login-portrait::after {
position: absolute;
inset: 0;
content: "";
pointer-events: none;
background:
repeating-linear-gradient(0deg, transparent 0 4px, rgba(7, 3, 13, 0.16) 5px),
linear-gradient(180deg, transparent 55%, rgba(7, 3, 13, 0.8));
}
.login-portrait img {
width: 100%;
height: 100%;
min-height: 390px;
object-fit: cover;
object-position: 50% 27%;
filter: saturate(1.12) contrast(1.04);
}
.portrait-code {
position: absolute;
z-index: 1;
right: 14px;
bottom: 12px;
color: var(--neorelay-acid);
font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.18em;
}
.login-intro {
position: relative;
align-self: center;
padding: 48px clamp(32px, 7vw, 86px);
}
.login-intro::before {
position: absolute;
top: 28px;
left: 0;
width: 46px;
height: 3px;
content: "";
background: var(--neorelay-pink);
box-shadow: 19px 8px 0 var(--neorelay-cyan);
}
.login-kicker {
margin: 0 0 20px;
color: var(--neorelay-acid);
font: 700 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.2em;
}
.login-intro h1 {
margin: 0;
color: var(--neorelay-text);
font-family: "Arial Black", Impact, sans-serif;
font-size: clamp(48px, 8vw, 92px);
font-weight: 900;
line-height: 0.78;
letter-spacing: -0.08em;
text-transform: uppercase;
text-shadow: 4px 4px 0 rgba(111, 44, 255, 0.65);
}
.login-intro h1 span {
display: block;
margin-left: 0.47em;
color: transparent;
-webkit-text-stroke: 1px var(--neorelay-pink);
text-shadow: none;
}
.login-deck {
max-width: 520px;
margin: 30px 0 25px;
color: #cfc3dd;
font-size: 15px;
line-height: 1.75;
}
.login-status {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.login-status span {
padding: 7px 9px 6px;
border: 1px solid rgba(85, 231, 255, 0.35);
color: var(--neorelay-cyan);
background: rgba(85, 231, 255, 0.04);
font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.12em;
}
.login-shell .panel-group {
display: grid;
grid-template-columns: 1.15fr 0.85fr;
gap: 1px;
padding: 1px;
background: var(--neorelay-line);
}
.login-shell .panel {
margin: 0 !important;
border: 0;
border-radius: 0;
color: var(--neorelay-text);
background: rgba(20, 9, 35, 0.97) !important;
box-shadow: none;
}
.login-shell .panel:first-of-type,
.login-shell .panel:nth-of-type(2) {
grid-column: 1 / -1;
}
.login-shell .panel-heading {
padding: 17px 20px;
border: 0;
background: linear-gradient(90deg, rgba(111, 44, 255, 0.18), transparent);
}
.login-shell .panel-title {
color: var(--neorelay-text);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.login-shell .panel[data-state=active] .panel-title::before {
color: var(--neorelay-acid);
}
.login-shell .panel[data-state=collapsed] .panel-title::before {
color: var(--neorelay-pink);
}
.login-shell .panel-body {
padding: 24px 20px;
border-color: var(--neorelay-line) !important;
color: #c9bdd8;
}
.connection-panel .panel-body {
padding: clamp(18px, 4vw, 38px);
}
.relay-terminal {
border: 1px solid rgba(157, 92, 255, 0.5);
background: rgba(5, 2, 10, 0.94);
box-shadow: 12px 12px 0 rgba(111, 44, 255, 0.12);
font: 700 clamp(12px, 1.8vw, 16px)/1.75 ui-monospace, SFMono-Regular, Menlo, monospace;
}
.relay-terminal-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--neorelay-line);
color: var(--neorelay-muted);
font-size: 10px;
letter-spacing: 0.08em;
}
.relay-terminal-bar span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--neorelay-pink);
}
.relay-terminal-bar span:nth-child(2) { background: var(--neorelay-violet); }
.relay-terminal-bar span:nth-child(3) { background: var(--neorelay-acid); }
.relay-terminal-bar b { margin-left: 6px; font-weight: 700; }
.relay-terminal-body { padding: clamp(20px, 4vw, 34px); }
.relay-terminal-body p { margin: 0 0 8px; color: #c8c2cf; }
.relay-prompt { color: var(--neorelay-violet); }
.relay-terminal dl {
margin: 22px 0;
border-top: 1px solid var(--neorelay-line);
}
.relay-terminal dl div {
display: grid;
grid-template-columns: minmax(100px, 0.35fr) 1fr;
gap: 18px;
padding: 10px 0;
border-bottom: 1px solid rgba(157, 92, 255, 0.16);
}
.relay-terminal dt {
color: var(--neorelay-muted);
font-size: 9px;
letter-spacing: 0.14em;
}
.relay-terminal dd {
margin: 0;
color: var(--neorelay-cyan);
font-size: 11px;
}
.relay-password-line {
display: flex;
align-items: center;
gap: 14px;
margin-top: 22px;
}
.relay-password-line label {
flex: 0 0 auto;
margin: 0;
color: #c8c2cf;
font: inherit;
cursor: text;
}
.relay-password-field {
position: relative;
display: block;
flex: 1 1 auto;
min-width: 180px;
min-height: 31px;
padding: 2px 10px;
overflow: hidden;
border-bottom: 1px solid rgba(85, 231, 255, 0.34);
color: var(--neorelay-acid);
white-space: nowrap;
}
.relay-password-field:focus-within {
border-color: var(--neorelay-cyan);
box-shadow: 0 5px 14px rgba(85, 231, 255, 0.08);
}
.relay-password-placeholder { color: #71687d; font-weight: 400; }
.relay-password-mask { letter-spacing: 0.14em; }
body .relay-password-field input[type="password"] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0 !important;
opacity: 0;
cursor: text;
box-shadow: none !important;
}
.relay-password-error { margin-top: 10px !important; color: var(--neorelay-pink) !important; }
.relay-input-line { margin-top: 18px !important; }
.relay-cursor { color: var(--neorelay-acid); animation: relay-blink 1s steps(1, end) infinite; }
.connection-options {
display: flex;
flex-wrap: wrap;
gap: 10px 22px;
margin: 30px 0 22px;
color: #c9bdd8;
font: 700 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
}
.connection-options label { margin: 0; cursor: pointer; }
.connection-options input { margin: 0 7px 0 0; accent-color: var(--neorelay-pink); }
.connection-options input:disabled { opacity: 1; cursor: default; }
.relay-connect { min-width: 180px; }
.login-shell .form-control,
body .form-control,
body input[type="text"],
body input[type="password"],
body #sendMessage {
border: 1px solid rgba(157, 92, 255, 0.38) !important;
border-radius: 0 !important;
color: var(--neorelay-text) !important;
background: rgba(4, 1, 9, 0.65) !important;
box-shadow: inset 3px 0 0 rgba(157, 92, 255, 0.25) !important;
}
body .form-control:focus,
body input:focus,
body #sendMessage:focus {
border-color: var(--neorelay-cyan) !important;
box-shadow: inset 3px 0 0 var(--neorelay-cyan), 0 0 18px rgba(85, 231, 255, 0.12) !important;
}
@keyframes relay-blink {
50% { opacity: 0; }
}
.btn-primary,
.login-shell .btn-primary {
border: 0;
border-radius: 0;
color: #09020f;
background: linear-gradient(90deg, var(--neorelay-pink), var(--neorelay-violet));
box-shadow: 6px 6px 0 rgba(85, 231, 255, 0.16);
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.btn-primary:hover,
.btn-primary:focus {
color: #09020f;
background: linear-gradient(90deg, var(--neorelay-acid), var(--neorelay-cyan));
}
.alert {
border: 1px solid currentColor;
border-radius: 0;
background: rgba(10, 4, 18, 0.95);
}
.alert-danger {
color: #ff8bcf !important;
border-color: rgba(255, 101, 200, 0.55);
background: rgba(83, 13, 60, 0.36) !important;
}
/* Connected client */
body #topbar {
border-bottom: 1px solid var(--neorelay-violet);
background: linear-gradient(90deg, #1b0a30, #0a0411 65%) !important;
box-shadow: 0 8px 24px rgba(3, 0, 8, 0.45);
}
body #topbar .brand img {
border-radius: 50%;
filter: saturate(1.15);
}
body #topbar .actions {
color: var(--neorelay-muted);
background: rgba(23, 9, 38, 0.9) !important;
}
body #sidebar,
body #nicklist {
border-color: var(--neorelay-line);
background: rgba(12, 5, 21, 0.98) !important;
}
body #sidebar { display:flex; flex-direction:column; overflow:hidden; }
body #sidebar > ul.nav { flex:1 1 auto; min-height:0; margin-bottom:0; overflow-y:auto; }
body #nicklist { right:var(--neorelay-page-gutter); box-sizing:border-box; }
body #bufferlines.withnicklist { margin-right:105px !important; }
body .footer.withnicklist { padding-right:105px; }
body #bufferlines > table { width:calc(100% - 10px); margin-right:var(--neorelay-page-gutter); margin-left:var(--neorelay-page-gutter); }
body .footer > [input-bar] { display:block; margin-right:var(--neorelay-page-gutter); margin-left:var(--neorelay-page-gutter); }
#sidebar-resizer {
display: none;
}
@media (min-width: 969px) {
body #sidebar {
width: var(--neorelay-sidebar-width);
}
body #topbar .title {
left: calc(var(--neorelay-sidebar-width) + 5px);
}
body .content[sidebar-state="visible"] #bufferlines {
margin-left: calc(var(--neorelay-sidebar-width) + 5px);
}
body .content[sidebar-state="visible"] .footer {
padding-left: calc(var(--neorelay-sidebar-width) + 5px);
}
body .content[sidebar-state="visible"] #sidebar-resizer {
position: fixed;
z-index: 3;
top: 35px;
bottom: 0;
left: calc(var(--neorelay-sidebar-width) - 4px);
display: block;
width: 9px;
cursor: col-resize;
touch-action: none;
}
body #sidebar .nav-pills > li.buffer > a {
padding-top: 2.5px;
padding-bottom: 2.5px;
}
body.neorelay-sidebar-resizing,
body.neorelay-sidebar-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
body.neorelay-sidebar-resizing #sidebar,
body.neorelay-sidebar-resizing #bufferlines,
body.neorelay-sidebar-resizing .footer {
transition: none !important;
}
}
@media (min-width: 1400px) {
:root {
--neorelay-sidebar-preferred-width: 200px;
}
body #sidebar .nav-pills > li.buffer > a {
padding-top: 5px;
padding-bottom: 5px;
}
body #bufferlines.withnicklist { margin-right:145px !important; }
body .footer.withnicklist { padding-right:153px !important; }
}
body .nav-pills li:nth-child(2n) {
background: rgba(157, 92, 255, 0.035);
}
body .nav-pills > li > a {
color: #cbbdde;
}
body .nav-pills > li.active > a,
body .nav-pills > li.active > a:hover {
color: #fff;
background: linear-gradient(90deg, rgba(111, 44, 255, 0.65), rgba(255, 101, 200, 0.15)) !important;
box-shadow: inset 3px 0 0 var(--neorelay-pink);
}
body .nav-pills > li.highlight > a,
body .nav-pills > li.highlight > a span {
color: #08030d;
background: var(--neorelay-acid) !important;
}
.sidebar-actions { position:static; flex:0 0 auto; display:grid; grid-template-columns:repeat(auto-fit,minmax(48px,1fr)); gap:1px; margin-top:0; border-top:1px solid var(--neorelay-line); background:var(--neorelay-void); }
.sidebar-actions button { min-width:0; padding:9px 3px; border:0; color:var(--neorelay-muted); background:rgba(21,10,36,.96); font:700 9px/1.2 ui-monospace,monospace; }
.sidebar-actions button:hover { color:var(--neorelay-cyan); background:rgba(111,44,255,.28); }
.sidebar-actions i,.sidebar-actions span { display:block; margin:auto; }
.sidebar-actions span { margin-top:4px; }
.neo-context-menu { position:fixed; z-index:1200; display:grid; width:220px; overflow:hidden; border:1px solid var(--neorelay-violet); background:rgba(7,3,13,.96); box-shadow:10px 10px 24px rgba(0,0,0,.5); }
.neo-context-menu button { padding:9px 12px; border:0; border-bottom:1px solid rgba(157,92,255,.15); color:var(--neorelay-text); text-align:left; background:transparent; }
.neo-context-menu button:hover,.neo-context-menu button:focus { color:var(--neorelay-cyan); background:rgba(111,44,255,.35); outline:0; }
.neo-context-menu button.destructive { color:var(--neorelay-pink); }
.neo-server-manager .modal-dialog { width:min(1100px,calc(100% - 24px)); }
.neo-server-layout { display:grid; grid-template-columns:190px 1fr; gap:18px; max-height:72vh; overflow:auto; }
.neo-server-layout aside { display:flex; flex-direction:column; gap:4px; }
.neo-server-layout aside button { padding:8px; border:1px solid var(--neorelay-line); color:var(--neorelay-text); text-align:left; background:rgba(7,3,13,.7); }
.neo-server-layout aside .neo-add-server { box-sizing:border-box; width:100%; min-height:38px; padding:8px 10px; border:1px solid var(--neorelay-cyan); color:var(--neorelay-void); text-align:center; white-space:normal; background:linear-gradient(90deg,var(--neorelay-pink),var(--neorelay-violet)); box-shadow:none; font-size:10px; line-height:20px; }
.neo-server-layout aside .neo-add-server:hover,.neo-server-layout aside .neo-add-server:focus { color:var(--neorelay-void); background:linear-gradient(90deg,var(--neorelay-acid),var(--neorelay-cyan)); }
.neo-server-options { display:grid; grid-template-columns:repeat(2,minmax(240px,1fr)); gap:8px 14px; margin-top:14px; }
.neo-server-options label { display:grid; grid-template-columns:minmax(130px,.8fr) minmax(120px,1.2fr) auto; align-items:center; gap:8px; margin:0; color:var(--neorelay-muted); font:11px/1.3 ui-monospace,monospace; }
.neo-server-manager,.neo-server-manager button,.neo-server-manager input,.neo-server-manager select { font-family:"OpenDyslexic","Atkinson Hyperlegible",system-ui,sans-serif; }
.neo-server-manager .neo-server-control { box-sizing:border-box; width:100%; min-width:0; height:34px; min-height:34px; margin:0 !important; padding:6px 10px; border:1px solid var(--neorelay-line) !important; color:var(--neorelay-text); background:var(--neorelay-void); font-size:12px; line-height:20px; }
.neo-server-manager .modal-footer .btn { min-width:92px; height:34px; padding:7px 13px; font-size:11px; line-height:20px; }
.neo-server-manager .modal-footer .btn-default { border:1px solid var(--neorelay-line); color:var(--neorelay-text); background:var(--neorelay-panel-raised); }
.neo-server-manager .modal-footer .btn-default:hover,.neo-server-manager .modal-footer .btn-default:focus { color:var(--neorelay-cyan); background:rgba(111,44,255,.28); }
.neo-server-options .inherit { white-space:nowrap; }
#settingsModal .modal-dialog { width:min(900px,calc(100% - 24px)); }
.neo-font-picker { position:relative; }
.neo-font-trigger { display:flex; align-items:center; justify-content:space-between; width:100%; overflow:hidden; text-align:left; }
.neo-font-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.neo-font-menu { position:absolute; z-index:1300; top:calc(100% + 4px); left:0; display:grid; width:max(100%,320px); padding:4px; border:1px solid var(--neorelay-violet); background:var(--neorelay-void); box-shadow:10px 10px 24px rgba(0,0,0,.55); }
.neo-font-option { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:9px 11px; border:0; border-bottom:1px solid rgba(157,92,255,.18); color:var(--neorelay-text); background:transparent; font-size:16px !important; text-align:left; }
.neo-font-option:last-child { border-bottom:0; }
.neo-font-option:hover,.neo-font-option:focus { color:var(--neorelay-void); background:linear-gradient(90deg,var(--neorelay-cyan),var(--neorelay-acid)); outline:0; }
.neo-font-option span { font-size:.88em !important; }
.neo-font-preview { margin:8px 0 0; padding:9px 11px; border:1px solid var(--neorelay-line); color:var(--neorelay-text); background:rgba(7,3,13,.55); }
.neo-settings-tabs { display:flex; gap:1px; margin:-15px -15px 18px; padding:1px; background:var(--neorelay-line); }
.neo-settings-tabs button { flex:1 1 0; padding:11px 16px; border:0; color:var(--neorelay-muted); background:var(--neorelay-ink); font:800 11px/1 ui-monospace,monospace; letter-spacing:.08em; text-transform:uppercase; }
.neo-settings-tabs button.active { color:var(--neorelay-void); background:linear-gradient(90deg,var(--neorelay-cyan),var(--neorelay-acid)); }
.neo-settings-panel { margin:0; }
.neo-colour-settings h5,.neo-weechat-settings h5 { margin:20px 0 9px; color:var(--neorelay-cyan); font:800 11px/1 ui-monospace,monospace; letter-spacing:.1em; text-transform:uppercase; }
.neo-colour-settings p,.neo-weechat-settings p { color:var(--neorelay-muted); }
.neo-settings-toolbar { display:flex; align-items:end; gap:10px; margin-bottom:12px; }
.neo-settings-toolbar p { flex:1 1 auto; margin:0; }
.neo-settings-toolbar label { flex:1 1 220px; margin:0; color:var(--neorelay-muted); font-size:11px; }
.neo-settings-toolbar .form-control { height:34px; margin:4px 0 0 !important; }
.neo-colour-grid { display:grid; grid-template-columns:repeat(2,minmax(250px,1fr)); gap:8px; }
.neo-colour-grid label { display:grid; grid-template-columns:minmax(110px,1fr) minmax(120px,.8fr) 28px; align-items:center; gap:8px 10px; margin:0; padding:8px 10px; border:1px solid rgba(157,92,255,.2); background:rgba(7,3,13,.45); color:var(--neorelay-text); font-weight:600; }
.neo-colour-grid .form-control { height:34px; margin:0 !important; }
.neo-colour-grid code { grid-column:1 / -1; overflow:hidden; padding:0; color:var(--neorelay-muted); background:transparent; font-size:9px; text-overflow:ellipsis; white-space:nowrap; }
.neo-colour-swatch { display:block; width:26px; height:26px; border:1px solid rgba(255,255,255,.45); box-shadow:inset 0 0 0 1px rgba(0,0,0,.35); }
.neo-option-list { display:grid; gap:7px; max-height:55vh; overflow:auto; }
.neo-option { display:grid; grid-template-columns:minmax(260px,1fr) minmax(160px,.65fr) auto; align-items:center; gap:10px; padding:10px; border:1px solid rgba(157,92,255,.2); background:rgba(7,3,13,.45); }
.neo-option strong,.neo-option small { display:block; overflow-wrap:anywhere; }
.neo-option strong { color:var(--neorelay-cyan); font:700 10px/1.3 ui-monospace,monospace; }
.neo-option small { margin-top:3px; color:var(--neorelay-muted); font-size:9px; }
.neo-option .form-control { height:34px; margin:0 !important; }
.neo-option-actions { display:flex; gap:5px; }
.neo-option-actions .btn { min-width:64px; height:34px; }
.neo-palette-setting { display:grid; grid-template-columns:1fr auto; gap:8px; padding:10px; border:1px solid rgba(157,92,255,.2); background:rgba(7,3,13,.45); }
.neo-palette-setting .form-control { height:34px; margin:0 !important; }
.neo-palette-setting code { grid-column:1 / -1; color:var(--neorelay-muted); background:transparent; font-size:9px; }
.neo-palette-preview { grid-column:1 / -1; display:flex; align-items:center; gap:3px; min-height:20px; overflow-x:auto; }
.neo-palette-preview i { flex:0 0 20px; width:20px; height:20px; border:1px solid rgba(255,255,255,.4); box-shadow:inset 0 0 0 1px rgba(0,0,0,.3); }
body #nicklist a { cursor:text; }
body .neo-nick { color:var(--neorelay-nick) !important; }
body .neo-nick-op { color:var(--neorelay-nick-op) !important; }
body .neo-nick-halfop { color:var(--neorelay-nick-halfop) !important; }
body .neo-nick-voice { color:var(--neorelay-nick-voice) !important; }
body .neo-nick-away { color:var(--neorelay-nick-away) !important; font-style:italic; opacity:.85; }
body #nicklist .cof-chat_nick_self { color:var(--neorelay-nick-self) !important; font-style:normal; font-weight:900; opacity:1; text-shadow:1px 1px 0 var(--neorelay-purple),0 0 8px rgba(157,92,255,.45); }
body tr.bufferline:hover {
background: rgba(157, 92, 255, 0.08) !important;
}
body #bufferlines tr.bufferline:has(td.prefix .highlight) {
background: var(--neorelay-chat-highlight-bg) !important;
box-shadow: inset 3px 0 0 var(--neorelay-pink);
}
body #bufferlines td.prefix .highlight {
color: var(--neorelay-chat-highlight) !important;
font-weight: 900;
text-shadow: 0 0 8px rgba(255, 101, 200, 0.48);
}
body td.prefix {
border-color: rgba(157, 92, 255, 0.2);
}
body .footer {
bottom: 5px;
padding-bottom: 0;
border-top: 1px solid rgba(157, 92, 255, 0.35);
background: var(--neorelay-ink);
}
body #bufferlines { bottom:40px; }
.command-menu {
position: absolute;
z-index: 5;
right: 0;
bottom: calc(100% + 4px);
left: 0;
max-height: min(40dvh, 20rem);
overflow-y: auto;
border: 1px solid var(--neorelay-line);
background: rgba(7, 3, 13, 0.90);
box-shadow: 8px -8px 24px rgba(3, 0, 8, 0.45);
overscroll-behavior: contain;
}
.command-menu-item {
display: grid;
grid-template-columns: minmax(9rem, 0.3fr) minmax(12rem, 0.7fr) minmax(14rem, 1fr);
gap: 12px;
width: 100%;
padding: 7px 10px;
border: 0;
border-bottom: 1px solid rgba(157, 92, 255, 0.14);
color: var(--neorelay-text);
text-align: left;
background: transparent;
}
.command-menu-item:hover,
.command-menu-item.selected {
background: linear-gradient(90deg, rgba(111, 44, 255, 0.55), rgba(255, 101, 200, 0.08));
box-shadow: inset 3px 0 0 var(--neorelay-cyan);
}
.command-menu-name {
color: var(--neorelay-cyan);
font-weight: 800;
}
.command-menu-args {
color: #8f8699;
white-space: pre-wrap;
}
.command-menu-description {
overflow: hidden;
color: var(--neorelay-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.command-menu-empty {
padding: 10px;
color: var(--neorelay-muted);
}
body .modal-content,
body .dropdown-menu {
border: 1px solid var(--neorelay-line);
border-radius: 0;
color: var(--neorelay-text);
background: var(--neorelay-panel) !important;
box-shadow: 12px 12px 0 rgba(111, 44, 255, 0.15);
}
body .badge.danger,
body .danger {
color: #09020f;
background: var(--neorelay-pink) !important;
}
body #readmarker {
border-top-color: var(--neorelay-pink);
border-bottom-color: var(--neorelay-cyan);
}
.neorelay-core-title {
color: var(--neorelay-text);
font: 800 12px/35px "OpenDyslexic", ui-monospace, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.neorelay-core-title small {
margin-left: 8px;
color: var(--neorelay-acid);
font: 700 9px/35px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.14em;
}
.core-login-hero {
width: min(960px, calc(100% - 32px));
margin: 6vh auto 60px;
}
body .cof-chat_nick,
body .cof-chat_host,
body .cof-chat_value {
color: var(--neorelay-nick);
}
body .cof-chat_nick_other { color:var(--neorelay-nick-other); }
body .cof-chat_prefix_join,
body .cof-chat_nick_prefix {
color: var(--neorelay-acid);
}
body .cof-chat_nick_self {
color: var(--neorelay-nick-self);
font-style: normal;
font-weight: 900;
opacity: 1;
text-shadow: 1px 1px 0 var(--neorelay-purple), 0 0 8px rgba(157, 92, 255, 0.45);
}
body .cof-chat_time { color:var(--neorelay-chat-time); }
body .cof-chat_prefix_network,
body .cof-chat_prefix_more,
body .cof-chat_read_marker {
color: var(--neorelay-pink);
}
::-webkit-scrollbar-track-piece {
background: var(--neorelay-void) !important;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(var(--neorelay-purple), var(--neorelay-pink)) !important;
}
@media (max-width: 760px) {
.login-shell {
width: min(100% - 24px, 560px);
padding-top: 28px;
}
.neo-server-layout { grid-template-columns:1fr; }
.neo-server-options { grid-template-columns:1fr; }
.neo-colour-grid { grid-template-columns:1fr; }
.neo-settings-toolbar { align-items:stretch; flex-direction:column; }
.neo-settings-toolbar label { width:100%; flex-basis:auto; }
.neo-option { grid-template-columns:1fr; }
.neo-option-actions .btn { flex:1 1 auto; }
.login-hero {
grid-template-columns: 1fr;
box-shadow: 10px 10px 0 rgba(72, 24, 122, 0.12);
}
.login-portrait,
.login-portrait img {
min-height: 300px;
max-height: 380px;
}
.login-portrait {
border-right: 0;
border-bottom: 1px solid var(--neorelay-line);
}
.login-intro {
padding: 42px 24px 36px;
}
.login-intro h1 {
font-size: clamp(48px, 18vw, 76px);
}
.login-shell .panel-group {
display: block;
}
.login-shell .panel + .panel {
border-top: 1px solid var(--neorelay-line);
}
.connection-panel .panel-body { padding: 14px; }
.relay-terminal { box-shadow: 8px 8px 0 rgba(111, 44, 255, 0.1); }
.relay-password-line { display: block; }
.relay-password-field { margin-top: 5px; }
.connection-options { display: grid; }
.relay-connect { width: 100%; }
.command-menu {
max-height: min(35dvh, 16rem);
}
.command-menu-item {
grid-template-columns: minmax(8rem, 0.45fr) 1fr;
min-height: 44px;
touch-action: manipulation;
}
.command-menu-description {
display: none;
}
.neorelay-core-title small {
display: none;
}
.core-login-hero {
margin: 32px 12px 60px;
width: calc(100% - 24px);
}
}
@media (max-width: 968px) {
body .footer.withnicklist { padding-right:113px !important; }
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
<li class="standard-labels">
<form class="form-horizontal" role="form">
<div class="form-group">
<label for="drop-profile" class="col-sm-3 control-label make-thinner">NeoDrop retention</label>
<div class="col-sm-8">
<select id="drop-profile" class="form-control" ng-model="settings.dropProfile">
<option value="small">50 MiB maximum / 30 days</option>
<option value="large">1 GiB maximum / 7 days</option>
</select>
</div>
</div>
</form>
</li>
+94
View File
@@ -0,0 +1,94 @@
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
<section class="login-hero" aria-labelledby="neorelay-login-title">
<div class="login-portrait">
<img src="assets/neorelay-avatar-login.jpg" alt="Operator-provided avatar">
<span class="portrait-code" aria-hidden="true">{{NODE_LABEL}}</span>
</div>
<div class="login-intro">
<p class="login-kicker">PRIVATE RELAY // TLS 1.3</p>
<h1 id="neorelay-login-title">{{RELAY_TITLE_HTML}}</h1>
<p class="login-deck">A quiet line into IRC.</p>
<div class="login-status" aria-label="Service capabilities">
<span>INTERNAL ONLY</span>
<span>SASL EXTERNAL</span>
<span>ALWAYS ON</span>
</div>
</div>
</section>
+326
View File
@@ -0,0 +1,326 @@
(function(root, factory) {
'use strict';
var api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.NeoDropCrypto = api;
}(typeof self !== 'undefined' ? self : this, function() {
'use strict';
var CHUNK_SIZE = 4 * 1024 * 1024;
var SMALL_MAX = 50 * 1024 * 1024;
var LARGE_MAX = 1024 * 1024 * 1024;
var encoder = new TextEncoder();
var SHA256_K = new Uint32Array([
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
]);
function rotate(value, bits) {
return (value >>> bits) | (value << (32 - bits));
}
function Sha256() {
this.state = new Uint32Array([
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,
0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
]);
this.buffer = new Uint8Array(64);
this.bufferLength = 0;
this.bytes = 0;
}
Sha256.prototype.transform = function(bytes, offset) {
var words = new Uint32Array(64);
var view = new DataView(bytes.buffer, bytes.byteOffset + offset, 64);
var i;
for (i = 0; i < 16; i++) words[i] = view.getUint32(i * 4, false);
for (i = 16; i < 64; i++) {
var x = words[i - 15];
var y = words[i - 2];
var s0 = rotate(x, 7) ^ rotate(x, 18) ^ (x >>> 3);
var s1 = rotate(y, 17) ^ rotate(y, 19) ^ (y >>> 10);
words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0;
}
var a = this.state[0], b = this.state[1], c = this.state[2], d = this.state[3];
var e = this.state[4], f = this.state[5], g = this.state[6], h = this.state[7];
for (i = 0; i < 64; i++) {
var sum1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25);
var choice = (e & f) ^ (~e & g);
var temporary1 = (h + sum1 + choice + SHA256_K[i] + words[i]) >>> 0;
var sum0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22);
var majority = (a & b) ^ (a & c) ^ (b & c);
var temporary2 = (sum0 + majority) >>> 0;
h = g; g = f; f = e; e = (d + temporary1) >>> 0;
d = c; c = b; b = a; a = (temporary1 + temporary2) >>> 0;
}
this.state[0] = (this.state[0] + a) >>> 0;
this.state[1] = (this.state[1] + b) >>> 0;
this.state[2] = (this.state[2] + c) >>> 0;
this.state[3] = (this.state[3] + d) >>> 0;
this.state[4] = (this.state[4] + e) >>> 0;
this.state[5] = (this.state[5] + f) >>> 0;
this.state[6] = (this.state[6] + g) >>> 0;
this.state[7] = (this.state[7] + h) >>> 0;
};
Sha256.prototype.update = function(bytes) {
this.bytes += bytes.length;
var offset = 0;
if (this.bufferLength) {
var copied = Math.min(64 - this.bufferLength, bytes.length);
this.buffer.set(bytes.subarray(0, copied), this.bufferLength);
this.bufferLength += copied;
offset = copied;
if (this.bufferLength === 64) {
this.transform(this.buffer, 0);
this.bufferLength = 0;
}
}
while (offset + 64 <= bytes.length) {
this.transform(bytes, offset);
offset += 64;
}
if (offset < bytes.length) {
this.buffer.set(bytes.subarray(offset), 0);
this.bufferLength = bytes.length - offset;
}
};
Sha256.prototype.hex = function() {
var high = Math.floor(this.bytes / 0x20000000);
var low = (this.bytes * 8) >>> 0;
this.buffer[this.bufferLength++] = 0x80;
if (this.bufferLength > 56) {
this.buffer.fill(0, this.bufferLength);
this.transform(this.buffer, 0);
this.bufferLength = 0;
}
this.buffer.fill(0, this.bufferLength, 56);
var view = new DataView(this.buffer.buffer);
view.setUint32(56, high, false);
view.setUint32(60, low, false);
this.transform(this.buffer, 0);
var result = '';
for (var i = 0; i < this.state.length; i++) {
result += this.state[i].toString(16).padStart(8, '0');
}
return result;
};
function b64url(bytes) {
var binary = '';
for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function derive(secret, info, length) {
return crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveBits']).then(function(key) {
return crypto.subtle.deriveBits({
name: 'HKDF', hash: 'SHA-256',
salt: encoder.encode('{{NEODROP_CRYPTO_CONTEXT}}'),
info: encoder.encode(info)
}, key, length * 8);
}).then(function(bits) { return new Uint8Array(bits); });
}
function importAes(raw) {
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt']);
}
function setU32(bytes, offset, value) {
new DataView(bytes.buffer).setUint32(offset, value, false);
}
function setU64(bytes, offset, value) {
new DataView(bytes.buffer).setBigUint64(offset, BigInt(value), false);
}
function makeAad(type, profile, objectId, size, count, index, length) {
var bytes = new Uint8Array(60);
bytes.set(encoder.encode('NEODRP01'));
bytes[8] = type;
bytes[9] = profile;
bytes.set(objectId, 12);
setU64(bytes, 36, size);
setU32(bytes, 44, CHUNK_SIZE);
setU32(bytes, 48, count);
setU32(bytes, 52, index);
setU32(bytes, 56, length);
return bytes;
}
function makeIv(type, index) {
var bytes = new Uint8Array(12);
setU32(bytes, 0, type);
setU64(bytes, 4, index);
return bytes;
}
function encrypt(key, plaintext, nonce, additionalData) {
return crypto.subtle.encrypt({
name: 'AES-GCM', iv: nonce, additionalData: additionalData, tagLength: 128
}, key, plaintext);
}
function typeFor(type, name) {
var value = String(type || '').toLowerCase();
if (value && value !== 'application/octet-stream') return value;
var match = String(name || '').toLowerCase().match(/\.([a-z0-9]+)$/);
var types = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
webp: 'image/webp', avif: 'image/avif', flac: 'audio/flac', m4a: 'audio/mp4',
mp3: 'audio/mpeg', oga: 'audio/ogg', ogg: 'audio/ogg', opus: 'audio/opus',
wav: 'audio/wav', wave: 'audio/wav', m4v: 'video/mp4', mkv: 'video/x-matroska',
mov: 'video/quicktime', mp4: 'video/mp4', ogv: 'video/ogg', webm: 'video/webm'
};
return match && types[match[1]] ? types[match[1]] : 'application/octet-stream';
}
function kindFor(type) {
if (/^image\/(png|jpeg|gif|webp|avif)$/i.test(type)) return 'image';
if (/^audio\//i.test(type)) return 'audio';
if (/^video\//i.test(type)) return 'video';
if (/^text\//i.test(type) || /^(application\/(json|javascript|x-.*script))$/i.test(type)) return 'text';
return 'binary';
}
function safeName(name) {
var value = String(name || 'drop.bin').split(/[\\/]/).pop()
.replace(/[\u0000-\u001f\u007f]/g, '').replace(/^\.+|\.+$/g, '').slice(0, 240);
return value || 'drop.bin';
}
function request(base, path, options, jsonResponse) {
return fetch(base + path, options).then(function(response) {
if (!response.ok) throw new Error('NeoDrop request failed');
return jsonResponse ? response.json() : response;
}).catch(function(error) {
if (error && error.name === 'AbortError') throw error;
throw new Error('NeoDrop request failed');
});
}
async function upload(file, options) {
options = options || {};
if (!file || typeof file.size !== 'number') throw new Error('Select a file.');
var profile = Number(options.profile);
if (profile !== 0 && profile !== 1) throw new Error('Invalid retention profile.');
var maximum = profile === 0 ? SMALL_MAX : LARGE_MAX;
if (file.size > maximum) throw new Error('File exceeds the selected profile limit.');
var base = String(options.base || '');
var progress = typeof options.onProgress === 'function' ? options.onProgress : function() {};
var signal = options.signal;
var secret = crypto.getRandomValues(new Uint8Array(32));
var objectId;
var objectIdText;
var uploadSession;
var chunks = file.size ? Math.ceil(file.size / CHUNK_SIZE) : 0;
var totalSteps = chunks + 3;
var completed = 0;
try {
var keys = await Promise.all([
derive(secret, 'object-id', 24),
derive(secret, 'content-key', 32).then(importAes),
derive(secret, 'metadata-key', 32).then(importAes)
]);
objectId = keys[0];
objectIdText = b64url(objectId);
uploadSession = await request(base, '/api/v1/uploads', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
v: 1, id: objectIdText, profile: profile, size: file.size,
chunkSize: CHUNK_SIZE, chunks: chunks
}),
signal: signal
}, true);
if (!uploadSession || !/^[A-Za-z0-9_-]{22}$/.test(uploadSession.uploadId || '') ||
!/^[A-Za-z0-9_-]{43}$/.test(uploadSession.uploadToken || '')) {
throw new Error('NeoDrop request failed');
}
progress(++completed / totalSteps);
var authorization = 'Bearer ' + uploadSession.uploadToken;
var hasher = new Sha256();
for (var index = 0; index < chunks; index++) {
var plaintext = new Uint8Array(await file.slice(
index * CHUNK_SIZE, Math.min(file.size, (index + 1) * CHUNK_SIZE)
).arrayBuffer());
hasher.update(plaintext);
var cipher = await encrypt(
keys[1], plaintext, makeIv(1, index),
makeAad(1, profile, objectId, file.size, chunks, index, plaintext.byteLength)
);
await request(base, '/api/v1/uploads/' + uploadSession.uploadId + '/chunks/' + index, {
method: 'PUT',
headers: {'Content-Type': 'application/octet-stream', 'Authorization': authorization},
body: cipher,
signal: signal
}, false);
progress(++completed / totalSteps);
}
var metadataName = safeName(file.name);
var metadataValue = {
v: 1,
name: metadataName,
type: typeFor(file.type, metadataName).slice(0, 255),
size: file.size,
lastModified: Number.isSafeInteger(file.lastModified) ? file.lastModified : null,
sha256: hasher.hex()
};
var metadata = encoder.encode(JSON.stringify(metadataValue));
if (metadata.length > 4096) throw new Error('File metadata is too large.');
var encryptedMetadata = await encrypt(
keys[2], metadata, makeIv(2, 0),
makeAad(0, profile, objectId, file.size, chunks, 0xffffffff, metadata.length)
);
await request(base, '/api/v1/uploads/' + uploadSession.uploadId + '/metadata', {
method: 'PUT',
headers: {'Content-Type': 'application/octet-stream', 'Authorization': authorization},
body: encryptedMetadata,
signal: signal
}, false);
progress(++completed / totalSteps);
var committed = await request(base, '/api/v1/uploads/' + uploadSession.uploadId + '/commit', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': authorization},
body: '{}',
signal: signal
}, true);
if (!committed || !Number.isSafeInteger(committed.expiresAt)) {
throw new Error('NeoDrop request failed');
}
progress(1);
return {
id: objectIdText,
readUrl: base + '/d/' + objectIdText + '?k=' + kindFor(metadataValue.type) + '#d1.' + b64url(secret),
deleteCapability: objectIdText + '.' + uploadSession.uploadToken,
name: metadataValue.name,
type: metadataValue.type,
size: file.size,
sha256: metadataValue.sha256,
profile: profile,
expiresAt: committed.expiresAt
};
} finally {
secret.fill(0);
}
}
return {
CHUNK_SIZE: CHUNK_SIZE,
SMALL_MAX: SMALL_MAX,
LARGE_MAX: LARGE_MAX,
upload: upload
};
}));
+27
View File
@@ -0,0 +1,27 @@
function neoDropFrame(url, kind) {
return function() {
var element = this.getElement();
var frame = document.createElement('iframe');
frame.src = url.replace('?k=', '?embed=1&k=');
frame.title = 'Encrypted NeoDrop ' + kind + ' preview';
frame.width = '100%';
frame.height = kind === 'audio' ? '80' : kind === 'text' ? '420' : '520';
frame.loading = 'lazy';
frame.referrerPolicy = 'no-referrer';
frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads');
frame.setAttribute('frameborder', '0');
element.replaceChildren(frame);
};
}
var neoDropMediaPlugin = new UrlPlugin('NeoDrop media', function(url) {
var match = url.match(/^https:\/\/{{DROPS_HOST_REGEX}}\/d\/([A-Za-z0-9_-]{32})\?k=(image|audio|video)#d1\.([A-Za-z0-9_-]{43})$/);
return match ? neoDropFrame(url, match[2]) : null;
});
neoDropMediaPlugin.exclusive = true;
var neoDropTextPlugin = new UrlPlugin('NeoDrop preview', function(url) {
var match = url.match(/^https:\/\/{{DROPS_HOST_REGEX}}\/d\/([A-Za-z0-9_-]{32})\?k=text#d1\.([A-Za-z0-9_-]{43})$/);
return match ? neoDropFrame(url, 'text') : null;
});
neoDropTextPlugin.exclusive = true;
+62
View File
@@ -0,0 +1,62 @@
(function() {
'use strict';
var weechat = angular.module('weechat');
var neoDropCrypto = require('./neodrop-crypto');
weechat.factory('imgur', ['$rootScope', 'settings', function($rootScope, settings) {
var BASE = '{{DROPS_ORIGIN}}';
var showErrorMsg = function(message) {
$rootScope.uploadError = true;
$rootScope.uploadErrorMessage = message || 'Encrypted upload failed.';
if (!$rootScope.$$phase) $rootScope.$apply();
setTimeout(function() {
$rootScope.uploadError = false;
if (!$rootScope.$$phase) $rootScope.$apply();
}, 5000);
};
var process = function(file, callback) {
if (!file || typeof file.size !== 'number') return;
if (file.size > neoDropCrypto.LARGE_MAX) {
showErrorMsg('NeoDrop files are limited to 1 GiB.');
return;
}
var profile = (settings.dropProfile === 'large' || file.size > neoDropCrypto.SMALL_MAX) ? 1 : 0;
var progressBars = document.getElementById('imgur-upload-progress');
var bar = document.createElement('div');
bar.className = 'imgur-progress-bar';
progressBars.appendChild(bar);
neoDropCrypto.upload(file, {
base: BASE,
profile: profile,
onProgress: function(value) { bar.style.width = Math.round(value * 100) + '%'; }
}).then(function(result) {
if (typeof $rootScope.registerNeoDrop === 'function') {
$rootScope.registerNeoDrop(result.readUrl, result.deleteCapability);
}
if (callback) callback(result.readUrl, result.deleteCapability);
}).catch(function(error) {
showErrorMsg(error.message);
}).finally(function() {
setTimeout(function() { if (bar.parentNode) bar.parentNode.removeChild(bar); }, 500);
});
};
var deleteImage = function(capability, callback) {
var split = String(capability || '').split('.');
if (split.length !== 2) return showErrorMsg('Invalid deletion capability.');
fetch(BASE + '/api/v1/objects/' + split[0], {
method: 'DELETE', headers: {'Authorization': 'Bearer ' + split[1]}
}).then(function(response) {
if (!response.ok) throw new Error('Unable to delete encrypted file.');
if (callback) callback(capability);
}).catch(function() { showErrorMsg('Unable to delete encrypted file.'); });
};
return {process: process, deleteImage: deleteImage};
}]);
})();
+17
View File
@@ -0,0 +1,17 @@
<div class="gb-modal neo-server-manager" data-state="visible" ng-if="serverManager.visible">
<div class="backdrop" ng-click="closeNeoServerManager()"></div>
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><button type="button" class="close" ng-click="closeNeoServerManager()">&times;</button><h4>IRC server manager</h4><p>Add a network or edit every WeeChat server option. Password fields are write-only.</p></div>
<div class="modal-body neo-server-layout">
<aside><button type="button" class="btn btn-primary neo-add-server" ng-click="newNeoServer()">Add new server</button><button type="button" ng-repeat="name in serverManager.servers" ng-click="selectNeoServer(name)" ng-bind="name"></button></aside>
<section ng-if="serverManager.selected">
<label>Server name<input class="form-control neo-server-control" ng-model="serverManager.selected.name" ng-disabled="!serverManager.isNew"></label>
<div class="neo-server-options">
<label ng-repeat="option in serverManager.selected.options" title="{{option.description}}"><span ng-bind="option.name"></span><select class="form-control neo-server-control" ng-if="option.boolean" ng-model="option.value" ng-change="option.dirty=true"><option value="on">On</option><option value="off">Off</option></select><input ng-if="!option.boolean" class="form-control neo-server-control" type="{{option.secret?'password':'text'}}" autocomplete="off" ng-model="option.value" ng-change="option.dirty=true" placeholder="{{option.secret?'Leave blank to keep existing value':''}}"><span class="inherit"><input type="checkbox" ng-model="option.inherited" ng-change="option.dirty=true" ng-disabled="serverManager.isNew||option.secret"> inherit</span></label>
</div>
</section>
<p class="status" ng-bind="serverManager.status"></p>
</div>
<div class="modal-footer"><button type="button" class="btn btn-default" ng-click="closeNeoServerManager()">Cancel</button><button type="button" class="btn btn-primary" ng-click="saveNeoServer(false)" ng-disabled="serverManager.saving">Save</button><button type="button" class="btn btn-primary" ng-click="saveNeoServer(true)" ng-disabled="serverManager.saving">Save and connect</button></div>
</div></div>
</div>
+56
View File
@@ -0,0 +1,56 @@
'use strict';
(function() {
var weechat = angular.module('weechat');
var names = 'addresses proxy ipv6 ssl ssl_cert ssl_password ssl_priorities ssl_dhkey_size ssl_fingerprint ssl_verify password capabilities sasl_mechanism sasl_username sasl_password sasl_key sasl_timeout sasl_fail autoconnect autoreconnect autoreconnect_delay nicks nicks_alternate username realname local_hostname usermode command command_delay autojoin autojoin_dynamic autorejoin autorejoin_delay connection_timeout anti_flood_prio_high anti_flood_prio_low away_check away_check_max_nicks msg_kick msg_part msg_quit notify split_msg_max_length charset_message default_chantypes'.split(' ');
var secrets = {ssl_password: true, password: true, sasl_password: true};
var booleans = {ipv6:true,ssl:true,ssl_verify:true,autoconnect:true,autoreconnect:true,nicks_alternate:true,autojoin_dynamic:true,autorejoin:true};
weechat.directive('neoServerManager', ['connection', function(connection) {
return {restrict: 'E', templateUrl: 'directives/server-manager.html', link: function(scope) {
scope.serverManager = {visible:false, loading:false, saving:false, servers:[], selected:null, isNew:false, status:''};
var manager = scope.serverManager;
function validName(value) { return /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/.test(value || ''); }
function safeValue(value) { return typeof value === 'string' && !/[\r\n\0"]/.test(value); }
function optionModel(name, row) { return {name:name, description:row&&row.description||'', value:row&&row.value_is_null?String(row.parent_value||''):String(row&&row.value||''), inherited:Boolean(row&&row.value_is_null), secret:Boolean(secrets[name]), boolean:Boolean(booleans[name]), dirty:false}; }
function refresh() {
manager.loading = true;
return connection.requestInfolist('option', 0, 'irc.server.*.addresses').then(function(rows) {
manager.servers = rows.map(function(row) { return row.full_name.slice(11, -10); }).filter(validName).sort();
manager.loading = false;
}, function() { manager.loading=false; manager.status='Unable to load server settings.'; });
}
function select(name) {
manager.isNew = false; manager.loading = true; manager.status = '';
Promise.all(names.map(function(option) {
if (secrets[option]) return Promise.resolve(optionModel(option, null));
return connection.requestInfolist('option', 0, 'irc.server.' + name + '.' + option).then(function(rows) { return optionModel(option, rows[0]); });
})).then(function(options) { scope.$applyAsync(function() { manager.selected={name:name, originalName:name, options:options}; manager.loading=false; }); });
}
scope.newNeoServer = function() { manager.isNew=true; manager.selected={name:'',originalName:'',options:names.map(function(name){return optionModel(name,null);})}; manager.selected.options[0].inherited=false; manager.status=''; };
scope.selectNeoServer = select;
scope.closeNeoServerManager = function() { manager.visible=false; manager.selected=null; };
scope.saveNeoServer = function(connectAfter) {
var server=manager.selected; if(!server||!validName(server.name)){manager.status='Use a safe server name.';return;}
var addresses=server.options[0].value; if(manager.isNew&&!safeValue(addresses)){manager.status='Enter a safe server address.';return;}
manager.saving=true;
if(manager.isNew) connection.sendCoreCommand('/server add '+server.name+' '+addresses);
server.options.forEach(function(option) {
if ((!manager.isNew&&!option.dirty)||(manager.isNew&&option.name==='addresses')||option.secret&&!option.value) return;
if (!safeValue(option.value)){manager.status='Values cannot contain quotes or control characters.';return;}
var full='irc.server.'+server.name+'.'+option.name;
if (option.secret) {
var secureKey='neobear_'+server.name.toLowerCase()+'_'+option.name;
connection.sendCoreCommand('/secure set '+secureKey+' '+option.value);
connection.sendCoreCommand('/set '+full+' "${sec.data.'+secureKey+'}"');
} else {
connection.sendCoreCommand(option.inherited?'/unset '+full:'/set '+full+' "'+option.value+'"');
}
});
connection.sendCoreCommand('/save irc');
if(connectAfter) connection.sendCoreCommand('/connect '+server.name);
manager.status='Changes sent to WeeChat. Check the core buffer for any rejected setting.'; manager.saving=false; refresh();
};
scope.$on('neoServerManagerOpen', function(event, name) { manager.visible=true; refresh().then(function(){ if(name) select(name); else scope.$applyAsync(scope.newNeoServer); }); });
}};
}]);
}());
+85
View File
@@ -0,0 +1,85 @@
$timeout(function() {
var sidebar = document.getElementById('sidebar');
if (!sidebar || document.getElementById('sidebar-resizer')) {
return;
}
var sidebarResizer = document.createElement('div');
var desktopSidebarMedia = window.matchMedia('(min-width: 969px)');
var activeSidebarPointer = null;
var workingSidebarWidth = 0;
sidebarResizer.id = 'sidebar-resizer';
sidebarResizer.tabIndex = 0;
sidebarResizer.setAttribute('role', 'separator');
sidebarResizer.setAttribute('aria-orientation', 'vertical');
sidebarResizer.setAttribute('aria-controls', 'sidebar');
sidebarResizer.setAttribute('aria-label', 'Resize channel sidebar');
sidebarResizer.setAttribute('aria-valuemin', '120');
sidebarResizer.setAttribute('aria-valuemax', '480');
sidebar.parentNode.insertBefore(sidebarResizer, sidebar.nextSibling);
var maximumSidebarWidth = function() {
return Math.min(480, window.innerWidth * 0.4);
};
var applySidebarWidth = function(value, limitToViewport) {
var width = Number(value);
if (!Number.isFinite(width) || width <= 0) {
return;
}
width = Math.max(120, Math.min(limitToViewport ? maximumSidebarWidth() : 480, width));
width = Math.round(width);
document.documentElement.style.setProperty('--neorelay-sidebar-preferred-width', width + 'px');
sidebarResizer.setAttribute('aria-valuenow', width);
workingSidebarWidth = width;
};
applySidebarWidth(settings.neorelaySidebarWidth, false);
sidebarResizer.addEventListener('pointerdown', function(event) {
if (!desktopSidebarMedia.matches || event.button !== 0) {
return;
}
activeSidebarPointer = event.pointerId;
workingSidebarWidth = Math.round(sidebar.getBoundingClientRect().width);
sidebarResizer.setPointerCapture(event.pointerId);
document.body.classList.add('neorelay-sidebar-resizing');
event.preventDefault();
});
sidebarResizer.addEventListener('pointermove', function(event) {
if (event.pointerId === activeSidebarPointer && desktopSidebarMedia.matches) {
applySidebarWidth(event.clientX, true);
}
});
var finishSidebarResize = function(event) {
if (event.pointerId !== activeSidebarPointer) {
return;
}
activeSidebarPointer = null;
document.body.classList.remove('neorelay-sidebar-resizing');
if (workingSidebarWidth > 0) {
settings.neorelaySidebarWidth = workingSidebarWidth;
}
};
sidebarResizer.addEventListener('pointerup', finishSidebarResize);
sidebarResizer.addEventListener('pointercancel', finishSidebarResize);
sidebarResizer.addEventListener('keydown', function(event) {
if (!desktopSidebarMedia.matches ||
(event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) {
return;
}
var direction = event.key === 'ArrowLeft' ? -1 : 1;
var step = event.shiftKey ? 25 : 10;
applySidebarWidth(sidebar.getBoundingClientRect().width + direction * step, true);
settings.neorelaySidebarWidth = workingSidebarWidth;
event.preventDefault();
});
}, 0);
+20
View File
@@ -0,0 +1,20 @@
<section class="neo-weechat-settings" ng-show="neoSettingsTab === 'weechat'" role="tabpanel" aria-label="WeeChat settings">
<div class="neo-settings-toolbar">
<label>Section<select class="form-control" ng-model="neoWeeChatSettings.section" ng-options="section.mask as section.label for section in neoWeeChatSettings.sections" ng-change="loadNeoWeeChatSettings()"></select></label>
<label>Filter<input class="form-control" type="text" ng-model="neoWeeChatSettings.filter" placeholder="Filter loaded settings"></label>
<button type="button" class="btn btn-default" ng-click="loadNeoWeeChatSettings()" ng-disabled="neoWeeChatSettings.loading">Refresh</button>
</div>
<p>Changes are validated, written to WeeChat, read back, and then saved to the corresponding configuration file.</p>
<p class="status" ng-bind="neoWeeChatSettings.status"></p>
<div class="neo-option-list">
<article class="neo-option" ng-repeat="option in neoWeeChatSettings.options | filter:neoWeeChatSettings.filter | limitTo:200 track by option.name">
<div><strong ng-bind="option.name"></strong><small ng-bind="option.description"></small></div>
<select class="form-control" ng-if="option.type === 'boolean'" ng-model="option.editValue"><option value="on">On</option><option value="off">Off</option></select>
<select class="form-control" ng-if="option.type !== 'boolean' && option.choices.length" ng-model="option.editValue" ng-options="choice for choice in option.choices"></select>
<select class="form-control" ng-if="option.type === 'color' && !option.choices.length" ng-model="option.editValue" ng-options="colour for colour in neoWeeChatColourChoices"></select>
<input class="form-control" ng-if="option.type === 'integer' && !option.choices.length" type="number" ng-model="option.editValue" min="{{option.min}}" max="{{option.max}}">
<input class="form-control" ng-if="option.type !== 'boolean' && option.type !== 'color' && option.type !== 'integer' && !option.choices.length" type="text" ng-model="option.editValue">
<div class="neo-option-actions"><button type="button" class="btn btn-primary" ng-click="saveNeoWeeChatOption(option)" ng-disabled="option.saving">Save</button><button type="button" class="btn btn-default" ng-click="resetNeoWeeChatOption(option)" ng-disabled="option.saving">Reset</button></div>
</article>
</div>
</section>
+150
View File
@@ -0,0 +1,150 @@
var neoBasicColours = ['default','black','darkgray','red','lightred','green','lightgreen','brown','yellow','blue','lightblue','magenta','lightmagenta','cyan','lightcyan','gray','white'];
$scope.neoWeeChatColourChoices = neoBasicColours.concat(Array.from({length: 256}, function(_, index) { return String(index); }));
var neoColourHex = {default:'#f1eaff',black:'#000000',darkgray:'#555555',red:'#aa0000',lightred:'#ff5555',green:'#00aa00',lightgreen:'#55ff55',brown:'#aa5500',yellow:'#ffff55',blue:'#0000aa',lightblue:'#5555ff',magenta:'#aa00aa',lightmagenta:'#ff55ff',cyan:'#00aaaa',lightcyan:'#55ffff',gray:'#aaaaaa',white:'#ffffff'};
var neoAllowedSections = [
{label:'WeeChat appearance',mask:'weechat.look.*'},
{label:'WeeChat colours',mask:'weechat.color.*'},
{label:'Completion',mask:'weechat.completion.*'},
{label:'History',mask:'weechat.history.*'},
{label:'Network',mask:'weechat.network.*'},
{label:'Plugins',mask:'weechat.plugin.*'},
{label:'IRC appearance',mask:'irc.look.*'},
{label:'IRC colours',mask:'irc.color.*'},
{label:'IRC network',mask:'irc.network.*'}
];
var neoAllowedMasks = neoAllowedSections.reduce(function(result, item) { result[item.mask] = true; return result; }, {});
var neoLoadedOptions = {};
function neoResolveWeeChatColour(value) {
value = String(value || 'default').replace(/^[*!/_|]+/, '');
if (neoColourHex[value]) return neoColourHex[value];
var number = Number(value);
if (!Number.isInteger(number) || number < 0 || number > 255) return neoColourHex.default;
if (number < 16) return neoColourHex[neoBasicColours[number + 1] || 'default'];
if (number >= 232) {
var gray = 8 + (number - 232) * 10;
return 'rgb(' + gray + ',' + gray + ',' + gray + ')';
}
number -= 16;
var levels = [0,95,135,175,215,255];
return 'rgb(' + levels[Math.floor(number / 36)] + ',' + levels[Math.floor(number / 6) % 6] + ',' + levels[number % 6] + ')';
}
function neoValidWeeChatColour(value) {
value = String(value || '').replace(/^[*!/_|]+/, '');
return neoBasicColours.indexOf(value) >= 0 || (/^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(value));
}
function neoEffectiveOptionValue(row) {
if (!row.value_is_null) return Object.prototype.hasOwnProperty.call(row, 'value') ? String(row.value) : '';
if (Object.prototype.hasOwnProperty.call(row, 'parent_value')) return String(row.parent_value);
return Object.prototype.hasOwnProperty.call(row, 'default_value') ? String(row.default_value) : '';
}
function neoNormalizeOption(row) {
var choices = row.string_values ? String(row.string_values).split('|').filter(Boolean) : [];
var type = String(row.type || 'string');
var value = neoEffectiveOptionValue(row);
return {
name: String(row.full_name || ''),
configName: String(row.config_name || '').replace(/[^A-Za-z0-9_-]/g, ''),
description: String(row.description_nls || row.description || ''),
type: type,
value: value,
editValue: type === 'integer' && !choices.length && /^-?[0-9]+$/.test(value) ? Number(value) : value,
defaultValue: Object.prototype.hasOwnProperty.call(row, 'default_value') ? String(row.default_value) : '',
min: Number(row.min),
max: Number(row.max),
choices: choices,
saving: false
};
}
function neoSafeOption(row) {
return row && /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,511}$/.test(row.name) &&
/^(weechat|irc)$/.test(row.configName) &&
!/(?:password|passphrase|secret|token)(?:$|[._-])/i.test(row.name);
}
function neoValidateOption(row, value) {
value = String(value);
if (!neoSafeOption(row) || neoLoadedOptions[row.name] !== row) throw new Error('Setting is not editable.');
if (value.length > 4096 || /[\u0000-\u001f\u007f-\u009f"\\\u2028\u2029]/.test(value)) throw new Error('Value contains unsupported characters.');
if (row.type === 'boolean' && !/^(?:on|off)$/.test(value)) throw new Error('Choose On or Off.');
if (row.choices.length && row.choices.indexOf(value) < 0) throw new Error('Choose a listed value.');
if (row.type === 'integer' && !row.choices.length) {
if (!/^-?[0-9]+$/.test(value)) throw new Error('Enter an integer.');
var number = Number(value);
if ((Number.isFinite(row.min) && number < row.min) || (Number.isFinite(row.max) && row.max > row.min && number > row.max)) throw new Error('Integer is outside the allowed range.');
}
if (row.type === 'color' && !neoValidWeeChatColour(value)) throw new Error('Choose a WeeChat colour.');
if (row.name === 'irc.color.nick_prefixes') {
var prefixEntries = value.split(';').filter(Boolean);
if (!prefixEntries.length || prefixEntries.some(function(entry) {
var match = entry.match(/^[A-Za-z*]:([*!/_|]*)([^;]+)$/);
return !match || !neoValidWeeChatColour(match[2]);
})) throw new Error('Invalid IRC prefix colour map.');
}
return value;
}
function neoRefreshExact(row, expected, success, failure, attempt) {
connection.requestInfolist('option', 0, row.name).then(function(rows) {
var updated = rows[0] ? neoNormalizeOption(rows[0]) : null;
if (updated && (expected === null || updated.value === expected)) {
Object.assign(row, updated);
neoLoadedOptions[row.name] = row;
connection.sendCoreCommand('/save ' + updated.configName);
$scope.$applyAsync(function() { success(row); });
} else if (attempt < 5) {
$timeout(function() { neoRefreshExact(row, expected, success, failure, attempt + 1); }, 200 * attempt);
} else {
$scope.$applyAsync(function() { failure('WeeChat did not confirm the setting change.'); });
}
}, function() { $scope.$applyAsync(function() { failure('Unable to verify the setting change.'); }); });
}
function neoWriteOption(row, value, success, failure) {
try { value = neoValidateOption(row, value); }
catch (error) { failure(error.message); return; }
connection.sendCoreCommand('/set ' + row.name + ' "' + value + '"');
$timeout(function() { neoRefreshExact(row, value, success, failure, 1); }, 150);
}
$scope.neoWeeChatSettings = {sections: neoAllowedSections, section: neoAllowedSections[0].mask, filter: '', options: [], loading: false, status: ''};
$scope.loadNeoWeeChatSettings = function() {
var state = $scope.neoWeeChatSettings;
if (!$rootScope.connected || state.loading || !neoAllowedMasks[state.section]) return;
state.loading = true;
state.status = 'Loading settings from WeeChat...';
connection.requestInfolist('option', 0, state.section).then(function(rows) {
$scope.$applyAsync(function() {
state.options = rows.map(neoNormalizeOption).filter(neoSafeOption).sort(function(a, b) { return a.name.localeCompare(b.name); });
state.options.forEach(function(row) { neoLoadedOptions[row.name] = row; });
state.loading = false;
state.status = 'Loaded ' + state.options.length + ' editable settings.';
});
}, function() { $scope.$applyAsync(function() { state.loading = false; state.status = 'Unable to load settings.'; }); });
};
$scope.saveNeoWeeChatOption = function(row) {
row.saving = true;
neoWriteOption(row, row.editValue, function(updated) {
Object.assign(row, updated);
row.saving = false;
$scope.neoWeeChatSettings.status = 'Saved ' + row.name + '.';
}, function(message) { row.saving = false; $scope.neoWeeChatSettings.status = message; });
};
$scope.resetNeoWeeChatOption = function(row) {
if (!neoSafeOption(row) || neoLoadedOptions[row.name] !== row) return;
row.saving = true;
connection.sendCoreCommand('/unset ' + row.name);
$timeout(function() {
neoRefreshExact(row, null, function(updated) {
Object.assign(row, updated);
row.saving = false;
$scope.neoWeeChatSettings.status = 'Reset ' + row.name + '.';
}, function(message) { row.saving = false; $scope.neoWeeChatSettings.status = message; }, 1);
}, 150);
};
+32
View File
@@ -0,0 +1,32 @@
:root { color-scheme: dark; --bg: #09040f; --panel: #170c22; --line: #62377d; --pink: #f45bd2; --cyan: #62e7ee; --text: #f5edf8; --muted: #b9a8c2; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 80% 0, #281139, transparent 42%), var(--bg); color: var(--text); font: 16px/1.5 "Atkinson Hyperlegible", monospace; }
main { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 72px; }
header { border-left: 4px solid var(--pink); padding-left: 20px; margin-bottom: 36px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 8px; font-size: clamp(2rem, 6vw, 4rem); letter-spacing: -.04em; }
h1 span { color: var(--pink); }
.kicker { color: var(--cyan); letter-spacing: .16em; font-size: .78rem; }
section { background: color-mix(in srgb, var(--panel) 90%, transparent); border: 1px solid var(--line); padding: 24px; margin-top: 22px; box-shadow: 8px 8px 0 #050208; }
form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, .6fr) auto; gap: 16px; align-items: end; }
label { display: grid; gap: 7px; color: var(--muted); font-size: .85rem; text-transform: uppercase; letter-spacing: .08em; }
input, select, button { min-height: 44px; border: 1px solid var(--line); background: #0c0612; color: var(--text); padding: 9px 12px; font: inherit; }
button { cursor: pointer; border-color: var(--pink); }
button:hover, button:focus-visible { background: var(--pink); color: #16051a; outline: none; }
button:disabled { opacity: .55; cursor: wait; }
.progress { height: 8px; margin: 20px 0 12px; background: #08030c; border: 1px solid #3d254b; }
.progress span { display: block; width: 0; height: 100%; background: linear-gradient(90deg, var(--pink), var(--cyan)); transition: width .15s; }
#status { margin-bottom: 0; color: var(--cyan); }
#status.error { color: #ff829c; }
#retry { margin-top: 12px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 760px; }
th, td { padding: 12px 10px; border-top: 1px solid #3e294a; text-align: left; vertical-align: middle; }
th { color: var(--cyan); font-size: .78rem; text-transform: uppercase; letter-spacing: .09em; }
.actions { display: flex; gap: 8px; }
.actions button { min-height: 36px; padding: 5px 10px; }
.actions .danger { border-color: #ff607f; }
.muted { color: var(--muted); }
.hidden { display: none !important; }
@media (max-width: 760px) { main { width: min(100% - 20px, 1120px); padding-top: 24px; } section { padding: 16px; } form { grid-template-columns: 1fr; } .section-heading { align-items: flex-start; } }
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#150a24">
<title>NeoDrop Management</title>
<link rel="stylesheet" href="/manage/style.css">
</head>
<body>
<main>
<header>
<p class="kicker">{{OPERATOR_LABEL}}</p>
<h1>NeoDrop <span>Management</span></h1>
<p>Files are encrypted in this browser. The storage service receives ciphertext only.</p>
</header>
<section class="upload-panel" aria-labelledby="upload-title">
<h2 id="upload-title">New encrypted drop</h2>
<form id="upload-form">
<label class="file-picker">File <input id="file" type="file" required></label>
<label>Retention
<select id="profile">
<option value="0">30 days, up to 50 MiB</option>
<option value="1">7 days, up to 1 GiB</option>
</select>
</label>
<button id="upload" type="submit">Encrypt and upload</button>
</form>
<div class="progress" aria-hidden="true"><span id="progress"></span></div>
<p id="status" role="status">Ready.</p>
<button id="retry" type="button" class="hidden">Retry catalog registration</button>
</section>
<section aria-labelledby="inventory-title">
<div class="section-heading">
<h2 id="inventory-title">Managed drops</h2>
<button id="refresh" type="button">Refresh</button>
</div>
<p id="empty" class="muted hidden">No managed drops.</p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Expires</th><th>Actions</th></tr></thead>
<tbody id="drops"></tbody>
</table>
</div>
</section>
</main>
<script src="/manage/neodrop-crypto.js"></script>
<script src="/manage/manage.js"></script>
</body>
</html>
+142
View File
@@ -0,0 +1,142 @@
"use strict";
(function() {
var pendingRegistration = null;
var busy = false;
var $ = function(id) { return document.getElementById(id); };
function formatSize(value) {
var units = ["B", "KiB", "MiB", "GiB"];
var size = Number(value);
var index = 0;
while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; }
return size.toFixed(index ? 1 : 0) + " " + units[index];
}
function setStatus(message, error) {
$("status").textContent = message;
$("status").classList.toggle("error", Boolean(error));
}
async function api(path, options) {
var response = await fetch(path, Object.assign({cache: "no-store"}, options || {}));
var value;
try { value = await response.json(); } catch (_) { value = null; }
if (!response.ok) throw new Error("Management request failed.");
return value;
}
async function copyUrl(url) {
await navigator.clipboard.writeText(url);
setStatus("Share URL copied.", false);
}
function actionButton(label, handler, className) {
var button = document.createElement("button");
button.type = "button";
button.textContent = label;
if (className) button.className = className;
button.addEventListener("click", handler);
return button;
}
function render(rows) {
var body = $("drops");
body.textContent = "";
$("empty").classList.toggle("hidden", rows.length !== 0);
rows.forEach(function(row) {
var tr = document.createElement("tr");
[row.name, row.type || "application/octet-stream", formatSize(row.size),
new Date(row.expiresAt * 1000).toLocaleString()].forEach(function(value) {
var td = document.createElement("td");
td.textContent = value;
tr.appendChild(td);
});
var actions = document.createElement("td");
actions.className = "actions";
actions.appendChild(actionButton("Copy", function() { copyUrl(row.readUrl).catch(function() {
setStatus("Unable to copy the share URL.", true);
}); }));
actions.appendChild(actionButton("Open", function() {
window.open(row.readUrl, "_blank", "noopener,noreferrer");
}));
actions.appendChild(actionButton("Delete", async function() {
if (!window.confirm("Permanently delete " + row.name + "?")) return;
try {
await api("/manage/api/v1/drops/" + row.id, {method: "DELETE"});
await load();
setStatus("Drop deleted.", false);
} catch (_) { setStatus("Delete failed.", true); }
}, "danger"));
tr.appendChild(actions);
body.appendChild(tr);
});
}
async function load() {
var value = await api("/manage/api/v1/list", {
method: "POST", headers: {"Content-Type": "application/json"}, body: "{}"
});
render(Array.isArray(value.drops) ? value.drops : []);
}
async function register(result) {
await api("/manage/api/v1/register", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({readUrl: result.readUrl, deleteCapability: result.deleteCapability})
});
pendingRegistration = null;
$("retry").classList.add("hidden");
await load();
}
$("upload-form").addEventListener("submit", async function(event) {
event.preventDefault();
if (busy) return;
var file = $("file").files[0];
if (!file) return setStatus("Select a file.", true);
busy = true;
$("upload").disabled = true;
$("progress").style.width = "0";
setStatus("Encrypting and uploading...", false);
try {
var result = await NeoDropCrypto.upload(file, {
base: location.origin,
profile: Number($("profile").value),
onProgress: function(value) { $("progress").style.width = Math.round(value * 100) + "%"; }
});
pendingRegistration = result;
setStatus("Upload complete. Registering encrypted catalog entry...", false);
try {
await register(result);
$("upload-form").reset();
setStatus("Encrypted drop uploaded and registered.", false);
} catch (_) {
$("retry").classList.remove("hidden");
setStatus("Upload succeeded, but catalog registration must be retried in this tab.", true);
}
} catch (error) {
if (error.name !== "AbortError") setStatus(error.message || "Encrypted upload failed.", true);
} finally {
busy = false;
$("upload").disabled = false;
}
});
$("retry").addEventListener("click", async function() {
if (!pendingRegistration || busy) return;
busy = true;
try {
await register(pendingRegistration);
setStatus("Catalog registration completed.", false);
} catch (_) { setStatus("Catalog registration failed; retry from this tab.", true); }
finally { busy = false; }
});
$("refresh").addEventListener("click", function() {
load().then(function() { setStatus("Managed drops refreshed.", false); })
.catch(function() { setStatus("Unable to load managed drops.", true); });
});
load().catch(function() { setStatus("Unable to load managed drops.", true); });
}());
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,327 @@
#include <errno.h>
#include <stddef.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <weechat/weechat-plugin.h>
WEECHAT_PLUGIN_NAME("neodrop_manager")
WEECHAT_PLUGIN_DESCRIPTION("NeoDrop manager control buffer")
WEECHAT_PLUGIN_AUTHOR("{{PRODUCT_NAME}}")
WEECHAT_PLUGIN_VERSION("1.1")
WEECHAT_PLUGIN_LICENSE("GPL3")
#define CONTROL_SOCKET "{{MANAGER_CONTROL_SOCKET_PATH}}"
#define SEND_LIMIT 8192U
#define RECEIVE_LIMIT 16384U
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif
struct t_weechat_plugin *weechat_plugin;
static struct t_gui_buffer *manager_buffer;
static struct t_hook *manager_fd_hook;
static int manager_fd = -1;
static char receive_buffer[RECEIVE_LIMIT];
static size_t receive_length;
static void print_error(const char *message)
{
if (manager_buffer != NULL)
weechat_printf(manager_buffer, "%s%s", weechat_color("lightred"), message);
}
static void disconnect_manager(int announce)
{
int was_connected = manager_fd >= 0;
if (manager_fd_hook != NULL) {
weechat_unhook(manager_fd_hook);
manager_fd_hook = NULL;
}
if (manager_fd >= 0)
close(manager_fd);
manager_fd = -1;
receive_length = 0;
if (announce && was_connected)
print_error("NeoDrop manager disconnected; reconnecting in the background.");
}
static void print_protocol_line(const char *line, size_t length)
{
const char *payload;
const char *color = NULL;
size_t payload_length;
size_t index;
char output[RECEIVE_LIMIT];
if (manager_buffer == NULL)
return;
if (length > 0 && line[length - 1] == '\r')
length--;
if (length >= 5 && memcmp(line, "LINE\t", 5) == 0) {
payload = line + 5;
payload_length = length - 5;
} else if (length >= 6 && memcmp(line, "ERROR\t", 6) == 0) {
payload = line + 6;
payload_length = length - 6;
color = "lightred";
} else {
print_error("NeoDrop manager sent an invalid response.");
return;
}
for (index = 0; index < payload_length; index++) {
unsigned char byte = (unsigned char)payload[index];
output[index] = (byte < 0x20U || byte == 0x7fU) ? ' ' : (char)byte;
}
output[payload_length] = '\0';
if (color != NULL)
weechat_printf(manager_buffer, "%s%s", weechat_color(color), output);
else
weechat_printf(manager_buffer, "%s", output);
}
static int manager_read_cb(const void *pointer, void *data, int fd)
{
ssize_t count;
size_t consumed = 0;
char *newline;
(void)pointer;
(void)data;
(void)fd;
if (receive_length >= RECEIVE_LIMIT - 1U) {
print_error("NeoDrop manager response exceeded the line limit.");
disconnect_manager(0);
return WEECHAT_RC_OK;
}
count = recv(manager_fd, receive_buffer + receive_length,
RECEIVE_LIMIT - receive_length - 1U, 0);
if (count < 0 && errno == EINTR)
return WEECHAT_RC_OK;
if (count <= 0) {
disconnect_manager(1);
return WEECHAT_RC_OK;
}
receive_length += (size_t)count;
while (consumed < receive_length) {
size_t available = receive_length - consumed;
newline = memchr(receive_buffer + consumed, '\n', available);
if (newline == NULL)
break;
print_protocol_line(receive_buffer + consumed,
(size_t)(newline - (receive_buffer + consumed)));
consumed = (size_t)(newline - receive_buffer) + 1U;
}
if (consumed > 0) {
receive_length -= consumed;
memmove(receive_buffer, receive_buffer + consumed, receive_length);
}
if (receive_length >= RECEIVE_LIMIT - 1U) {
print_error("NeoDrop manager response exceeded the line limit.");
disconnect_manager(0);
}
return WEECHAT_RC_OK;
}
static int connect_manager(void)
{
struct sockaddr_un address;
int fd;
if (manager_fd >= 0)
return 1;
fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0)
return 0;
#ifdef SO_NOSIGPIPE
{
int enabled = 1;
if (setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)) != 0) {
close(fd);
return 0;
}
}
#endif
memset(&address, 0, sizeof(address));
address.sun_family = AF_UNIX;
memcpy(address.sun_path, CONTROL_SOCKET, sizeof(CONTROL_SOCKET));
if (connect(fd, (struct sockaddr *)&address, sizeof(address)) != 0) {
close(fd);
return 0;
}
manager_fd = fd;
manager_fd_hook = weechat_hook_fd(manager_fd, 1, 0, 0,
&manager_read_cb, NULL, NULL);
if (manager_fd_hook == NULL) {
disconnect_manager(0);
return 0;
}
return 1;
}
static void send_command(const char *command)
{
static const char prefix[] = "COMMAND\t";
char message[SEND_LIMIT];
size_t command_length;
size_t message_length;
size_t sent = 0;
if (command == NULL || command[0] == '\0')
return;
command_length = strlen(command);
if (command_length > SEND_LIMIT - sizeof("COMMAND\t\n")) {
print_error("NeoDrop manager command exceeded the line limit.");
return;
}
if (strpbrk(command, "\t\r\n") != NULL) {
print_error("NeoDrop manager commands cannot contain tabs or newlines.");
return;
}
if (!connect_manager()) {
print_error("Unable to connect to the NeoDrop manager.");
return;
}
memcpy(message, prefix, sizeof(prefix) - 1U);
memcpy(message + sizeof(prefix) - 1U, command, command_length);
message_length = sizeof(prefix) - 1U + command_length;
message[message_length++] = '\n';
while (sent < message_length) {
ssize_t count = send(manager_fd, message + sent, message_length - sent,
MSG_NOSIGNAL);
if (count < 0 && errno == EINTR)
continue;
if (count <= 0) {
disconnect_manager(1);
return;
}
sent += (size_t)count;
}
}
static int reconnect_cb(const void *pointer, void *data, int remaining_calls)
{
(void)pointer;
(void)data;
(void)remaining_calls;
(void)connect_manager();
return WEECHAT_RC_OK;
}
static int input_cb(const void *pointer, void *data, struct t_gui_buffer *buffer,
const char *input)
{
(void)pointer;
(void)data;
(void)buffer;
send_command(input);
return WEECHAT_RC_OK;
}
static int close_cb(const void *pointer, void *data, struct t_gui_buffer *buffer)
{
(void)pointer;
(void)data;
(void)buffer;
manager_buffer = NULL;
return WEECHAT_RC_OK;
}
static int ensure_buffer(void)
{
if (manager_buffer == NULL)
manager_buffer = weechat_buffer_search("neodrop_manager", "control");
if (manager_buffer == NULL) {
manager_buffer = weechat_buffer_new("control", &input_cb, NULL, NULL,
&close_cb, NULL, NULL);
if (manager_buffer == NULL)
return 0;
}
weechat_buffer_set_pointer(manager_buffer, "input_callback", &input_cb);
weechat_buffer_set_pointer(manager_buffer, "close_callback", &close_cb);
weechat_buffer_set(manager_buffer, "short_name", "NeoDrop");
weechat_buffer_set(manager_buffer, "title",
"NeoDrop | list | put-text NAME TEXT | show/read/download/delete ID | edit ID | help");
weechat_buffer_set(manager_buffer, "hidden", "0");
weechat_buffer_set(manager_buffer, "notify", "3");
weechat_buffer_set(manager_buffer, "localvar_set_no_log", "1");
weechat_buffer_set(manager_buffer, "localvar_set_pinned", "true");
weechat_buffer_set(manager_buffer, "localvar_set_type", "other");
return 1;
}
static int command_cb(const void *pointer, void *data, struct t_gui_buffer *buffer,
int argc, char **argv, char **argv_eol)
{
const char *command = NULL;
int background_registration;
(void)pointer;
(void)data;
(void)buffer;
(void)argv;
if (argc > 1 && argv_eol != NULL && argv_eol[1] != NULL && argv_eol[1][0] != '\0')
command = argv_eol[1];
background_registration = command != NULL && strncmp(command, "register ", 9) == 0;
if (!ensure_buffer())
return WEECHAT_RC_ERROR;
if (!background_registration)
weechat_buffer_set(manager_buffer, "display", "1");
if (command != NULL)
send_command(command);
else
send_command("list");
return WEECHAT_RC_OK;
}
int weechat_plugin_init(struct t_weechat_plugin *plugin, int argc, char *argv[])
{
(void)argc;
(void)argv;
weechat_plugin = plugin;
if (!ensure_buffer())
return WEECHAT_RC_ERROR;
if (weechat_hook_command("drops", "open/control NeoDrop manager",
"list || status || help || put-text <name> <text> || show <selector> || read <selector> || download <selector> || delete <selector> || edit <selector> || line <number> <text> || insert <number> <text> || delete-line <number> || save || cancel",
"Manage encrypted NeoDrop files and text revisions",
"list|status|help|put-text|show|read|download|delete|edit|line|insert|delete-line|save|cancel",
&command_cb, NULL, NULL) == NULL)
return WEECHAT_RC_ERROR;
if (weechat_hook_timer(5000, 0, 0, &reconnect_cb, NULL, NULL) == NULL)
return WEECHAT_RC_ERROR;
return WEECHAT_RC_OK;
}
int weechat_plugin_end(struct t_weechat_plugin *plugin)
{
(void)plugin;
disconnect_manager(0);
return WEECHAT_RC_OK;
}
+88
View File
@@ -0,0 +1,88 @@
"use strict";
(function () {
const CHUNK_SIZE = 4 * 1024 * 1024;
const PREVIEW_MAX = 50 * 1024 * 1024;
const enc = new TextEncoder();
const dec = new TextDecoder("utf-8", {fatal:true});
const state = {secret:null, objectId:null, manifest:null, metadata:null, chunks:null, url:null};
const $ = id => document.getElementById(id);
const show = id => ["idle","loading","viewer","error"].forEach(x => $(x).classList.toggle("hidden", x !== id));
const progress = value => $("meter-fill").style.width = Math.round(value * 100) + "%";
const from64 = value => { const s=value.replace(/-/g,"+").replace(/_/g,"/"); const raw=atob(s+"=".repeat((4-s.length%4)%4)); return Uint8Array.from(raw,c=>c.charCodeAt(0)); };
const to64 = bytes => btoa(String.fromCharCode(...bytes)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/g,"");
const u32 = (v,b,o) => new DataView(b.buffer).setUint32(o,v,false);
const u64 = (v,b,o) => new DataView(b.buffer).setBigUint64(o,BigInt(v),false);
function aad(type, profile, id, size, count, index, length) {
const out=new Uint8Array(60); out.set(enc.encode("NEODRP01")); out[8]=type; out[9]=profile; out.set(id,12);
u64(size,out,36); u32(CHUNK_SIZE,out,44); u32(count,out,48); u32(index,out,52); u32(length,out,56); return out;
}
function iv(type,index) { const out=new Uint8Array(12); u32(type,out,0); u64(index,out,4); return out; }
async function derive(secret, info, length) {
const base=await crypto.subtle.importKey("raw",secret,"HKDF",false,["deriveBits"]);
return new Uint8Array(await crypto.subtle.deriveBits({name:"HKDF",hash:"SHA-256",salt:enc.encode("{{NEODROP_CRYPTO_CONTEXT}}"),info:enc.encode(info)},base,length*8));
}
async function aes(raw) { return crypto.subtle.importKey("raw",raw,{name:"AES-GCM"},false,["decrypt"]); }
async function decryptRecord(key, cipher, nonce, additional) { return new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:nonce,additionalData:additional,tagLength:128},key,cipher)); }
async function fetchJson(url) { const response=await fetch(url,{cache:"no-store"}); if(!response.ok) throw new Error("unavailable"); return response.json(); }
function typeFor(type,name) { const value=String(type||"").toLowerCase(); if(value&&value!=="application/octet-stream")return value; const match=String(name||"").toLowerCase().match(/\.([a-z0-9]+)$/); const types={png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",webp:"image/webp",avif:"image/avif",flac:"audio/flac",m4a:"audio/mp4",mp3:"audio/mpeg",oga:"audio/ogg",ogg:"audio/ogg",opus:"audio/opus",wav:"audio/wav",wave:"audio/wav",m4v:"video/mp4",mkv:"video/x-matroska",mov:"video/quicktime",mp4:"video/mp4",ogv:"video/ogg",webm:"video/webm"}; return match&&types[match[1]]?types[match[1]]:"application/octet-stream"; }
function classify(type,name) { type=typeFor(type,name); if(/^image\/(png|jpeg|gif|webp|avif)$/i.test(type)) return "image"; if(/^audio\//i.test(type)) return "audio"; if(/^video\//i.test(type)) return "video"; if(/^text\//i.test(type)||/^(application\/(json|javascript|x-.*script))$/i.test(type)) return "text"; return "binary"; }
function formatSize(size) { const units=["B","KiB","MiB","GiB"]; let i=0,n=size; while(n>=1024&&i<3){n/=1024;i++;} return n.toFixed(i?1:0)+" "+units[i]; }
async function decryptChunk(index, key) {
const response=await fetch(`/api/v1/objects/${state.objectId}/chunks/${index}`,{cache:"no-store"}); if(!response.ok) throw new Error("unavailable");
const cipher=new Uint8Array(await response.arrayBuffer()); const plainLength=Math.min(CHUNK_SIZE,state.manifest.size-index*CHUNK_SIZE);
if(cipher.length!==plainLength+16) throw new Error("invalid length");
return decryptRecord(key,cipher,iv(1,index),aad(1,state.manifest.profile,from64(state.objectId),state.manifest.size,state.manifest.chunks,index,plainLength));
}
async function allChunks(key) { const parts=[]; for(let i=0;i<state.manifest.chunks;i++){parts.push(await decryptChunk(i,key));progress((i+1)/(state.manifest.chunks+1));} return parts; }
function highlightText(text) {
const pre=document.createElement("pre"); pre.className="text-preview"; let fenced=false;
text.split("\n").forEach((line,i)=>{ const span=document.createElement("span"); if(/^```/.test(line)){fenced=!fenced;span.className="code";} else if(fenced||/^ {4}/.test(line))span.className="code"; else if(/^#{1,6}\s/.test(line))span.className="heading"; else if(/^>\s?/.test(line))span.className="quote"; else if(/^\s*([-*+] |\d+\. )/.test(line))span.className="list"; span.textContent=line+(i<text.split("\n").length-1?"\n":""); pre.appendChild(span); });
return pre;
}
function setDetails() {
$("file-name").textContent=state.metadata.name;
$("file-size").textContent=`${formatSize(state.manifest.size)} (${state.manifest.size.toLocaleString("en-US")} bytes)`;
$("file-type").textContent=state.metadata.type||"application/octet-stream";
$("file-sha256").textContent=state.metadata.sha256||"Not provided by uploader";
const expires=new Date(state.manifest.expiresAt*1000); $("file-expires").dateTime=expires.toISOString(); $("file-expires").textContent=expires.toISOString();
}
async function renderPreview(parts) {
const blob=new Blob(parts,{type:typeFor(state.metadata.type,state.metadata.name)}); const kind=classify(state.metadata.type,state.metadata.name); const preview=$("preview"); preview.textContent="";
if(kind==="image"&&blob.size<=PREVIEW_MAX){const image=document.createElement("img");image.alt=state.metadata.name;image.src=URL.createObjectURL(blob);preview.appendChild(image);}
else if((kind==="audio"||kind==="video")&&blob.size<=PREVIEW_MAX){const media=document.createElement(kind);media.controls=true;media.preload="metadata";media.src=URL.createObjectURL(blob);if(kind==="video")media.playsInline=true;preview.appendChild(media);}
else if(kind==="text"&&blob.size<=10*1024*1024){preview.appendChild(highlightText(dec.decode(await blob.arrayBuffer())));}
else {const p=document.createElement("p");p.textContent="Encrypted binary object. Use Download to decrypt and save it.";preview.appendChild(p);}
setDetails(); show("viewer");
$("download").onclick=()=>{const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=state.metadata.name||"download.bin";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),30000);};
}
async function downloadLarge(key) {
try {
if(window.showSaveFilePicker){
const handle=await window.showSaveFilePicker({suggestedName:state.metadata.name||"download.bin"}); const writable=await handle.createWritable();
try { for(let i=0;i<state.manifest.chunks;i++){await writable.write(await decryptChunk(i,key));progress((i+1)/state.manifest.chunks);} await writable.close(); }
catch(error){await writable.abort();throw error;}
} else {
const parts=await allChunks(key); const blob=new Blob(parts,{type:state.metadata.type||"application/octet-stream"}); const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=state.metadata.name||"download.bin";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),30000);
}
} catch(error) { if(error.name!=="AbortError")show("error"); }
}
function renderDownloadOnly(key) {
const preview=$("preview"); preview.textContent=""; const p=document.createElement("p"); p.textContent="Encrypted binary object. Decryption starts only when you choose Download."; preview.appendChild(p);
setDetails(); show("viewer"); $("download").onclick=()=>downloadLarge(key);
}
async function load() {
try {
if(new URLSearchParams(location.search).get("embed")==="1")document.body.classList.add("embed");
const match=location.pathname.match(/^\/d\/([A-Za-z0-9_-]{32})$/); const fragment=location.hash.match(/^#d1\.([A-Za-z0-9_-]{43})$/); if(!match||!fragment)return;
show("loading"); state.url=location.href; state.objectId=match[1]; state.secret=from64(fragment[1]); history.replaceState(null,"",location.pathname+location.search);
const derivedId=await derive(state.secret,"object-id",24); if(to64(derivedId)!==state.objectId)throw new Error("identifier mismatch");
state.manifest=await fetchJson(`/api/v1/objects/${state.objectId}`); if(state.manifest.v!==1||state.manifest.chunkSize!==CHUNK_SIZE)throw new Error("manifest");
const metaKey=await aes(await derive(state.secret,"metadata-key",32)); const metaCipher=from64(state.manifest.metadata); const metaLength=metaCipher.length-16;
const plain=await decryptRecord(metaKey,metaCipher,iv(2,0),aad(0,state.manifest.profile,derivedId,state.manifest.size,state.manifest.chunks,0xffffffff,metaLength)); state.metadata=JSON.parse(dec.decode(plain));
if(state.metadata.v!==1||state.metadata.size!==state.manifest.size||(state.metadata.sha256!==undefined&&!/^[a-f0-9]{64}$/.test(state.metadata.sha256)))throw new Error("metadata"); progress(1/(state.manifest.chunks+1));
const contentKey=await aes(await derive(state.secret,"content-key",32)); const kind=classify(state.metadata.type,state.metadata.name);
if(((kind==="image"||kind==="audio"||kind==="video")&&state.manifest.size<=PREVIEW_MAX)||(kind==="text"&&state.manifest.size<=10*1024*1024)){state.chunks=await allChunks(contentKey);await renderPreview(state.chunks);progress(1);} else {renderDownloadOnly(contentKey);}
} catch(e) { show("error"); }
}
load();
})();
+71
View File
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#150a24">
<title>{{PRODUCT_NAME}} - Secure File Drop</title>
<link rel="icon" href="/favicon.png">
<link rel="stylesheet" href="/style.css?v=7">
</head>
<body>
<main>
<section class="login-hero" aria-labelledby="neodrop-title">
<div class="login-portrait">
<img src="/avatar-login.jpg" alt="Operator-provided avatar">
<span class="portrait-code" aria-hidden="true">{{DROP_NODE_LABEL}}</span>
</div>
<div class="login-intro">
<p class="login-kicker">PRIVATE RELAY // TLS 1.3</p>
<h1 id="neodrop-title">{{DROPS_TITLE_HTML}}</h1>
<p class="login-deck">{{DROP_TAGLINE}}</p>
<div class="login-status" aria-label="Service capabilities">
<span>ZERO KNOWLEDGE</span>
<span>CLIENT ENCRYPTED</span>
<span>CIPHERTEXT ONLY</span>
</div>
</div>
</section>
<section id="idle" class="terminal" aria-label="NeoDrop service status">
<div class="terminal-bar"><span></span><span></span><span></span><b>drops@relay:~</b></div>
<div class="terminal-body">
<p><span class="prompt">&gt;&gt;</span> neodrop status --public</p>
<p class="terminal-title">Secure File Drop</p>
<p>Encrypted in your browser. The server stores ciphertext and never receives the key in this link.</p>
<dl>
<div><dt>ENCRYPTION</dt><dd>AES-256-GCM</dd></div>
<div><dt>ACCESS</dt><dd>CAPABILITY LINK</dd></div>
<div><dt>STORAGE</dt><dd>CIPHERTEXT ONLY</dd></div>
</dl>
<p class="input-line"><span class="prompt">&gt;&gt;</span> <span class="cursor" aria-hidden="true">_</span><span class="sr-only">Awaiting input</span></p>
</div>
</section>
<section id="loading" class="panel hidden" aria-live="polite">
<p class="status">AUTHENTICATING ENCRYPTED OBJECT</p>
<div class="meter"><span id="meter-fill"></span></div>
</section>
<section id="viewer" class="panel hidden">
<div class="file-heading">
<div><p class="label">DECRYPTED OBJECT</p><h1 id="file-name"></h1></div>
<button id="download">Download</button>
</div>
<dl class="file-details">
<div><dt>SIZE</dt><dd id="file-size"></dd></div>
<div><dt>SHA-256</dt><dd><code id="file-sha256"></code></dd></div>
<div><dt>TYPE</dt><dd id="file-type"></dd></div>
<div><dt>EXPIRES</dt><dd><time id="file-expires"></time></dd></div>
</dl>
<div id="preview" class="preview"></div>
</section>
<section id="error" class="panel error hidden" role="alert">
<p class="status">OBJECT UNAVAILABLE</p>
<p>This link is invalid, expired, deleted, or could not be authenticated.</p>
</section>
</main>
<script src="/app.js?v=3"></script>
</body>
</html>
+613
View File
@@ -0,0 +1,613 @@
#!/usr/bin/python3
import base64
import hashlib
import hmac
import ipaddress
import json
import os
import re
import secrets
import shutil
import sqlite3
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
APP_ROOT = Path("/usr/share/neodrop")
DATA_ROOT = Path("{{NEODROP_DATA_ROOT}}")
STAGING_ROOT = DATA_ROOT / "staging"
OBJECT_ROOT = DATA_ROOT / "objects"
DB_PATH = DATA_ROOT / "neodrop.sqlite3"
MANAGER_TOKEN_HASH_PATH = Path("{{NEODROP_MANAGER_TOKEN_HASH_PATH}}")
LISTEN_ADDRESS = "{{NEODROP_BIND_ADDRESS}}"
LISTEN_PORT = 8080
TRUSTED_PROXY = ipaddress.ip_address("{{TRUSTED_PROXY_ADDRESS}}")
TRUSTED_UPLOADERS = (
ipaddress.ip_network("{{UPLOAD_CIDR_1}}"),
ipaddress.ip_network("{{UPLOAD_CIDR_2}}"),
ipaddress.ip_network("{{UPLOAD_CIDR_3}}"),
)
ALLOWED_ORIGIN = "{{RELAY_ORIGIN}}"
MANAGEMENT_ORIGIN = "{{DROPS_ORIGIN}}"
MANAGEMENT_UPLOADERS = (
ipaddress.ip_network("{{MANAGEMENT_CIDR_1}}"),
ipaddress.ip_network("{{MANAGEMENT_CIDR_2}}"),
ipaddress.ip_network("{{MANAGEMENT_CIDR_3}}"),
)
UPLOAD_ORIGINS = (ALLOWED_ORIGIN, MANAGEMENT_ORIGIN)
CHUNK_SIZE = 4 * 1024 * 1024
PROFILES = {
0: (50 * 1024 * 1024, 30 * 24 * 60 * 60),
1: (1024 * 1024 * 1024, 7 * 24 * 60 * 60),
}
UPLOAD_TTL = 24 * 60 * 60
ID_RE = re.compile(r"^[A-Za-z0-9_-]{32}$")
UPLOAD_RE = re.compile(r"^[A-Za-z0-9_-]{22}$")
UNAVAILABLE = b'{"error":"unavailable"}\n'
DB_LOCK = threading.RLock()
def now():
return int(time.time())
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def token_hash(token):
return hashlib.sha256(token.encode("ascii")).digest()
def valid_manager_token(token):
if not token or len(token) > 128:
return False
try:
expected = MANAGER_TOKEN_HASH_PATH.read_text().strip()
actual = hashlib.sha256(token.encode("ascii", "strict")).hexdigest()
except (OSError, UnicodeEncodeError):
return False
return len(expected) == 64 and hmac.compare_digest(actual, expected)
def valid_object_id(value):
if not ID_RE.fullmatch(value or ""):
return False
try:
return len(base64.urlsafe_b64decode(value + "==")) == 24
except (ValueError, TypeError):
return False
def database():
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA journal_mode=WAL")
return conn
def initialize_database():
DATA_ROOT.mkdir(mode=0o700, parents=True, exist_ok=True)
STAGING_ROOT.mkdir(mode=0o700, exist_ok=True)
OBJECT_ROOT.mkdir(mode=0o700, exist_ok=True)
with database() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS tombstones (
object_id TEXT PRIMARY KEY,
first_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS uploads (
upload_id TEXT PRIMARY KEY,
object_id TEXT UNIQUE NOT NULL,
token_hash BLOB NOT NULL,
profile INTEGER NOT NULL,
size INTEGER NOT NULL,
chunks INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS objects (
object_id TEXT PRIMARY KEY,
token_hash BLOB NOT NULL,
profile INTEGER NOT NULL,
size INTEGER NOT NULL,
chunks INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS objects_expiry ON objects(expires_at);
CREATE INDEX IF NOT EXISTS uploads_created ON uploads(created_at);
"""
)
def object_path(object_id):
return OBJECT_ROOT / object_id[:2] / object_id
def report_invalid(client_ip, source, detail, destination_port=None):
event = {
"ip": str(client_ip),
"source": source,
"detail": detail[:160],
}
if destination_port is not None:
event["destination_port"] = destination_port
payload = json.dumps(event).encode("utf-8")
request = urllib.request.Request(
"{{NEOGUARD_INTERNAL_URL}}",
data=payload,
method="POST",
headers={"Content-Type": "application/json"},
)
try:
urllib.request.urlopen(request, timeout=0.25).close()
except Exception:
pass
def cleanup():
cutoff = now() - UPLOAD_TTL
expired_paths = []
object_paths = []
with DB_LOCK, database() as conn:
for row in conn.execute("SELECT upload_id FROM uploads WHERE created_at <= ?", (cutoff,)):
expired_paths.append(STAGING_ROOT / row["upload_id"])
conn.execute("DELETE FROM uploads WHERE created_at <= ?", (cutoff,))
for row in conn.execute("SELECT object_id FROM objects WHERE expires_at <= ?", (now(),)):
object_paths.append((row["object_id"], object_path(row["object_id"])))
for path in expired_paths:
shutil.rmtree(path, ignore_errors=True)
for object_id, path in object_paths:
try:
shutil.rmtree(path)
except FileNotFoundError:
pass
except OSError:
continue
with DB_LOCK, database() as conn:
conn.execute("DELETE FROM objects WHERE object_id = ? AND expires_at <= ?", (object_id, now()))
def cleanup_loop():
while True:
try:
cleanup()
except Exception:
pass
time.sleep(3600)
class NeoDropHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "NeoDrop"
sys_version = ""
def log_message(self, fmt, *args):
print("%s %s" % (self.address_string(), fmt % args), flush=True)
def client_ip(self):
peer = ipaddress.ip_address(self.client_address[0])
if peer == TRUSTED_PROXY:
forwarded = self.headers.get("X-Real-IP", "")
try:
return ipaddress.ip_address(forwarded)
except ValueError:
return peer
return peer
def destination_port(self):
peer = ipaddress.ip_address(self.client_address[0])
if peer == TRUSTED_PROXY:
value = self.headers.get("X-Forwarded-Port", "")
if value.isdecimal() and 1 <= int(value) <= 65535:
return int(value)
return None
return int(self.server.server_address[1])
def is_trusted_uploader(self):
address = self.client_ip()
return any(address in network for network in TRUSTED_UPLOADERS)
def send_headers(self, status, content_type, length, extra=None):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(length))
self.send_header("Cache-Control", "private, no-store, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("X-Content-Type-Options", "nosniff")
if extra:
for key, value in extra.items():
self.send_header(key, value)
self.end_headers()
def send_json(self, status, value, cors=False):
body = json.dumps(value, separators=(",", ":")).encode("utf-8") + b"\n"
extra = {}
if cors:
origin = self.headers.get("Origin", "")
if origin in UPLOAD_ORIGINS:
extra.update({
"Access-Control-Allow-Origin": origin,
"Vary": "Origin",
})
self.send_headers(status, "application/json", len(body), extra)
if self.command != "HEAD":
self.wfile.write(body)
def unavailable(self, detail="unavailable"):
report_invalid(self.client_ip(), "drops", detail, self.destination_port())
self.send_headers(404, "application/json", len(UNAVAILABLE))
if self.command != "HEAD":
self.wfile.write(UNAVAILABLE)
def forbidden_upload(self, detail):
self.close_connection = True
report_invalid(self.client_ip(), "drops", detail, self.destination_port())
self.send_json(403, {"error": "forbidden"}, cors=True)
def read_json(self, maximum=8192):
try:
length = int(self.headers.get("Content-Length", "-1"))
except ValueError:
return None
if length < 0 or length > maximum:
return None
try:
return json.loads(self.rfile.read(length))
except (json.JSONDecodeError, UnicodeDecodeError):
return None
def upload_authorized(self):
origin = self.headers.get("Origin")
address = self.client_ip()
if origin == ALLOWED_ORIGIN:
source_allowed = any(address in network for network in TRUSTED_UPLOADERS)
elif origin == MANAGEMENT_ORIGIN:
source_allowed = any(address in network for network in MANAGEMENT_UPLOADERS)
else:
source_allowed = False
if not source_allowed:
self.forbidden_upload("external upload")
return False
if origin not in UPLOAD_ORIGINS:
self.forbidden_upload("invalid upload origin")
return False
return True
def bearer(self):
value = self.headers.get("Authorization", "")
if not value.startswith("Bearer "):
return ""
return value[7:]
def manager_authorized(self):
if not self.is_trusted_uploader():
return False
return valid_manager_token(self.bearer())
def lookup_upload(self, upload_id):
token = self.bearer()
if not token or len(token) > 128:
return None
with database() as conn:
row = conn.execute("SELECT * FROM uploads WHERE upload_id = ?", (upload_id,)).fetchone()
if not row or not hmac.compare_digest(row["token_hash"], token_hash(token)):
return None
if row["created_at"] + UPLOAD_TTL <= now():
return None
return row
def do_OPTIONS(self):
if not self.upload_authorized():
return
path = urlsplit(self.path).path
requested = self.headers.get("Access-Control-Request-Method", "")
valid = (
(requested == "POST" and (
path == "/api/v1/uploads"
or re.fullmatch(r"/api/v1/uploads/[A-Za-z0-9_-]{22}/commit", path)
))
or (requested == "PUT" and (
re.fullmatch(r"/api/v1/uploads/[A-Za-z0-9_-]{22}/metadata", path)
or re.fullmatch(r"/api/v1/uploads/[A-Za-z0-9_-]{22}/chunks/\d{1,4}", path)
))
or (requested == "DELETE" and re.fullmatch(
r"/api/v1/objects/[A-Za-z0-9_-]{32}", path
))
)
requested_headers = {
value.strip().lower()
for value in self.headers.get("Access-Control-Request-Headers", "").split(",")
if value.strip()
}
if not valid or not requested_headers.issubset({"authorization", "content-type"}):
return self.unavailable("invalid preflight")
self.send_response(204)
self.send_header("Content-Length", "0")
self.send_header("Access-Control-Allow-Origin", self.headers["Origin"])
self.send_header("Access-Control-Allow-Methods", requested + ", OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
self.send_header("Access-Control-Max-Age", "600")
self.send_header("Vary", "Origin")
self.end_headers()
def do_POST(self):
path = urlsplit(self.path).path
if path == "/api/v1/uploads":
return self.create_upload()
match = re.fullmatch(r"/api/v1/uploads/([A-Za-z0-9_-]{22})/commit", path)
if match:
return self.commit_upload(match.group(1))
self.unavailable("invalid POST path")
def create_upload(self):
if not self.upload_authorized():
return
if self.headers.get("Content-Type") != "application/json":
self.close_connection = True
return self.send_json(415, {"error": "invalid request"}, cors=True)
data = self.read_json()
if not isinstance(data, dict):
return self.send_json(400, {"error": "invalid request"}, cors=True)
try:
object_id = data["id"]
profile = int(data["profile"])
size = int(data["size"])
chunk_size = int(data["chunkSize"])
chunks = int(data["chunks"])
except (KeyError, TypeError, ValueError):
return self.send_json(400, {"error": "invalid request"}, cors=True)
if not valid_object_id(object_id) or profile not in PROFILES:
return self.send_json(400, {"error": "invalid request"}, cors=True)
maximum, _ = PROFILES[profile]
expected_chunks = (size + CHUNK_SIZE - 1) // CHUNK_SIZE if size else 0
if size < 0 or size > maximum or chunk_size != CHUNK_SIZE or chunks != expected_chunks:
return self.send_json(400, {"error": "invalid request"}, cors=True)
upload_id = b64url(secrets.token_bytes(16))
token = b64url(secrets.token_bytes(32))
created = now()
with DB_LOCK:
try:
with database() as conn:
conn.execute("INSERT INTO tombstones VALUES (?, ?)", (object_id, created))
conn.execute(
"INSERT INTO uploads VALUES (?, ?, ?, ?, ?, ?, ?)",
(upload_id, object_id, token_hash(token), profile, size, chunks, created),
)
stage = STAGING_ROOT / upload_id
(stage / "chunks").mkdir(mode=0o700, parents=True)
except (sqlite3.IntegrityError, FileExistsError):
return self.send_json(409, {"error": "identifier unavailable"}, cors=True)
self.send_json(201, {
"v": 1,
"uploadId": upload_id,
"uploadToken": token,
"sessionExpiresAt": created + UPLOAD_TTL,
}, cors=True)
def do_PUT(self):
path = urlsplit(self.path).path
metadata = re.fullmatch(r"/api/v1/uploads/([A-Za-z0-9_-]{22})/metadata", path)
chunk = re.fullmatch(r"/api/v1/uploads/([A-Za-z0-9_-]{22})/chunks/(\d{1,4})", path)
if metadata:
return self.put_record(metadata.group(1), None)
if chunk:
return self.put_record(chunk.group(1), int(chunk.group(2)))
self.unavailable("invalid PUT path")
def put_record(self, upload_id, index):
if not self.upload_authorized():
return
if self.headers.get("Content-Type") != "application/octet-stream":
self.close_connection = True
return self.send_json(415, {"error": "invalid record"}, cors=True)
row = self.lookup_upload(upload_id)
if not row:
return self.forbidden_upload("invalid upload authorization")
try:
length = int(self.headers.get("Content-Length", "-1"))
except ValueError:
length = -1
if index is None:
target = STAGING_ROOT / upload_id / "metadata.bin"
valid_length = 16 <= length <= 4112
else:
if index < 0 or index >= row["chunks"]:
valid_length = False
expected = -1
else:
remaining = row["size"] - index * CHUNK_SIZE
expected = min(CHUNK_SIZE, remaining) + 16
valid_length = length == expected
target = STAGING_ROOT / upload_id / "chunks" / ("%08d.bin" % index)
if not valid_length:
return self.send_json(400, {"error": "invalid record"}, cors=True)
body = self.rfile.read(length)
if len(body) != length:
return self.send_json(400, {"error": "incomplete record"}, cors=True)
if target.exists():
if target.read_bytes() != body:
return self.send_json(409, {"error": "immutable record"}, cors=True)
else:
temporary = target.with_name(target.name + "." + secrets.token_hex(8) + ".tmp")
with open(temporary, "xb") as handle:
handle.write(body)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, target)
self.send_json(200, {"ok": True}, cors=True)
def commit_upload(self, upload_id):
if not self.upload_authorized():
return
if self.headers.get("Content-Type") != "application/json":
self.close_connection = True
return self.send_json(415, {"error": "invalid request"}, cors=True)
data = self.read_json(maximum=64)
if data != {}:
return self.send_json(400, {"error": "invalid request"}, cors=True)
row = self.lookup_upload(upload_id)
if not row:
return self.forbidden_upload("invalid commit authorization")
stage = STAGING_ROOT / upload_id
metadata = stage / "metadata.bin"
if not metadata.is_file() or not 16 <= metadata.stat().st_size <= 4112:
return self.send_json(409, {"error": "upload incomplete"}, cors=True)
for index in range(row["chunks"]):
path = stage / "chunks" / ("%08d.bin" % index)
remaining = row["size"] - index * CHUNK_SIZE
if not path.is_file() or path.stat().st_size != min(CHUNK_SIZE, remaining) + 16:
return self.send_json(409, {"error": "upload incomplete"}, cors=True)
created = now()
expires = created + PROFILES[row["profile"]][1]
destination = object_path(row["object_id"])
destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
moved = False
with DB_LOCK:
try:
os.rename(stage, destination)
moved = True
with database() as conn:
conn.execute(
"INSERT INTO objects VALUES (?, ?, ?, ?, ?, ?, ?)",
(row["object_id"], row["token_hash"], row["profile"], row["size"],
row["chunks"], created, expires),
)
conn.execute("DELETE FROM uploads WHERE upload_id = ?", (upload_id,))
except Exception:
if moved and destination.exists() and not stage.exists():
os.rename(destination, stage)
raise
self.send_json(200, {"v": 1, "expiresAt": expires}, cors=True)
def do_DELETE(self):
path = urlsplit(self.path).path
match = re.fullmatch(r"/api/v1/objects/([A-Za-z0-9_-]{32})", path)
if not match or not self.upload_authorized():
return
if self.headers.get("Content-Length", "0") != "0":
self.close_connection = True
return self.send_json(400, {"error": "invalid request"}, cors=True)
object_id = match.group(1)
token = self.bearer()
target = object_path(object_id)
with DB_LOCK, database() as conn:
row = conn.execute("SELECT token_hash FROM objects WHERE object_id = ?", (object_id,)).fetchone()
if not row or not token or not hmac.compare_digest(row["token_hash"], token_hash(token)):
return self.forbidden_upload("invalid delete authorization")
if target.exists():
shutil.rmtree(target)
conn.execute("DELETE FROM objects WHERE object_id = ?", (object_id,))
self.send_json(200, {"deleted": True}, cors=True)
def do_HEAD(self):
return self.do_GET()
def do_GET(self):
path = urlsplit(self.path).path
if path == "/api/v1/manager/objects":
if not self.manager_authorized():
return self.response_forbidden()
return self.manager_inventory()
manifest = re.fullmatch(r"/api/v1/objects/([A-Za-z0-9_-]{32})", path)
chunk = re.fullmatch(r"/api/v1/objects/([A-Za-z0-9_-]{32})/chunks/(\d{1,4})", path)
if manifest:
return self.get_manifest(manifest.group(1))
if chunk:
return self.get_chunk(chunk.group(1), int(chunk.group(2)))
if path == "/" or re.fullmatch(r"/d/[A-Za-z0-9_-]{32}", path):
return self.static_file("index.html")
if path in ("/app.js", "/style.css", "/favicon.png", "/avatar-login.jpg"):
return self.static_file(path[1:])
self.unavailable("invalid GET path")
def response_forbidden(self):
self.send_json(403, {"error": "forbidden"})
def manager_inventory(self):
with database() as conn:
rows = list(conn.execute(
"SELECT object_id,profile,size,chunks,created_at,expires_at "
"FROM objects ORDER BY created_at DESC LIMIT 10000"
))
self.send_json(200, {"objects": [
{
"id": row["object_id"],
"profile": row["profile"],
"size": row["size"],
"chunks": row["chunks"],
"createdAt": row["created_at"],
"expiresAt": row["expires_at"],
}
for row in rows
]})
def active_object(self, object_id):
if not valid_object_id(object_id):
return None
with database() as conn:
return conn.execute(
"SELECT * FROM objects WHERE object_id = ? AND expires_at > ?",
(object_id, now()),
).fetchone()
def get_manifest(self, object_id):
row = self.active_object(object_id)
metadata = object_path(object_id) / "metadata.bin"
if not row or not metadata.is_file():
return self.unavailable("unknown object")
self.send_json(200, {
"v": 1,
"profile": row["profile"],
"size": row["size"],
"chunkSize": CHUNK_SIZE,
"chunks": row["chunks"],
"metadata": b64url(metadata.read_bytes()),
"expiresAt": row["expires_at"],
})
def get_chunk(self, object_id, index):
row = self.active_object(object_id)
if not row or index < 0 or index >= row["chunks"]:
return self.unavailable("unknown object chunk")
path = object_path(object_id) / "chunks" / ("%08d.bin" % index)
if not path.is_file():
return self.unavailable("unknown object chunk")
length = path.stat().st_size
self.send_headers(200, "application/octet-stream", length)
if self.command != "HEAD":
with open(path, "rb") as handle:
shutil.copyfileobj(handle, self.wfile, length=64 * 1024)
def static_file(self, name):
path = APP_ROOT / name
if not path.is_file():
return self.unavailable("missing application asset")
types = {"index.html": "text/html; charset=utf-8", "app.js": "application/javascript",
"style.css": "text/css", "favicon.png": "image/png",
"avatar-login.jpg": "image/jpeg"}
body = path.read_bytes()
cached = name in ("favicon.png", "avatar-login.jpg")
extra = {"Cache-Control": "public, max-age=604800"} if cached else {"Cache-Control": "no-cache"}
self.send_headers(200, types[name], len(body), extra)
if self.command != "HEAD":
self.wfile.write(body)
class NeoDropServer(ThreadingHTTPServer):
daemon_threads = True
request_queue_size = 256
if __name__ == "__main__":
os.umask(0o077)
initialize_database()
cleanup()
threading.Thread(target=cleanup_loop, daemon=True).start()
server = NeoDropServer((LISTEN_ADDRESS, LISTEN_PORT), NeoDropHandler)
server.serve_forever()
+79
View File
@@ -0,0 +1,79 @@
:root {
color-scheme: dark;
--void:#07030d; --panel:#150a24; --raised:#211034; --violet:#9d5cff;
--purple:#6f2cff; --pink:#ff65c8; --cyan:#55e7ff; --acid:#b9ff4f;
--text:#f1eaff; --muted:#aa9abd; --line:rgba(157,92,255,.38);
}
* { box-sizing:border-box; }
html,body { min-height:100%; margin:0; }
body {
color:var(--text); background-color:var(--void);
background-image:linear-gradient(rgba(157,92,255,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(157,92,255,.045) 1px,transparent 1px),radial-gradient(circle at 80% 0,rgba(111,44,255,.24),transparent 38%);
background-size:32px 32px,32px 32px,auto;
font-family:"OpenDyslexic","Atkinson Hyperlegible",system-ui,sans-serif;
}
main { width:min(1120px,calc(100% - 40px)); margin:0 auto; padding:54px 0 80px; }
.label,.status { color:var(--acid); font:700 10px/1.4 ui-monospace,monospace; letter-spacing:.18em; }
.login-hero { display:grid; grid-template-columns:minmax(250px,.78fr) minmax(360px,1.35fr); min-height:390px; margin-bottom:28px; overflow:hidden; border-top:1px solid var(--violet); border-bottom:1px solid var(--line); background:linear-gradient(115deg,rgba(22,9,40,.96),rgba(9,3,17,.78)),linear-gradient(90deg,transparent 49.8%,rgba(85,231,255,.12) 50%,transparent 50.2%); box-shadow:20px 20px 0 rgba(72,24,122,.12); }
.login-portrait { position:relative; min-height:390px; overflow:hidden; border-right:1px solid var(--line); }
.login-portrait::after { position:absolute; inset:0; content:""; pointer-events:none; background:repeating-linear-gradient(0deg,transparent 0 4px,rgba(7,3,13,.16) 5px),linear-gradient(180deg,transparent 55%,rgba(7,3,13,.8)); }
.login-portrait img { width:100%; height:100%; min-height:390px; object-fit:cover; object-position:50% 27%; filter:saturate(1.12) contrast(1.04); }
.portrait-code { position:absolute; z-index:1; right:14px; bottom:12px; color:var(--acid); font:700 10px/1 ui-monospace,monospace; letter-spacing:.18em; }
.login-intro { position:relative; align-self:center; padding:48px clamp(32px,7vw,86px); }
.login-intro::before { position:absolute; top:28px; left:0; width:46px; height:3px; content:""; background:var(--pink); box-shadow:19px 8px 0 var(--cyan); }
.login-kicker { margin:0 0 20px; color:var(--acid); font:700 11px/1.4 ui-monospace,monospace; letter-spacing:.2em; }
.login-intro h1 { margin:0; color:var(--text); font-family:"Arial Black",Impact,sans-serif; font-size:clamp(42px,6.4vw,76px); font-weight:900; line-height:.78; letter-spacing:-.07em; text-transform:uppercase; text-shadow:4px 4px 0 rgba(111,44,255,.65); }
.login-intro h1 span { display:block; margin-left:.32em; color:transparent; -webkit-text-stroke:1px var(--pink); text-shadow:none; text-transform:lowercase; }
.login-deck { max-width:520px; margin:30px 0 25px; color:#cfc3dd; font-size:15px; line-height:1.75; }
.login-status { display:flex; flex-wrap:wrap; gap:8px; }
.login-status span { padding:7px 9px 6px; border:1px solid rgba(85,231,255,.35); color:var(--cyan); background:rgba(85,231,255,.04); font:700 9px/1 ui-monospace,monospace; letter-spacing:.12em; }
.terminal { margin:28px clamp(20px,7vw,82px) 52px; border:1px solid rgba(157,92,255,.5); background:rgba(5,2,10,.94); box-shadow:12px 12px 0 rgba(111,44,255,.12); font:700 clamp(12px,1.8vw,16px)/1.75 ui-monospace,monospace; }
.terminal-bar { display:flex; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; letter-spacing:.08em; }
.terminal-bar span { width:8px; height:8px; border-radius:50%; background:var(--pink); }
.terminal-bar span:nth-child(2) { background:var(--violet); }
.terminal-bar span:nth-child(3) { background:var(--acid); }
.terminal-bar b { margin-left:6px; font-weight:700; }
.terminal-body { padding:clamp(20px,4vw,34px); }
.terminal-body p { margin:0 0 8px; color:#c8c2cf; }
.terminal-title { margin:20px 0 8px!important; color:var(--text)!important; font-size:clamp(20px,3vw,32px); text-transform:uppercase; }
.terminal .prompt { color:var(--violet); }
.terminal dl { margin:24px 0 0; }
.terminal dt { color:var(--muted); }
.terminal dd { color:var(--cyan); }
.input-line { margin-top:18px!important; }
.cursor { color:var(--acid); animation:blink 1s steps(1,end) infinite; }
.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); }
.panel { margin-top:24px; padding:clamp(24px,5vw,48px); border-top:1px solid var(--pink); border-bottom:1px solid var(--line); background:linear-gradient(120deg,rgba(34,14,57,.97),rgba(10,4,18,.97)); box-shadow:16px 16px rgba(111,44,255,.1); }
.panel h1 { margin:.2em 0 .6em; font-size:clamp(26px,5vw,48px); }
dl { margin:28px 0 0; border-top:1px solid var(--line); }
dl div { display:grid; grid-template-columns:140px 1fr; gap:20px; padding:12px 0; border-bottom:1px solid rgba(157,92,255,.16); }
dt { color:var(--muted); font:700 9px/1.5 ui-monospace,monospace; letter-spacing:.14em; }
dd { margin:0; color:var(--cyan); font:700 11px/1.5 ui-monospace,monospace; }
.hidden { display:none!important; }
.meter { height:6px; margin-top:22px; background:#090310; }
.meter span { display:block; width:0; height:100%; background:linear-gradient(90deg,var(--pink),var(--violet),var(--cyan)); transition:width .2s; }
.file-heading { display:flex; align-items:end; justify-content:space-between; gap:24px; }
button { padding:12px 18px; border:0; color:#09020f; background:linear-gradient(90deg,var(--pink),var(--violet)); box-shadow:5px 5px rgba(85,231,255,.14); font-weight:800; text-transform:uppercase; cursor:pointer; }
button:hover { background:linear-gradient(90deg,var(--acid),var(--cyan)); }
.file-details { margin-top:20px; }
.file-details dd { overflow-wrap:anywhere; }
.file-details code { color:var(--acid); font:inherit; letter-spacing:.025em; }
.preview { margin-top:28px; }
.preview img { display:block; max-width:100%; max-height:72vh; margin:auto; object-fit:contain; }
.preview audio { display:block; width:100%; }
.preview video { display:block; width:100%; max-height:72vh; margin:auto; background:#000; object-fit:contain; }
.text-preview { overflow:auto; max-height:68vh; padding:22px; border:1px solid var(--line); color:#d9cee7; background:#08020e; font:13px/1.65 ui-monospace,monospace; white-space:pre-wrap; word-break:break-word; }
.text-preview .heading { color:var(--pink); font-weight:800; }
.text-preview .quote { color:var(--cyan); }
.text-preview .list { color:var(--acid); }
.text-preview .code { color:#c69cff; }
.error { border-top-color:#ff487f; }
.error .status { color:#ff78b4; }
body.embed main { width:100%; padding:0; }
body.embed .login-hero,body.embed #idle { display:none; }
body.embed .panel { margin:0; padding:0; box-shadow:none; border:0; background:transparent; }
body.embed .file-heading,body.embed .file-details { display:none; }
body.embed .preview { margin:0; }
@keyframes blink { 50% { opacity:0; } }
@media(max-width:760px){main{width:min(100% - 24px,560px);padding-top:28px}.login-hero{grid-template-columns:1fr;box-shadow:10px 10px 0 rgba(72,24,122,.12)}.login-portrait,.login-portrait img{min-height:300px;max-height:380px}.login-portrait{border-right:0;border-bottom:1px solid var(--line)}.login-intro{padding:42px 24px 36px}.login-intro h1{font-size:clamp(40px,13vw,68px)}.terminal{margin-left:14px;margin-right:14px}.terminal-body{overflow-wrap:anywhere}.file-heading{display:block}.file-heading button{margin-top:12px}dl div{grid-template-columns:100px 1fr}.panel{box-shadow:8px 8px rgba(111,44,255,.1)}}
@media(prefers-reduced-motion:reduce){.cursor{animation:none}}
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/python3
import hashlib
import hmac
import json
import os
import secrets
import socket
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
SOCKET_PATH = "{{NEOGUARD_CONTROL_SOCKET_PATH}}"
BASE = "{{DROPS_ORIGIN}}"
KEY_PATH = Path("{{NEOGUARD_KEY_PATH}}")
CLIENTS = set()
CLIENTS_LOCK = threading.Lock()
LAST_EVENT = 0
def signed_request(path, value=None):
body = b"" if value is None else json.dumps(value, separators=(",", ":")).encode()
method = "GET" if value is None else "POST"
for attempt in range(3):
timestamp = int(time.time())
nonce = secrets.token_hex(16)
canonical = "%d\n%s\n%s\n%s\n%s" % (
timestamp, nonce, method, path, hashlib.sha256(body).hexdigest())
signature = hmac.new(KEY_PATH.read_bytes(), canonical.encode(), hashlib.sha256).hexdigest()
request = urllib.request.Request(BASE + path, data=None if value is None else body, method=method, headers={
"Content-Type": "application/json", "X-Neo-Timestamp": str(timestamp),
"X-Neo-Nonce": nonce, "X-Neo-Signature": signature,
})
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code not in (502, 503, 504) or attempt == 2:
raise
time.sleep(0.25 * (attempt + 1))
def broadcast(message):
payload = (message.replace("\x00", "") + "\n").encode()
dead = []
with CLIENTS_LOCK:
for client in CLIENTS:
try:
client.sendall(payload)
except OSError:
dead.append(client)
for client in dead:
CLIENTS.discard(client)
client.close()
def attempt_payload(fields):
if len(fields) == 3:
address, detail = fields[1], fields[2]
port = None
elif len(fields) == 4:
address, port_text, detail = fields[1], fields[2], fields[3]
if not port_text.isdecimal() or not 1 <= int(port_text) <= 65535:
raise ValueError("invalid destination port")
port = int(port_text)
else:
raise ValueError("invalid attempt event")
payload = {"type": "attempt", "ip": address, "source": "relay", "detail": detail}
if port is not None:
payload["destination_port"] = port
return payload
def handle_client(client):
buffer = b""
try:
client.sendall(b"STATUS\tNeoGuard agent connected\n")
while True:
data = client.recv(4096)
if not data:
break
buffer += data
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
fields = line.decode("utf-8", "replace").split("\t", 3)
try:
if fields[0] == "ATTEMPT":
signed_request("/api/v1/neoguard/event", attempt_payload(fields))
elif fields[0] == "COMMAND" and len(fields) >= 2:
result = signed_request("/api/v1/neoguard/event", {"type":"command","command":fields[1]})
client.sendall(("RESPONSE\t" + result.get("response", "No response") + "\n").encode())
except Exception as error:
client.sendall(("ERROR\t" + str(error)[:300] + "\n").encode())
finally:
with CLIENTS_LOCK:
CLIENTS.discard(client)
client.close()
def socket_server():
try:
os.unlink(SOCKET_PATH)
except FileNotFoundError:
pass
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(SOCKET_PATH)
os.chmod(SOCKET_PATH, 0o660)
server.listen(4)
while True:
client, _ = server.accept()
with CLIENTS_LOCK:
CLIENTS.add(client)
threading.Thread(target=handle_client, args=(client,), daemon=True).start()
def poll_events():
global LAST_EVENT
while True:
try:
result = signed_request("/api/v1/neoguard/poll?after=%d" % LAST_EVENT)
for event in result.get("events", []):
LAST_EVENT = max(LAST_EVENT, int(event["id"]))
broadcast("EVENT\t%s\t%s" % (event["level"], event["message"]))
except Exception:
pass
time.sleep(5)
if __name__ == "__main__":
threading.Thread(target=poll_events, daemon=True).start()
socket_server()
+186
View File
@@ -0,0 +1,186 @@
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <weechat/weechat-plugin.h>
WEECHAT_PLUGIN_NAME("neoguard")
WEECHAT_PLUGIN_DESCRIPTION("NeoGuard abuse-control buffer")
WEECHAT_PLUGIN_AUTHOR("{{PRODUCT_NAME}}")
WEECHAT_PLUGIN_VERSION("1.1")
WEECHAT_PLUGIN_LICENSE("GPL3")
struct t_weechat_plugin *weechat_plugin;
static struct t_gui_buffer *guard_buffer;
static struct t_hook *fd_hook;
static int agent_fd = -1;
static char receive_buffer[16384];
static size_t receive_length;
static void print_line(const char *line)
{
const char *color = "chat";
if (strncmp(line, "EVENT\tBAN", 9) == 0 || strncmp(line, "EVENT\tPERM", 10) == 0)
color = "lightred";
else if (strncmp(line, "EVENT\tWARN", 10) == 0)
color = "yellow";
else if (strncmp(line, "ERROR\t", 6) == 0)
color = "red";
weechat_printf(guard_buffer, "%s%s", weechat_color(color), line);
}
static void disconnect_agent(void)
{
if (fd_hook) { weechat_unhook(fd_hook); fd_hook = NULL; }
if (agent_fd >= 0) close(agent_fd);
agent_fd = -1;
receive_length = 0;
}
static int agent_read_cb(const void *pointer, void *data, int fd)
{
(void)pointer; (void)data; (void)fd;
ssize_t count = read(agent_fd, receive_buffer + receive_length,
sizeof(receive_buffer) - receive_length - 1);
if (count <= 0) { disconnect_agent(); return WEECHAT_RC_OK; }
receive_length += (size_t)count;
receive_buffer[receive_length] = '\0';
char *start = receive_buffer;
char *newline;
while ((newline = strchr(start, '\n'))) {
*newline = '\0';
print_line(start);
start = newline + 1;
}
receive_length = strlen(start);
memmove(receive_buffer, start, receive_length);
return WEECHAT_RC_OK;
}
static int connect_agent(void)
{
struct sockaddr_un address;
if (agent_fd >= 0) return 1;
agent_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (agent_fd < 0) return 0;
memset(&address, 0, sizeof(address));
address.sun_family = AF_UNIX;
snprintf(address.sun_path, sizeof(address.sun_path), "%s", "{{NEOGUARD_CONTROL_SOCKET_PATH}}");
if (connect(agent_fd, (struct sockaddr *)&address, sizeof(address)) != 0) {
close(agent_fd); agent_fd = -1; return 0;
}
fd_hook = weechat_hook_fd(agent_fd, 1, 0, 0, &agent_read_cb, NULL, NULL);
return 1;
}
static void send_agent(const char *kind, const char *first, const char *second)
{
char message[4096];
if (!connect_agent()) return;
snprintf(message, sizeof(message), "%s\t%s%s%s\n", kind, first,
second ? "\t" : "", second ? second : "");
if (write(agent_fd, message, strlen(message)) < 0) disconnect_agent();
}
static void send_attempt(const char *address, int port, const char *detail)
{
char message[4096];
if (!connect_agent()) return;
if (port >= 1 && port <= 65535)
snprintf(message, sizeof(message), "ATTEMPT\t%s\t%d\t%s\n", address, port, detail);
else
snprintf(message, sizeof(message), "ATTEMPT\t%s\t%s\n", address, detail);
if (write(agent_fd, message, strlen(message)) < 0) disconnect_agent();
}
static int reconnect_cb(const void *pointer, void *data, int remaining_calls)
{
(void)pointer; (void)data; (void)remaining_calls;
connect_agent();
return WEECHAT_RC_OK;
}
static int input_cb(const void *pointer, void *data, struct t_gui_buffer *buffer, const char *input)
{
(void)pointer; (void)data; (void)buffer;
if (input && input[0]) send_agent("COMMAND", input, NULL);
return WEECHAT_RC_OK;
}
static int close_cb(const void *pointer, void *data, struct t_gui_buffer *buffer)
{
(void)pointer; (void)data; (void)buffer;
guard_buffer = NULL;
return WEECHAT_RC_OK;
}
static int ensure_buffer(void)
{
if (!guard_buffer)
guard_buffer = weechat_buffer_search("neoguard", "control");
if (!guard_buffer)
guard_buffer = weechat_buffer_new("control", &input_cb, NULL, NULL, &close_cb, NULL, NULL);
if (!guard_buffer) return 0;
weechat_buffer_set_pointer(guard_buffer, "input_callback", &input_cb);
weechat_buffer_set_pointer(guard_buffer, "close_callback", &close_cb);
weechat_buffer_set(guard_buffer, "short_name", "NeoGuard");
weechat_buffer_set(guard_buffer, "title", "NeoGuard | status/list | show/add/remove/extend IP | action IP redirect|message");
weechat_buffer_set(guard_buffer, "hidden", "0");
weechat_buffer_set(guard_buffer, "notify", "3");
weechat_buffer_set(guard_buffer, "localvar_set_no_log", "1");
weechat_buffer_set(guard_buffer, "localvar_set_pinned", "true");
weechat_buffer_set(guard_buffer, "localvar_set_type", "other");
return 1;
}
static int command_cb(const void *pointer, void *data, struct t_gui_buffer *buffer,
int argc, char **argv, char **argv_eol)
{
(void)pointer; (void)data; (void)buffer; (void)argc; (void)argv;
if (ensure_buffer()) {
weechat_buffer_set(guard_buffer, "display", "1");
if (argv_eol[1] && argv_eol[1][0]) send_agent("COMMAND", argv_eol[1], NULL);
}
return WEECHAT_RC_OK;
}
static int relay_failure_cb(const void *pointer, void *data, const char *signal,
const char *type_data, void *signal_data)
{
(void)pointer; (void)data; (void)signal; (void)type_data;
struct t_infolist *list = weechat_infolist_get("relay", signal_data, NULL);
if (list && weechat_infolist_next(list)) {
const char *real_ip = weechat_infolist_string(list, "real_ip");
const char *address = weechat_infolist_string(list, "address");
int port = weechat_infolist_integer(list, "server_port");
send_attempt((real_ip && real_ip[0]) ? real_ip : address, port,
"relay authentication failed");
}
if (list) weechat_infolist_free(list);
return WEECHAT_RC_OK;
}
int weechat_plugin_init(struct t_weechat_plugin *plugin, int argc, char *argv[])
{
(void)argc; (void)argv;
weechat_plugin = plugin;
weechat_hook_command("neoguard", "open/control NeoGuard",
"list || status || show <ip> || add <ip> [<duration>] || remove <ip> || extend <ip> <duration> || action <ip> redirect <https-url> || action <ip> message <text>",
"Inspect and control NeoGuard bans and per-address actions",
"list|status|show|add|remove|extend|action",
&command_cb, NULL, NULL);
weechat_hook_signal("relay_client_auth_failed", &relay_failure_cb, NULL, NULL);
weechat_hook_timer(5000, 0, 0, &reconnect_cb, NULL, NULL);
command_cb(NULL, NULL, NULL, 0, NULL, (char *[]){"", "status"});
return WEECHAT_RC_OK;
}
int weechat_plugin_end(struct t_weechat_plugin *plugin)
{
(void)plugin;
disconnect_agent();
return WEECHAT_RC_OK;
}
+455
View File
@@ -0,0 +1,455 @@
#!/usr/bin/python3
import hashlib
import hmac
import html
import ipaddress
import json
import os
import re
import secrets
import shlex
import socket
import sqlite3
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
DATA_ROOT = Path("{{NEOGUARD_DATA_ROOT}}")
DB_PATH = DATA_ROOT / "neoguard.sqlite3"
KEY_PATH = Path("{{NEOGUARD_KEY_PATH}}")
LISTEN_ADDRESS = "{{NEOGUARD_BIND_ADDRESS}}"
LISTEN_PORT = 8081
TRUSTED_PROXY = ipaddress.ip_address("{{TRUSTED_PROXY_ADDRESS}}")
EXEMPT_NETWORKS = (
ipaddress.ip_network("{{UPLOAD_CIDR_1}}"),
ipaddress.ip_network("{{UPLOAD_CIDR_2}}"),
ipaddress.ip_network("{{UPLOAD_CIDR_3}}"),
)
WINDOW = 600
MAX_FAILURES = 21
BAN_SECONDS = 24 * 60 * 60
DEFAULT_REDIRECT = "{{BLOCKED_REDIRECT_URL}}"
DB_LOCK = threading.RLock()
NONCES = {}
URL_RE = re.compile(r"^https://[^\s\\\"'{};$]{1,2039}$")
def now():
return int(time.time())
def database():
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=FULL")
return conn
def initialize_database():
DATA_ROOT.mkdir(mode=0o700, parents=True, exist_ok=True)
with database() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
occurred_at INTEGER NOT NULL,
address TEXT NOT NULL,
source TEXT NOT NULL,
detail TEXT NOT NULL,
destination_port INTEGER
);
CREATE INDEX IF NOT EXISTS attempts_window ON attempts(address, occurred_at);
CREATE TABLE IF NOT EXISTS offenses (
address TEXT PRIMARY KEY,
count INTEGER NOT NULL,
last_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS bans (
address TEXT PRIMARY KEY,
started_at INTEGER NOT NULL,
expires_at INTEGER,
offense INTEGER NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT NOT NULL,
reason TEXT NOT NULL,
rdns TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
occurred_at INTEGER NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
destination_port INTEGER
);
"""
)
for table in ("attempts", "events"):
columns = {row["name"] for row in conn.execute("PRAGMA table_info(%s)" % table)}
if "destination_port" not in columns:
conn.execute("ALTER TABLE %s ADD COLUMN destination_port INTEGER" % table)
def normalize_address(value):
address = ipaddress.ip_address(value)
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
address = address.ipv4_mapped
return address
def exempt(address):
return any(address in network for network in EXEMPT_NETWORKS)
def add_event(conn, level, message, destination_port=None):
conn.execute("INSERT INTO events(occurred_at, level, message, destination_port) VALUES (?, ?, ?, ?)",
(now(), level, message[:2000], destination_port))
def confirmed_rdns(address):
try:
hostname = socket.gethostbyaddr(str(address))[0].lower().rstrip(".")
if not hostname or len(hostname) > 253 or any(ord(c) < 33 for c in hostname):
return ""
results = socket.getaddrinfo(hostname, None)
returned = {normalize_address(item[4][0]) for item in results}
return hostname if address in returned else ""
except (OSError, ValueError):
return ""
def resolve_ban(address_text):
address = normalize_address(address_text)
hostname = confirmed_rdns(address)
with DB_LOCK, database() as conn:
conn.execute("UPDATE bans SET rdns = ? WHERE address = ?", (hostname, str(address)))
if hostname:
add_event(conn, "INFO", "[RDNS] ip=%s rdns=%s" % (address, hostname))
def expire_bans(conn):
timestamp = now()
rows = list(conn.execute("SELECT address FROM bans WHERE active = 1 AND expires_at IS NOT NULL AND expires_at <= ?", (timestamp,)))
for row in rows:
conn.execute("UPDATE bans SET active = 0 WHERE address = ?", (row["address"],))
add_event(conn, "INFO", "[UNBAN] ip=%s reason=expired" % row["address"])
def normalize_destination_port(value):
if value is None or value == "":
return None
if isinstance(value, bool):
raise ValueError("invalid destination port")
if isinstance(value, int):
port = value
elif isinstance(value, str) and value.isdecimal():
port = int(value)
else:
raise ValueError("invalid destination port")
if not 1 <= port <= 65535:
raise ValueError("invalid destination port")
return port
def record_attempt(address_value, source, detail, destination_port=None):
destination_port = normalize_destination_port(destination_port)
try:
address = normalize_address(address_value)
except ValueError:
return
if exempt(address):
return
address_text = str(address)
timestamp = now()
created_ban = False
with DB_LOCK, database() as conn:
expire_bans(conn)
active = conn.execute("SELECT 1 FROM bans WHERE address = ? AND active = 1", (address_text,)).fetchone()
conn.execute("DELETE FROM attempts WHERE occurred_at < ?", (timestamp - WINDOW,))
if active:
return
conn.execute("INSERT INTO attempts(occurred_at,address,source,detail,destination_port) VALUES (?,?,?,?,?)",
(timestamp, address_text, source[:32], detail[:300], destination_port))
count = conn.execute("SELECT COUNT(*) AS n FROM attempts WHERE address = ? AND occurred_at >= ?",
(address_text, timestamp - WINDOW)).fetchone()["n"]
if count in (1, 10, 20):
port_text = " port=%d" % destination_port if destination_port else ""
add_event(conn, "WARN", "[WARN] ip=%s%s rdns=pending count=%d/%d window=10m source=%s" %
(address_text, port_text, count, MAX_FAILURES, source[:32]), destination_port)
if count >= MAX_FAILURES:
offense_row = conn.execute("SELECT count FROM offenses WHERE address = ?", (address_text,)).fetchone()
offense = (offense_row["count"] if offense_row else 0) + 1
conn.execute("INSERT INTO offenses VALUES (?,?,?) ON CONFLICT(address) DO UPDATE SET count=excluded.count,last_at=excluded.last_at",
(address_text, offense, timestamp))
expires = None if offense >= 3 else timestamp + BAN_SECONDS
conn.execute("""
INSERT INTO bans(address,started_at,expires_at,offense,action,target,message,reason,rdns,active)
VALUES (?,?,?,?,?,?,?,?,?,1)
ON CONFLICT(address) DO UPDATE SET started_at=excluded.started_at,expires_at=excluded.expires_at,
offense=excluded.offense,action=excluded.action,target=excluded.target,message=excluded.message,
reason=excluded.reason,rdns='',active=1
""", (address_text, timestamp, expires, offense, "redirect", DEFAULT_REDIRECT,
"Access denied by NeoGuard.", "automatic threshold", ""))
conn.execute("DELETE FROM attempts WHERE address = ?", (address_text,))
level = "PERM" if expires is None else "BAN"
until = "permanent" if expires is None else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(expires))
port_text = " port=%d" % destination_port if destination_port else ""
add_event(conn, level, "[%s] ip=%s%s rdns=pending offense=%d failures=%d until=%s action=redirect" %
(level, address_text, port_text, offense, count, until), destination_port)
created_ban = True
if created_ban:
threading.Thread(target=resolve_ban, args=(address_text,), daemon=True).start()
def active_ban(address_value):
try:
address = normalize_address(address_value)
except ValueError:
return None
if exempt(address):
return None
with DB_LOCK, database() as conn:
expire_bans(conn)
return conn.execute("SELECT * FROM bans WHERE address = ? AND active = 1", (str(address),)).fetchone()
def duration_value(value):
if value == "permanent":
return None
match = re.fullmatch(r"(\d+)(m|h|d)", value)
if not match:
raise ValueError("duration must be Nm, Nh, Nd, or permanent")
factor = {"m": 60, "h": 3600, "d": 86400}[match.group(2)]
duration = int(match.group(1)) * factor
if duration < 60 or duration > 10 * 365 * 86400:
raise ValueError("duration out of range")
return now() + duration
def format_ban(row):
until = "permanent" if row["expires_at"] is None else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(row["expires_at"]))
return "ip=%s rdns=%s offense=%d until=%s action=%s target=%s reason=%s" % (
row["address"], row["rdns"] or "-", row["offense"], until, row["action"],
row["target"] if row["action"] == "redirect" else "-", row["reason"])
def run_command(command):
try:
args = shlex.split(command)
except ValueError as error:
return "ERROR " + str(error)
if not args:
return "Commands: list, show IP, add IP DURATION, remove IP, extend IP DURATION, action IP redirect URL|message TEXT, status"
operation = args[0].lower()
with DB_LOCK, database() as conn:
expire_bans(conn)
if operation == "list":
rows = list(conn.execute("SELECT * FROM bans WHERE active=1 ORDER BY started_at DESC LIMIT 25"))
return "No active bans." if not rows else "\n".join(format_ban(row) for row in rows)
if operation == "status":
active = conn.execute("SELECT COUNT(*) n FROM bans WHERE active=1").fetchone()["n"]
attempts = conn.execute("SELECT COUNT(*) n FROM attempts WHERE occurred_at>=?", (now()-WINDOW,)).fetchone()["n"]
return "NeoGuard active_bans=%d recent_attempts=%d threshold=%d/10m" % (active, attempts, MAX_FAILURES)
if len(args) < 2:
return "ERROR missing IP address"
try:
address = normalize_address(args[1])
except ValueError:
return "ERROR invalid IP address"
if exempt(address):
return "ERROR protected internal/trusted address"
address_text = str(address)
if operation == "show":
row = conn.execute("SELECT * FROM bans WHERE address=?", (address_text,)).fetchone()
offense = conn.execute("SELECT count FROM offenses WHERE address=?", (address_text,)).fetchone()
return (format_ban(row) if row else "No ban for %s" % address_text) + " offenses=%d" % (offense["count"] if offense else 0)
if operation == "remove":
conn.execute("UPDATE bans SET active=0 WHERE address=?", (address_text,))
conn.execute("DELETE FROM attempts WHERE address=?", (address_text,))
add_event(conn, "ADMIN", "[ADMIN] operation=remove ip=%s" % address_text)
return "Removed active ban for %s; offense history preserved." % address_text
if operation == "add":
expiry = duration_value(args[2] if len(args) > 2 else "24h")
offense_row = conn.execute("SELECT count FROM offenses WHERE address=?", (address_text,)).fetchone()
offense = offense_row["count"] if offense_row else 0
conn.execute("""
INSERT INTO bans VALUES (?,?,?,?,?,?,?,?,?,1)
ON CONFLICT(address) DO UPDATE SET started_at=excluded.started_at,expires_at=excluded.expires_at,
action=excluded.action,target=excluded.target,message=excluded.message,reason=excluded.reason,active=1
""", (address_text, now(), expiry, offense, "redirect", DEFAULT_REDIRECT,
"Access denied by NeoGuard.", "manual", confirmed_rdns(address)))
add_event(conn, "ADMIN", "[ADMIN] operation=add ip=%s" % address_text)
return "Added " + address_text
row = conn.execute("SELECT * FROM bans WHERE address=? AND active=1", (address_text,)).fetchone()
if not row:
return "ERROR no active ban for %s" % address_text
if operation == "extend":
if len(args) < 3:
return "ERROR missing duration"
expiry = duration_value(args[2])
conn.execute("UPDATE bans SET expires_at=? WHERE address=?", (expiry, address_text))
add_event(conn, "ADMIN", "[ADMIN] operation=extend ip=%s duration=%s" % (address_text, args[2]))
return "Extended " + address_text
if operation == "action":
if len(args) < 4 or args[2] not in ("redirect", "message"):
return "ERROR usage: action IP redirect URL | action IP message TEXT"
action = args[2]
value = " ".join(args[3:])[:1000]
if action == "redirect" and not URL_RE.fullmatch(value):
return "ERROR redirect must be a safe https:// URL"
if action == "message" and any(ord(c) < 32 and c not in "\t" for c in value):
return "ERROR invalid message"
conn.execute("UPDATE bans SET action=?,target=?,message=? WHERE address=?",
(action, value if action == "redirect" else "", value if action == "message" else "", address_text))
add_event(conn, "ADMIN", "[ADMIN] operation=action ip=%s action=%s" % (address_text, action))
return "Updated action for " + address_text
return "ERROR unknown command"
def verify_hmac(handler, body):
try:
timestamp = int(handler.headers.get("X-Neo-Timestamp", "0"))
nonce = handler.headers.get("X-Neo-Nonce", "")
signature = handler.headers.get("X-Neo-Signature", "")
if abs(now() - timestamp) > 60 or not re.fullmatch(r"[0-9a-f]{32}", nonce):
return False
with DB_LOCK:
cutoff = now() - 120
for key in list(NONCES):
if NONCES[key] < cutoff:
del NONCES[key]
if nonce in NONCES:
return False
path = handler.path
digest = hashlib.sha256(body).hexdigest()
canonical = "%d\n%s\n%s\n%s\n%s" % (timestamp, nonce, handler.command, path, digest)
expected = hmac.new(KEY_PATH.read_bytes(), canonical.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
return False
NONCES[nonce] = now()
return True
except (ValueError, OSError):
return False
class NeoGuardHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "NeoGuard"
sys_version = ""
def log_message(self, fmt, *args):
pass
def peer(self):
return normalize_address(self.client_address[0])
def body(self, maximum=16384):
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
return None
return self.rfile.read(length) if 0 <= length <= maximum else None
def response(self, status, body=b"", content_type="application/json", headers=None):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
if headers:
for key, value in headers.items():
self.send_header(key, value)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def json_response(self, status, value):
self.response(status, json.dumps(value, separators=(",", ":")).encode() + b"\n")
def proxy_client(self):
if self.peer() != TRUSTED_PROXY:
return None
try:
return normalize_address(self.headers.get("X-Real-IP", ""))
except ValueError:
return None
def do_HEAD(self):
return self.do_GET()
def do_GET(self):
path = urlsplit(self.path).path
if path == "/check":
client = self.proxy_client()
if not client:
return self.response(403)
return self.response(403 if active_ban(client) else 204)
if path == "/blocked":
client = self.proxy_client()
row = active_ban(client) if client else None
if not row:
return self.response(404)
if row["action"] == "redirect":
return self.response(302, headers={"Location": row["target"]})
message = html.escape(row["message"] or "Access denied by NeoGuard.")
page = ("<!doctype html><meta charset=utf-8><meta name=robots content=noindex>"
"<title>NeoGuard</title><style>body{margin:0;background:#07030d;color:#f1eaff;font:18px monospace}"
"main{max-width:760px;margin:12vh auto;padding:48px;border:1px solid #9d5cff;background:#150a24;"
"box-shadow:14px 14px #351360}h1{color:#ff65c8}b{color:#b9ff4f}</style>"
"<main><b>NEOGUARD // ACCESS DENIED</b><h1>Connection blocked</h1><p>%s</p></main>" % message).encode()
return self.response(403, page, "text/html; charset=utf-8")
if path == "/api/v1/neoguard/poll":
if not verify_hmac(self, b""):
return self.response(403)
try:
after = int(parse_qs(urlsplit(self.path).query).get("after", ["0"])[0])
except ValueError:
after = 0
with database() as conn:
rows = list(conn.execute("SELECT * FROM events WHERE id>? ORDER BY id LIMIT 100", (after,)))
return self.json_response(200, {"events": [dict(row) for row in rows]})
return self.response(404)
def do_POST(self):
path = urlsplit(self.path).path
body = self.body()
if body is None:
return self.response(400)
if path == "/internal/attempt" and self.peer().is_loopback:
try:
data = json.loads(body)
record_attempt(data["ip"], data.get("source", "drops"), data.get("detail", "invalid request"),
data.get("destination_port"))
return self.response(204)
except (ValueError, KeyError, json.JSONDecodeError):
return self.response(400)
if path == "/api/v1/neoguard/event":
if not verify_hmac(self, body):
return self.response(403)
try:
data = json.loads(body)
if data.get("type") == "attempt":
record_attempt(data["ip"], data.get("source", "relay"), data.get("detail", "authentication failed"),
data.get("destination_port"))
return self.json_response(200, {"ok": True})
if data.get("type") == "command":
return self.json_response(200, {"response": run_command(data.get("command", ""))})
except (ValueError, KeyError, json.JSONDecodeError):
pass
return self.response(400)
return self.response(404)
class NeoGuardServer(ThreadingHTTPServer):
daemon_threads = True
request_queue_size = 256
if __name__ == "__main__":
os.umask(0o077)
initialize_database()
NeoGuardServer((LISTEN_ADDRESS, LISTEN_PORT), NeoGuardHandler).serve_forever()