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
+109
View File
@@ -0,0 +1,109 @@
# AGENTS.md
## Scope
This source kit contains application components and frontend customization
templates. The operator owns domains, DNS, TLS, routing, hosts, firewalls,
service supervision, filesystem layout, users, credentials, and deployment
tooling.
Do not turn this kit into a copy of an example or inferred architecture. Ask
the operator for every missing trust, origin, address, path, and lifecycle
decision.
## Non-Negotiable Invariants
- Never infer or reuse example domains, addresses, accounts, paths, or topology.
- Never add DNS mutation, certificate issuance, SSH fan-out, or static routing
without explicit operator instructions.
- Never disable TLS verification or SSH host-key verification.
- Never trust forwarded client-address headers from an unverified proxy peer.
- Never broaden upload, management, or trusted-proxy networks implicitly.
- Never package credentials, keys, certificates, capability URLs, catalogs,
databases, logs, local configuration, or personal images.
- Keep the generic source kit's documented placeholders intact; never publish
a rendered deployment artifact containing unresolved placeholders.
- Treat `{{NEODROP_CRYPTO_CONTEXT}}` as immutable after the first uploaded drop.
- Keep the same crypto context and protocol framing in every NeoDrop client and
manager implementation.
## Source Of Truth
Resolve the placeholders in source templates or generate deployment-specific
configuration from a separately protected operator configuration. Do not edit
rendered deployment files as the only source of truth.
When introducing generated files, include a generated-file header and a digest
of the non-secret configuration that produced them. Keep secret values out of
that digest and out of repository history.
Render values with context-appropriate validation and escaping. HTML text,
HTML fragments, regular expressions, JavaScript, Python, C strings, CSS, and
Perl substitutions are different contexts; a value safe in one may be unsafe
in another.
## Deployment Adaptation
Before writing deployment automation, obtain explicit operator decisions for:
- public relay and drop origins;
- product, operator, node, and drop display labels;
- component placement and bind addresses;
- reverse-proxy peers and forwarded-header behavior;
- upload and management source CIDRs;
- state, socket, credential, and backup paths;
- process identities and file ownership;
- TLS termination and certificate lifecycle;
- DNS publication and update lifecycle;
- firewall rules, health checks, monitoring, rollback, and recovery;
- IRC server, account, certificate authentication, and channel policy.
Treat nginx, systemd, containers, orchestration, and configuration-management
systems as operator choices. Add only the integration files needed for the
confirmed target environment.
## Component Contracts
NeoBear requires a WeeChat relay endpoint and a NeoDrop public origin. NeoDrop
requires persistent ciphertext storage, a trusted proxy definition, explicit
upload/management networks, and the shared crypto context. NeoGuard requires a
protected shared key and authenticated internal reporting path. The manager
requires protected catalog/token credentials, private state, restricted Unix
sockets, and access to the NeoDrop object API.
Preserve origin validation, source-IP validation, authenticated encryption,
metadata authentication, capability secrecy, request-size limits, and socket
permission checks when adapting components.
## Required Workflow
1. Inventory all unresolved `{{PLACEHOLDER}}` values.
2. Ask for missing operator decisions instead of selecting convenient defaults.
3. Render into a temporary directory, never over the pristine source kit.
4. Run syntax and protocol tests for every changed language/component.
5. Scan all paths, file bytes, and archive metadata for private information.
6. Verify no secret-like file types or key headers are present.
7. Verify archive ownership, modes, timestamps, member types, and checksums.
8. Test encrypted upload, authenticated metadata, download, checksum, expiry,
catalog registration, and deletion in staging.
9. Review the final manifest before publication.
## Release Checks
After rendering an operator tree, at minimum run:
```sh
test -z "$(rg -l '\{\{[A-Z0-9_]+\}\}' files scripts)"
python3 -m py_compile files/neodrop/*.py files/neodrop-manager/*.py files/neoguard/*.py
node --check files/neodrop/app.js
node --check files/glowing-bear/neodrop-crypto.js
perl -c scripts/customize-glowing-bear.pl
```
Also run the repository's NeoDrop protocol tests when working from the full
source repository. A deployment-specific release process must add leakage,
reproducibility, integration, and archive-metadata tests appropriate to its
toolchain.
Do not add a file to a release allowlist merely to make a test pass. Every file
must have a documented source-distribution or runtime purpose.
+40
View File
@@ -0,0 +1,40 @@
f79675e2a01fc10dd57ba6213bdc2a56ce56cf09a9ea090fd293ed4f221b0604 AGENTS.md
eda1df701967c59116caac8e044d03eb1c3674c512dc47f67c413e813959f137 README.md
7c1a1efa6f5d1656b0d37606dd08036a859c94077d0033e44db934e71158844c UPGRADING.md
f5e5c9534532840fcd45bb002e69ed85c61dba2753450e26b448b7df1481e24d VERSION
8e8d15f69ef22bb01bbe91bda464b3f6d108157dc20bf703d7082e82ad89eb2d files/glowing-bear/colour-settings.html
7eb960735c7f18be921ff65b0ef1159b013a9bd262631019db965e64567cb131 files/glowing-bear/colour-settings.js.inc
5bdf903fa189a695223b518a2c668361930cf2c6168b66fa38b2e562cacb6491 files/glowing-bear/command-menu.html
fa0947074c7f924cc4856147718fe39d99ab670151b4f2923e7bc1738bd7cac3 files/glowing-bear/command-menu.js.inc
c98a8b56683b9b6829983bfb7e8d33ba8259109ad2aa4a6a709379d3ceaa81c3 files/glowing-bear/connection-panel.html
4d4928a6c3266b79b584801ff6322ca01af18f67687f79cdc8ada2d09463cb07 files/glowing-bear/context-menu.html
9fe9508496492841c06f1af0a935ed090f2c9a1b77e637b4a5b05ecb231a586c files/glowing-bear/context-menu.js
a8bc23b061470152aab9c329b5bab1793180d5f9d7bfd2b9e2fa9d2d8552e555 files/glowing-bear/cyberpunk.css
39ee1654fca851ffe37639c448fc406819329fa836449849db3664c50c4f64df files/glowing-bear/drop-settings.html
caafcccfb70fc72458fcbda812ec8f0a06cb300cbabb87eabbb30b946124394b files/glowing-bear/fonts/OFL.txt
b534a0b84ef3cca941ebdb506ce3f4e0010aa4ef881271bac8b6959dbf694fbf files/glowing-bear/fonts/OpenDyslexic-Bold.woff2
d055755690b99774a69e90600d1efc60b99ce05b3092155165609453b4b81c34 files/glowing-bear/fonts/OpenDyslexic-BoldItalic.woff2
b18685dcb99b4ba903549d215c0ae2ab39a5087097614fd4100ae6d387332b47 files/glowing-bear/fonts/OpenDyslexic-Italic.woff2
0441bc21071e42db57c217f93fbc48d3b55a2987c02814c94dc93621c42e8695 files/glowing-bear/fonts/OpenDyslexic-Regular.woff2
c85f448d0964f84a31cb378fd0700fbc060f2828996468cfc8c678e3bc478fb6 files/glowing-bear/login-hero.html
687c35d82bcd7b8aa37c9bc6d594c519b7d4304e014a9ba17364f6de39003c05 files/glowing-bear/neodrop-crypto.js
187e083c7f73d31b003c15e2872eefd20bb2de5f171d375d482d1997afd6e580 files/glowing-bear/neodrop-plugin.js.inc
0f6d4866b6863b8431122be599d91a57baabb5387d94a8188fe831e782186986 files/glowing-bear/neodrop.js
697f49b8b7ea88ec98e6eae9af67125d6ea2bd31bce9b645a36ac5e7584954fe files/glowing-bear/server-manager.html
bafbfed58bd31c244688f97be442aff7d93345f30fd1c5757fdb2c4a8d611811 files/glowing-bear/server-manager.js
03884100e99459f6655c9d4350ee0d3b5dfa803f5ba006535419992726c49402 files/glowing-bear/sidebar-resize.js.inc
08ed847831f41ced57a55760024e0c37688787bc1975bb1a6b85c98faaff67bf files/glowing-bear/weechat-settings.html
09fb6a4846eb42f0dd65108713dc8ab13303d9e744028279ef4c8f37cf9adf00 files/glowing-bear/weechat-settings.js.inc
c928fcccc782ddb29fb426a8e46a7d1e82cc979a8964605f891ca6612d484d9a files/neodrop/app.js
c5c46ab018804de11d021d86a3a54c57403960e7e652fde7fdc41e513ad56223 files/neodrop/index.html
d819b4b8caea327c08d1bad75f8009cf6b05ca5178fc9d387f7d50d5b0cef016 files/neodrop/neodrop_server.py
30e84ea6c687fc27e705b299850afe1218d554c9b4c5fd1894ed3022cc2e7185 files/neodrop/style.css
e5ef6ce6a49d8cc53d5747b0feb64c4c15db2cb372674daa4b099c666a155c03 files/neodrop-manager/manage.css
c77d4d421dca43606be294dc2a630591fbe156138c0606192628042aa2776759 files/neodrop-manager/manage.html
de60984b4d82a068be692b62662d07daff10e3a9f370cb77232296d22177ee79 files/neodrop-manager/manage.js
172fd0a85beab0aefd0483fb1a4396391a983a56f75f58400b671931b3f43710 files/neodrop-manager/neodrop_manager_agent.py
c2eb97cf9dcffd5b1a7fa978a74f481a22c91cc3f6b91356a61a1e8926c7b956 files/neodrop-manager/neodrop_manager_plugin.c
37bccd91a193d4c150a28becd68a8f5b9cdc9db9fac3b188d06510bc3ed8b701 files/neoguard/neoguard_agent.py
2f50ef95f42c88c8b3a04bc347b5db0db228bae96dfe65107af5894e362a9367 files/neoguard/neoguard_plugin.c
17a5343fcae588ee7c6ac092a7e3f3a14d3b2c0a3429aa2561e5d3a89a52dd93 files/neoguard/neoguardd.py
d16b31e7d68d8845892225a79bbaf4d5be2083b876e9120839c08664dc32f589 scripts/customize-glowing-bear.pl
+132
View File
@@ -0,0 +1,132 @@
# NeoRelay Generic Source Kit
The release version is recorded in `VERSION`. Existing operators should follow
`UPGRADING.md` and deploy the full compatible component set rather than copying
individual changed files.
This archive contains sanitized application source templates for NeoBear,
NeoDrop, NeoGuard, and the encrypted NeoDrop manager. It is intentionally not a
turnkey infrastructure deployment and cannot run until every `{{PLACEHOLDER}}`
has been resolved.
The operator owns the deployment topology, domains, DNS records, TLS
certificates, reverse proxies, routing, firewalls, service supervision, users,
filesystem layout, and secret distribution. Nothing in this kit assumes how
those responsibilities are implemented.
## Security Boundary
The kit includes only reviewed application source and frontend customization
files. It deliberately excludes:
- live deployment and provisioning scripts;
- DNS mutation or authoritative-server configuration;
- certificate issuance and renewal automation;
- nginx configuration and TLS/key paths;
- SSH hosts, jump hosts, identities, and copy commands;
- private or public network diagrams, addresses, and routes;
- systemd units tied to a particular filesystem layout;
- operator identities, IRC accounts, domains, and personal branding images;
- keys, credentials, catalogs, databases, capability URLs, and Git history.
The NeoBear assets include OpenDyslexic WOFF2 web fonts and their `OFL.txt`
license. Keep the license with the font files in rendered distributions.
`MANIFEST.sha256` records the checksum of each regular file in the extracted archive.
The archive itself uses normalized root ownership and timestamps, so it does
not disclose the build workstation account.
## Placeholders
The published generic kit intentionally retains this exact documented
placeholder set. Resolve all placeholders consistently before running or
compiling anything. A rendered deployment artifact must contain no unresolved
placeholders.
Several occur in multiple protocol implementations and must have identical
values everywhere.
### Public Identity
- `{{RELAY_ORIGIN}}`: complete HTTPS origin for the web IRC client.
- `{{RELAY_HOST}}`: hostname only for the web IRC client.
- `{{DROPS_ORIGIN}}`: complete HTTPS origin for NeoDrop.
- `{{DROPS_HOST}}`: hostname only for NeoDrop.
- `{{DROPS_HOST_REGEX}}`: regex-escaped NeoDrop hostname.
- `{{RELAY_TITLE_HTML}}`, `{{DROPS_TITLE_HTML}}`: trusted static heading HTML.
- `{{RELAY_GATEWAY_LABEL}}`, `{{OPERATOR_LABEL}}`: plain display labels.
- `{{PRODUCT_NAME}}`: product/author label used by the frontend and plugins.
- `{{NODE_LABEL}}`, `{{DROP_NODE_LABEL}}`, `{{DROP_TAGLINE}}`: plain branding text.
### Trust And Networking
- `{{NEODROP_BIND_ADDRESS}}`, `{{NEOGUARD_BIND_ADDRESS}}`: component binds.
- `{{TRUSTED_PROXY_ADDRESS}}`: the only peer allowed to supply client IPs.
- `{{UPLOAD_CIDR_1}}`, `{{UPLOAD_CIDR_2}}`, `{{UPLOAD_CIDR_3}}`: upload source networks.
- `{{MANAGEMENT_CIDR_1}}`, `{{MANAGEMENT_CIDR_2}}`, `{{MANAGEMENT_CIDR_3}}`: manager networks.
- `{{NEOGUARD_INTERNAL_URL}}`: private NeoGuard attempt-report endpoint.
- `{{BLOCKED_REDIRECT_URL}}`: operator-selected blocked-client destination.
Use a deliberately non-routable CIDR for unused slots. Never broaden a trust
list merely to make a request succeed. Preserve the trusted-proxy check before
accepting forwarded client addresses.
### Storage And Credentials
- `{{NEODROP_DATA_ROOT}}`, `{{NEOGUARD_DATA_ROOT}}`: persistent state roots.
- `{{NEODROP_MANAGER_TOKEN_HASH_PATH}}`: manager-token hash location.
- `{{NEOGUARD_KEY_PATH}}`: shared NeoGuard key location.
- `{{CATALOG_KEY_PATH}}`, `{{MANAGER_API_TOKEN_PATH}}`: manager credentials.
- `{{MANAGER_CATALOG_PATH}}`, `{{MANAGER_DOWNLOAD_ROOT}}`: manager state.
- `{{MANAGER_CONTROL_SOCKET_PATH}}`, `{{MANAGER_HTTP_SOCKET_PATH}}`: manager sockets.
- `{{NEOGUARD_CONTROL_SOCKET_PATH}}`: NeoGuard agent socket.
### Protocol Compatibility
- `{{NEODROP_CRYPTO_CONTEXT}}`: immutable HKDF salt/context for one deployment.
Choose the crypto context once, record it in protected operator documentation,
and use the exact same byte string in all browser and manager components.
Changing it invalidates existing capability links.
## Branding Assets
Personal image files are not included. Before using
`scripts/customize-glowing-bear.pl`, provide reviewed JPEG and PNG assets at
the filenames expected by that script or adapt the customizer to your asset
pipeline. Strip image metadata before distribution.
## Integration Contracts
NeoDrop stores ciphertext and exposes its upload/object HTTP API. NeoGuard
receives abuse-attempt reports from trusted components. The manager holds
encrypted catalog state and deletion capabilities. NeoBear connects to a
WeeChat relay and uses the NeoDrop public origin for browser uploads.
The supplied Python, C, JavaScript, HTML, CSS, and Perl files are source
components, not proof that a particular deployment is secure. Review process
ownership, socket permissions, state backups, request-size limits, proxy
headers, origin checks, and network policy in the target environment.
## Adaptation Workflow
1. Read `AGENTS.md` before asking a coding agent to modify the kit.
2. Design the target topology and trust boundaries outside this source tree.
3. Resolve every placeholder from explicit operator decisions.
4. Supply neutral branding assets and deployment-specific integration files.
5. Run syntax, protocol, leakage, and archive-integrity checks.
6. Deploy to a staging environment and test encrypted upload/download/delete.
7. Publish only an artifact produced from the reviewed, rendered tree.
Useful pre-deployment checks include:
```sh
test -z "$(rg -l '\{\{[A-Z0-9_]+\}\}' files scripts)"
sha256sum -c MANIFEST.sha256
python3 -m py_compile files/neodrop/*.py files/neodrop-manager/*.py files/neoguard/*.py
node --check files/neodrop/app.js
node --check files/glowing-bear/neodrop-crypto.js
perl -c scripts/customize-glowing-bear.pl
```
Recreate `MANIFEST.sha256` after rendering because placeholder resolution
necessarily changes file contents.
+84
View File
@@ -0,0 +1,84 @@
# Upgrading NeoRelay
This source kit is a complete, versioned application source set for upgrading
an existing NeoRelay deployment. It is not a deployment-specific patch and it
does not contain infrastructure configuration, credentials, or persistent
state.
## Compatibility
- Upgrade NeoBear, NeoDrop, NeoGuard, and the NeoDrop manager as one tested set.
- Preserve the existing `{{NEODROP_CRYPTO_CONTEXT}}` value exactly. Changing it
makes existing encrypted links unreadable.
- Preserve the existing NeoDrop protocol framing and all operator trust
decisions, including origins, proxy peers, source networks, and state paths.
- `scripts/customize-glowing-bear.pl` targets Glowing Bear revision
`d182657a3b882cfb54e294f9a56fbdd2954d73b0`. Test any other revision before
deployment.
- Do not replace operator-managed WeeChat, IRC, proxy, service, DNS, TLS, or
firewall configuration with values inferred from this kit.
## 1. Verify And Inventory
1. Verify the archive checksum through a trusted channel.
2. Run `sha256sum -c MANIFEST.sha256` from the extracted kit.
3. Record the currently deployed version and hashes of its rendered runtime
files and integration configuration.
4. Inventory every `{{PLACEHOLDER}}` and map it to the existing deployment's
protected source of truth. Do not select new values during an application
update unless the operator has approved a separate configuration change.
## 2. Back Up
Create and test a protected, application-consistent backup of:
- NeoDrop's database and ciphertext object tree as one coordinated snapshot;
- the encrypted manager catalog, catalog key, API token material, and verifier;
- the NeoGuard database and shared authentication key;
- WeeChat configuration, secure data, certificates, and plugin state;
- the current rendered application files and operator integration files.
Stop writers or use storage-specific atomic snapshot procedures. Preserve file
ownership and modes. Confirm that the backup can be restored before upgrading.
## 3. Render A Staging Tree
1. Extract the kit into a new temporary directory, not over the live tree.
2. Resolve placeholders from the existing protected configuration with
context-appropriate validation and escaping.
3. Supply reviewed branding assets and operator-owned integration files.
4. Recreate `MANIFEST.sha256` after rendering.
5. Confirm the rendered tree contains no unresolved placeholders, credentials,
private keys, capability URLs, state, logs, or backups.
The generic source kit intentionally contains documented placeholders. A
rendered deployment artifact must not contain any unresolved placeholders.
## 4. Test
Run the checks listed in `README.md`, compile native plugins against the target
WeeChat headers, and validate the operator's proxy and service configuration.
In staging, verify:
- an existing encrypted link still downloads and authenticates correctly;
- a new upload, metadata read, download, checksum, expiry, and deletion;
- manager catalog registration and access to existing entries;
- NeoGuard authenticated reporting and recovery;
- origin, source-address, and trusted-proxy enforcement;
- startup from a restored copy of existing persistent state.
## 5. Deploy
Install the rendered files into a versioned directory or otherwise retain the
complete previous runtime tree. Deploy the compatible browser, server, manager,
and guard components as one release. Run health checks before restoring normal
traffic or writes, then record the deployed version and rendered manifest.
## 6. Roll Back
If validation fails, isolate the new version and restore the previous runtime
files and integration configuration. Preserve the original crypto context and
credentials. Restore persistent state only if the upgrade performed
incompatible writes; restore NeoDrop metadata and ciphertext together. Verify
an existing encrypted link, manager catalog access, and component health after
rollback.
+1
View File
@@ -0,0 +1 @@
1.0.3
+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()
+524
View File
@@ -0,0 +1,524 @@
#!/usr/bin/perl
use strict;
use warnings;
use File::Copy qw(copy);
use File::Path qw(make_path);
my ($source_dir, $assets_dir) = @ARGV;
die "usage: $0 SOURCE_DIR ASSETS_DIR\n" unless $source_dir && $assets_dir;
sub read_file {
my ($path) = @_;
open my $fh, '<', $path or die "open $path: $!\n";
local $/;
return <$fh>;
}
sub write_file {
my ($path, $content) = @_;
open my $fh, '>', $path or die "write $path: $!\n";
print {$fh} $content;
close $fh or die "close $path: $!\n";
}
my $index_path = "$source_dir/src/index.html";
my $index = read_file($index_path);
my $hero = read_file("$assets_dir/login-hero.html");
my $core_welcome = $hero;
$core_welcome =~ s{<section class="login-hero"}{<section class="login-hero core-login-hero" ng-show="activeBuffer().fullName === 'core.weechat'"} or
die "could not prepare core login hero\n";
$core_welcome =~ s{neorelay-login-title}{neorelay-core-title}g;
my $drop_settings = read_file("$assets_dir/drop-settings.html");
my $colour_settings = read_file("$assets_dir/colour-settings.html");
my $colour_settings_js = read_file("$assets_dir/colour-settings.js.inc");
my $weechat_settings = read_file("$assets_dir/weechat-settings.html");
my $weechat_settings_js = read_file("$assets_dir/weechat-settings.js.inc");
my $drop_plugin = read_file("$assets_dir/neodrop-plugin.js.inc");
my $connection_panel = read_file("$assets_dir/connection-panel.html");
my $sidebar_resize = read_file("$assets_dir/sidebar-resize.js.inc");
my $command_menu = read_file("$assets_dir/command-menu.html");
my $command_menu_js = read_file("$assets_dir/command-menu.js.inc");
my $context_menu = read_file("$assets_dir/context-menu.html");
my $server_manager = read_file("$assets_dir/server-manager.html");
my $count = ($index =~ s{
<div\ ng-hide="connected"\ class="container">\s*
<h2>.*?</h2>
}{<div ng-hide="connected" class="container login-shell">\n$hero}sx);
die "could not replace Glowing Bear login header\n" unless $count == 1;
$count = ($index =~ s{
(<link\ href="css/glowingbear\.css"\ rel="stylesheet"\ media="screen">)
}{$1\n <link href="css/neorelay-cyberpunk.css?v=31" rel="stylesheet" media="screen">}x);
die "could not inject cyberpunk stylesheet\n" unless $count == 1;
$count = ($index =~ s{
<div\ class="panel"\ data-state="active">\s*
<div\ class="panel-heading">\s*<h4\ class="panel-title">\s*
<a\ class="accordion-toggle"\ ng-click="toggleAccordion\(\$event\)">\s*
Connection\ settings.*?
(?=<div\ class="panel"\ data-state="collapsed"\ id="gettingStartedAccordion">)
}{$connection_panel}sx);
die "could not replace connection panel\n" unless $count == 1;
$count = ($index =~ s{
<div\ class="panel"[^>]*id="gettingStartedAccordion">.*?
(?=\s*</div>\s*</div>\s*<div\ class="content"\ id="content")
}{}sx);
die "could not remove public help panels\n" unless $count == 1;
$index =~ s{assets/img/glowing_bear_128x128\.png}{assets/neorelay-avatar-icon.png}g;
$index =~ s{assets/img/favicon\.png}{assets/neorelay-avatar-icon.png}g;
$index =~ s{<meta name="theme-color" content="#[0-9a-fA-F]+">}{<meta name="theme-color" content="#150a24">};
$index =~ s{Image upload failed\.}{\{\{ uploadErrorMessage || 'Encrypted upload failed.' \}\}} or
die "could not customize upload error\n";
$count = ($index =~ s{
<li\ class="standard-labels">\s*<form\ class="form-horizontal"\ role="form">\s*
<div\ class="form-group">\s*<label[^>]+for="iToken".*?</form>\s*</li>
}{$drop_settings}sx);
die "could not replace Imgur settings\n" unless $count == 1;
$count = ($index =~ s{
(<div\ id="settingsModal".*?<div\ class="modal-body">)\s*<ul\ class="">
}{$1
<nav class="neo-settings-tabs" role="tablist" aria-label="Settings sections">
<button type="button" ng-class="{active: neoSettingsTab !== 'colours' && neoSettingsTab !== 'weechat'}" ng-click="neoSettingsTab = 'general'" role="tab">General</button>
<button type="button" ng-class="{active: neoSettingsTab === 'colours'}" ng-click="neoSettingsTab = 'colours'; loadNeoWeeChatColours()" role="tab">Colours</button>
<button type="button" ng-class="{active: neoSettingsTab === 'weechat'}" ng-click="neoSettingsTab = 'weechat'; loadNeoWeeChatSettings()" role="tab">WeeChat</button>
</nav>
<ul class="neo-settings-panel" ng-show="neoSettingsTab !== 'colours' && neoSettingsTab !== 'weechat'">}sx);
die "could not add settings tabs\n" unless $count == 1;
$index =~ s{Settings\ will\ be\ stored\ in\ your\ browser\.}{Browser preferences stay local; Colours and WeeChat settings are saved to the relay.} or
die "could not clarify settings persistence\n";
$count = ($index =~ s{
</ul>\s*(</div>\s*<div\ class="modal-footer">)
}{</ul>
$colour_settings
$weechat_settings
$1}x);
die "could not add colour settings panel\n" unless $count == 1;
$index =~ s{ng-class="\{'no-overflow':\ connected\}"}{ng-class="{'no-overflow': connected, 'neo-preferred-font': connected}"} or
die "could not scope preferred typography to the connected client\n";
$index =~ s{<input\ type="text"\ ng-model="settings\.fontfamily"\ class="form-control"\ id="font">}{<div class="neo-font-picker" id="font">
<button type="button" class="form-control neo-font-trigger" ng-click="neoFontPickerOpen = !neoFontPickerOpen" ng-style="{'font-family': settings.fontfamily}" aria-haspopup="listbox" aria-expanded="{{ neoFontPickerOpen }}">
<span class="neo-font-value">{{ settings.fontfamily }}</span><span class="caret" aria-hidden="true"></span>
</button>
<div class="neo-font-menu" ng-show="neoFontPickerOpen" role="listbox" aria-label="Available fonts">
<button type="button" class="neo-font-option" role="option" ng-click="settings.fontfamily = 'OpenDyslexic, Atkinson Hyperlegible, monospace'; neoFontPickerOpen = false" style="font-family:'OpenDyslexic',sans-serif"><strong>OpenDyslexic</strong><span>Aa Bb Cc 123</span></button>
<button type="button" class="neo-font-option" role="option" ng-click="settings.fontfamily = 'system-ui, sans-serif'; neoFontPickerOpen = false" style="font-family:system-ui,sans-serif"><strong>System Sans</strong><span>Aa Bb Cc 123</span></button>
<button type="button" class="neo-font-option" role="option" ng-click="settings.fontfamily = 'ui-serif, Georgia, serif'; neoFontPickerOpen = false" style="font-family:Georgia,serif"><strong>System Serif</strong><span>Aa Bb Cc 123</span></button>
<button type="button" class="neo-font-option" role="option" ng-click="settings.fontfamily = 'ui-monospace, SFMono-Regular, Consolas, monospace'; neoFontPickerOpen = false" style="font-family:monospace"><strong>System Monospace</strong><span>Aa Bb Cc 123</span></button>
</div>
</div>
<p class="neo-font-preview" ng-style="{'font-family': settings.fontfamily, 'font-size': settings.fontsize}">The quick brown fox jumps over the lazy dog. 0123456789</p>} or
die "could not add preferred font previews\n";
$count = ($index =~ s{
(<div\ class="title"[^>]*>\s*)
(<span\ class="desktop"\ ng-repeat=)
}{$1<span class="desktop neorelay-core-title" ng-show="activeBuffer().fullName === 'core.weechat'">{{PRODUCT_NAME}} <small>private IRC gateway</small></span>\n <span class="desktop" ng-hide="activeBuffer().fullName === 'core.weechat'" ng-repeat=}x);
die "could not replace core buffer title\n" unless $count == 1;
$count = ($index =~ s{
(<span\ class="mobile")
}{<span class="mobile neorelay-core-title" ng-show="activeBuffer().fullName === 'core.weechat'">{{PRODUCT_NAME}}</span>\n $1 ng-hide="activeBuffer().fullName === 'core.weechat'"}x);
die "could not guard mobile core title\n" unless $count == 1;
$count = ($index =~ s{
<div\ class="actions\ pull-right\ vertical-line-left">.*?</div>
}{}sx);
die "could not remove topbar actions\n" unless $count == 1;
my $core_label = q!{{ buffer.fullName === 'core.weechat' ? '{{PRODUCT_NAME}}' : (buffer.trimmedName || buffer.fullName) }}!;
$count = ($index =~ s!(class="buffername" ng-class="::buffer\.nameClasses">)\{\{ buffer\.trimmedName \|\| buffer\.fullName \}\}!$1$core_label!);
die "could not rename visible core buffer\n" unless $count == 1;
$count = ($index =~ s{
(<div\ id="bufferlines"[^>]*>)\s*<table>
}{$1\n $core_welcome\n <table>}x);
die "could not replace core buffer contents\n" unless $count == 1;
$index =~ s{('private': buffer\.type === 'private')}{$1,
'server': buffer.type === 'server'} or
die "could not mark server buffers\n";
$index =~ s{<li\ class="buffer"\ ng-class=}{<li class="buffer" neo-context="{kind:'buffer',buffer:buffer}" ng-class=} or
die "could not add buffer context menu\n";
$index =~ s{<a\ ng-click="openBuffer\(nick\.name\)"><span\ ng-class="::nick\.prefixClasses"\ ng-bind="::nick\.prefix"></span><span\ ng-class="::nick\.nameClasses"\ ng-bind="::nick\.name"></span></a>}{<a ng-click="insertAtCaret(nick.name)" neo-context="{kind:'nick',nick:nick,buffer:activeBuffer()}" title="Insert nickname; right-click for actions"><span ng-class="::nick.prefixClasses" ng-bind="::nick.prefix"></span><span ng-class="::nick.displayClasses" ng-bind="::nick.name"></span></a>} or
die "could not add nick context menu\n";
$index =~ s{<span\ ng-repeat="part\ in\ ::bufferline\.prefix"\ ng-class="::part\.classes"}{<span ng-repeat="part in ::bufferline.prefix" ng-class="part.classes.concat(activeBuffer().getNickColourClasses(bufferline.nick))"} or
die "could not apply WeeChat nick colours to chat prefixes\n";
$count = ($index =~ s{
</ul>\s*</div>\s*<div\ id="nicklist"
}{</ul>
<div class="sidebar-actions">
<button type="button" ng-click="showModal('settingsModal')" title="Settings"><i class="glyphicon glyphicon-cog"></i><span>Settings</span></button>
<button type="button" ng-click="openServerManager()" title="Manage IRC servers"><i class="glyphicon glyphicon-plus"></i><span>Servers</span></button>
<button type="button" ng-if="pwaInstallAvailable" ng-click="installPwa()" title="Install NeoBear app"><i class="glyphicon glyphicon-download-alt"></i><span>Install</span></button>
<button type="button" ng-click="disconnect()" title="Disconnect"><i class="glyphicon glyphicon-off"></i><span>Logout</span></button>
</div>
</div>
<div id="nicklist"}x);
die "could not add sidebar actions\n" unless $count == 1;
$index =~ s{\s*</body>}{
<neo-context-menu-view></neo-context-menu-view>
<neo-server-manager></neo-server-manager>
</body>} or die "could not add NeoBear UI overlays\n";
write_file($index_path, $index);
my $js_path = "$source_dir/src/js/glowingbear.js";
my $js = read_file($js_path);
$js =~ s/'hostField': 'localhost'/'hostField': '{{RELAY_HOST}}'/ or
die "could not set relay host default\n";
$js =~ s/'port': 9001/'port': 443/ or die "could not set relay port default\n";
$js =~ s/'customCSS': '',/'customCSS': '',
'dropProfile': 'small',
'neorelaySidebarWidth': 0,/ or
die "could not add NeoDrop profile setting\n";
$js =~ s{(\s+\$scope\.settings\s*=\s*settings;)}{$1\n\n$weechat_settings_js\n$colour_settings_js\n$sidebar_resize} or
die "could not add desktop sidebar resizing\n";
$js =~ s{
'fontfamily':\s*\(utils\.isMobileUi\(\)\s*\?\s*'sans-serif'\s*:\s*'[^']+'\)
}{'fontfamily': 'OpenDyslexic, Atkinson Hyperlegible, monospace'}x or
die "could not set font default\n";
$js =~ s{
(\$scope\.command\s*=\s*'';)
}{$1
\$scope.maskPassword = function(value) {
return value ? new Array(value.length + 1).join('*') : '';
};
\$scope.openServerManager = function() {
\$scope.\$broadcast('neoServerManagerOpen');
};
var neoPwaInstallPrompt = null;
\$scope.pwaInstallAvailable = false;
window.addEventListener('beforeinstallprompt', function(event) {
event.preventDefault();
neoPwaInstallPrompt = event;
\$scope.\$applyAsync(function() { \$scope.pwaInstallAvailable = true; });
});
window.addEventListener('appinstalled', function() {
neoPwaInstallPrompt = null;
\$scope.\$applyAsync(function() { \$scope.pwaInstallAvailable = false; });
});
\$scope.installPwa = function() {
if (!neoPwaInstallPrompt) return;
neoPwaInstallPrompt.prompt();
neoPwaInstallPrompt.userChoice.finally(function() {
neoPwaInstallPrompt = null;
\$scope.\$applyAsync(function() { \$scope.pwaInstallAvailable = false; });
});
};}x or die "could not add password masker\n";
$js =~ s{
\$scope\.init\s*=\s*function\(\)\s*\{\s*
\$scope\.parseHost\(\);\s*
\$scope\.parseHash\(\);\s*
\};
}{\$scope.init = function() {
settings.hostField = '{{RELAY_HOST}}';
settings.host = '{{RELAY_HOST}}';
settings.port = 443;
settings.path = 'weechat';
settings.tls = true;
\$scope.parseHost();
};}x or die "could not hardcode relay initialization\n";
$js =~ s{
(\$scope\.connect\s*=\s*function\(\)\s*\{)
}{$1
settings.hostField = '{{RELAY_HOST}}';
settings.host = '{{RELAY_HOST}}';
settings.port = 443;
settings.path = 'weechat';
settings.tls = true;}x or die "could not hardcode relay connection\n";
$js =~ s{
document\.getElementById\('audioNotificationInitializer'\)\.play\(\);
}{var audioPlay = document.getElementById('audioNotificationInitializer').play();
if (audioPlay && audioPlay.catch) {
audioPlay.catch(function() {});
}}x or die "could not handle blocked notification audio\n";
$js =~ s{
(\$rootScope\.bufferlistfilter\s*=\s*function\(buffer\)\s*\{)
}{$1\n if (buffer.fullName === 'relay.relay.list') {\n return false;\n }}x or
die "could not hide relay list buffer\n";
$js =~ s{
(\$rootScope\.\$on\('relayDisconnect',\ function\(\)\ \{)
}{$1\n \$rootScope.loadingLines = false;}x or
die "could not reset line loading on disconnect\n";
$js =~ s{
(settings\.addCallback\('fontfamily',\ function\(fontfamily\)\ \{\s*
utils\.changeClassStyle\('favorite-font',\ 'fontFamily',\ fontfamily\);)
}{$1
document.documentElement.style.setProperty('--neo-preferred-font', fontfamily);}x or
die "could not apply preferred font to connected controls\n";
$js =~ s{
(utils\.changeClassStyle\('favorite-font',\ 'fontSize',\ fontsize\);\s*
\}\);)
}{utils.changeClassStyle('favorite-font', 'fontSize', fontsize);
document.documentElement.style.setProperty('--neo-preferred-font-size', fontsize);
\});}x or
die "could not apply preferred font size to connected controls\n";
write_file($js_path, $js);
my $models_path = "$source_dir/src/js/models.js";
my $models = read_file($models_path);
$models =~ s{(var\ nicklist\ =\ \{\};)}{$1\n var nickColours = {};} or
die "could not add nick colour index\n";
$models =~ s{(nicklist\[group\]\.nicks\.push\(nick\);)}{$1\n nickColours[nick.name] = nick.displayClasses;} or
die "could not index added nick colour\n";
$models =~ s{(if\ \(group\ ===\ undefined\)\ \{.*?\n\s*\})\s*(for\ \(i\ in\ group\.nicks\)\ \{)}{$1\n $2}sx or
die "could not locate nick deletion loop\n";
$models =~ s{(if\ \(group\.nicks\[i\]\.name\ ==\ nick\.name\)\ \{)}{$1\n delete nickColours[nick.name];} or
die "could not remove deleted nick colour\n";
$models =~ s{(var\ clearNicklist\ =\ function\(\)\ \{)}{$1\n nickColours = {};} or
die "could not clear nick colour index\n";
$models =~ s{(if\ \(group\.nicks\[i\]\.name\ ===\ nick\.name\)\ \{\s*group\.nicks\[i\]\ =\ nick;)}{$1\n nickColours[nick.name] = nick.displayClasses;}x or
die "could not update nick colour index\n";
$models =~ s{
(//\ Check\ whether\ a\ particular\ nick\ is\ in\ the\ nicklist)
}{$1
var getNickColourClasses = function(nick) {
return nick ? (nickColours[nick] || ['neo-nick']) : [];
};
}x or die "could not add nick colour lookup\n";
$models =~ s{(nicklistRequested:\ nicklistRequested,)}{$1\n getNickColourClasses: getNickColourClasses,} or
die "could not export nick colour lookup\n";
$models =~ s{(for\ \(var\ pti\ =\ 0;\ pti\ <\ prefix\.length;\ \+\+pti\)\ \{\s*prefixtext\ \+=\ prefix\[pti\]\.text;\s*\})}{$1\n var nick = showHiddenBrackets && prefix.length ? prefix[prefix.length - 1].text : '';}x or
die "could not identify chat prefix nick\n";
$models =~ s{(prefixtext:\ prefixtext,)}{$1\n nick: nick,} or
die "could not export chat prefix nick\n";
$models =~ s{
(var\ colorClasses\ =\ nickGetClasses\(message\);)\s*
return\ \{\s*
prefix:\ prefix,\s*
visible:\ visible,\s*
name:\ name,\s*
prefixClasses:\ colorClasses\.prefix,\s*
nameClasses:\ colorClasses\.name\s*
\};
}{$1
var roleClass = 'neo-nick';
if (colorClasses.name.indexOf('cof-nicklist_away') >= 0) roleClass = 'neo-nick-away';
else if (['~', '&', String.fromCharCode(64)].indexOf(prefix.charAt(0)) >= 0) roleClass = 'neo-nick-op';
else if (prefix.charAt(0) === '%') roleClass = 'neo-nick-halfop';
else if (prefix.charAt(0) === '+') roleClass = 'neo-nick-voice';
var isSelf = colorClasses.name.indexOf('cof-chat_nick_self') >= 0;
var isAway = colorClasses.name.indexOf('cof-nicklist_away') >= 0;
var hasWeeChatRoleColour = prefix && !(
colorClasses.prefix.length === 1 && colorClasses.prefix[0] === 'cwf-default'
);
var displayClasses = colorClasses.name.slice();
if (!isSelf && !isAway && hasWeeChatRoleColour) displayClasses = [roleClass];
else if (!isSelf && isAway) displayClasses.push('neo-nick-away');
else if (!isSelf && prefix && !hasWeeChatRoleColour) displayClasses.push(roleClass);
return {
prefix: prefix,
visible: visible,
name: name,
prefixClasses: colorClasses.prefix,
nameClasses: colorClasses.name,
roleClass: roleClass,
displayClasses: displayClasses
};
}x or die "could not add semantic nick colours\n";
write_file($models_path, $models);
my $connection_path = "$source_dir/src/js/connection.js";
my $connection = read_file($connection_path);
$count = ($connection =~ s!\Qconnect(d[0], d[1], d[2], d[3], d[4], false, "", d[5], function() {\E!connect(d[0], d[1], d[2], d[3], d[4], d[5], function() {!);
die "could not fix reconnect callback arguments\n" unless $count == 1;
write_file($connection_path, $connection);
my $websockets_path = "$source_dir/src/js/websockets.js";
my $websockets = read_file($websockets_path);
$count = ($websockets =~ s{
if\ \(ws\ !==\ null\ &&\ ws\.readyState\ !==\ WebSocket\.CLOSED\)\ \{\s*
ws\.close\(\);\s*
\}
}{if (ws !== null && ws.readyState !== WebSocket.CLOSED) {
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
ws.close();
}}x);
die "could not detach stale WebSocket handlers\n" unless $count == 1;
write_file($websockets_path, $websockets);
my $input_path = "$source_dir/src/directives/input.html";
my $input = read_file($input_path);
$input =~ s/title="Send image"/title="Upload encrypted file"/g;
$input =~ s/ accept="image\/\*"//;
$input =~ s{(<form\ class="form\ form-horizontal"\ id="inputform"[^>]*>)}{$1\n$command_menu} or
die "could not add command menu template\n";
$input =~ s{(ng-paste="inputPasted\(\$event\)")}{$1 aria-autocomplete="list" aria-controls="{{inputId}}-command-menu" aria-expanded="{{commandMenuOpen}}"} or
die "could not add command menu accessibility attributes\n";
write_file($input_path, $input);
my $inputbar_path = "$source_dir/src/js/inputbar.js";
my $inputbar = read_file($inputbar_path);
$inputbar =~ s{(\s+\$scope\.utils\s*=\s*utils;)}{$1\n\n$command_menu_js} or
die "could not add command menu behavior\n";
$inputbar =~ s{
(\s+//\ Cancel\ any\ command\ completion\ that\ was\ still\ ongoing)
}{\n updateCommandMenu();\n$1}x or
die "could not update command menu on input\n";
$inputbar =~ s{
(\s+//\ Support\ different\ browser\ quirks\s*
var\ code\ =\ \$event\.keyCode\ \?\ \$event\.keyCode\ :\ \$event\.charCode;)
}{$1
if (\$scope.handleCommandMenuKey(\$event, code, inputNode)) {
return true;
}}x or die "could not add command menu keyboard handling\n";
$count = ($inputbar =~ s{
(\s+\$rootScope\.insertAtCaret\s*=\s*function\(toInsert\)\s*\{)
}{
\$rootScope.registerNeoDrop = function(readUrl, deleteCapability) {
connection.sendMessage('/drops register ' + readUrl + ' ' + deleteCapability);
};\n$1
}x);
die "could not add NeoDrop manager registration callback\n" unless $count == 1;
$count = ($inputbar =~ s{
\s*//\ Show\ toast\ with\ delete\ link
\s*var\ toastImgur\s*=.*?
\s*console\.log\('An\ image\ was\ uploaded\ to\ imgur,\ delete\ it\ with\ .*?;
}{}sx);
die "could not remove exposed Imgur deletion capability\n" unless $count == 1;
write_file($inputbar_path, $inputbar);
my $connection_source_path = "$source_dir/src/js/connection.js";
my $connection_source = read_file($connection_source_path);
$count = ($connection_source =~ s{
(\s+var\ requestCompletion\ =\ function\(bufferId,\ position,\ data\)\ \{)
}{
var requestInfolist = function(name, pointer, args) {
return ngWebsockets.send(
weeChat.Protocol.formatInfolist({name: name, pointer: pointer || 0, args: args || ''})
).then(function(message) {
if (!message.objects || !message.objects[0]) return [];
return message.objects[0].content.map(function(fields) {
var item = {};
fields.forEach(function(field) {
Object.keys(field).forEach(function(key) { item[key] = field[key]; });
});
return item;
});
});
};
var requestCommandHooks = function() {
return requestInfolist('hook', 0, 'command').then(function(items) {
return items.map(function(hook) {
return {
command: hook.command || '',
args: hook.args_nls || hook.args || '',
description: hook.description_nls || hook.description || '',
priority: hook.priority || 0
};
}).filter(function(hook) { return hook.command; });
});
};
var sendMessageToBuffer = function(bufferId, message) {
ngWebsockets.send(weeChat.Protocol.formatInput({buffer: '0x' + bufferId, data: message}));
};$1
}x);
die "could not add command hook request\n" unless $count == 1;
$connection_source =~ s{
(\s+requestCompletion:\ requestCompletion)
}{$1,\n requestCommandHooks: requestCommandHooks,\n requestInfolist: requestInfolist,\n sendMessageToBuffer: sendMessageToBuffer}x or
die "could not export command hook request\n";
$connection_source =~ s{
(\s+\$rootScope\.connected\s*=\s*true;)
}{$1
\$rootScope.reconnecting = false;
\$rootScope.loadingLines = false;
\$rootScope.errorMessage = false;}x or
die "could not reset successful connection state\n";
write_file($connection_source_path, $connection_source);
my $plugin_directive_path = "$source_dir/src/js/plugin-directive.js";
my $plugin_directive = read_file($plugin_directive_path);
$plugin_directive =~ s{
\$scope\.plugin\.visible\s*=\s*!settings\.noembed\s*&&\s*!\$scope\.plugin\.nsfw;
}{\$scope.plugin.visible = !\$scope.plugin.nsfw &&
(\$scope.plugin.name === 'NeoDrop media' || !settings.noembed);}x or
die "could not enable automatic NeoDrop media previews\n";
write_file($plugin_directive_path, $plugin_directive);
my $plugins_path = "$source_dir/src/js/plugins.js";
my $plugins = read_file($plugins_path);
$plugins =~ s{(\s+return \{\s*plugins: \[)}{$drop_plugin$1neoDropMediaPlugin, neoDropTextPlugin, } or
die "could not register NeoDrop preview plugin\n";
write_file($plugins_path, $plugins);
my $main_path = "$source_dir/src/main.js";
my $main = read_file($main_path);
$main =~ s{(import "\./js/glowingbear\.js";)}{$1
import "./js/context-menu.js";
import "./js/server-manager.js";} or
die "could not import NeoBear UI modules\n";
$main .= q!
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('serviceworker.js');
});
}
!;
write_file($main_path, $main);
my $webpack_path = "$source_dir/webpack.config.js";
my $webpack = read_file($webpack_path);
$webpack =~ s{
(output:\s*\{\s*path:\s*path\.resolve\(__dirname,\s*'build'\),)
}{$1\n filename: 'main.neorelay.27.js',}x or
die "could not version JavaScript bundle\n";
$webpack =~ s{"\*\*/\*\.png",}{"**/*.png",\n "**/*.jpg",} or
die "could not enable JPEG asset copying\n";
$webpack =~ s{"\*\*/\*\.jpg",}{"**/*.jpg",\n "**/*.woff2",\n "assets/fonts/OFL.txt",} or
die "could not enable OpenDyslexic asset copying\n";
write_file($webpack_path, $webpack);
for my $manifest (qw(webapp.manifest.json manifest.json manifest.webapp)) {
my $path = "$source_dir/src/$manifest";
next unless -f $path;
my $content = read_file($path);
$content =~ s{assets/img/glowing_bear_(?:60x60|90x90|128x128)\.png}{assets/neorelay-avatar-icon.png}g;
$content =~ s{assets/img/favicon\.png}{assets/neorelay-avatar-icon.png}g;
$content =~ s/"theme_color"\s*:\s*"#[0-9a-fA-F]+"/"theme_color": "#150a24"/g;
$content =~ s/"background_color"\s*:\s*"#[0-9a-fA-F]+"/"background_color": "#07030d"/g;
write_file($path, $content);
}
make_path("$source_dir/src/assets");
make_path("$source_dir/src/assets/fonts");
for my $font (qw(
OpenDyslexic-Regular.woff2
OpenDyslexic-Italic.woff2
OpenDyslexic-Bold.woff2
OpenDyslexic-BoldItalic.woff2
OFL.txt
)) {
copy("$assets_dir/fonts/$font", "$source_dir/src/assets/fonts/$font") or
die "copy OpenDyslexic asset $font: $!\n";
}
copy("$assets_dir/avatar-login.jpg", "$source_dir/src/assets/neorelay-avatar-login.jpg") or
die "copy login avatar: $!\n";
copy("$assets_dir/avatar-icon.png", "$source_dir/src/assets/neorelay-avatar-icon.png") or
die "copy favicon: $!\n";
copy("$assets_dir/cyberpunk.css", "$source_dir/src/css/neorelay-cyberpunk.css") or
die "copy cyberpunk stylesheet: $!\n";
copy("$assets_dir/neodrop.js", "$source_dir/src/js/imgur.js") or
die "copy NeoDrop uploader: $!\n";
copy("$assets_dir/neodrop-crypto.js", "$source_dir/src/js/neodrop-crypto.js") or
die "copy NeoDrop crypto: $!\n";
copy("$assets_dir/context-menu.js", "$source_dir/src/js/context-menu.js") or
die "copy context menu: $!\n";
copy("$assets_dir/context-menu.html", "$source_dir/src/directives/context-menu.html") or
die "copy context menu template: $!\n";
copy("$assets_dir/server-manager.js", "$source_dir/src/js/server-manager.js") or
die "copy server manager: $!\n";
copy("$assets_dir/server-manager.html", "$source_dir/src/directives/server-manager.html") or
die "copy server manager template: $!\n";