commit 7141326ea05e7c2fcbb3b93a9003ba4ed5c7fb64 Author: NIA Sanitized Release Publisher Date: Mon Jul 20 22:35:36 2026 +0000 Sanitized public release v1.0.3 Source-Tag: v1.0.3 Manifest-SHA256: b7b09bf48f9e092ed8ad82f99c0319d6c3837d0a1549bd0cc4a374276c0f3896 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e6dc014 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 new file mode 100644 index 0000000..63430cd --- /dev/null +++ b/MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6bc72a --- /dev/null +++ b/README.md @@ -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. diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..cc984af --- /dev/null +++ b/UPGRADING.md @@ -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. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..21e8796 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.3 diff --git a/files/glowing-bear/colour-settings.html b/files/glowing-bear/colour-settings.html new file mode 100644 index 0000000..4a40afb --- /dev/null +++ b/files/glowing-bear/colour-settings.html @@ -0,0 +1,20 @@ +
+
+

These values are read from and saved to WeeChat. Role colours update irc.color.nick_prefixes.

+ +
+

+
Chat
+
+ +
+
Nickname roles
+
+ + + + +
+
Channel nickname palette
+
weechat.color.chat_nick_colors
+
diff --git a/files/glowing-bear/colour-settings.js.inc b/files/glowing-bear/colour-settings.js.inc new file mode 100644 index 0000000..f7b6e1d --- /dev/null +++ b/files/glowing-bear/colour-settings.js.inc @@ -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); + }); diff --git a/files/glowing-bear/command-menu.html b/files/glowing-bear/command-menu.html new file mode 100644 index 0000000..4e89b9e --- /dev/null +++ b/files/glowing-bear/command-menu.html @@ -0,0 +1,9 @@ +
+ +
Loading commands...
+
No matching commands
+
diff --git a/files/glowing-bear/command-menu.js.inc b/files/glowing-bear/command-menu.js.inc new file mode 100644 index 0000000..f67abeb --- /dev/null +++ b/files/glowing-bear/command-menu.js.inc @@ -0,0 +1,146 @@ + var commandHooks = null; + var commandHooksRequest = null; + var commandMenuGeneration = 0; + var suppressCommandMenuKeypress = false; + var serverCommandFallbacks = [ + {command:'defcon',args:'[]',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:'[] / [] || LIST',description:'Add or list IP/network bans',insertion:'/quote DLINE',serverCommand:true}, + {command:'kline',args:'[] [] || LIST',description:'Add or list client-mask bans',insertion:'/quote KLINE',serverCommand:true}, + {command:'uban',args:'ADD|DEL|LIST|INFO ',description:'Manage unified bans',insertion:'/quote UBAN',serverCommand:true}, + {command:'undline',args:'/',description:'Remove an IP/network ban',insertion:'/quote UNDLINE',serverCommand:true}, + {command:'unkline',args:'',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); diff --git a/files/glowing-bear/connection-panel.html b/files/glowing-bear/connection-panel.html new file mode 100644 index 0000000..4ede9ba --- /dev/null +++ b/files/glowing-bear/connection-panel.html @@ -0,0 +1,36 @@ +
+
+
+ +
+
+
diff --git a/files/glowing-bear/context-menu.html b/files/glowing-bear/context-menu.html new file mode 100644 index 0000000..344835f --- /dev/null +++ b/files/glowing-bear/context-menu.html @@ -0,0 +1,3 @@ + diff --git a/files/glowing-bear/context-menu.js b/files/glowing-bear/context-menu.js new file mode 100644 index 0000000..99242c2 --- /dev/null +++ b/files/glowing-bear/context-menu.js @@ -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); }); + }}; + }]); +}()); diff --git a/files/glowing-bear/cyberpunk.css b/files/glowing-bear/cyberpunk.css new file mode 100644 index 0000000..4c79f32 --- /dev/null +++ b/files/glowing-bear/cyberpunk.css @@ -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; + } +} diff --git a/files/glowing-bear/drop-settings.html b/files/glowing-bear/drop-settings.html new file mode 100644 index 0000000..fd79e72 --- /dev/null +++ b/files/glowing-bear/drop-settings.html @@ -0,0 +1,13 @@ +
  • +
    +
    + +
    + +
    +
    +
    +
  • diff --git a/files/glowing-bear/fonts/OFL.txt b/files/glowing-bear/fonts/OFL.txt new file mode 100644 index 0000000..bb86782 --- /dev/null +++ b/files/glowing-bear/fonts/OFL.txt @@ -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. diff --git a/files/glowing-bear/fonts/OpenDyslexic-Bold.woff2 b/files/glowing-bear/fonts/OpenDyslexic-Bold.woff2 new file mode 100644 index 0000000..b5fd5e4 Binary files /dev/null and b/files/glowing-bear/fonts/OpenDyslexic-Bold.woff2 differ diff --git a/files/glowing-bear/fonts/OpenDyslexic-BoldItalic.woff2 b/files/glowing-bear/fonts/OpenDyslexic-BoldItalic.woff2 new file mode 100644 index 0000000..bc72380 Binary files /dev/null and b/files/glowing-bear/fonts/OpenDyslexic-BoldItalic.woff2 differ diff --git a/files/glowing-bear/fonts/OpenDyslexic-Italic.woff2 b/files/glowing-bear/fonts/OpenDyslexic-Italic.woff2 new file mode 100644 index 0000000..9b78966 Binary files /dev/null and b/files/glowing-bear/fonts/OpenDyslexic-Italic.woff2 differ diff --git a/files/glowing-bear/fonts/OpenDyslexic-Regular.woff2 b/files/glowing-bear/fonts/OpenDyslexic-Regular.woff2 new file mode 100644 index 0000000..4ee220b Binary files /dev/null and b/files/glowing-bear/fonts/OpenDyslexic-Regular.woff2 differ diff --git a/files/glowing-bear/login-hero.html b/files/glowing-bear/login-hero.html new file mode 100644 index 0000000..cad7368 --- /dev/null +++ b/files/glowing-bear/login-hero.html @@ -0,0 +1,16 @@ + diff --git a/files/glowing-bear/neodrop-crypto.js b/files/glowing-bear/neodrop-crypto.js new file mode 100644 index 0000000..8eb8aad --- /dev/null +++ b/files/glowing-bear/neodrop-crypto.js @@ -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 + }; +})); diff --git a/files/glowing-bear/neodrop-plugin.js.inc b/files/glowing-bear/neodrop-plugin.js.inc new file mode 100644 index 0000000..861661e --- /dev/null +++ b/files/glowing-bear/neodrop-plugin.js.inc @@ -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; diff --git a/files/glowing-bear/neodrop.js b/files/glowing-bear/neodrop.js new file mode 100644 index 0000000..25fce0b --- /dev/null +++ b/files/glowing-bear/neodrop.js @@ -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}; +}]); + +})(); diff --git a/files/glowing-bear/server-manager.html b/files/glowing-bear/server-manager.html new file mode 100644 index 0000000..08d99b4 --- /dev/null +++ b/files/glowing-bear/server-manager.html @@ -0,0 +1,17 @@ +
    +
    + +
    diff --git a/files/glowing-bear/server-manager.js b/files/glowing-bear/server-manager.js new file mode 100644 index 0000000..df7042e --- /dev/null +++ b/files/glowing-bear/server-manager.js @@ -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); }); }); + }}; + }]); +}()); diff --git a/files/glowing-bear/sidebar-resize.js.inc b/files/glowing-bear/sidebar-resize.js.inc new file mode 100644 index 0000000..6d9c492 --- /dev/null +++ b/files/glowing-bear/sidebar-resize.js.inc @@ -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); diff --git a/files/glowing-bear/weechat-settings.html b/files/glowing-bear/weechat-settings.html new file mode 100644 index 0000000..b304e0c --- /dev/null +++ b/files/glowing-bear/weechat-settings.html @@ -0,0 +1,20 @@ +
    +
    + + + +
    +

    Changes are validated, written to WeeChat, read back, and then saved to the corresponding configuration file.

    +

    +
    +
    +
    + + + + + +
    +
    +
    +
    diff --git a/files/glowing-bear/weechat-settings.js.inc b/files/glowing-bear/weechat-settings.js.inc new file mode 100644 index 0000000..440a598 --- /dev/null +++ b/files/glowing-bear/weechat-settings.js.inc @@ -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); + }; diff --git a/files/neodrop-manager/manage.css b/files/neodrop-manager/manage.css new file mode 100644 index 0000000..7725555 --- /dev/null +++ b/files/neodrop-manager/manage.css @@ -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; } } diff --git a/files/neodrop-manager/manage.html b/files/neodrop-manager/manage.html new file mode 100644 index 0000000..ab2fc95 --- /dev/null +++ b/files/neodrop-manager/manage.html @@ -0,0 +1,52 @@ + + + + + + + NeoDrop Management + + + +
    +
    +

    {{OPERATOR_LABEL}}

    +

    NeoDrop Management

    +

    Files are encrypted in this browser. The storage service receives ciphertext only.

    +
    + +
    +

    New encrypted drop

    +
    + + + +
    + +

    Ready.

    + +
    + +
    +
    +

    Managed drops

    + +
    + +
    + + + +
    NameTypeSizeExpiresActions
    +
    +
    +
    + + + + diff --git a/files/neodrop-manager/manage.js b/files/neodrop-manager/manage.js new file mode 100644 index 0000000..77b292b --- /dev/null +++ b/files/neodrop-manager/manage.js @@ -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); }); +}()); diff --git a/files/neodrop-manager/neodrop_manager_agent.py b/files/neodrop-manager/neodrop_manager_agent.py new file mode 100644 index 0000000..27a4d92 --- /dev/null +++ b/files/neodrop-manager/neodrop_manager_agent.py @@ -0,0 +1,1143 @@ +#!/usr/bin/python3 +import base64 +import grp +import ipaddress +import json +import os +import pwd +import re +import secrets +import socket +import socketserver +import struct +import threading +import time +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler +from pathlib import Path +from urllib.parse import urlsplit + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + + +BASE_URL = "{{DROPS_ORIGIN}}" +ORIGIN = "{{RELAY_ORIGIN}}" +MANAGEMENT_ORIGIN = "{{DROPS_ORIGIN}}" +SOCKET_PATH = Path("{{MANAGER_CONTROL_SOCKET_PATH}}") +CATALOG_PATH = Path("{{MANAGER_CATALOG_PATH}}") +DOWNLOAD_ROOT = Path("{{MANAGER_DOWNLOAD_ROOT}}") +ASSET_ROOT = Path("/usr/local/share/neodrop-manager") +HTTP_SOCKET_PATH = Path("{{MANAGER_HTTP_SOCKET_PATH}}") +MANAGEMENT_NETWORKS = ( + ipaddress.ip_network("{{MANAGEMENT_CIDR_1}}"), + ipaddress.ip_network("{{MANAGEMENT_CIDR_2}}"), + ipaddress.ip_network("{{MANAGEMENT_CIDR_3}}"), +) +CATALOG_KEY_PATH = Path( + "{{CATALOG_KEY_PATH}}" +) +API_TOKEN_PATH = Path( + "{{MANAGER_API_TOKEN_PATH}}" +) +CHUNK_SIZE = 4 * 1024 * 1024 +TEXT_READ_MAX = 1024 * 1024 +TEXT_EDIT_MAX = 256 * 1024 +TEXT_EDIT_LINES_MAX = 2000 +HKDF_SALT = b"{{NEODROP_CRYPTO_CONTEXT}}" +CATALOG_HEADER = b"NEODROP-CATALOG\x00\x01" +OBJECT_ID_RE = re.compile(r"^[A-Za-z0-9_-]{32}$") +TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{43}$") +READ_URL_RE = re.compile( + r"^https://{{DROPS_HOST_REGEX}}/d/([A-Za-z0-9_-]{32})" + r"(?:\?k=(image|audio|video|text|binary))?#d1\.([A-Za-z0-9_-]{43})$" +) +PROFILES = {0: 50 * 1024 * 1024, 1: 1024 * 1024 * 1024} +PROTOCOL_PAYLOAD_MAX = 12 * 1024 + + +class ManagerError(Exception): + pass + + +class Response: + def __init__(self, status, body, headers=None): + self.status = status + self.body = body + self.headers = headers or {} + + def json(self): + try: + return json.loads(self.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ManagerError("invalid JSON response") from error + + +class HTTPTransport: + def request(self, method, path, body=None, headers=None): + if not path.startswith("/api/v1/") or "?" in path or "#" in path: + raise ManagerError("invalid API path") + request = urllib.request.Request( + BASE_URL + path, data=body, method=method, headers=headers or {} + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return Response(response.status, response.read(), dict(response.headers)) + except urllib.error.HTTPError as error: + error.read() + raise ManagerError("NeoDrop request failed (%d)" % error.code) from error + except urllib.error.URLError as error: + raise ManagerError("NeoDrop request unavailable") from error + + +def b64url(data): + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def unb64url(value, expected=None): + try: + raw = base64.urlsafe_b64decode(value + "=" * ((4 - len(value) % 4) % 4)) + except (ValueError, TypeError) as error: + raise ManagerError("invalid base64 value") from error + if expected is not None and len(raw) != expected: + raise ManagerError("invalid base64 value") + if b64url(raw) != value: + raise ManagerError("invalid base64 value") + return raw + + +def derive(secret, info, length): + return HKDF( + algorithm=hashes.SHA256(), + length=length, + salt=HKDF_SALT, + info=info.encode("ascii"), + ).derive(secret) + + +def make_aad(record_type, profile, object_id, size, count, index, length): + return ( + b"NEODRP01" + + bytes((record_type, profile, 0, 0)) + + object_id + + struct.pack(">QIIII", size, CHUNK_SIZE, count, index, length) + ) + + +def make_iv(record_type, index): + return struct.pack(">IQ", record_type, index) + + +def is_text_type(content_type): + return bool( + re.match(r"^text/", content_type, re.IGNORECASE) + or re.match( + r"^application/(json|javascript|x-.*script)$", + content_type, + re.IGNORECASE, + ) + ) + + +def content_kind(content_type, name=""): + if re.match(r"^image/(png|jpeg|gif|webp|avif)$", content_type, re.IGNORECASE): + return "image" + if re.match(r"^audio/", content_type, re.IGNORECASE): + return "audio" + if re.match(r"^video/", content_type, re.IGNORECASE): + return "video" + extension = Path(name).suffix.lower() + if extension in (".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"): + return "image" + if extension in (".flac", ".m4a", ".mp3", ".oga", ".ogg", ".opus", ".wav", ".wave"): + return "audio" + if extension in (".m4v", ".mkv", ".mov", ".mp4", ".ogv", ".webm"): + return "video" + if is_text_type(content_type): + return "text" + return "binary" + + +def safe_filename(value): + name = str(value or "drop.bin").replace("\\", "/").split("/")[-1] + name = "".join(char for char in name if ord(char) >= 32 and ord(char) != 127) + name = name.strip().strip(".")[:240] + return name or "drop.bin" + + +class EncryptedCatalog: + def __init__(self, path, key): + if len(key) != 32: + raise ManagerError("catalog key must be exactly 32 bytes") + self.path = Path(path) + self.key = key + + def load(self): + try: + payload = self.path.read_bytes() + except FileNotFoundError: + return {} + minimum = len(CATALOG_HEADER) + 12 + 16 + if len(payload) < minimum or not payload.startswith(CATALOG_HEADER): + raise ManagerError("invalid encrypted catalog") + nonce_at = len(CATALOG_HEADER) + nonce = payload[nonce_at:nonce_at + 12] + try: + plaintext = AESGCM(self.key).decrypt( + nonce, payload[nonce_at + 12:], CATALOG_HEADER + ) + document = json.loads(plaintext.decode("utf-8")) + except Exception as error: + raise ManagerError("unable to decrypt catalog") from error + if not isinstance(document, dict) or document.get("v") != 1: + raise ManagerError("invalid catalog version") + entries = document.get("entries") + if not isinstance(entries, list): + raise ManagerError("invalid catalog entries") + result = {} + for entry in entries: + if not isinstance(entry, dict) or not OBJECT_ID_RE.fullmatch(entry.get("id", "")): + raise ManagerError("invalid catalog entry") + result[entry["id"]] = entry + return result + + def save(self, entries): + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + document = {"v": 1, "entries": sorted(entries.values(), key=lambda item: item["id"])} + plaintext = json.dumps( + document, separators=(",", ":"), sort_keys=True, ensure_ascii=True + ).encode("utf-8") + nonce = secrets.token_bytes(12) + payload = CATALOG_HEADER + nonce + AESGCM(self.key).encrypt( + nonce, plaintext, CATALOG_HEADER + ) + temporary = self.path.with_name( + ".%s.%s.tmp" % (self.path.name, secrets.token_hex(8)) + ) + try: + with open(temporary, "xb") as handle: + os.fchmod(handle.fileno(), 0o600) + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, self.path) + directory = os.open(self.path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +class NeoDropManager: + def __init__(self, catalog, api_token, transport=None, download_root=None): + if not api_token or any(ord(char) < 33 or ord(char) > 126 for char in api_token): + raise ManagerError("invalid manager API token") + self.catalog = catalog + self.api_token = api_token + self.transport = transport or HTTPTransport() + self.download_root = Path(download_root or DOWNLOAD_ROOT) + self.edit_session = None + self.lock = threading.RLock() + + def request(self, method, path, body=None, headers=None): + response = self.transport.request(method, path, body=body, headers=headers or {}) + if response.status < 200 or response.status >= 300: + raise ManagerError("NeoDrop request failed (%d)" % response.status) + return response + + def json_request(self, method, path, value=None, headers=None): + body = None + request_headers = dict(headers or {}) + if value is not None: + body = json.dumps(value, separators=(",", ":")).encode("utf-8") + request_headers["Content-Type"] = "application/json" + return self.request(method, path, body, request_headers).json() + + def inventory(self): + value = self.json_request( + "GET", + "/api/v1/manager/objects", + headers={"Authorization": "Bearer " + self.api_token}, + ) + rows = value.get("objects") if isinstance(value, dict) else value + if not isinstance(rows, list): + raise ManagerError("invalid manager inventory") + result = {} + for row in rows: + if not isinstance(row, dict): + raise ManagerError("invalid manager inventory") + object_id = row.get("id") or row.get("objectId") or row.get("object_id") + if not OBJECT_ID_RE.fullmatch(object_id or ""): + raise ManagerError("invalid manager inventory") + result[object_id] = row + return result + + def resolve(self, selector, entries=None): + if not selector or not re.fullmatch(r"[A-Za-z0-9_-]{1,32}", selector): + raise ManagerError("invalid selector") + entries = entries if entries is not None else self.catalog.load() + matches = [entry for object_id, entry in entries.items() if object_id.startswith(selector)] + if not matches: + raise ManagerError("object not found") + if len(matches) != 1: + raise ManagerError("selector is ambiguous") + return matches[0] + + def parse_read_url(self, read_url): + match = READ_URL_RE.fullmatch(read_url) + if not match: + raise ManagerError("invalid NeoDrop read URL") + object_id, _, encoded_secret = match.groups() + secret = unb64url(encoded_secret, 32) + derived_id = derive(secret, "object-id", 24) + if b64url(derived_id) != object_id: + raise ManagerError("read URL identifier mismatch") + return object_id, secret, derived_id + + def manifest_and_metadata(self, read_url): + object_id, secret, object_id_raw = self.parse_read_url(read_url) + manifest = self.json_request("GET", "/api/v1/objects/" + object_id) + try: + version = manifest["v"] + profile = int(manifest["profile"]) + size = int(manifest["size"]) + chunk_size = int(manifest["chunkSize"]) + chunks = int(manifest["chunks"]) + expires_at = int(manifest["expiresAt"]) + metadata_cipher = unb64url(manifest["metadata"]) + except (KeyError, TypeError, ValueError): + raise ManagerError("invalid object manifest") from None + expected_chunks = (size + CHUNK_SIZE - 1) // CHUNK_SIZE if size else 0 + if ( + version != 1 + or profile not in PROFILES + or size < 0 + or size > PROFILES[profile] + or chunk_size != CHUNK_SIZE + or chunks != expected_chunks + or len(metadata_cipher) < 16 + or len(metadata_cipher) > 4112 + ): + raise ManagerError("invalid object manifest") + metadata_length = len(metadata_cipher) - 16 + try: + plaintext = AESGCM(derive(secret, "metadata-key", 32)).decrypt( + make_iv(2, 0), + metadata_cipher, + make_aad( + 0, profile, object_id_raw, size, chunks, 0xFFFFFFFF, metadata_length + ), + ) + metadata = json.loads(plaintext.decode("utf-8")) + except Exception as error: + raise ManagerError("metadata authentication failed") from error + if ( + not isinstance(metadata, dict) + or metadata.get("v") != 1 + or metadata.get("size") != size + or not isinstance(metadata.get("name"), str) + or not isinstance(metadata.get("type"), str) + ): + raise ManagerError("invalid authenticated metadata") + normalized = { + "v": 1, + "profile": profile, + "size": size, + "chunkSize": chunk_size, + "chunks": chunks, + "expiresAt": expires_at, + } + return object_id, secret, object_id_raw, normalized, metadata + + def register(self, read_url, delete_capability): + object_id, _, _, manifest, metadata = self.manifest_and_metadata(read_url) + parts = delete_capability.split(".") + if ( + len(parts) != 2 + or parts[0] != object_id + or not TOKEN_RE.fullmatch(parts[1]) + ): + raise ManagerError("invalid delete capability") + entries = self.catalog.load() + if object_id in entries: + existing = entries[object_id] + if ( + existing.get("read_url") == read_url + and existing.get("delete_capability") == delete_capability + ): + return ["registered %s generation %d" % (object_id, existing["generation"])] + raise ManagerError("object is already registered") + entry = { + "id": object_id, + "read_url": read_url, + "delete_capability": delete_capability, + "metadata": metadata, + "size": manifest["size"], + "profile": manifest["profile"], + "chunks": manifest["chunks"], + "expires_at": manifest["expiresAt"], + "generation": 1, + } + entries[object_id] = entry + self.catalog.save(entries) + return ["registered %s generation 1" % object_id] + + def browser_entry(self, entry): + metadata = entry.get("metadata", {}) + return { + "id": entry["id"], + "name": metadata.get("name", "drop.bin"), + "type": metadata.get("type", "application/octet-stream"), + "size": entry["size"], + "profile": entry["profile"], + "expiresAt": entry["expires_at"], + "readUrl": entry["read_url"], + } + + def browser_list(self): + entries = self.catalog.load() + return [ + self.browser_entry(entry) + for entry in sorted( + entries.values(), key=lambda item: (item["expires_at"], item["id"]) + ) + ] + + def browser_register(self, read_url, delete_capability): + self.register(read_url, delete_capability) + object_id, _, _ = self.parse_read_url(read_url) + return self.browser_entry(self.catalog.load()[object_id]) + + def browser_delete(self, object_id): + if not OBJECT_ID_RE.fullmatch(object_id or ""): + raise ManagerError("invalid object identifier") + self.delete(object_id) + + def list_objects(self): + entries = self.catalog.load() + inventory = self.inventory() + lines = [] + for object_id in sorted(set(entries) | set(inventory)): + if object_id in entries: + entry = entries[object_id] + lines.append( + "%s managed gen=%d size=%d profile=%d name=%s" + % ( + object_id, + entry["generation"], + entry["size"], + entry["profile"], + entry["metadata"]["name"], + ) + ) + else: + row = inventory[object_id] + lines.append( + "%s unmanaged size=%s profile=%s" + % (object_id, row.get("size", "?"), row.get("profile", "?")) + ) + return lines or ["no objects"] + + def show(self, selector): + entry = self.resolve(selector) + metadata = json.dumps( + entry["metadata"], separators=(",", ":"), sort_keys=True, ensure_ascii=True + ) + return [ + "id %s" % entry["id"], + "generation %d" % entry["generation"], + "profile %d" % entry["profile"], + "size %d" % entry["size"], + "chunks %d" % entry["chunks"], + "expires %d" % entry["expires_at"], + "metadata %s" % metadata, + "url %s" % entry["read_url"], + ] + + def prepare_content(self, entry, maximum=None): + object_id, secret, object_id_raw, manifest, metadata = self.manifest_and_metadata( + entry["read_url"] + ) + if object_id != entry["id"]: + raise ManagerError("catalog identifier mismatch") + if maximum is not None and manifest["size"] > maximum: + raise ManagerError("object is too large") + key = AESGCM(derive(secret, "content-key", 32)) + return object_id, object_id_raw, manifest, metadata, key + + def decrypted_chunks(self, prepared): + object_id, object_id_raw, manifest, _, key = prepared + for index in range(manifest["chunks"]): + plain_length = min(CHUNK_SIZE, manifest["size"] - index * CHUNK_SIZE) + cipher = self.request( + "GET", "/api/v1/objects/%s/chunks/%d" % (object_id, index) + ).body + if len(cipher) != plain_length + 16: + raise ManagerError("invalid encrypted chunk length") + try: + plain = key.decrypt( + make_iv(1, index), + cipher, + make_aad( + 1, + manifest["profile"], + object_id_raw, + manifest["size"], + manifest["chunks"], + index, + plain_length, + ), + ) + except Exception as error: + raise ManagerError("content authentication failed") from error + yield plain + + def decrypt_content(self, entry, maximum=None): + prepared = self.prepare_content(entry, maximum) + manifest = prepared[2] + metadata = prepared[3] + parts = list(self.decrypted_chunks(prepared)) + return b"".join(parts), metadata, manifest + + def read_text(self, selector, edit_limit=False): + entry = self.resolve(selector) + maximum = TEXT_EDIT_MAX if edit_limit else TEXT_READ_MAX + content, metadata, _ = self.decrypt_content(entry, maximum) + if not is_text_type(metadata["type"]): + raise ManagerError("object is not text") + try: + text = content.decode("utf-8") + except UnicodeDecodeError as error: + raise ManagerError("text is not valid UTF-8") from error + return entry, text, metadata + + def read_command(self, selector): + _, text, _ = self.read_text(selector) + return text.split("\n") + + def unique_download_path(self, name): + self.download_root.mkdir(mode=0o700, parents=True, exist_ok=True) + safe = safe_filename(name) + candidate = self.download_root / safe + stem = candidate.stem + suffix = candidate.suffix + number = 1 + while candidate.exists(): + candidate = self.download_root / ("%s (%d)%s" % (stem, number, suffix)) + number += 1 + return candidate + + def download(self, selector): + entry = self.resolve(selector) + prepared = self.prepare_content(entry) + metadata = prepared[3] + destination = self.unique_download_path(metadata["name"]) + temporary = destination.with_name(".%s.%s.tmp" % (destination.name, secrets.token_hex(8))) + try: + with open(temporary, "xb") as handle: + os.fchmod(handle.fileno(), 0o600) + for chunk in self.decrypted_chunks(prepared): + handle.write(chunk) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, destination) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + return ["downloaded %s" % destination, "url %s" % entry["read_url"]] + + def delete_remote(self, entry): + object_id, token = entry["delete_capability"].split(".", 1) + if object_id != entry["id"] or not TOKEN_RE.fullmatch(token): + raise ManagerError("invalid stored delete capability") + self.json_request( + "DELETE", + "/api/v1/objects/" + object_id, + headers={"Authorization": "Bearer " + token, "Origin": ORIGIN}, + ) + + def delete(self, selector): + entries = self.catalog.load() + entry = self.resolve(selector, entries) + self.delete_remote(entry) + del entries[entry["id"]] + self.catalog.save(entries) + if self.edit_session and self.edit_session["entry"]["id"] == entry["id"]: + self.edit_session = None + return ["deleted %s" % entry["id"]] + + def numbered_context(self, index): + lines = self.edit_session["lines"] + if not lines: + return ["(empty)"] + index = max(0, min(index, len(lines) - 1)) + first = max(0, index - 2) + last = min(len(lines), index + 3) + return ["%4d | %s" % (position + 1, lines[position]) for position in range(first, last)] + + def edit(self, selector): + entry, text, _ = self.read_text(selector, edit_limit=True) + lines = text.split("\n") + trailing_newline = bool(lines and lines[-1] == "") + if trailing_newline: + lines.pop() + if len(lines) > TEXT_EDIT_LINES_MAX: + raise ManagerError("text has too many lines") + self.edit_session = { + "entry": entry, + "lines": lines, + "trailing_newline": trailing_newline, + } + return ["editing %s (%d lines)" % (entry["id"], len(lines))] + self.numbered_context(0) + + def require_edit(self): + if self.edit_session is None: + raise ManagerError("no edit session") + + def mutate_line(self, operation, number, text=None): + self.require_edit() + if number < 1: + raise ManagerError("line number out of range") + lines = self.edit_session["lines"] + original = list(lines) + if operation == "line": + if number > len(lines): + raise ManagerError("line number out of range") + lines[number - 1] = text + focus = number - 1 + elif operation == "insert": + if number > len(lines) + 1: + raise ManagerError("line number out of range") + lines.insert(number - 1, text) + focus = number - 1 + else: + if number > len(lines): + raise ManagerError("line number out of range") + del lines[number - 1] + focus = max(0, number - 2) + if len(lines) > TEXT_EDIT_LINES_MAX: + lines[:] = original + raise ManagerError("text has too many lines") + if len(self.edit_bytes()) > TEXT_EDIT_MAX: + lines[:] = original + raise ManagerError("edited text is too large") + return self.numbered_context(focus) + + def edit_bytes(self): + text = "\n".join(self.edit_session["lines"]) + if self.edit_session["trailing_newline"]: + text += "\n" + return text.encode("utf-8") + + def upload(self, content, metadata, profile, generation): + if profile not in PROFILES or len(content) > PROFILES[profile]: + raise ManagerError("content exceeds profile limit") + secret = secrets.token_bytes(32) + object_id_raw = derive(secret, "object-id", 24) + object_id = b64url(object_id_raw) + chunks = (len(content) + CHUNK_SIZE - 1) // CHUNK_SIZE if content else 0 + session = self.json_request( + "POST", + "/api/v1/uploads", + { + "v": 1, + "id": object_id, + "profile": profile, + "size": len(content), + "chunkSize": CHUNK_SIZE, + "chunks": chunks, + }, + {"Origin": ORIGIN}, + ) + upload_id = session.get("uploadId") if isinstance(session, dict) else None + upload_token = session.get("uploadToken") if isinstance(session, dict) else None + if not re.fullmatch(r"[A-Za-z0-9_-]{22}", upload_id or "") or not TOKEN_RE.fullmatch( + upload_token or "" + ): + raise ManagerError("invalid upload session") + stored_metadata = dict(metadata) + stored_metadata.update({"v": 1, "size": len(content), "name": safe_filename(metadata["name"])}) + stored_metadata["lastModified"] = int(time.time() * 1000) + metadata_plain = json.dumps( + stored_metadata, separators=(",", ":"), ensure_ascii=True + ).encode("utf-8") + if len(metadata_plain) > 4096: + raise ManagerError("metadata is too large") + metadata_cipher = AESGCM(derive(secret, "metadata-key", 32)).encrypt( + make_iv(2, 0), + metadata_plain, + make_aad( + 0, + profile, + object_id_raw, + len(content), + chunks, + 0xFFFFFFFF, + len(metadata_plain), + ), + ) + authorization = "Bearer " + upload_token + binary_headers = { + "Authorization": authorization, + "Content-Type": "application/octet-stream", + "Origin": ORIGIN, + } + self.request( + "PUT", + "/api/v1/uploads/%s/metadata" % upload_id, + metadata_cipher, + binary_headers, + ) + content_key = AESGCM(derive(secret, "content-key", 32)) + for index in range(chunks): + plain = content[index * CHUNK_SIZE:(index + 1) * CHUNK_SIZE] + cipher = content_key.encrypt( + make_iv(1, index), + plain, + make_aad( + 1, + profile, + object_id_raw, + len(content), + chunks, + index, + len(plain), + ), + ) + self.request( + "PUT", + "/api/v1/uploads/%s/chunks/%d" % (upload_id, index), + cipher, + binary_headers, + ) + commit = self.json_request( + "POST", + "/api/v1/uploads/%s/commit" % upload_id, + {}, + {"Authorization": authorization, "Origin": ORIGIN}, + ) + try: + expires_at = int(commit["expiresAt"]) + except (KeyError, TypeError, ValueError): + raise ManagerError("invalid upload commit response") from None + read_url = "%s/d/%s?k=%s#d1.%s" % ( + BASE_URL, + object_id, + content_kind(stored_metadata["type"], stored_metadata["name"]), + b64url(secret), + ) + return { + "id": object_id, + "read_url": read_url, + "delete_capability": object_id + "." + upload_token, + "metadata": stored_metadata, + "size": len(content), + "profile": profile, + "chunks": chunks, + "expires_at": expires_at, + "generation": generation, + } + + def save_edit(self): + self.require_edit() + old_entry = self.edit_session["entry"] + content = self.edit_bytes() + new_entry = self.upload( + content, + old_entry["metadata"], + old_entry["profile"], + old_entry["generation"] + 1, + ) + entries = self.catalog.load() + current = entries.get(old_entry["id"]) + if current is None or current.get("generation") != old_entry["generation"]: + raise ManagerError("catalog changed during edit") + del entries[old_entry["id"]] + entries[new_entry["id"]] = new_entry + self.catalog.save(entries) + self.edit_session = None + lines = [ + "saved %s generation %d" % (new_entry["id"], new_entry["generation"]), + "url %s" % new_entry["read_url"], + ] + try: + self.delete_remote(old_entry) + except ManagerError: + lines.append("warning old object deletion failed") + return lines + + def put_text(self, name, text): + content = text.encode("utf-8") + if len(content) > TEXT_EDIT_MAX: + raise ManagerError("text is too large") + entry = self.upload( + content, + {"v": 1, "name": safe_filename(name), "type": "text/plain", "size": len(content)}, + 0, + 1, + ) + entries = self.catalog.load() + entries[entry["id"]] = entry + self.catalog.save(entries) + return [ + "created %s generation 1" % entry["id"], + "url %s" % entry["read_url"], + ] + + def cancel_edit(self): + self.require_edit() + self.edit_session = None + return ["edit cancelled"] + + def handle_command(self, command): + with self.lock: + command = command.rstrip("\r") + if command == "help": + return [ + "help | status | list | register READ_URL DELETE_CAPABILITY", + "put-text NAME TEXT", + "show SELECTOR | read SELECTOR | download SELECTOR | delete SELECTOR", + "edit SELECTOR | line N TEXT | insert N TEXT | delete-line N | save | cancel", + ] + if command == "status": + return [ + "catalog=%d inventory=%d edit=%s" + % ( + len(self.catalog.load()), + len(self.inventory()), + "active" if self.edit_session else "none", + ) + ] + if command == "list": + return self.list_objects() + match = re.fullmatch(r"register\s+(\S+)\s+(\S+)", command) + if match: + return self.register(match.group(1), match.group(2)) + match = re.fullmatch(r"put-text\s+(\S+)\s+(.*)", command) + if match: + return self.put_text(match.group(1), match.group(2)) + match = re.fullmatch(r"(show|read|download|delete|edit)\s+(\S+)", command) + if match: + action, selector = match.groups() + return { + "show": self.show, + "read": self.read_command, + "download": self.download, + "delete": self.delete, + "edit": self.edit, + }[action](selector) + match = re.fullmatch(r"(line|insert)\s+(\d+)\s(.*)", command) + if match: + return self.mutate_line(match.group(1), int(match.group(2)), match.group(3)) + match = re.fullmatch(r"delete-line\s+(\d+)", command) + if match: + return self.mutate_line("delete-line", int(match.group(1))) + if command == "save": + return self.save_edit() + if command == "cancel": + return self.cancel_edit() + raise ManagerError("unknown command; use help") + + +def protocol_text(value): + return str(value).replace("\x00", "").replace("\r", " ").replace("\n", " ") + + +def protocol_records(record_type, value): + if record_type not in ("LINE", "ERROR"): + raise ValueError("invalid protocol record type") + payload = protocol_text(value).encode("utf-8") + prefix = (record_type + "\t").encode("ascii") + if not payload: + yield prefix + b"\n" + return + while payload: + cut = min(PROTOCOL_PAYLOAD_MAX, len(payload)) + if cut < len(payload): + while cut > 0 and payload[cut] & 0xC0 == 0x80: + cut -= 1 + yield prefix + payload[:cut] + b"\n" + payload = payload[cut:] + + +def handle_client(client, manager): + buffer = b"" + try: + while True: + data = client.recv(4096) + if not data: + return + buffer += data + if len(buffer) > 1024 * 1024: + raise ManagerError("command is too large") + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + try: + text = line.decode("utf-8") + prefix, command = text.split("\t", 1) + if prefix != "COMMAND": + raise ManagerError("expected COMMAND record") + for output in manager.handle_command(command): + for record in protocol_records("LINE", output): + client.sendall(record) + except (ManagerError, UnicodeDecodeError, ValueError) as error: + for record in protocol_records("ERROR", protocol_text(error)[:300]): + client.sendall(record) + except (ManagerError, OSError): + pass + finally: + client.close() + + +class ManagementHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "NeoDrop-Manager" + sys_version = "" + + def log_message(self, fmt, *args): + # Request bodies contain capabilities; keep this endpoint deliberately quiet. + return + + def client_ip(self): + try: + return ipaddress.ip_address(self.headers.get("X-Real-IP", "")) + except ValueError: + return None + + def source_allowed(self): + if not self.server.peer_allowed(self.connection): + return False + address = self.client_ip() + return ( + self.headers.get("Host") == "{{DROPS_HOST}}" + and address is not None + and any(address in network for network in MANAGEMENT_NETWORKS) + ) + + def api_allowed(self): + return self.source_allowed() and self.headers.get("Origin") == MANAGEMENT_ORIGIN + + def send_body(self, status, content_type, body): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "private, no-store, max-age=0") + self.send_header("Pragma", "no-cache") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header( + "Content-Security-Policy", + "default-src 'none'; script-src 'self'; style-src 'self'; " + "connect-src 'self'; frame-ancestors 'none'; " + "base-uri 'none'; form-action 'none'", + ) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def send_json(self, status, value): + body = json.dumps(value, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + b"\n" + self.send_body(status, "application/json", body) + + def discard_request_body(self): + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + return + if 0 < length <= 4096: + self.rfile.read(length) + + def forbidden(self): + self.discard_request_body() + self.close_connection = True + self.send_json(403, {"error": "forbidden"}) + + def unavailable(self): + self.discard_request_body() + self.close_connection = True + self.send_json(404, {"error": "unavailable"}) + + def read_api_json(self, maximum): + if self.headers.get("Content-Type") != "application/json": + self.discard_request_body() + self.close_connection = True + return None + try: + length = int(self.headers.get("Content-Length", "-1")) + except ValueError: + self.close_connection = True + return None + if length < 0 or length > maximum: + self.discard_request_body() + self.close_connection = True + return None + try: + return json.loads(self.rfile.read(length).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + self.close_connection = True + return None + + def static_asset(self, path): + assets = { + "/manage/": ("manage.html", "text/html; charset=utf-8"), + "/manage/style.css": ("manage.css", "text/css; charset=utf-8"), + "/manage/manage.js": ("manage.js", "application/javascript"), + "/manage/neodrop-crypto.js": ("neodrop-crypto.js", "application/javascript"), + } + asset = assets.get(path) + if not asset: + return self.unavailable() + try: + body = (ASSET_ROOT / asset[0]).read_bytes() + except OSError: + return self.unavailable() + self.send_body(200, asset[1], body) + + def do_HEAD(self): + return self.do_GET() + + def do_GET(self): + parsed = urlsplit(self.path) + if self.path != parsed.path or not self.source_allowed(): + return self.forbidden() + return self.static_asset(parsed.path) + + def do_POST(self): + parsed = urlsplit(self.path) + if self.path != parsed.path or not self.api_allowed(): + return self.forbidden() + if parsed.path == "/manage/api/v1/list": + data = self.read_api_json(64) + if data != {}: + return self.send_json(400, {"error": "invalid request"}) + try: + with self.server.manager.lock: + rows = self.server.manager.browser_list() + except ManagerError: + return self.send_json(503, {"error": "unavailable"}) + return self.send_json(200, {"drops": rows}) + if parsed.path == "/manage/api/v1/register": + data = self.read_api_json(2048) + if not isinstance(data, dict) or set(data) != {"readUrl", "deleteCapability"}: + return self.send_json(400, {"error": "invalid request"}) + if not isinstance(data["readUrl"], str) or not isinstance(data["deleteCapability"], str): + return self.send_json(400, {"error": "invalid request"}) + try: + with self.server.manager.lock: + row = self.server.manager.browser_register( + data["readUrl"], data["deleteCapability"] + ) + except ManagerError: + return self.send_json(400, {"error": "invalid request"}) + return self.send_json(201, {"drop": row}) + return self.unavailable() + + def do_DELETE(self): + parsed = urlsplit(self.path) + match = re.fullmatch(r"/manage/api/v1/drops/([A-Za-z0-9_-]{32})", parsed.path) + if self.path != parsed.path or not self.api_allowed(): + return self.forbidden() + if not match or self.headers.get("Content-Length", "0") != "0": + return self.unavailable() + try: + with self.server.manager.lock: + self.server.manager.browser_delete(match.group(1)) + except ManagerError: + return self.send_json(404, {"error": "unavailable"}) + self.send_json(200, {"deleted": True}) + + def reject_method(self): + parsed = urlsplit(self.path) + if self.path != parsed.path or not self.api_allowed(): + return self.forbidden() + return self.unavailable() + + do_OPTIONS = reject_method + do_PATCH = reject_method + do_PUT = reject_method + + +def linux_peer_uid(connection): + option = getattr(socket, "SO_PEERCRED", None) + if option is None: + raise OSError("SO_PEERCRED is unavailable") + credentials = connection.getsockopt( + socket.SOL_SOCKET, option, struct.calcsize("3i") + ) + _, uid, _ = struct.unpack("3i", credentials) + return uid + + +class ManagementHTTPServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): + daemon_threads = True + request_queue_size = 32 + + def __init__(self, address, manager, expected_peer_uid, peer_uid=linux_peer_uid): + super().__init__(address, ManagementHandler) + self.manager = manager + self.expected_peer_uid = expected_peer_uid + self.peer_uid = peer_uid + + def peer_allowed(self, connection): + try: + return self.peer_uid(connection) == self.expected_peer_uid + except (OSError, struct.error, ValueError): + return False + + +def load_credentials(): + key = CATALOG_KEY_PATH.read_bytes() + if len(key) != 32: + raise ManagerError("catalog key credential must be exactly 32 raw bytes") + try: + token = API_TOKEN_PATH.read_bytes().strip().decode("ascii") + except UnicodeDecodeError as error: + raise ManagerError("invalid manager API token credential") from error + return key, token + + +def socket_server(manager): + SOCKET_PATH.parent.mkdir(mode=0o750, parents=True, exist_ok=True) + try: + SOCKET_PATH.unlink() + except FileNotFoundError: + pass + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(str(SOCKET_PATH)) + os.chmod(SOCKET_PATH, 0o660) + os.chown(SOCKET_PATH, -1, grp.getgrnam("neodrop-control").gr_gid) + server.listen(8) + while True: + client, _ = server.accept() + threading.Thread(target=handle_client, args=(client, manager), daemon=True).start() + + +def main(): + os.umask(0o077) + key, api_token = load_credentials() + manager = NeoDropManager(EncryptedCatalog(CATALOG_PATH, key), api_token) + try: + HTTP_SOCKET_PATH.unlink() + except FileNotFoundError: + pass + nginx_uid = pwd.getpwnam("www-data").pw_uid + http_server = ManagementHTTPServer( + str(HTTP_SOCKET_PATH), manager, expected_peer_uid=nginx_uid + ) + os.chmod(HTTP_SOCKET_PATH, 0o660) + threading.Thread(target=http_server.serve_forever, daemon=True).start() + socket_server(manager) + + +if __name__ == "__main__": + main() diff --git a/files/neodrop-manager/neodrop_manager_plugin.c b/files/neodrop-manager/neodrop_manager_plugin.c new file mode 100644 index 0000000..296e722 --- /dev/null +++ b/files/neodrop-manager/neodrop_manager_plugin.c @@ -0,0 +1,327 @@ +#include +#include +#include +#include +#include +#include +#include + +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 || show || read || download || delete || edit || line || insert || delete-line || 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; +} diff --git a/files/neodrop/app.js b/files/neodrop/app.js new file mode 100644 index 0000000..d73da19 --- /dev/null +++ b/files/neodrop/app.js @@ -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{ 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{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;iURL.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(); +})(); diff --git a/files/neodrop/index.html b/files/neodrop/index.html new file mode 100644 index 0000000..7c0d7de --- /dev/null +++ b/files/neodrop/index.html @@ -0,0 +1,71 @@ + + + + + + + {{PRODUCT_NAME}} - Secure File Drop + + + + +
    + + +
    +
    drops@relay:~
    +
    +

    >> neodrop status --public

    +

    Secure File Drop

    +

    Encrypted in your browser. The server stores ciphertext and never receives the key in this link.

    +
    +
    ENCRYPTION
    AES-256-GCM
    +
    ACCESS
    CAPABILITY LINK
    +
    STORAGE
    CIPHERTEXT ONLY
    +
    +

    >> Awaiting input

    +
    +
    + + + + + + +
    + + + diff --git a/files/neodrop/neodrop_server.py b/files/neodrop/neodrop_server.py new file mode 100644 index 0000000..cfdad69 --- /dev/null +++ b/files/neodrop/neodrop_server.py @@ -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() diff --git a/files/neodrop/style.css b/files/neodrop/style.css new file mode 100644 index 0000000..8f0dace --- /dev/null +++ b/files/neodrop/style.css @@ -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}} diff --git a/files/neoguard/neoguard_agent.py b/files/neoguard/neoguard_agent.py new file mode 100644 index 0000000..0a697b2 --- /dev/null +++ b/files/neoguard/neoguard_agent.py @@ -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() diff --git a/files/neoguard/neoguard_plugin.c b/files/neoguard/neoguard_plugin.c new file mode 100644 index 0000000..710e0d4 --- /dev/null +++ b/files/neoguard/neoguard_plugin.c @@ -0,0 +1,186 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +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 || add [] || remove || extend || action redirect || action message ", + "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; +} diff --git a/files/neoguard/neoguardd.py b/files/neoguard/neoguardd.py new file mode 100644 index 0000000..fdaca63 --- /dev/null +++ b/files/neoguard/neoguardd.py @@ -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 = ("" + "NeoGuard" + "
    NEOGUARD // ACCESS DENIED

    Connection blocked

    %s

    " % 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() diff --git a/scripts/customize-glowing-bear.pl b/scripts/customize-glowing-bear.pl new file mode 100755 index 0000000..94cdc09 --- /dev/null +++ b/scripts/customize-glowing-bear.pl @@ -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{