Sanitized public release v1.0.3
Source-Tag: v1.0.3 Manifest-SHA256: b7b09bf48f9e092ed8ad82f99c0319d6c3837d0a1549bd0cc4a374276c0f3896
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,20 @@
|
||||
<section class="neo-colour-settings" ng-show="neoSettingsTab === 'colours'" role="tabpanel" aria-label="Colours">
|
||||
<div class="neo-settings-toolbar">
|
||||
<p>These values are read from and saved to WeeChat. Role colours update <code>irc.color.nick_prefixes</code>.</p>
|
||||
<button type="button" class="btn btn-default" ng-click="loadNeoWeeChatColours()" ng-disabled="neoWeeChatColours.loading">Refresh</button>
|
||||
</div>
|
||||
<p class="status" ng-bind="neoWeeChatColours.status"></p>
|
||||
<h5>Chat</h5>
|
||||
<div class="neo-colour-grid">
|
||||
<label ng-repeat="item in neoWeeChatColours.items"><span ng-bind="item.label"></span><select class="form-control" ng-model="neoWeeChatColours.values[item.name]" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoWeeChatColour(item.name)"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.values[item.name])}" title="{{neoWeeChatColours.values[item.name]}}"></i><code ng-bind="item.name"></code></label>
|
||||
</div>
|
||||
<h5>Nickname roles</h5>
|
||||
<div class="neo-colour-grid">
|
||||
<label><span>Operator</span><select class="form-control" ng-model="neoWeeChatColours.prefixes.o" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('o')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes.o)}" title="{{neoWeeChatColours.prefixes.o}}"></i><code>mode o</code></label>
|
||||
<label><span>Half-operator</span><select class="form-control" ng-model="neoWeeChatColours.prefixes.h" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('h')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes.h)}" title="{{neoWeeChatColours.prefixes.h}}"></i><code>mode h</code></label>
|
||||
<label><span>Voice</span><select class="form-control" ng-model="neoWeeChatColours.prefixes.v" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('v')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes.v)}" title="{{neoWeeChatColours.prefixes.v}}"></i><code>mode v</code></label>
|
||||
<label><span>Other prefixes</span><select class="form-control" ng-model="neoWeeChatColours.prefixes['*']" ng-options="colour for colour in neoWeeChatColourChoices" ng-change="setNeoNickPrefixColour('*')"></select><i class="neo-colour-swatch" ng-style="{'background-color': neoWeeChatColourPreview(neoWeeChatColours.prefixes['*'])}" title="{{neoWeeChatColours.prefixes['*']}}"></i><code>fallback *</code></label>
|
||||
</div>
|
||||
<h5>Channel nickname palette</h5>
|
||||
<div class="neo-palette-setting"><input class="form-control" type="text" ng-model="neoWeeChatColours.values['weechat.color.chat_nick_colors']" ng-change="updateNeoPalettePreview()"><button type="button" class="btn btn-primary" ng-click="setNeoWeeChatColour('weechat.color.chat_nick_colors')">Save palette</button><div class="neo-palette-preview" aria-label="Nickname palette preview"><i ng-repeat="colour in neoWeeChatColours.palettePreview track by $index" ng-style="{'background-color': neoWeeChatColourPreview(colour)}" title="{{colour}}"></i></div><code>weechat.color.chat_nick_colors</code></div>
|
||||
</section>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="command-menu" ng-if="commandMenuOpen" role="listbox" id="{{inputId}}-command-menu">
|
||||
<button type="button" class="command-menu-item" ng-repeat="item in commandMenuMatches track by item.command" ng-class="{selected: $index === commandMenuIndex}" id="{{inputId}}-command-{{$index}}" role="option" aria-selected="{{$index === commandMenuIndex}}" ng-mousedown="$event.preventDefault()" ng-mouseenter="commandMenuIndex = $index" ng-click="selectCommand(item)">
|
||||
<span class="command-menu-name">/{{item.command}}</span>
|
||||
<span class="command-menu-args" ng-bind="item.args"></span>
|
||||
<span class="command-menu-description" ng-bind="item.description"></span>
|
||||
</button>
|
||||
<div class="command-menu-empty" ng-if="commandMenuLoading">Loading commands...</div>
|
||||
<div class="command-menu-empty" ng-if="!commandMenuLoading && !commandMenuMatches.length">No matching commands</div>
|
||||
</div>
|
||||
@@ -0,0 +1,146 @@
|
||||
var commandHooks = null;
|
||||
var commandHooksRequest = null;
|
||||
var commandMenuGeneration = 0;
|
||||
var suppressCommandMenuKeypress = false;
|
||||
var serverCommandFallbacks = [
|
||||
{command:'defcon',args:'[<level>]',description:'Set or show Ergo emergency restrictions',insertion:'/quote DEFCON',serverCommand:true},
|
||||
{command:'deoper',args:'',description:'Remove IRC operator privileges',insertion:'/quote DEOPER',serverCommand:true},
|
||||
{command:'dline',args:'[<duration>] <ip>/<net> [<reason>] || LIST',description:'Add or list IP/network bans',insertion:'/quote DLINE',serverCommand:true},
|
||||
{command:'kline',args:'[<duration>] <mask> [<reason>] || LIST',description:'Add or list client-mask bans',insertion:'/quote KLINE',serverCommand:true},
|
||||
{command:'uban',args:'ADD|DEL|LIST|INFO <target>',description:'Manage unified bans',insertion:'/quote UBAN',serverCommand:true},
|
||||
{command:'undline',args:'<ip>/<net>',description:'Remove an IP/network ban',insertion:'/quote UNDLINE',serverCommand:true},
|
||||
{command:'unkline',args:'<mask>',description:'Remove a client-mask ban',insertion:'/quote UNKLINE',serverCommand:true}
|
||||
];
|
||||
|
||||
var mergeCommandHooks = function(hooks) {
|
||||
var unique = Object.create(null);
|
||||
var hasOwn = Object.prototype.hasOwnProperty;
|
||||
hooks.forEach(function(hook) {
|
||||
var key = String(hook.command || '').toLowerCase();
|
||||
if (key && (!hasOwn.call(unique, key) || hook.priority > unique[key].priority)) {
|
||||
unique[key] = Object.assign({}, hook, {command:key});
|
||||
}
|
||||
});
|
||||
serverCommandFallbacks.forEach(function(hook) {
|
||||
if (!hasOwn.call(unique, hook.command)) unique[hook.command] = hook;
|
||||
});
|
||||
return Object.keys(unique).map(function(key){return unique[key];}).sort(function(left,right){return left.command.localeCompare(right.command);});
|
||||
};
|
||||
|
||||
$scope.commandMenuOpen = false;
|
||||
$scope.commandMenuLoading = false;
|
||||
$scope.commandMenuMatches = [];
|
||||
$scope.commandMenuIndex = 0;
|
||||
|
||||
var closeCommandMenu = function() {
|
||||
$scope.commandMenuOpen = false;
|
||||
$scope.commandMenuLoading = false;
|
||||
$scope.commandMenuMatches = [];
|
||||
$scope.commandMenuIndex = 0;
|
||||
};
|
||||
|
||||
var updateCommandMenu = function() {
|
||||
var match = /^\/([^\s]*)$/.exec($scope.command || '');
|
||||
if (!match) {
|
||||
closeCommandMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.commandMenuOpen = true;
|
||||
$scope.commandMenuIndex = 0;
|
||||
if (commandHooks === null) {
|
||||
$scope.commandMenuLoading = true;
|
||||
if (commandHooksRequest === null) {
|
||||
var requestGeneration = commandMenuGeneration;
|
||||
commandHooksRequest = connection.requestCommandHooks().then(function(hooks) {
|
||||
if (requestGeneration !== commandMenuGeneration) {
|
||||
return;
|
||||
}
|
||||
commandHooks = mergeCommandHooks(hooks);
|
||||
commandHooksRequest = null;
|
||||
updateCommandMenu();
|
||||
}, function() {
|
||||
if (requestGeneration !== commandMenuGeneration) {
|
||||
return;
|
||||
}
|
||||
commandHooks = mergeCommandHooks([]);
|
||||
commandHooksRequest = null;
|
||||
updateCommandMenu();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var prefix = match[1].toLowerCase();
|
||||
var activeBuffer = models.getActiveBuffer();
|
||||
var isIrcBuffer = activeBuffer && activeBuffer.plugin === 'irc';
|
||||
$scope.commandMenuLoading = false;
|
||||
$scope.commandMenuMatches = commandHooks.filter(function(hook) {
|
||||
return (!hook.serverCommand || isIrcBuffer) && hook.command.indexOf(prefix) === 0;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.selectCommand = function(item) {
|
||||
var suffix = item.args ? ' ' : '';
|
||||
$scope.command = (item.insertion || ('/' + item.command.toLowerCase())) + suffix;
|
||||
closeCommandMenu();
|
||||
setTimeout(function() {
|
||||
var inputNode = $scope.getInputNode();
|
||||
inputNode.focus();
|
||||
inputNode.setSelectionRange($scope.command.length, $scope.command.length);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
var scrollCommandMenuSelection = function() {
|
||||
setTimeout(function() {
|
||||
var selected = document.getElementById(
|
||||
$scope.inputId + '-command-' + $scope.commandMenuIndex
|
||||
);
|
||||
if (selected) {
|
||||
selected.scrollIntoView({block: 'nearest'});
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
$scope.handleCommandMenuKey = function(event, code, inputNode) {
|
||||
if (suppressCommandMenuKeypress && event.type === 'keypress' &&
|
||||
(code === 9 || code === 13)) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (!$scope.commandMenuOpen || event.type !== 'keydown' ||
|
||||
document.activeElement !== inputNode || event.altKey ||
|
||||
event.ctrlKey || event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var count = $scope.commandMenuMatches.length;
|
||||
if ((code === 38 || code === 40) && count > 0) {
|
||||
var direction = code === 38 ? -1 : 1;
|
||||
$scope.commandMenuIndex = ($scope.commandMenuIndex + direction + count) % count;
|
||||
scrollCommandMenuSelection();
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if ((code === 9 || code === 13) && count > 0 && !event.shiftKey) {
|
||||
$scope.selectCommand($scope.commandMenuMatches[$scope.commandMenuIndex]);
|
||||
suppressCommandMenuKeypress = true;
|
||||
setTimeout(function() { suppressCommandMenuKeypress = false; }, 0);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (code === 27) {
|
||||
closeCommandMenu();
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
var removeCommandMenuDisconnect = $rootScope.$on('relayDisconnect', function() {
|
||||
commandMenuGeneration++;
|
||||
commandHooks = null;
|
||||
commandHooksRequest = null;
|
||||
closeCommandMenu();
|
||||
});
|
||||
$scope.$on('$destroy', removeCommandMenuDisconnect);
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="panel connection-panel" data-state="active">
|
||||
<div class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
<form class="form-signin" role="form">
|
||||
<div class="relay-terminal">
|
||||
<div class="relay-terminal-bar"><span></span><span></span><span></span><b>relay@{{RELAY_HOST}}:~</b></div>
|
||||
<div class="relay-terminal-body">
|
||||
<p><span class="relay-prompt">>></span> relay config --show</p>
|
||||
<dl>
|
||||
<div><dt>HOSTNAME</dt><dd>{{RELAY_HOST}}</dd></div>
|
||||
<div><dt>PORT</dt><dd>443</dd></div>
|
||||
<div><dt>PATH</dt><dd>/weechat</dd></div>
|
||||
<div><dt>TRANSPORT</dt><dd>WSS / TLS</dd></div>
|
||||
</dl>
|
||||
<div class="relay-password-line">
|
||||
<label for="password"><span class="relay-prompt">>></span> password</label>
|
||||
<span class="relay-password-field">
|
||||
<span class="relay-password-mask" ng-if="password" ng-cloak>{{ maskPassword(password) }}</span>
|
||||
<span class="relay-password-placeholder" ng-if="!password">[ enter relay password ]</span>
|
||||
<input type="password" id="password" ng-model="password" autocomplete="current-password" aria-label="WeeChat relay password">
|
||||
</span>
|
||||
</div>
|
||||
<p class="relay-password-error" ng-show="passwordError" ng-cloak><span class="relay-prompt">!!</span> authentication rejected</p>
|
||||
<p class="relay-input-line"><span class="relay-prompt">>></span> <span class="relay-cursor" aria-hidden="true">_</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="connection-options">
|
||||
<label><input type="checkbox" ng-model="settings.tls" disabled> TLS encryption</label>
|
||||
<label><input type="checkbox" ng-model="settings.savepassword"> Save password in this browser</label>
|
||||
<label><input type="checkbox" ng-model="settings.autoconnect"> Automatically connect</label>
|
||||
</div>
|
||||
<button class="btn btn-lg btn-primary relay-connect" ng-click="connect()" ng-cloak>{{ connectbutton }} <i ng-class="connectbuttonicon" class="glyphicon"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="neo-context-menu" ng-if="menu.visible" ng-style="menu.position" role="menu" aria-label="{{menu.label}}">
|
||||
<button type="button" role="menuitem" ng-repeat="item in menu.actions track by item.id" ng-class="{destructive:item.destructive}" ng-click="runContextAction(item)" ng-bind="item.label"></button>
|
||||
</div>
|
||||
@@ -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); });
|
||||
}};
|
||||
}]);
|
||||
}());
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<li class="standard-labels">
|
||||
<form class="form-horizontal" role="form">
|
||||
<div class="form-group">
|
||||
<label for="drop-profile" class="col-sm-3 control-label make-thinner">NeoDrop retention</label>
|
||||
<div class="col-sm-8">
|
||||
<select id="drop-profile" class="form-control" ng-model="settings.dropProfile">
|
||||
<option value="small">50 MiB maximum / 30 days</option>
|
||||
<option value="large">1 GiB maximum / 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</li>
|
||||
@@ -0,0 +1,94 @@
|
||||
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
|
||||
with Reserved Font Name OpenDyslexic.
|
||||
Copyright (c) 12/2012 - 2019
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
<section class="login-hero" aria-labelledby="neorelay-login-title">
|
||||
<div class="login-portrait">
|
||||
<img src="assets/neorelay-avatar-login.jpg" alt="Operator-provided avatar">
|
||||
<span class="portrait-code" aria-hidden="true">{{NODE_LABEL}}</span>
|
||||
</div>
|
||||
<div class="login-intro">
|
||||
<p class="login-kicker">PRIVATE RELAY // TLS 1.3</p>
|
||||
<h1 id="neorelay-login-title">{{RELAY_TITLE_HTML}}</h1>
|
||||
<p class="login-deck">A quiet line into IRC.</p>
|
||||
<div class="login-status" aria-label="Service capabilities">
|
||||
<span>INTERNAL ONLY</span>
|
||||
<span>SASL EXTERNAL</span>
|
||||
<span>ALWAYS ON</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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
|
||||
};
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
}]);
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="gb-modal neo-server-manager" data-state="visible" ng-if="serverManager.visible">
|
||||
<div class="backdrop" ng-click="closeNeoServerManager()"></div>
|
||||
<div class="modal-dialog"><div class="modal-content">
|
||||
<div class="modal-header"><button type="button" class="close" ng-click="closeNeoServerManager()">×</button><h4>IRC server manager</h4><p>Add a network or edit every WeeChat server option. Password fields are write-only.</p></div>
|
||||
<div class="modal-body neo-server-layout">
|
||||
<aside><button type="button" class="btn btn-primary neo-add-server" ng-click="newNeoServer()">Add new server</button><button type="button" ng-repeat="name in serverManager.servers" ng-click="selectNeoServer(name)" ng-bind="name"></button></aside>
|
||||
<section ng-if="serverManager.selected">
|
||||
<label>Server name<input class="form-control neo-server-control" ng-model="serverManager.selected.name" ng-disabled="!serverManager.isNew"></label>
|
||||
<div class="neo-server-options">
|
||||
<label ng-repeat="option in serverManager.selected.options" title="{{option.description}}"><span ng-bind="option.name"></span><select class="form-control neo-server-control" ng-if="option.boolean" ng-model="option.value" ng-change="option.dirty=true"><option value="on">On</option><option value="off">Off</option></select><input ng-if="!option.boolean" class="form-control neo-server-control" type="{{option.secret?'password':'text'}}" autocomplete="off" ng-model="option.value" ng-change="option.dirty=true" placeholder="{{option.secret?'Leave blank to keep existing value':''}}"><span class="inherit"><input type="checkbox" ng-model="option.inherited" ng-change="option.dirty=true" ng-disabled="serverManager.isNew||option.secret"> inherit</span></label>
|
||||
</div>
|
||||
</section>
|
||||
<p class="status" ng-bind="serverManager.status"></p>
|
||||
</div>
|
||||
<div class="modal-footer"><button type="button" class="btn btn-default" ng-click="closeNeoServerManager()">Cancel</button><button type="button" class="btn btn-primary" ng-click="saveNeoServer(false)" ng-disabled="serverManager.saving">Save</button><button type="button" class="btn btn-primary" ng-click="saveNeoServer(true)" ng-disabled="serverManager.saving">Save and connect</button></div>
|
||||
</div></div>
|
||||
</div>
|
||||
@@ -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); }); });
|
||||
}};
|
||||
}]);
|
||||
}());
|
||||
@@ -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);
|
||||
@@ -0,0 +1,20 @@
|
||||
<section class="neo-weechat-settings" ng-show="neoSettingsTab === 'weechat'" role="tabpanel" aria-label="WeeChat settings">
|
||||
<div class="neo-settings-toolbar">
|
||||
<label>Section<select class="form-control" ng-model="neoWeeChatSettings.section" ng-options="section.mask as section.label for section in neoWeeChatSettings.sections" ng-change="loadNeoWeeChatSettings()"></select></label>
|
||||
<label>Filter<input class="form-control" type="text" ng-model="neoWeeChatSettings.filter" placeholder="Filter loaded settings"></label>
|
||||
<button type="button" class="btn btn-default" ng-click="loadNeoWeeChatSettings()" ng-disabled="neoWeeChatSettings.loading">Refresh</button>
|
||||
</div>
|
||||
<p>Changes are validated, written to WeeChat, read back, and then saved to the corresponding configuration file.</p>
|
||||
<p class="status" ng-bind="neoWeeChatSettings.status"></p>
|
||||
<div class="neo-option-list">
|
||||
<article class="neo-option" ng-repeat="option in neoWeeChatSettings.options | filter:neoWeeChatSettings.filter | limitTo:200 track by option.name">
|
||||
<div><strong ng-bind="option.name"></strong><small ng-bind="option.description"></small></div>
|
||||
<select class="form-control" ng-if="option.type === 'boolean'" ng-model="option.editValue"><option value="on">On</option><option value="off">Off</option></select>
|
||||
<select class="form-control" ng-if="option.type !== 'boolean' && option.choices.length" ng-model="option.editValue" ng-options="choice for choice in option.choices"></select>
|
||||
<select class="form-control" ng-if="option.type === 'color' && !option.choices.length" ng-model="option.editValue" ng-options="colour for colour in neoWeeChatColourChoices"></select>
|
||||
<input class="form-control" ng-if="option.type === 'integer' && !option.choices.length" type="number" ng-model="option.editValue" min="{{option.min}}" max="{{option.max}}">
|
||||
<input class="form-control" ng-if="option.type !== 'boolean' && option.type !== 'color' && option.type !== 'integer' && !option.choices.length" type="text" ng-model="option.editValue">
|
||||
<div class="neo-option-actions"><button type="button" class="btn btn-primary" ng-click="saveNeoWeeChatOption(option)" ng-disabled="option.saving">Save</button><button type="button" class="btn btn-default" ng-click="resetNeoWeeChatOption(option)" ng-disabled="option.saving">Reset</button></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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; } }
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="theme-color" content="#150a24">
|
||||
<title>NeoDrop Management</title>
|
||||
<link rel="stylesheet" href="/manage/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<p class="kicker">{{OPERATOR_LABEL}}</p>
|
||||
<h1>NeoDrop <span>Management</span></h1>
|
||||
<p>Files are encrypted in this browser. The storage service receives ciphertext only.</p>
|
||||
</header>
|
||||
|
||||
<section class="upload-panel" aria-labelledby="upload-title">
|
||||
<h2 id="upload-title">New encrypted drop</h2>
|
||||
<form id="upload-form">
|
||||
<label class="file-picker">File <input id="file" type="file" required></label>
|
||||
<label>Retention
|
||||
<select id="profile">
|
||||
<option value="0">30 days, up to 50 MiB</option>
|
||||
<option value="1">7 days, up to 1 GiB</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="upload" type="submit">Encrypt and upload</button>
|
||||
</form>
|
||||
<div class="progress" aria-hidden="true"><span id="progress"></span></div>
|
||||
<p id="status" role="status">Ready.</p>
|
||||
<button id="retry" type="button" class="hidden">Retry catalog registration</button>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="inventory-title">
|
||||
<div class="section-heading">
|
||||
<h2 id="inventory-title">Managed drops</h2>
|
||||
<button id="refresh" type="button">Refresh</button>
|
||||
</div>
|
||||
<p id="empty" class="muted hidden">No managed drops.</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Expires</th><th>Actions</th></tr></thead>
|
||||
<tbody id="drops"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/manage/neodrop-crypto.js"></script>
|
||||
<script src="/manage/manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
"use strict";
|
||||
(function() {
|
||||
var pendingRegistration = null;
|
||||
var busy = false;
|
||||
var $ = function(id) { return document.getElementById(id); };
|
||||
|
||||
function formatSize(value) {
|
||||
var units = ["B", "KiB", "MiB", "GiB"];
|
||||
var size = Number(value);
|
||||
var index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; }
|
||||
return size.toFixed(index ? 1 : 0) + " " + units[index];
|
||||
}
|
||||
|
||||
function setStatus(message, error) {
|
||||
$("status").textContent = message;
|
||||
$("status").classList.toggle("error", Boolean(error));
|
||||
}
|
||||
|
||||
async function api(path, options) {
|
||||
var response = await fetch(path, Object.assign({cache: "no-store"}, options || {}));
|
||||
var value;
|
||||
try { value = await response.json(); } catch (_) { value = null; }
|
||||
if (!response.ok) throw new Error("Management request failed.");
|
||||
return value;
|
||||
}
|
||||
|
||||
async function copyUrl(url) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setStatus("Share URL copied.", false);
|
||||
}
|
||||
|
||||
function actionButton(label, handler, className) {
|
||||
var button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
if (className) button.className = className;
|
||||
button.addEventListener("click", handler);
|
||||
return button;
|
||||
}
|
||||
|
||||
function render(rows) {
|
||||
var body = $("drops");
|
||||
body.textContent = "";
|
||||
$("empty").classList.toggle("hidden", rows.length !== 0);
|
||||
rows.forEach(function(row) {
|
||||
var tr = document.createElement("tr");
|
||||
[row.name, row.type || "application/octet-stream", formatSize(row.size),
|
||||
new Date(row.expiresAt * 1000).toLocaleString()].forEach(function(value) {
|
||||
var td = document.createElement("td");
|
||||
td.textContent = value;
|
||||
tr.appendChild(td);
|
||||
});
|
||||
var actions = document.createElement("td");
|
||||
actions.className = "actions";
|
||||
actions.appendChild(actionButton("Copy", function() { copyUrl(row.readUrl).catch(function() {
|
||||
setStatus("Unable to copy the share URL.", true);
|
||||
}); }));
|
||||
actions.appendChild(actionButton("Open", function() {
|
||||
window.open(row.readUrl, "_blank", "noopener,noreferrer");
|
||||
}));
|
||||
actions.appendChild(actionButton("Delete", async function() {
|
||||
if (!window.confirm("Permanently delete " + row.name + "?")) return;
|
||||
try {
|
||||
await api("/manage/api/v1/drops/" + row.id, {method: "DELETE"});
|
||||
await load();
|
||||
setStatus("Drop deleted.", false);
|
||||
} catch (_) { setStatus("Delete failed.", true); }
|
||||
}, "danger"));
|
||||
tr.appendChild(actions);
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
var value = await api("/manage/api/v1/list", {
|
||||
method: "POST", headers: {"Content-Type": "application/json"}, body: "{}"
|
||||
});
|
||||
render(Array.isArray(value.drops) ? value.drops : []);
|
||||
}
|
||||
|
||||
async function register(result) {
|
||||
await api("/manage/api/v1/register", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({readUrl: result.readUrl, deleteCapability: result.deleteCapability})
|
||||
});
|
||||
pendingRegistration = null;
|
||||
$("retry").classList.add("hidden");
|
||||
await load();
|
||||
}
|
||||
|
||||
$("upload-form").addEventListener("submit", async function(event) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
var file = $("file").files[0];
|
||||
if (!file) return setStatus("Select a file.", true);
|
||||
busy = true;
|
||||
$("upload").disabled = true;
|
||||
$("progress").style.width = "0";
|
||||
setStatus("Encrypting and uploading...", false);
|
||||
try {
|
||||
var result = await NeoDropCrypto.upload(file, {
|
||||
base: location.origin,
|
||||
profile: Number($("profile").value),
|
||||
onProgress: function(value) { $("progress").style.width = Math.round(value * 100) + "%"; }
|
||||
});
|
||||
pendingRegistration = result;
|
||||
setStatus("Upload complete. Registering encrypted catalog entry...", false);
|
||||
try {
|
||||
await register(result);
|
||||
$("upload-form").reset();
|
||||
setStatus("Encrypted drop uploaded and registered.", false);
|
||||
} catch (_) {
|
||||
$("retry").classList.remove("hidden");
|
||||
setStatus("Upload succeeded, but catalog registration must be retried in this tab.", true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") setStatus(error.message || "Encrypted upload failed.", true);
|
||||
} finally {
|
||||
busy = false;
|
||||
$("upload").disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("retry").addEventListener("click", async function() {
|
||||
if (!pendingRegistration || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await register(pendingRegistration);
|
||||
setStatus("Catalog registration completed.", false);
|
||||
} catch (_) { setStatus("Catalog registration failed; retry from this tab.", true); }
|
||||
finally { busy = false; }
|
||||
});
|
||||
|
||||
$("refresh").addEventListener("click", function() {
|
||||
load().then(function() { setStatus("Managed drops refreshed.", false); })
|
||||
.catch(function() { setStatus("Unable to load managed drops.", true); });
|
||||
});
|
||||
|
||||
load().catch(function() { setStatus("Unable to load managed drops.", true); });
|
||||
}());
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
#include <errno.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
#include <weechat/weechat-plugin.h>
|
||||
|
||||
WEECHAT_PLUGIN_NAME("neodrop_manager")
|
||||
WEECHAT_PLUGIN_DESCRIPTION("NeoDrop manager control buffer")
|
||||
WEECHAT_PLUGIN_AUTHOR("{{PRODUCT_NAME}}")
|
||||
WEECHAT_PLUGIN_VERSION("1.1")
|
||||
WEECHAT_PLUGIN_LICENSE("GPL3")
|
||||
|
||||
#define CONTROL_SOCKET "{{MANAGER_CONTROL_SOCKET_PATH}}"
|
||||
#define SEND_LIMIT 8192U
|
||||
#define RECEIVE_LIMIT 16384U
|
||||
|
||||
#ifndef MSG_NOSIGNAL
|
||||
#define MSG_NOSIGNAL 0
|
||||
#endif
|
||||
|
||||
struct t_weechat_plugin *weechat_plugin;
|
||||
static struct t_gui_buffer *manager_buffer;
|
||||
static struct t_hook *manager_fd_hook;
|
||||
static int manager_fd = -1;
|
||||
static char receive_buffer[RECEIVE_LIMIT];
|
||||
static size_t receive_length;
|
||||
|
||||
static void print_error(const char *message)
|
||||
{
|
||||
if (manager_buffer != NULL)
|
||||
weechat_printf(manager_buffer, "%s%s", weechat_color("lightred"), message);
|
||||
}
|
||||
|
||||
static void disconnect_manager(int announce)
|
||||
{
|
||||
int was_connected = manager_fd >= 0;
|
||||
|
||||
if (manager_fd_hook != NULL) {
|
||||
weechat_unhook(manager_fd_hook);
|
||||
manager_fd_hook = NULL;
|
||||
}
|
||||
if (manager_fd >= 0)
|
||||
close(manager_fd);
|
||||
manager_fd = -1;
|
||||
receive_length = 0;
|
||||
|
||||
if (announce && was_connected)
|
||||
print_error("NeoDrop manager disconnected; reconnecting in the background.");
|
||||
}
|
||||
|
||||
static void print_protocol_line(const char *line, size_t length)
|
||||
{
|
||||
const char *payload;
|
||||
const char *color = NULL;
|
||||
size_t payload_length;
|
||||
size_t index;
|
||||
char output[RECEIVE_LIMIT];
|
||||
|
||||
if (manager_buffer == NULL)
|
||||
return;
|
||||
|
||||
if (length > 0 && line[length - 1] == '\r')
|
||||
length--;
|
||||
|
||||
if (length >= 5 && memcmp(line, "LINE\t", 5) == 0) {
|
||||
payload = line + 5;
|
||||
payload_length = length - 5;
|
||||
} else if (length >= 6 && memcmp(line, "ERROR\t", 6) == 0) {
|
||||
payload = line + 6;
|
||||
payload_length = length - 6;
|
||||
color = "lightred";
|
||||
} else {
|
||||
print_error("NeoDrop manager sent an invalid response.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (index = 0; index < payload_length; index++) {
|
||||
unsigned char byte = (unsigned char)payload[index];
|
||||
output[index] = (byte < 0x20U || byte == 0x7fU) ? ' ' : (char)byte;
|
||||
}
|
||||
output[payload_length] = '\0';
|
||||
|
||||
if (color != NULL)
|
||||
weechat_printf(manager_buffer, "%s%s", weechat_color(color), output);
|
||||
else
|
||||
weechat_printf(manager_buffer, "%s", output);
|
||||
}
|
||||
|
||||
static int manager_read_cb(const void *pointer, void *data, int fd)
|
||||
{
|
||||
ssize_t count;
|
||||
size_t consumed = 0;
|
||||
char *newline;
|
||||
|
||||
(void)pointer;
|
||||
(void)data;
|
||||
(void)fd;
|
||||
|
||||
if (receive_length >= RECEIVE_LIMIT - 1U) {
|
||||
print_error("NeoDrop manager response exceeded the line limit.");
|
||||
disconnect_manager(0);
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
count = recv(manager_fd, receive_buffer + receive_length,
|
||||
RECEIVE_LIMIT - receive_length - 1U, 0);
|
||||
if (count < 0 && errno == EINTR)
|
||||
return WEECHAT_RC_OK;
|
||||
if (count <= 0) {
|
||||
disconnect_manager(1);
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
receive_length += (size_t)count;
|
||||
|
||||
while (consumed < receive_length) {
|
||||
size_t available = receive_length - consumed;
|
||||
|
||||
newline = memchr(receive_buffer + consumed, '\n', available);
|
||||
if (newline == NULL)
|
||||
break;
|
||||
print_protocol_line(receive_buffer + consumed,
|
||||
(size_t)(newline - (receive_buffer + consumed)));
|
||||
consumed = (size_t)(newline - receive_buffer) + 1U;
|
||||
}
|
||||
|
||||
if (consumed > 0) {
|
||||
receive_length -= consumed;
|
||||
memmove(receive_buffer, receive_buffer + consumed, receive_length);
|
||||
}
|
||||
if (receive_length >= RECEIVE_LIMIT - 1U) {
|
||||
print_error("NeoDrop manager response exceeded the line limit.");
|
||||
disconnect_manager(0);
|
||||
}
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
static int connect_manager(void)
|
||||
{
|
||||
struct sockaddr_un address;
|
||||
int fd;
|
||||
|
||||
if (manager_fd >= 0)
|
||||
return 1;
|
||||
|
||||
fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
if (fd < 0)
|
||||
return 0;
|
||||
|
||||
#ifdef SO_NOSIGPIPE
|
||||
{
|
||||
int enabled = 1;
|
||||
|
||||
if (setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)) != 0) {
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
memset(&address, 0, sizeof(address));
|
||||
address.sun_family = AF_UNIX;
|
||||
memcpy(address.sun_path, CONTROL_SOCKET, sizeof(CONTROL_SOCKET));
|
||||
if (connect(fd, (struct sockaddr *)&address, sizeof(address)) != 0) {
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
manager_fd = fd;
|
||||
manager_fd_hook = weechat_hook_fd(manager_fd, 1, 0, 0,
|
||||
&manager_read_cb, NULL, NULL);
|
||||
if (manager_fd_hook == NULL) {
|
||||
disconnect_manager(0);
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void send_command(const char *command)
|
||||
{
|
||||
static const char prefix[] = "COMMAND\t";
|
||||
char message[SEND_LIMIT];
|
||||
size_t command_length;
|
||||
size_t message_length;
|
||||
size_t sent = 0;
|
||||
|
||||
if (command == NULL || command[0] == '\0')
|
||||
return;
|
||||
|
||||
command_length = strlen(command);
|
||||
if (command_length > SEND_LIMIT - sizeof("COMMAND\t\n")) {
|
||||
print_error("NeoDrop manager command exceeded the line limit.");
|
||||
return;
|
||||
}
|
||||
if (strpbrk(command, "\t\r\n") != NULL) {
|
||||
print_error("NeoDrop manager commands cannot contain tabs or newlines.");
|
||||
return;
|
||||
}
|
||||
if (!connect_manager()) {
|
||||
print_error("Unable to connect to the NeoDrop manager.");
|
||||
return;
|
||||
}
|
||||
|
||||
memcpy(message, prefix, sizeof(prefix) - 1U);
|
||||
memcpy(message + sizeof(prefix) - 1U, command, command_length);
|
||||
message_length = sizeof(prefix) - 1U + command_length;
|
||||
message[message_length++] = '\n';
|
||||
|
||||
while (sent < message_length) {
|
||||
ssize_t count = send(manager_fd, message + sent, message_length - sent,
|
||||
MSG_NOSIGNAL);
|
||||
|
||||
if (count < 0 && errno == EINTR)
|
||||
continue;
|
||||
if (count <= 0) {
|
||||
disconnect_manager(1);
|
||||
return;
|
||||
}
|
||||
sent += (size_t)count;
|
||||
}
|
||||
}
|
||||
|
||||
static int reconnect_cb(const void *pointer, void *data, int remaining_calls)
|
||||
{
|
||||
(void)pointer;
|
||||
(void)data;
|
||||
(void)remaining_calls;
|
||||
(void)connect_manager();
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
static int input_cb(const void *pointer, void *data, struct t_gui_buffer *buffer,
|
||||
const char *input)
|
||||
{
|
||||
(void)pointer;
|
||||
(void)data;
|
||||
(void)buffer;
|
||||
send_command(input);
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
static int close_cb(const void *pointer, void *data, struct t_gui_buffer *buffer)
|
||||
{
|
||||
(void)pointer;
|
||||
(void)data;
|
||||
(void)buffer;
|
||||
manager_buffer = NULL;
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
static int ensure_buffer(void)
|
||||
{
|
||||
if (manager_buffer == NULL)
|
||||
manager_buffer = weechat_buffer_search("neodrop_manager", "control");
|
||||
if (manager_buffer == NULL) {
|
||||
manager_buffer = weechat_buffer_new("control", &input_cb, NULL, NULL,
|
||||
&close_cb, NULL, NULL);
|
||||
if (manager_buffer == NULL)
|
||||
return 0;
|
||||
}
|
||||
weechat_buffer_set_pointer(manager_buffer, "input_callback", &input_cb);
|
||||
weechat_buffer_set_pointer(manager_buffer, "close_callback", &close_cb);
|
||||
weechat_buffer_set(manager_buffer, "short_name", "NeoDrop");
|
||||
weechat_buffer_set(manager_buffer, "title",
|
||||
"NeoDrop | list | put-text NAME TEXT | show/read/download/delete ID | edit ID | help");
|
||||
weechat_buffer_set(manager_buffer, "hidden", "0");
|
||||
weechat_buffer_set(manager_buffer, "notify", "3");
|
||||
weechat_buffer_set(manager_buffer, "localvar_set_no_log", "1");
|
||||
weechat_buffer_set(manager_buffer, "localvar_set_pinned", "true");
|
||||
weechat_buffer_set(manager_buffer, "localvar_set_type", "other");
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int command_cb(const void *pointer, void *data, struct t_gui_buffer *buffer,
|
||||
int argc, char **argv, char **argv_eol)
|
||||
{
|
||||
const char *command = NULL;
|
||||
int background_registration;
|
||||
|
||||
(void)pointer;
|
||||
(void)data;
|
||||
(void)buffer;
|
||||
(void)argv;
|
||||
|
||||
if (argc > 1 && argv_eol != NULL && argv_eol[1] != NULL && argv_eol[1][0] != '\0')
|
||||
command = argv_eol[1];
|
||||
background_registration = command != NULL && strncmp(command, "register ", 9) == 0;
|
||||
|
||||
if (!ensure_buffer())
|
||||
return WEECHAT_RC_ERROR;
|
||||
|
||||
if (!background_registration)
|
||||
weechat_buffer_set(manager_buffer, "display", "1");
|
||||
if (command != NULL)
|
||||
send_command(command);
|
||||
else
|
||||
send_command("list");
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
int weechat_plugin_init(struct t_weechat_plugin *plugin, int argc, char *argv[])
|
||||
{
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
weechat_plugin = plugin;
|
||||
|
||||
if (!ensure_buffer())
|
||||
return WEECHAT_RC_ERROR;
|
||||
|
||||
if (weechat_hook_command("drops", "open/control NeoDrop manager",
|
||||
"list || status || help || put-text <name> <text> || show <selector> || read <selector> || download <selector> || delete <selector> || edit <selector> || line <number> <text> || insert <number> <text> || delete-line <number> || save || cancel",
|
||||
"Manage encrypted NeoDrop files and text revisions",
|
||||
"list|status|help|put-text|show|read|download|delete|edit|line|insert|delete-line|save|cancel",
|
||||
&command_cb, NULL, NULL) == NULL)
|
||||
return WEECHAT_RC_ERROR;
|
||||
if (weechat_hook_timer(5000, 0, 0, &reconnect_cb, NULL, NULL) == NULL)
|
||||
return WEECHAT_RC_ERROR;
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
int weechat_plugin_end(struct t_weechat_plugin *plugin)
|
||||
{
|
||||
(void)plugin;
|
||||
disconnect_manager(0);
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use strict";
|
||||
(function () {
|
||||
const CHUNK_SIZE = 4 * 1024 * 1024;
|
||||
const PREVIEW_MAX = 50 * 1024 * 1024;
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder("utf-8", {fatal:true});
|
||||
const state = {secret:null, objectId:null, manifest:null, metadata:null, chunks:null, url:null};
|
||||
const $ = id => document.getElementById(id);
|
||||
const show = id => ["idle","loading","viewer","error"].forEach(x => $(x).classList.toggle("hidden", x !== id));
|
||||
const progress = value => $("meter-fill").style.width = Math.round(value * 100) + "%";
|
||||
const from64 = value => { const s=value.replace(/-/g,"+").replace(/_/g,"/"); const raw=atob(s+"=".repeat((4-s.length%4)%4)); return Uint8Array.from(raw,c=>c.charCodeAt(0)); };
|
||||
const to64 = bytes => btoa(String.fromCharCode(...bytes)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/g,"");
|
||||
const u32 = (v,b,o) => new DataView(b.buffer).setUint32(o,v,false);
|
||||
const u64 = (v,b,o) => new DataView(b.buffer).setBigUint64(o,BigInt(v),false);
|
||||
function aad(type, profile, id, size, count, index, length) {
|
||||
const out=new Uint8Array(60); out.set(enc.encode("NEODRP01")); out[8]=type; out[9]=profile; out.set(id,12);
|
||||
u64(size,out,36); u32(CHUNK_SIZE,out,44); u32(count,out,48); u32(index,out,52); u32(length,out,56); return out;
|
||||
}
|
||||
function iv(type,index) { const out=new Uint8Array(12); u32(type,out,0); u64(index,out,4); return out; }
|
||||
async function derive(secret, info, length) {
|
||||
const base=await crypto.subtle.importKey("raw",secret,"HKDF",false,["deriveBits"]);
|
||||
return new Uint8Array(await crypto.subtle.deriveBits({name:"HKDF",hash:"SHA-256",salt:enc.encode("{{NEODROP_CRYPTO_CONTEXT}}"),info:enc.encode(info)},base,length*8));
|
||||
}
|
||||
async function aes(raw) { return crypto.subtle.importKey("raw",raw,{name:"AES-GCM"},false,["decrypt"]); }
|
||||
async function decryptRecord(key, cipher, nonce, additional) { return new Uint8Array(await crypto.subtle.decrypt({name:"AES-GCM",iv:nonce,additionalData:additional,tagLength:128},key,cipher)); }
|
||||
async function fetchJson(url) { const response=await fetch(url,{cache:"no-store"}); if(!response.ok) throw new Error("unavailable"); return response.json(); }
|
||||
function typeFor(type,name) { const value=String(type||"").toLowerCase(); if(value&&value!=="application/octet-stream")return value; const match=String(name||"").toLowerCase().match(/\.([a-z0-9]+)$/); const types={png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",webp:"image/webp",avif:"image/avif",flac:"audio/flac",m4a:"audio/mp4",mp3:"audio/mpeg",oga:"audio/ogg",ogg:"audio/ogg",opus:"audio/opus",wav:"audio/wav",wave:"audio/wav",m4v:"video/mp4",mkv:"video/x-matroska",mov:"video/quicktime",mp4:"video/mp4",ogv:"video/ogg",webm:"video/webm"}; return match&&types[match[1]]?types[match[1]]:"application/octet-stream"; }
|
||||
function classify(type,name) { type=typeFor(type,name); if(/^image\/(png|jpeg|gif|webp|avif)$/i.test(type)) return "image"; if(/^audio\//i.test(type)) return "audio"; if(/^video\//i.test(type)) return "video"; if(/^text\//i.test(type)||/^(application\/(json|javascript|x-.*script))$/i.test(type)) return "text"; return "binary"; }
|
||||
function formatSize(size) { const units=["B","KiB","MiB","GiB"]; let i=0,n=size; while(n>=1024&&i<3){n/=1024;i++;} return n.toFixed(i?1:0)+" "+units[i]; }
|
||||
async function decryptChunk(index, key) {
|
||||
const response=await fetch(`/api/v1/objects/${state.objectId}/chunks/${index}`,{cache:"no-store"}); if(!response.ok) throw new Error("unavailable");
|
||||
const cipher=new Uint8Array(await response.arrayBuffer()); const plainLength=Math.min(CHUNK_SIZE,state.manifest.size-index*CHUNK_SIZE);
|
||||
if(cipher.length!==plainLength+16) throw new Error("invalid length");
|
||||
return decryptRecord(key,cipher,iv(1,index),aad(1,state.manifest.profile,from64(state.objectId),state.manifest.size,state.manifest.chunks,index,plainLength));
|
||||
}
|
||||
async function allChunks(key) { const parts=[]; for(let i=0;i<state.manifest.chunks;i++){parts.push(await decryptChunk(i,key));progress((i+1)/(state.manifest.chunks+1));} return parts; }
|
||||
function highlightText(text) {
|
||||
const pre=document.createElement("pre"); pre.className="text-preview"; let fenced=false;
|
||||
text.split("\n").forEach((line,i)=>{ const span=document.createElement("span"); if(/^```/.test(line)){fenced=!fenced;span.className="code";} else if(fenced||/^ {4}/.test(line))span.className="code"; else if(/^#{1,6}\s/.test(line))span.className="heading"; else if(/^>\s?/.test(line))span.className="quote"; else if(/^\s*([-*+] |\d+\. )/.test(line))span.className="list"; span.textContent=line+(i<text.split("\n").length-1?"\n":""); pre.appendChild(span); });
|
||||
return pre;
|
||||
}
|
||||
function setDetails() {
|
||||
$("file-name").textContent=state.metadata.name;
|
||||
$("file-size").textContent=`${formatSize(state.manifest.size)} (${state.manifest.size.toLocaleString("en-US")} bytes)`;
|
||||
$("file-type").textContent=state.metadata.type||"application/octet-stream";
|
||||
$("file-sha256").textContent=state.metadata.sha256||"Not provided by uploader";
|
||||
const expires=new Date(state.manifest.expiresAt*1000); $("file-expires").dateTime=expires.toISOString(); $("file-expires").textContent=expires.toISOString();
|
||||
}
|
||||
async function renderPreview(parts) {
|
||||
const blob=new Blob(parts,{type:typeFor(state.metadata.type,state.metadata.name)}); const kind=classify(state.metadata.type,state.metadata.name); const preview=$("preview"); preview.textContent="";
|
||||
if(kind==="image"&&blob.size<=PREVIEW_MAX){const image=document.createElement("img");image.alt=state.metadata.name;image.src=URL.createObjectURL(blob);preview.appendChild(image);}
|
||||
else if((kind==="audio"||kind==="video")&&blob.size<=PREVIEW_MAX){const media=document.createElement(kind);media.controls=true;media.preload="metadata";media.src=URL.createObjectURL(blob);if(kind==="video")media.playsInline=true;preview.appendChild(media);}
|
||||
else if(kind==="text"&&blob.size<=10*1024*1024){preview.appendChild(highlightText(dec.decode(await blob.arrayBuffer())));}
|
||||
else {const p=document.createElement("p");p.textContent="Encrypted binary object. Use Download to decrypt and save it.";preview.appendChild(p);}
|
||||
setDetails(); show("viewer");
|
||||
$("download").onclick=()=>{const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=state.metadata.name||"download.bin";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),30000);};
|
||||
}
|
||||
async function downloadLarge(key) {
|
||||
try {
|
||||
if(window.showSaveFilePicker){
|
||||
const handle=await window.showSaveFilePicker({suggestedName:state.metadata.name||"download.bin"}); const writable=await handle.createWritable();
|
||||
try { for(let i=0;i<state.manifest.chunks;i++){await writable.write(await decryptChunk(i,key));progress((i+1)/state.manifest.chunks);} await writable.close(); }
|
||||
catch(error){await writable.abort();throw error;}
|
||||
} else {
|
||||
const parts=await allChunks(key); const blob=new Blob(parts,{type:state.metadata.type||"application/octet-stream"}); const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=state.metadata.name||"download.bin";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),30000);
|
||||
}
|
||||
} catch(error) { if(error.name!=="AbortError")show("error"); }
|
||||
}
|
||||
function renderDownloadOnly(key) {
|
||||
const preview=$("preview"); preview.textContent=""; const p=document.createElement("p"); p.textContent="Encrypted binary object. Decryption starts only when you choose Download."; preview.appendChild(p);
|
||||
setDetails(); show("viewer"); $("download").onclick=()=>downloadLarge(key);
|
||||
}
|
||||
async function load() {
|
||||
try {
|
||||
if(new URLSearchParams(location.search).get("embed")==="1")document.body.classList.add("embed");
|
||||
const match=location.pathname.match(/^\/d\/([A-Za-z0-9_-]{32})$/); const fragment=location.hash.match(/^#d1\.([A-Za-z0-9_-]{43})$/); if(!match||!fragment)return;
|
||||
show("loading"); state.url=location.href; state.objectId=match[1]; state.secret=from64(fragment[1]); history.replaceState(null,"",location.pathname+location.search);
|
||||
const derivedId=await derive(state.secret,"object-id",24); if(to64(derivedId)!==state.objectId)throw new Error("identifier mismatch");
|
||||
state.manifest=await fetchJson(`/api/v1/objects/${state.objectId}`); if(state.manifest.v!==1||state.manifest.chunkSize!==CHUNK_SIZE)throw new Error("manifest");
|
||||
const metaKey=await aes(await derive(state.secret,"metadata-key",32)); const metaCipher=from64(state.manifest.metadata); const metaLength=metaCipher.length-16;
|
||||
const plain=await decryptRecord(metaKey,metaCipher,iv(2,0),aad(0,state.manifest.profile,derivedId,state.manifest.size,state.manifest.chunks,0xffffffff,metaLength)); state.metadata=JSON.parse(dec.decode(plain));
|
||||
if(state.metadata.v!==1||state.metadata.size!==state.manifest.size||(state.metadata.sha256!==undefined&&!/^[a-f0-9]{64}$/.test(state.metadata.sha256)))throw new Error("metadata"); progress(1/(state.manifest.chunks+1));
|
||||
const contentKey=await aes(await derive(state.secret,"content-key",32)); const kind=classify(state.metadata.type,state.metadata.name);
|
||||
if(((kind==="image"||kind==="audio"||kind==="video")&&state.manifest.size<=PREVIEW_MAX)||(kind==="text"&&state.manifest.size<=10*1024*1024)){state.chunks=await allChunks(contentKey);await renderPreview(state.chunks);progress(1);} else {renderDownloadOnly(contentKey);}
|
||||
} catch(e) { show("error"); }
|
||||
}
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="theme-color" content="#150a24">
|
||||
<title>{{PRODUCT_NAME}} - Secure File Drop</title>
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="stylesheet" href="/style.css?v=7">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="login-hero" aria-labelledby="neodrop-title">
|
||||
<div class="login-portrait">
|
||||
<img src="/avatar-login.jpg" alt="Operator-provided avatar">
|
||||
<span class="portrait-code" aria-hidden="true">{{DROP_NODE_LABEL}}</span>
|
||||
</div>
|
||||
<div class="login-intro">
|
||||
<p class="login-kicker">PRIVATE RELAY // TLS 1.3</p>
|
||||
<h1 id="neodrop-title">{{DROPS_TITLE_HTML}}</h1>
|
||||
<p class="login-deck">{{DROP_TAGLINE}}</p>
|
||||
<div class="login-status" aria-label="Service capabilities">
|
||||
<span>ZERO KNOWLEDGE</span>
|
||||
<span>CLIENT ENCRYPTED</span>
|
||||
<span>CIPHERTEXT ONLY</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="idle" class="terminal" aria-label="NeoDrop service status">
|
||||
<div class="terminal-bar"><span></span><span></span><span></span><b>drops@relay:~</b></div>
|
||||
<div class="terminal-body">
|
||||
<p><span class="prompt">>></span> neodrop status --public</p>
|
||||
<p class="terminal-title">Secure File Drop</p>
|
||||
<p>Encrypted in your browser. The server stores ciphertext and never receives the key in this link.</p>
|
||||
<dl>
|
||||
<div><dt>ENCRYPTION</dt><dd>AES-256-GCM</dd></div>
|
||||
<div><dt>ACCESS</dt><dd>CAPABILITY LINK</dd></div>
|
||||
<div><dt>STORAGE</dt><dd>CIPHERTEXT ONLY</dd></div>
|
||||
</dl>
|
||||
<p class="input-line"><span class="prompt">>></span> <span class="cursor" aria-hidden="true">_</span><span class="sr-only">Awaiting input</span></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="loading" class="panel hidden" aria-live="polite">
|
||||
<p class="status">AUTHENTICATING ENCRYPTED OBJECT</p>
|
||||
<div class="meter"><span id="meter-fill"></span></div>
|
||||
</section>
|
||||
|
||||
<section id="viewer" class="panel hidden">
|
||||
<div class="file-heading">
|
||||
<div><p class="label">DECRYPTED OBJECT</p><h1 id="file-name"></h1></div>
|
||||
<button id="download">Download</button>
|
||||
</div>
|
||||
<dl class="file-details">
|
||||
<div><dt>SIZE</dt><dd id="file-size"></dd></div>
|
||||
<div><dt>SHA-256</dt><dd><code id="file-sha256"></code></dd></div>
|
||||
<div><dt>TYPE</dt><dd id="file-type"></dd></div>
|
||||
<div><dt>EXPIRES</dt><dd><time id="file-expires"></time></dd></div>
|
||||
</dl>
|
||||
<div id="preview" class="preview"></div>
|
||||
</section>
|
||||
|
||||
<section id="error" class="panel error hidden" role="alert">
|
||||
<p class="status">OBJECT UNAVAILABLE</p>
|
||||
<p>This link is invalid, expired, deleted, or could not be authenticated.</p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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()
|
||||
@@ -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}}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,186 @@
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
#include <weechat/weechat-plugin.h>
|
||||
|
||||
WEECHAT_PLUGIN_NAME("neoguard")
|
||||
WEECHAT_PLUGIN_DESCRIPTION("NeoGuard abuse-control buffer")
|
||||
WEECHAT_PLUGIN_AUTHOR("{{PRODUCT_NAME}}")
|
||||
WEECHAT_PLUGIN_VERSION("1.1")
|
||||
WEECHAT_PLUGIN_LICENSE("GPL3")
|
||||
|
||||
struct t_weechat_plugin *weechat_plugin;
|
||||
static struct t_gui_buffer *guard_buffer;
|
||||
static struct t_hook *fd_hook;
|
||||
static int agent_fd = -1;
|
||||
static char receive_buffer[16384];
|
||||
static size_t receive_length;
|
||||
|
||||
static void print_line(const char *line)
|
||||
{
|
||||
const char *color = "chat";
|
||||
if (strncmp(line, "EVENT\tBAN", 9) == 0 || strncmp(line, "EVENT\tPERM", 10) == 0)
|
||||
color = "lightred";
|
||||
else if (strncmp(line, "EVENT\tWARN", 10) == 0)
|
||||
color = "yellow";
|
||||
else if (strncmp(line, "ERROR\t", 6) == 0)
|
||||
color = "red";
|
||||
weechat_printf(guard_buffer, "%s%s", weechat_color(color), line);
|
||||
}
|
||||
|
||||
static void disconnect_agent(void)
|
||||
{
|
||||
if (fd_hook) { weechat_unhook(fd_hook); fd_hook = NULL; }
|
||||
if (agent_fd >= 0) close(agent_fd);
|
||||
agent_fd = -1;
|
||||
receive_length = 0;
|
||||
}
|
||||
|
||||
static int agent_read_cb(const void *pointer, void *data, int fd)
|
||||
{
|
||||
(void)pointer; (void)data; (void)fd;
|
||||
ssize_t count = read(agent_fd, receive_buffer + receive_length,
|
||||
sizeof(receive_buffer) - receive_length - 1);
|
||||
if (count <= 0) { disconnect_agent(); return WEECHAT_RC_OK; }
|
||||
receive_length += (size_t)count;
|
||||
receive_buffer[receive_length] = '\0';
|
||||
char *start = receive_buffer;
|
||||
char *newline;
|
||||
while ((newline = strchr(start, '\n'))) {
|
||||
*newline = '\0';
|
||||
print_line(start);
|
||||
start = newline + 1;
|
||||
}
|
||||
receive_length = strlen(start);
|
||||
memmove(receive_buffer, start, receive_length);
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
static int connect_agent(void)
|
||||
{
|
||||
struct sockaddr_un address;
|
||||
if (agent_fd >= 0) return 1;
|
||||
agent_fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
if (agent_fd < 0) return 0;
|
||||
memset(&address, 0, sizeof(address));
|
||||
address.sun_family = AF_UNIX;
|
||||
snprintf(address.sun_path, sizeof(address.sun_path), "%s", "{{NEOGUARD_CONTROL_SOCKET_PATH}}");
|
||||
if (connect(agent_fd, (struct sockaddr *)&address, sizeof(address)) != 0) {
|
||||
close(agent_fd); agent_fd = -1; return 0;
|
||||
}
|
||||
fd_hook = weechat_hook_fd(agent_fd, 1, 0, 0, &agent_read_cb, NULL, NULL);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void send_agent(const char *kind, const char *first, const char *second)
|
||||
{
|
||||
char message[4096];
|
||||
if (!connect_agent()) return;
|
||||
snprintf(message, sizeof(message), "%s\t%s%s%s\n", kind, first,
|
||||
second ? "\t" : "", second ? second : "");
|
||||
if (write(agent_fd, message, strlen(message)) < 0) disconnect_agent();
|
||||
}
|
||||
|
||||
static void send_attempt(const char *address, int port, const char *detail)
|
||||
{
|
||||
char message[4096];
|
||||
if (!connect_agent()) return;
|
||||
if (port >= 1 && port <= 65535)
|
||||
snprintf(message, sizeof(message), "ATTEMPT\t%s\t%d\t%s\n", address, port, detail);
|
||||
else
|
||||
snprintf(message, sizeof(message), "ATTEMPT\t%s\t%s\n", address, detail);
|
||||
if (write(agent_fd, message, strlen(message)) < 0) disconnect_agent();
|
||||
}
|
||||
|
||||
static int reconnect_cb(const void *pointer, void *data, int remaining_calls)
|
||||
{
|
||||
(void)pointer; (void)data; (void)remaining_calls;
|
||||
connect_agent();
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
static int input_cb(const void *pointer, void *data, struct t_gui_buffer *buffer, const char *input)
|
||||
{
|
||||
(void)pointer; (void)data; (void)buffer;
|
||||
if (input && input[0]) send_agent("COMMAND", input, NULL);
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
static int close_cb(const void *pointer, void *data, struct t_gui_buffer *buffer)
|
||||
{
|
||||
(void)pointer; (void)data; (void)buffer;
|
||||
guard_buffer = NULL;
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
static int ensure_buffer(void)
|
||||
{
|
||||
if (!guard_buffer)
|
||||
guard_buffer = weechat_buffer_search("neoguard", "control");
|
||||
if (!guard_buffer)
|
||||
guard_buffer = weechat_buffer_new("control", &input_cb, NULL, NULL, &close_cb, NULL, NULL);
|
||||
if (!guard_buffer) return 0;
|
||||
weechat_buffer_set_pointer(guard_buffer, "input_callback", &input_cb);
|
||||
weechat_buffer_set_pointer(guard_buffer, "close_callback", &close_cb);
|
||||
weechat_buffer_set(guard_buffer, "short_name", "NeoGuard");
|
||||
weechat_buffer_set(guard_buffer, "title", "NeoGuard | status/list | show/add/remove/extend IP | action IP redirect|message");
|
||||
weechat_buffer_set(guard_buffer, "hidden", "0");
|
||||
weechat_buffer_set(guard_buffer, "notify", "3");
|
||||
weechat_buffer_set(guard_buffer, "localvar_set_no_log", "1");
|
||||
weechat_buffer_set(guard_buffer, "localvar_set_pinned", "true");
|
||||
weechat_buffer_set(guard_buffer, "localvar_set_type", "other");
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int command_cb(const void *pointer, void *data, struct t_gui_buffer *buffer,
|
||||
int argc, char **argv, char **argv_eol)
|
||||
{
|
||||
(void)pointer; (void)data; (void)buffer; (void)argc; (void)argv;
|
||||
if (ensure_buffer()) {
|
||||
weechat_buffer_set(guard_buffer, "display", "1");
|
||||
if (argv_eol[1] && argv_eol[1][0]) send_agent("COMMAND", argv_eol[1], NULL);
|
||||
}
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
static int relay_failure_cb(const void *pointer, void *data, const char *signal,
|
||||
const char *type_data, void *signal_data)
|
||||
{
|
||||
(void)pointer; (void)data; (void)signal; (void)type_data;
|
||||
struct t_infolist *list = weechat_infolist_get("relay", signal_data, NULL);
|
||||
if (list && weechat_infolist_next(list)) {
|
||||
const char *real_ip = weechat_infolist_string(list, "real_ip");
|
||||
const char *address = weechat_infolist_string(list, "address");
|
||||
int port = weechat_infolist_integer(list, "server_port");
|
||||
send_attempt((real_ip && real_ip[0]) ? real_ip : address, port,
|
||||
"relay authentication failed");
|
||||
}
|
||||
if (list) weechat_infolist_free(list);
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
int weechat_plugin_init(struct t_weechat_plugin *plugin, int argc, char *argv[])
|
||||
{
|
||||
(void)argc; (void)argv;
|
||||
weechat_plugin = plugin;
|
||||
weechat_hook_command("neoguard", "open/control NeoGuard",
|
||||
"list || status || show <ip> || add <ip> [<duration>] || remove <ip> || extend <ip> <duration> || action <ip> redirect <https-url> || action <ip> message <text>",
|
||||
"Inspect and control NeoGuard bans and per-address actions",
|
||||
"list|status|show|add|remove|extend|action",
|
||||
&command_cb, NULL, NULL);
|
||||
weechat_hook_signal("relay_client_auth_failed", &relay_failure_cb, NULL, NULL);
|
||||
weechat_hook_timer(5000, 0, 0, &reconnect_cb, NULL, NULL);
|
||||
command_cb(NULL, NULL, NULL, 0, NULL, (char *[]){"", "status"});
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
|
||||
int weechat_plugin_end(struct t_weechat_plugin *plugin)
|
||||
{
|
||||
(void)plugin;
|
||||
disconnect_agent();
|
||||
return WEECHAT_RC_OK;
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
#!/usr/bin/python3
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import socket
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
DATA_ROOT = Path("{{NEOGUARD_DATA_ROOT}}")
|
||||
DB_PATH = DATA_ROOT / "neoguard.sqlite3"
|
||||
KEY_PATH = Path("{{NEOGUARD_KEY_PATH}}")
|
||||
LISTEN_ADDRESS = "{{NEOGUARD_BIND_ADDRESS}}"
|
||||
LISTEN_PORT = 8081
|
||||
TRUSTED_PROXY = ipaddress.ip_address("{{TRUSTED_PROXY_ADDRESS}}")
|
||||
EXEMPT_NETWORKS = (
|
||||
ipaddress.ip_network("{{UPLOAD_CIDR_1}}"),
|
||||
ipaddress.ip_network("{{UPLOAD_CIDR_2}}"),
|
||||
ipaddress.ip_network("{{UPLOAD_CIDR_3}}"),
|
||||
)
|
||||
WINDOW = 600
|
||||
MAX_FAILURES = 21
|
||||
BAN_SECONDS = 24 * 60 * 60
|
||||
DEFAULT_REDIRECT = "{{BLOCKED_REDIRECT_URL}}"
|
||||
DB_LOCK = threading.RLock()
|
||||
NONCES = {}
|
||||
URL_RE = re.compile(r"^https://[^\s\\\"'{};$]{1,2039}$")
|
||||
|
||||
|
||||
def now():
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def database():
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=FULL")
|
||||
return conn
|
||||
|
||||
|
||||
def initialize_database():
|
||||
DATA_ROOT.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with database() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
occurred_at INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
destination_port INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS attempts_window ON attempts(address, occurred_at);
|
||||
CREATE TABLE IF NOT EXISTS offenses (
|
||||
address TEXT PRIMARY KEY,
|
||||
count INTEGER NOT NULL,
|
||||
last_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
address TEXT PRIMARY KEY,
|
||||
started_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
offense INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
rdns TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
occurred_at INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
destination_port INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
for table in ("attempts", "events"):
|
||||
columns = {row["name"] for row in conn.execute("PRAGMA table_info(%s)" % table)}
|
||||
if "destination_port" not in columns:
|
||||
conn.execute("ALTER TABLE %s ADD COLUMN destination_port INTEGER" % table)
|
||||
|
||||
|
||||
def normalize_address(value):
|
||||
address = ipaddress.ip_address(value)
|
||||
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
||||
address = address.ipv4_mapped
|
||||
return address
|
||||
|
||||
|
||||
def exempt(address):
|
||||
return any(address in network for network in EXEMPT_NETWORKS)
|
||||
|
||||
|
||||
def add_event(conn, level, message, destination_port=None):
|
||||
conn.execute("INSERT INTO events(occurred_at, level, message, destination_port) VALUES (?, ?, ?, ?)",
|
||||
(now(), level, message[:2000], destination_port))
|
||||
|
||||
|
||||
def confirmed_rdns(address):
|
||||
try:
|
||||
hostname = socket.gethostbyaddr(str(address))[0].lower().rstrip(".")
|
||||
if not hostname or len(hostname) > 253 or any(ord(c) < 33 for c in hostname):
|
||||
return ""
|
||||
results = socket.getaddrinfo(hostname, None)
|
||||
returned = {normalize_address(item[4][0]) for item in results}
|
||||
return hostname if address in returned else ""
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_ban(address_text):
|
||||
address = normalize_address(address_text)
|
||||
hostname = confirmed_rdns(address)
|
||||
with DB_LOCK, database() as conn:
|
||||
conn.execute("UPDATE bans SET rdns = ? WHERE address = ?", (hostname, str(address)))
|
||||
if hostname:
|
||||
add_event(conn, "INFO", "[RDNS] ip=%s rdns=%s" % (address, hostname))
|
||||
|
||||
|
||||
def expire_bans(conn):
|
||||
timestamp = now()
|
||||
rows = list(conn.execute("SELECT address FROM bans WHERE active = 1 AND expires_at IS NOT NULL AND expires_at <= ?", (timestamp,)))
|
||||
for row in rows:
|
||||
conn.execute("UPDATE bans SET active = 0 WHERE address = ?", (row["address"],))
|
||||
add_event(conn, "INFO", "[UNBAN] ip=%s reason=expired" % row["address"])
|
||||
|
||||
|
||||
def normalize_destination_port(value):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("invalid destination port")
|
||||
if isinstance(value, int):
|
||||
port = value
|
||||
elif isinstance(value, str) and value.isdecimal():
|
||||
port = int(value)
|
||||
else:
|
||||
raise ValueError("invalid destination port")
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("invalid destination port")
|
||||
return port
|
||||
|
||||
|
||||
def record_attempt(address_value, source, detail, destination_port=None):
|
||||
destination_port = normalize_destination_port(destination_port)
|
||||
try:
|
||||
address = normalize_address(address_value)
|
||||
except ValueError:
|
||||
return
|
||||
if exempt(address):
|
||||
return
|
||||
address_text = str(address)
|
||||
timestamp = now()
|
||||
created_ban = False
|
||||
with DB_LOCK, database() as conn:
|
||||
expire_bans(conn)
|
||||
active = conn.execute("SELECT 1 FROM bans WHERE address = ? AND active = 1", (address_text,)).fetchone()
|
||||
conn.execute("DELETE FROM attempts WHERE occurred_at < ?", (timestamp - WINDOW,))
|
||||
if active:
|
||||
return
|
||||
conn.execute("INSERT INTO attempts(occurred_at,address,source,detail,destination_port) VALUES (?,?,?,?,?)",
|
||||
(timestamp, address_text, source[:32], detail[:300], destination_port))
|
||||
count = conn.execute("SELECT COUNT(*) AS n FROM attempts WHERE address = ? AND occurred_at >= ?",
|
||||
(address_text, timestamp - WINDOW)).fetchone()["n"]
|
||||
if count in (1, 10, 20):
|
||||
port_text = " port=%d" % destination_port if destination_port else ""
|
||||
add_event(conn, "WARN", "[WARN] ip=%s%s rdns=pending count=%d/%d window=10m source=%s" %
|
||||
(address_text, port_text, count, MAX_FAILURES, source[:32]), destination_port)
|
||||
if count >= MAX_FAILURES:
|
||||
offense_row = conn.execute("SELECT count FROM offenses WHERE address = ?", (address_text,)).fetchone()
|
||||
offense = (offense_row["count"] if offense_row else 0) + 1
|
||||
conn.execute("INSERT INTO offenses VALUES (?,?,?) ON CONFLICT(address) DO UPDATE SET count=excluded.count,last_at=excluded.last_at",
|
||||
(address_text, offense, timestamp))
|
||||
expires = None if offense >= 3 else timestamp + BAN_SECONDS
|
||||
conn.execute("""
|
||||
INSERT INTO bans(address,started_at,expires_at,offense,action,target,message,reason,rdns,active)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,1)
|
||||
ON CONFLICT(address) DO UPDATE SET started_at=excluded.started_at,expires_at=excluded.expires_at,
|
||||
offense=excluded.offense,action=excluded.action,target=excluded.target,message=excluded.message,
|
||||
reason=excluded.reason,rdns='',active=1
|
||||
""", (address_text, timestamp, expires, offense, "redirect", DEFAULT_REDIRECT,
|
||||
"Access denied by NeoGuard.", "automatic threshold", ""))
|
||||
conn.execute("DELETE FROM attempts WHERE address = ?", (address_text,))
|
||||
level = "PERM" if expires is None else "BAN"
|
||||
until = "permanent" if expires is None else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(expires))
|
||||
port_text = " port=%d" % destination_port if destination_port else ""
|
||||
add_event(conn, level, "[%s] ip=%s%s rdns=pending offense=%d failures=%d until=%s action=redirect" %
|
||||
(level, address_text, port_text, offense, count, until), destination_port)
|
||||
created_ban = True
|
||||
if created_ban:
|
||||
threading.Thread(target=resolve_ban, args=(address_text,), daemon=True).start()
|
||||
|
||||
|
||||
def active_ban(address_value):
|
||||
try:
|
||||
address = normalize_address(address_value)
|
||||
except ValueError:
|
||||
return None
|
||||
if exempt(address):
|
||||
return None
|
||||
with DB_LOCK, database() as conn:
|
||||
expire_bans(conn)
|
||||
return conn.execute("SELECT * FROM bans WHERE address = ? AND active = 1", (str(address),)).fetchone()
|
||||
|
||||
|
||||
def duration_value(value):
|
||||
if value == "permanent":
|
||||
return None
|
||||
match = re.fullmatch(r"(\d+)(m|h|d)", value)
|
||||
if not match:
|
||||
raise ValueError("duration must be Nm, Nh, Nd, or permanent")
|
||||
factor = {"m": 60, "h": 3600, "d": 86400}[match.group(2)]
|
||||
duration = int(match.group(1)) * factor
|
||||
if duration < 60 or duration > 10 * 365 * 86400:
|
||||
raise ValueError("duration out of range")
|
||||
return now() + duration
|
||||
|
||||
|
||||
def format_ban(row):
|
||||
until = "permanent" if row["expires_at"] is None else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(row["expires_at"]))
|
||||
return "ip=%s rdns=%s offense=%d until=%s action=%s target=%s reason=%s" % (
|
||||
row["address"], row["rdns"] or "-", row["offense"], until, row["action"],
|
||||
row["target"] if row["action"] == "redirect" else "-", row["reason"])
|
||||
|
||||
|
||||
def run_command(command):
|
||||
try:
|
||||
args = shlex.split(command)
|
||||
except ValueError as error:
|
||||
return "ERROR " + str(error)
|
||||
if not args:
|
||||
return "Commands: list, show IP, add IP DURATION, remove IP, extend IP DURATION, action IP redirect URL|message TEXT, status"
|
||||
operation = args[0].lower()
|
||||
with DB_LOCK, database() as conn:
|
||||
expire_bans(conn)
|
||||
if operation == "list":
|
||||
rows = list(conn.execute("SELECT * FROM bans WHERE active=1 ORDER BY started_at DESC LIMIT 25"))
|
||||
return "No active bans." if not rows else "\n".join(format_ban(row) for row in rows)
|
||||
if operation == "status":
|
||||
active = conn.execute("SELECT COUNT(*) n FROM bans WHERE active=1").fetchone()["n"]
|
||||
attempts = conn.execute("SELECT COUNT(*) n FROM attempts WHERE occurred_at>=?", (now()-WINDOW,)).fetchone()["n"]
|
||||
return "NeoGuard active_bans=%d recent_attempts=%d threshold=%d/10m" % (active, attempts, MAX_FAILURES)
|
||||
if len(args) < 2:
|
||||
return "ERROR missing IP address"
|
||||
try:
|
||||
address = normalize_address(args[1])
|
||||
except ValueError:
|
||||
return "ERROR invalid IP address"
|
||||
if exempt(address):
|
||||
return "ERROR protected internal/trusted address"
|
||||
address_text = str(address)
|
||||
if operation == "show":
|
||||
row = conn.execute("SELECT * FROM bans WHERE address=?", (address_text,)).fetchone()
|
||||
offense = conn.execute("SELECT count FROM offenses WHERE address=?", (address_text,)).fetchone()
|
||||
return (format_ban(row) if row else "No ban for %s" % address_text) + " offenses=%d" % (offense["count"] if offense else 0)
|
||||
if operation == "remove":
|
||||
conn.execute("UPDATE bans SET active=0 WHERE address=?", (address_text,))
|
||||
conn.execute("DELETE FROM attempts WHERE address=?", (address_text,))
|
||||
add_event(conn, "ADMIN", "[ADMIN] operation=remove ip=%s" % address_text)
|
||||
return "Removed active ban for %s; offense history preserved." % address_text
|
||||
if operation == "add":
|
||||
expiry = duration_value(args[2] if len(args) > 2 else "24h")
|
||||
offense_row = conn.execute("SELECT count FROM offenses WHERE address=?", (address_text,)).fetchone()
|
||||
offense = offense_row["count"] if offense_row else 0
|
||||
conn.execute("""
|
||||
INSERT INTO bans VALUES (?,?,?,?,?,?,?,?,?,1)
|
||||
ON CONFLICT(address) DO UPDATE SET started_at=excluded.started_at,expires_at=excluded.expires_at,
|
||||
action=excluded.action,target=excluded.target,message=excluded.message,reason=excluded.reason,active=1
|
||||
""", (address_text, now(), expiry, offense, "redirect", DEFAULT_REDIRECT,
|
||||
"Access denied by NeoGuard.", "manual", confirmed_rdns(address)))
|
||||
add_event(conn, "ADMIN", "[ADMIN] operation=add ip=%s" % address_text)
|
||||
return "Added " + address_text
|
||||
row = conn.execute("SELECT * FROM bans WHERE address=? AND active=1", (address_text,)).fetchone()
|
||||
if not row:
|
||||
return "ERROR no active ban for %s" % address_text
|
||||
if operation == "extend":
|
||||
if len(args) < 3:
|
||||
return "ERROR missing duration"
|
||||
expiry = duration_value(args[2])
|
||||
conn.execute("UPDATE bans SET expires_at=? WHERE address=?", (expiry, address_text))
|
||||
add_event(conn, "ADMIN", "[ADMIN] operation=extend ip=%s duration=%s" % (address_text, args[2]))
|
||||
return "Extended " + address_text
|
||||
if operation == "action":
|
||||
if len(args) < 4 or args[2] not in ("redirect", "message"):
|
||||
return "ERROR usage: action IP redirect URL | action IP message TEXT"
|
||||
action = args[2]
|
||||
value = " ".join(args[3:])[:1000]
|
||||
if action == "redirect" and not URL_RE.fullmatch(value):
|
||||
return "ERROR redirect must be a safe https:// URL"
|
||||
if action == "message" and any(ord(c) < 32 and c not in "\t" for c in value):
|
||||
return "ERROR invalid message"
|
||||
conn.execute("UPDATE bans SET action=?,target=?,message=? WHERE address=?",
|
||||
(action, value if action == "redirect" else "", value if action == "message" else "", address_text))
|
||||
add_event(conn, "ADMIN", "[ADMIN] operation=action ip=%s action=%s" % (address_text, action))
|
||||
return "Updated action for " + address_text
|
||||
return "ERROR unknown command"
|
||||
|
||||
|
||||
def verify_hmac(handler, body):
|
||||
try:
|
||||
timestamp = int(handler.headers.get("X-Neo-Timestamp", "0"))
|
||||
nonce = handler.headers.get("X-Neo-Nonce", "")
|
||||
signature = handler.headers.get("X-Neo-Signature", "")
|
||||
if abs(now() - timestamp) > 60 or not re.fullmatch(r"[0-9a-f]{32}", nonce):
|
||||
return False
|
||||
with DB_LOCK:
|
||||
cutoff = now() - 120
|
||||
for key in list(NONCES):
|
||||
if NONCES[key] < cutoff:
|
||||
del NONCES[key]
|
||||
if nonce in NONCES:
|
||||
return False
|
||||
path = handler.path
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
canonical = "%d\n%s\n%s\n%s\n%s" % (timestamp, nonce, handler.command, path, digest)
|
||||
expected = hmac.new(KEY_PATH.read_bytes(), canonical.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
return False
|
||||
NONCES[nonce] = now()
|
||||
return True
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
class NeoGuardHandler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "NeoGuard"
|
||||
sys_version = ""
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def peer(self):
|
||||
return normalize_address(self.client_address[0])
|
||||
|
||||
def body(self, maximum=16384):
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
return None
|
||||
return self.rfile.read(length) if 0 <= length <= maximum else None
|
||||
|
||||
def response(self, status, body=b"", content_type="application/json", headers=None):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
if headers:
|
||||
for key, value in headers.items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def json_response(self, status, value):
|
||||
self.response(status, json.dumps(value, separators=(",", ":")).encode() + b"\n")
|
||||
|
||||
def proxy_client(self):
|
||||
if self.peer() != TRUSTED_PROXY:
|
||||
return None
|
||||
try:
|
||||
return normalize_address(self.headers.get("X-Real-IP", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def do_HEAD(self):
|
||||
return self.do_GET()
|
||||
|
||||
def do_GET(self):
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/check":
|
||||
client = self.proxy_client()
|
||||
if not client:
|
||||
return self.response(403)
|
||||
return self.response(403 if active_ban(client) else 204)
|
||||
if path == "/blocked":
|
||||
client = self.proxy_client()
|
||||
row = active_ban(client) if client else None
|
||||
if not row:
|
||||
return self.response(404)
|
||||
if row["action"] == "redirect":
|
||||
return self.response(302, headers={"Location": row["target"]})
|
||||
message = html.escape(row["message"] or "Access denied by NeoGuard.")
|
||||
page = ("<!doctype html><meta charset=utf-8><meta name=robots content=noindex>"
|
||||
"<title>NeoGuard</title><style>body{margin:0;background:#07030d;color:#f1eaff;font:18px monospace}"
|
||||
"main{max-width:760px;margin:12vh auto;padding:48px;border:1px solid #9d5cff;background:#150a24;"
|
||||
"box-shadow:14px 14px #351360}h1{color:#ff65c8}b{color:#b9ff4f}</style>"
|
||||
"<main><b>NEOGUARD // ACCESS DENIED</b><h1>Connection blocked</h1><p>%s</p></main>" % message).encode()
|
||||
return self.response(403, page, "text/html; charset=utf-8")
|
||||
if path == "/api/v1/neoguard/poll":
|
||||
if not verify_hmac(self, b""):
|
||||
return self.response(403)
|
||||
try:
|
||||
after = int(parse_qs(urlsplit(self.path).query).get("after", ["0"])[0])
|
||||
except ValueError:
|
||||
after = 0
|
||||
with database() as conn:
|
||||
rows = list(conn.execute("SELECT * FROM events WHERE id>? ORDER BY id LIMIT 100", (after,)))
|
||||
return self.json_response(200, {"events": [dict(row) for row in rows]})
|
||||
return self.response(404)
|
||||
|
||||
def do_POST(self):
|
||||
path = urlsplit(self.path).path
|
||||
body = self.body()
|
||||
if body is None:
|
||||
return self.response(400)
|
||||
if path == "/internal/attempt" and self.peer().is_loopback:
|
||||
try:
|
||||
data = json.loads(body)
|
||||
record_attempt(data["ip"], data.get("source", "drops"), data.get("detail", "invalid request"),
|
||||
data.get("destination_port"))
|
||||
return self.response(204)
|
||||
except (ValueError, KeyError, json.JSONDecodeError):
|
||||
return self.response(400)
|
||||
if path == "/api/v1/neoguard/event":
|
||||
if not verify_hmac(self, body):
|
||||
return self.response(403)
|
||||
try:
|
||||
data = json.loads(body)
|
||||
if data.get("type") == "attempt":
|
||||
record_attempt(data["ip"], data.get("source", "relay"), data.get("detail", "authentication failed"),
|
||||
data.get("destination_port"))
|
||||
return self.json_response(200, {"ok": True})
|
||||
if data.get("type") == "command":
|
||||
return self.json_response(200, {"response": run_command(data.get("command", ""))})
|
||||
except (ValueError, KeyError, json.JSONDecodeError):
|
||||
pass
|
||||
return self.response(400)
|
||||
return self.response(404)
|
||||
|
||||
|
||||
class NeoGuardServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
request_queue_size = 256
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.umask(0o077)
|
||||
initialize_database()
|
||||
NeoGuardServer((LISTEN_ADDRESS, LISTEN_PORT), NeoGuardHandler).serve_forever()
|
||||
Executable
+524
@@ -0,0 +1,524 @@
|
||||
#!/usr/bin/perl
|
||||
use strict;
|
||||
use warnings;
|
||||
use File::Copy qw(copy);
|
||||
use File::Path qw(make_path);
|
||||
|
||||
my ($source_dir, $assets_dir) = @ARGV;
|
||||
die "usage: $0 SOURCE_DIR ASSETS_DIR\n" unless $source_dir && $assets_dir;
|
||||
|
||||
sub read_file {
|
||||
my ($path) = @_;
|
||||
open my $fh, '<', $path or die "open $path: $!\n";
|
||||
local $/;
|
||||
return <$fh>;
|
||||
}
|
||||
|
||||
sub write_file {
|
||||
my ($path, $content) = @_;
|
||||
open my $fh, '>', $path or die "write $path: $!\n";
|
||||
print {$fh} $content;
|
||||
close $fh or die "close $path: $!\n";
|
||||
}
|
||||
|
||||
my $index_path = "$source_dir/src/index.html";
|
||||
my $index = read_file($index_path);
|
||||
my $hero = read_file("$assets_dir/login-hero.html");
|
||||
my $core_welcome = $hero;
|
||||
$core_welcome =~ s{<section class="login-hero"}{<section class="login-hero core-login-hero" ng-show="activeBuffer().fullName === 'core.weechat'"} or
|
||||
die "could not prepare core login hero\n";
|
||||
$core_welcome =~ s{neorelay-login-title}{neorelay-core-title}g;
|
||||
my $drop_settings = read_file("$assets_dir/drop-settings.html");
|
||||
my $colour_settings = read_file("$assets_dir/colour-settings.html");
|
||||
my $colour_settings_js = read_file("$assets_dir/colour-settings.js.inc");
|
||||
my $weechat_settings = read_file("$assets_dir/weechat-settings.html");
|
||||
my $weechat_settings_js = read_file("$assets_dir/weechat-settings.js.inc");
|
||||
my $drop_plugin = read_file("$assets_dir/neodrop-plugin.js.inc");
|
||||
my $connection_panel = read_file("$assets_dir/connection-panel.html");
|
||||
my $sidebar_resize = read_file("$assets_dir/sidebar-resize.js.inc");
|
||||
my $command_menu = read_file("$assets_dir/command-menu.html");
|
||||
my $command_menu_js = read_file("$assets_dir/command-menu.js.inc");
|
||||
my $context_menu = read_file("$assets_dir/context-menu.html");
|
||||
my $server_manager = read_file("$assets_dir/server-manager.html");
|
||||
|
||||
my $count = ($index =~ s{
|
||||
<div\ ng-hide="connected"\ class="container">\s*
|
||||
<h2>.*?</h2>
|
||||
}{<div ng-hide="connected" class="container login-shell">\n$hero}sx);
|
||||
die "could not replace Glowing Bear login header\n" unless $count == 1;
|
||||
|
||||
$count = ($index =~ s{
|
||||
(<link\ href="css/glowingbear\.css"\ rel="stylesheet"\ media="screen">)
|
||||
}{$1\n <link href="css/neorelay-cyberpunk.css?v=31" rel="stylesheet" media="screen">}x);
|
||||
die "could not inject cyberpunk stylesheet\n" unless $count == 1;
|
||||
|
||||
$count = ($index =~ s{
|
||||
<div\ class="panel"\ data-state="active">\s*
|
||||
<div\ class="panel-heading">\s*<h4\ class="panel-title">\s*
|
||||
<a\ class="accordion-toggle"\ ng-click="toggleAccordion\(\$event\)">\s*
|
||||
Connection\ settings.*?
|
||||
(?=<div\ class="panel"\ data-state="collapsed"\ id="gettingStartedAccordion">)
|
||||
}{$connection_panel}sx);
|
||||
die "could not replace connection panel\n" unless $count == 1;
|
||||
|
||||
$count = ($index =~ s{
|
||||
<div\ class="panel"[^>]*id="gettingStartedAccordion">.*?
|
||||
(?=\s*</div>\s*</div>\s*<div\ class="content"\ id="content")
|
||||
}{}sx);
|
||||
die "could not remove public help panels\n" unless $count == 1;
|
||||
|
||||
$index =~ s{assets/img/glowing_bear_128x128\.png}{assets/neorelay-avatar-icon.png}g;
|
||||
$index =~ s{assets/img/favicon\.png}{assets/neorelay-avatar-icon.png}g;
|
||||
$index =~ s{<meta name="theme-color" content="#[0-9a-fA-F]+">}{<meta name="theme-color" content="#150a24">};
|
||||
$index =~ s{Image upload failed\.}{\{\{ uploadErrorMessage || 'Encrypted upload failed.' \}\}} or
|
||||
die "could not customize upload error\n";
|
||||
|
||||
$count = ($index =~ s{
|
||||
<li\ class="standard-labels">\s*<form\ class="form-horizontal"\ role="form">\s*
|
||||
<div\ class="form-group">\s*<label[^>]+for="iToken".*?</form>\s*</li>
|
||||
}{$drop_settings}sx);
|
||||
die "could not replace Imgur settings\n" unless $count == 1;
|
||||
|
||||
$count = ($index =~ s{
|
||||
(<div\ id="settingsModal".*?<div\ class="modal-body">)\s*<ul\ class="">
|
||||
}{$1
|
||||
<nav class="neo-settings-tabs" role="tablist" aria-label="Settings sections">
|
||||
<button type="button" ng-class="{active: neoSettingsTab !== 'colours' && neoSettingsTab !== 'weechat'}" ng-click="neoSettingsTab = 'general'" role="tab">General</button>
|
||||
<button type="button" ng-class="{active: neoSettingsTab === 'colours'}" ng-click="neoSettingsTab = 'colours'; loadNeoWeeChatColours()" role="tab">Colours</button>
|
||||
<button type="button" ng-class="{active: neoSettingsTab === 'weechat'}" ng-click="neoSettingsTab = 'weechat'; loadNeoWeeChatSettings()" role="tab">WeeChat</button>
|
||||
</nav>
|
||||
<ul class="neo-settings-panel" ng-show="neoSettingsTab !== 'colours' && neoSettingsTab !== 'weechat'">}sx);
|
||||
die "could not add settings tabs\n" unless $count == 1;
|
||||
$index =~ s{Settings\ will\ be\ stored\ in\ your\ browser\.}{Browser preferences stay local; Colours and WeeChat settings are saved to the relay.} or
|
||||
die "could not clarify settings persistence\n";
|
||||
$count = ($index =~ s{
|
||||
</ul>\s*(</div>\s*<div\ class="modal-footer">)
|
||||
}{</ul>
|
||||
$colour_settings
|
||||
$weechat_settings
|
||||
$1}x);
|
||||
die "could not add colour settings panel\n" unless $count == 1;
|
||||
|
||||
$index =~ s{ng-class="\{'no-overflow':\ connected\}"}{ng-class="{'no-overflow': connected, 'neo-preferred-font': connected}"} or
|
||||
die "could not scope preferred typography to the connected client\n";
|
||||
$index =~ s{<input\ type="text"\ ng-model="settings\.fontfamily"\ class="form-control"\ id="font">}{<div class="neo-font-picker" id="font">
|
||||
<button type="button" class="form-control neo-font-trigger" ng-click="neoFontPickerOpen = !neoFontPickerOpen" ng-style="{'font-family': settings.fontfamily}" aria-haspopup="listbox" aria-expanded="{{ neoFontPickerOpen }}">
|
||||
<span class="neo-font-value">{{ settings.fontfamily }}</span><span class="caret" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="neo-font-menu" ng-show="neoFontPickerOpen" role="listbox" aria-label="Available fonts">
|
||||
<button type="button" class="neo-font-option" role="option" ng-click="settings.fontfamily = 'OpenDyslexic, Atkinson Hyperlegible, monospace'; neoFontPickerOpen = false" style="font-family:'OpenDyslexic',sans-serif"><strong>OpenDyslexic</strong><span>Aa Bb Cc 123</span></button>
|
||||
<button type="button" class="neo-font-option" role="option" ng-click="settings.fontfamily = 'system-ui, sans-serif'; neoFontPickerOpen = false" style="font-family:system-ui,sans-serif"><strong>System Sans</strong><span>Aa Bb Cc 123</span></button>
|
||||
<button type="button" class="neo-font-option" role="option" ng-click="settings.fontfamily = 'ui-serif, Georgia, serif'; neoFontPickerOpen = false" style="font-family:Georgia,serif"><strong>System Serif</strong><span>Aa Bb Cc 123</span></button>
|
||||
<button type="button" class="neo-font-option" role="option" ng-click="settings.fontfamily = 'ui-monospace, SFMono-Regular, Consolas, monospace'; neoFontPickerOpen = false" style="font-family:monospace"><strong>System Monospace</strong><span>Aa Bb Cc 123</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="neo-font-preview" ng-style="{'font-family': settings.fontfamily, 'font-size': settings.fontsize}">The quick brown fox jumps over the lazy dog. 0123456789</p>} or
|
||||
die "could not add preferred font previews\n";
|
||||
|
||||
$count = ($index =~ s{
|
||||
(<div\ class="title"[^>]*>\s*)
|
||||
(<span\ class="desktop"\ ng-repeat=)
|
||||
}{$1<span class="desktop neorelay-core-title" ng-show="activeBuffer().fullName === 'core.weechat'">{{PRODUCT_NAME}} <small>private IRC gateway</small></span>\n <span class="desktop" ng-hide="activeBuffer().fullName === 'core.weechat'" ng-repeat=}x);
|
||||
die "could not replace core buffer title\n" unless $count == 1;
|
||||
|
||||
$count = ($index =~ s{
|
||||
(<span\ class="mobile")
|
||||
}{<span class="mobile neorelay-core-title" ng-show="activeBuffer().fullName === 'core.weechat'">{{PRODUCT_NAME}}</span>\n $1 ng-hide="activeBuffer().fullName === 'core.weechat'"}x);
|
||||
die "could not guard mobile core title\n" unless $count == 1;
|
||||
|
||||
$count = ($index =~ s{
|
||||
<div\ class="actions\ pull-right\ vertical-line-left">.*?</div>
|
||||
}{}sx);
|
||||
die "could not remove topbar actions\n" unless $count == 1;
|
||||
|
||||
my $core_label = q!{{ buffer.fullName === 'core.weechat' ? '{{PRODUCT_NAME}}' : (buffer.trimmedName || buffer.fullName) }}!;
|
||||
$count = ($index =~ s!(class="buffername" ng-class="::buffer\.nameClasses">)\{\{ buffer\.trimmedName \|\| buffer\.fullName \}\}!$1$core_label!);
|
||||
die "could not rename visible core buffer\n" unless $count == 1;
|
||||
|
||||
$count = ($index =~ s{
|
||||
(<div\ id="bufferlines"[^>]*>)\s*<table>
|
||||
}{$1\n $core_welcome\n <table>}x);
|
||||
die "could not replace core buffer contents\n" unless $count == 1;
|
||||
$index =~ s{('private': buffer\.type === 'private')}{$1,
|
||||
'server': buffer.type === 'server'} or
|
||||
die "could not mark server buffers\n";
|
||||
$index =~ s{<li\ class="buffer"\ ng-class=}{<li class="buffer" neo-context="{kind:'buffer',buffer:buffer}" ng-class=} or
|
||||
die "could not add buffer context menu\n";
|
||||
$index =~ s{<a\ ng-click="openBuffer\(nick\.name\)"><span\ ng-class="::nick\.prefixClasses"\ ng-bind="::nick\.prefix"></span><span\ ng-class="::nick\.nameClasses"\ ng-bind="::nick\.name"></span></a>}{<a ng-click="insertAtCaret(nick.name)" neo-context="{kind:'nick',nick:nick,buffer:activeBuffer()}" title="Insert nickname; right-click for actions"><span ng-class="::nick.prefixClasses" ng-bind="::nick.prefix"></span><span ng-class="::nick.displayClasses" ng-bind="::nick.name"></span></a>} or
|
||||
die "could not add nick context menu\n";
|
||||
$index =~ s{<span\ ng-repeat="part\ in\ ::bufferline\.prefix"\ ng-class="::part\.classes"}{<span ng-repeat="part in ::bufferline.prefix" ng-class="part.classes.concat(activeBuffer().getNickColourClasses(bufferline.nick))"} or
|
||||
die "could not apply WeeChat nick colours to chat prefixes\n";
|
||||
$count = ($index =~ s{
|
||||
</ul>\s*</div>\s*<div\ id="nicklist"
|
||||
}{</ul>
|
||||
<div class="sidebar-actions">
|
||||
<button type="button" ng-click="showModal('settingsModal')" title="Settings"><i class="glyphicon glyphicon-cog"></i><span>Settings</span></button>
|
||||
<button type="button" ng-click="openServerManager()" title="Manage IRC servers"><i class="glyphicon glyphicon-plus"></i><span>Servers</span></button>
|
||||
<button type="button" ng-if="pwaInstallAvailable" ng-click="installPwa()" title="Install NeoBear app"><i class="glyphicon glyphicon-download-alt"></i><span>Install</span></button>
|
||||
<button type="button" ng-click="disconnect()" title="Disconnect"><i class="glyphicon glyphicon-off"></i><span>Logout</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="nicklist"}x);
|
||||
die "could not add sidebar actions\n" unless $count == 1;
|
||||
$index =~ s{\s*</body>}{
|
||||
<neo-context-menu-view></neo-context-menu-view>
|
||||
<neo-server-manager></neo-server-manager>
|
||||
</body>} or die "could not add NeoBear UI overlays\n";
|
||||
write_file($index_path, $index);
|
||||
|
||||
my $js_path = "$source_dir/src/js/glowingbear.js";
|
||||
my $js = read_file($js_path);
|
||||
$js =~ s/'hostField': 'localhost'/'hostField': '{{RELAY_HOST}}'/ or
|
||||
die "could not set relay host default\n";
|
||||
$js =~ s/'port': 9001/'port': 443/ or die "could not set relay port default\n";
|
||||
$js =~ s/'customCSS': '',/'customCSS': '',
|
||||
'dropProfile': 'small',
|
||||
'neorelaySidebarWidth': 0,/ or
|
||||
die "could not add NeoDrop profile setting\n";
|
||||
$js =~ s{(\s+\$scope\.settings\s*=\s*settings;)}{$1\n\n$weechat_settings_js\n$colour_settings_js\n$sidebar_resize} or
|
||||
die "could not add desktop sidebar resizing\n";
|
||||
$js =~ s{
|
||||
'fontfamily':\s*\(utils\.isMobileUi\(\)\s*\?\s*'sans-serif'\s*:\s*'[^']+'\)
|
||||
}{'fontfamily': 'OpenDyslexic, Atkinson Hyperlegible, monospace'}x or
|
||||
die "could not set font default\n";
|
||||
$js =~ s{
|
||||
(\$scope\.command\s*=\s*'';)
|
||||
}{$1
|
||||
|
||||
\$scope.maskPassword = function(value) {
|
||||
return value ? new Array(value.length + 1).join('*') : '';
|
||||
};
|
||||
|
||||
\$scope.openServerManager = function() {
|
||||
\$scope.\$broadcast('neoServerManagerOpen');
|
||||
};
|
||||
|
||||
var neoPwaInstallPrompt = null;
|
||||
\$scope.pwaInstallAvailable = false;
|
||||
window.addEventListener('beforeinstallprompt', function(event) {
|
||||
event.preventDefault();
|
||||
neoPwaInstallPrompt = event;
|
||||
\$scope.\$applyAsync(function() { \$scope.pwaInstallAvailable = true; });
|
||||
});
|
||||
window.addEventListener('appinstalled', function() {
|
||||
neoPwaInstallPrompt = null;
|
||||
\$scope.\$applyAsync(function() { \$scope.pwaInstallAvailable = false; });
|
||||
});
|
||||
\$scope.installPwa = function() {
|
||||
if (!neoPwaInstallPrompt) return;
|
||||
neoPwaInstallPrompt.prompt();
|
||||
neoPwaInstallPrompt.userChoice.finally(function() {
|
||||
neoPwaInstallPrompt = null;
|
||||
\$scope.\$applyAsync(function() { \$scope.pwaInstallAvailable = false; });
|
||||
});
|
||||
};}x or die "could not add password masker\n";
|
||||
$js =~ s{
|
||||
\$scope\.init\s*=\s*function\(\)\s*\{\s*
|
||||
\$scope\.parseHost\(\);\s*
|
||||
\$scope\.parseHash\(\);\s*
|
||||
\};
|
||||
}{\$scope.init = function() {
|
||||
settings.hostField = '{{RELAY_HOST}}';
|
||||
settings.host = '{{RELAY_HOST}}';
|
||||
settings.port = 443;
|
||||
settings.path = 'weechat';
|
||||
settings.tls = true;
|
||||
\$scope.parseHost();
|
||||
};}x or die "could not hardcode relay initialization\n";
|
||||
$js =~ s{
|
||||
(\$scope\.connect\s*=\s*function\(\)\s*\{)
|
||||
}{$1
|
||||
settings.hostField = '{{RELAY_HOST}}';
|
||||
settings.host = '{{RELAY_HOST}}';
|
||||
settings.port = 443;
|
||||
settings.path = 'weechat';
|
||||
settings.tls = true;}x or die "could not hardcode relay connection\n";
|
||||
$js =~ s{
|
||||
document\.getElementById\('audioNotificationInitializer'\)\.play\(\);
|
||||
}{var audioPlay = document.getElementById('audioNotificationInitializer').play();
|
||||
if (audioPlay && audioPlay.catch) {
|
||||
audioPlay.catch(function() {});
|
||||
}}x or die "could not handle blocked notification audio\n";
|
||||
$js =~ s{
|
||||
(\$rootScope\.bufferlistfilter\s*=\s*function\(buffer\)\s*\{)
|
||||
}{$1\n if (buffer.fullName === 'relay.relay.list') {\n return false;\n }}x or
|
||||
die "could not hide relay list buffer\n";
|
||||
$js =~ s{
|
||||
(\$rootScope\.\$on\('relayDisconnect',\ function\(\)\ \{)
|
||||
}{$1\n \$rootScope.loadingLines = false;}x or
|
||||
die "could not reset line loading on disconnect\n";
|
||||
$js =~ s{
|
||||
(settings\.addCallback\('fontfamily',\ function\(fontfamily\)\ \{\s*
|
||||
utils\.changeClassStyle\('favorite-font',\ 'fontFamily',\ fontfamily\);)
|
||||
}{$1
|
||||
document.documentElement.style.setProperty('--neo-preferred-font', fontfamily);}x or
|
||||
die "could not apply preferred font to connected controls\n";
|
||||
$js =~ s{
|
||||
(utils\.changeClassStyle\('favorite-font',\ 'fontSize',\ fontsize\);\s*
|
||||
\}\);)
|
||||
}{utils.changeClassStyle('favorite-font', 'fontSize', fontsize);
|
||||
document.documentElement.style.setProperty('--neo-preferred-font-size', fontsize);
|
||||
\});}x or
|
||||
die "could not apply preferred font size to connected controls\n";
|
||||
write_file($js_path, $js);
|
||||
|
||||
my $models_path = "$source_dir/src/js/models.js";
|
||||
my $models = read_file($models_path);
|
||||
$models =~ s{(var\ nicklist\ =\ \{\};)}{$1\n var nickColours = {};} or
|
||||
die "could not add nick colour index\n";
|
||||
$models =~ s{(nicklist\[group\]\.nicks\.push\(nick\);)}{$1\n nickColours[nick.name] = nick.displayClasses;} or
|
||||
die "could not index added nick colour\n";
|
||||
$models =~ s{(if\ \(group\ ===\ undefined\)\ \{.*?\n\s*\})\s*(for\ \(i\ in\ group\.nicks\)\ \{)}{$1\n $2}sx or
|
||||
die "could not locate nick deletion loop\n";
|
||||
$models =~ s{(if\ \(group\.nicks\[i\]\.name\ ==\ nick\.name\)\ \{)}{$1\n delete nickColours[nick.name];} or
|
||||
die "could not remove deleted nick colour\n";
|
||||
$models =~ s{(var\ clearNicklist\ =\ function\(\)\ \{)}{$1\n nickColours = {};} or
|
||||
die "could not clear nick colour index\n";
|
||||
$models =~ s{(if\ \(group\.nicks\[i\]\.name\ ===\ nick\.name\)\ \{\s*group\.nicks\[i\]\ =\ nick;)}{$1\n nickColours[nick.name] = nick.displayClasses;}x or
|
||||
die "could not update nick colour index\n";
|
||||
$models =~ s{
|
||||
(//\ Check\ whether\ a\ particular\ nick\ is\ in\ the\ nicklist)
|
||||
}{$1
|
||||
var getNickColourClasses = function(nick) {
|
||||
return nick ? (nickColours[nick] || ['neo-nick']) : [];
|
||||
};
|
||||
}x or die "could not add nick colour lookup\n";
|
||||
$models =~ s{(nicklistRequested:\ nicklistRequested,)}{$1\n getNickColourClasses: getNickColourClasses,} or
|
||||
die "could not export nick colour lookup\n";
|
||||
$models =~ s{(for\ \(var\ pti\ =\ 0;\ pti\ <\ prefix\.length;\ \+\+pti\)\ \{\s*prefixtext\ \+=\ prefix\[pti\]\.text;\s*\})}{$1\n var nick = showHiddenBrackets && prefix.length ? prefix[prefix.length - 1].text : '';}x or
|
||||
die "could not identify chat prefix nick\n";
|
||||
$models =~ s{(prefixtext:\ prefixtext,)}{$1\n nick: nick,} or
|
||||
die "could not export chat prefix nick\n";
|
||||
$models =~ s{
|
||||
(var\ colorClasses\ =\ nickGetClasses\(message\);)\s*
|
||||
return\ \{\s*
|
||||
prefix:\ prefix,\s*
|
||||
visible:\ visible,\s*
|
||||
name:\ name,\s*
|
||||
prefixClasses:\ colorClasses\.prefix,\s*
|
||||
nameClasses:\ colorClasses\.name\s*
|
||||
\};
|
||||
}{$1
|
||||
var roleClass = 'neo-nick';
|
||||
if (colorClasses.name.indexOf('cof-nicklist_away') >= 0) roleClass = 'neo-nick-away';
|
||||
else if (['~', '&', String.fromCharCode(64)].indexOf(prefix.charAt(0)) >= 0) roleClass = 'neo-nick-op';
|
||||
else if (prefix.charAt(0) === '%') roleClass = 'neo-nick-halfop';
|
||||
else if (prefix.charAt(0) === '+') roleClass = 'neo-nick-voice';
|
||||
var isSelf = colorClasses.name.indexOf('cof-chat_nick_self') >= 0;
|
||||
var isAway = colorClasses.name.indexOf('cof-nicklist_away') >= 0;
|
||||
var hasWeeChatRoleColour = prefix && !(
|
||||
colorClasses.prefix.length === 1 && colorClasses.prefix[0] === 'cwf-default'
|
||||
);
|
||||
var displayClasses = colorClasses.name.slice();
|
||||
if (!isSelf && !isAway && hasWeeChatRoleColour) displayClasses = [roleClass];
|
||||
else if (!isSelf && isAway) displayClasses.push('neo-nick-away');
|
||||
else if (!isSelf && prefix && !hasWeeChatRoleColour) displayClasses.push(roleClass);
|
||||
|
||||
return {
|
||||
prefix: prefix,
|
||||
visible: visible,
|
||||
name: name,
|
||||
prefixClasses: colorClasses.prefix,
|
||||
nameClasses: colorClasses.name,
|
||||
roleClass: roleClass,
|
||||
displayClasses: displayClasses
|
||||
};
|
||||
}x or die "could not add semantic nick colours\n";
|
||||
write_file($models_path, $models);
|
||||
|
||||
my $connection_path = "$source_dir/src/js/connection.js";
|
||||
my $connection = read_file($connection_path);
|
||||
$count = ($connection =~ s!\Qconnect(d[0], d[1], d[2], d[3], d[4], false, "", d[5], function() {\E!connect(d[0], d[1], d[2], d[3], d[4], d[5], function() {!);
|
||||
die "could not fix reconnect callback arguments\n" unless $count == 1;
|
||||
write_file($connection_path, $connection);
|
||||
|
||||
my $websockets_path = "$source_dir/src/js/websockets.js";
|
||||
my $websockets = read_file($websockets_path);
|
||||
$count = ($websockets =~ s{
|
||||
if\ \(ws\ !==\ null\ &&\ ws\.readyState\ !==\ WebSocket\.CLOSED\)\ \{\s*
|
||||
ws\.close\(\);\s*
|
||||
\}
|
||||
}{if (ws !== null && ws.readyState !== WebSocket.CLOSED) {
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.onmessage = null;
|
||||
ws.close();
|
||||
}}x);
|
||||
die "could not detach stale WebSocket handlers\n" unless $count == 1;
|
||||
write_file($websockets_path, $websockets);
|
||||
|
||||
my $input_path = "$source_dir/src/directives/input.html";
|
||||
my $input = read_file($input_path);
|
||||
$input =~ s/title="Send image"/title="Upload encrypted file"/g;
|
||||
$input =~ s/ accept="image\/\*"//;
|
||||
$input =~ s{(<form\ class="form\ form-horizontal"\ id="inputform"[^>]*>)}{$1\n$command_menu} or
|
||||
die "could not add command menu template\n";
|
||||
$input =~ s{(ng-paste="inputPasted\(\$event\)")}{$1 aria-autocomplete="list" aria-controls="{{inputId}}-command-menu" aria-expanded="{{commandMenuOpen}}"} or
|
||||
die "could not add command menu accessibility attributes\n";
|
||||
write_file($input_path, $input);
|
||||
|
||||
my $inputbar_path = "$source_dir/src/js/inputbar.js";
|
||||
my $inputbar = read_file($inputbar_path);
|
||||
$inputbar =~ s{(\s+\$scope\.utils\s*=\s*utils;)}{$1\n\n$command_menu_js} or
|
||||
die "could not add command menu behavior\n";
|
||||
$inputbar =~ s{
|
||||
(\s+//\ Cancel\ any\ command\ completion\ that\ was\ still\ ongoing)
|
||||
}{\n updateCommandMenu();\n$1}x or
|
||||
die "could not update command menu on input\n";
|
||||
$inputbar =~ s{
|
||||
(\s+//\ Support\ different\ browser\ quirks\s*
|
||||
var\ code\ =\ \$event\.keyCode\ \?\ \$event\.keyCode\ :\ \$event\.charCode;)
|
||||
}{$1
|
||||
|
||||
if (\$scope.handleCommandMenuKey(\$event, code, inputNode)) {
|
||||
return true;
|
||||
}}x or die "could not add command menu keyboard handling\n";
|
||||
$count = ($inputbar =~ s{
|
||||
(\s+\$rootScope\.insertAtCaret\s*=\s*function\(toInsert\)\s*\{)
|
||||
}{
|
||||
\$rootScope.registerNeoDrop = function(readUrl, deleteCapability) {
|
||||
connection.sendMessage('/drops register ' + readUrl + ' ' + deleteCapability);
|
||||
};\n$1
|
||||
}x);
|
||||
die "could not add NeoDrop manager registration callback\n" unless $count == 1;
|
||||
|
||||
$count = ($inputbar =~ s{
|
||||
\s*//\ Show\ toast\ with\ delete\ link
|
||||
\s*var\ toastImgur\s*=.*?
|
||||
\s*console\.log\('An\ image\ was\ uploaded\ to\ imgur,\ delete\ it\ with\ .*?;
|
||||
}{}sx);
|
||||
die "could not remove exposed Imgur deletion capability\n" unless $count == 1;
|
||||
write_file($inputbar_path, $inputbar);
|
||||
|
||||
my $connection_source_path = "$source_dir/src/js/connection.js";
|
||||
my $connection_source = read_file($connection_source_path);
|
||||
$count = ($connection_source =~ s{
|
||||
(\s+var\ requestCompletion\ =\ function\(bufferId,\ position,\ data\)\ \{)
|
||||
}{
|
||||
var requestInfolist = function(name, pointer, args) {
|
||||
return ngWebsockets.send(
|
||||
weeChat.Protocol.formatInfolist({name: name, pointer: pointer || 0, args: args || ''})
|
||||
).then(function(message) {
|
||||
if (!message.objects || !message.objects[0]) return [];
|
||||
return message.objects[0].content.map(function(fields) {
|
||||
var item = {};
|
||||
fields.forEach(function(field) {
|
||||
Object.keys(field).forEach(function(key) { item[key] = field[key]; });
|
||||
});
|
||||
return item;
|
||||
});
|
||||
});
|
||||
};
|
||||
var requestCommandHooks = function() {
|
||||
return requestInfolist('hook', 0, 'command').then(function(items) {
|
||||
return items.map(function(hook) {
|
||||
return {
|
||||
command: hook.command || '',
|
||||
args: hook.args_nls || hook.args || '',
|
||||
description: hook.description_nls || hook.description || '',
|
||||
priority: hook.priority || 0
|
||||
};
|
||||
}).filter(function(hook) { return hook.command; });
|
||||
});
|
||||
};
|
||||
var sendMessageToBuffer = function(bufferId, message) {
|
||||
ngWebsockets.send(weeChat.Protocol.formatInput({buffer: '0x' + bufferId, data: message}));
|
||||
};$1
|
||||
}x);
|
||||
die "could not add command hook request\n" unless $count == 1;
|
||||
$connection_source =~ s{
|
||||
(\s+requestCompletion:\ requestCompletion)
|
||||
}{$1,\n requestCommandHooks: requestCommandHooks,\n requestInfolist: requestInfolist,\n sendMessageToBuffer: sendMessageToBuffer}x or
|
||||
die "could not export command hook request\n";
|
||||
$connection_source =~ s{
|
||||
(\s+\$rootScope\.connected\s*=\s*true;)
|
||||
}{$1
|
||||
\$rootScope.reconnecting = false;
|
||||
\$rootScope.loadingLines = false;
|
||||
\$rootScope.errorMessage = false;}x or
|
||||
die "could not reset successful connection state\n";
|
||||
write_file($connection_source_path, $connection_source);
|
||||
|
||||
my $plugin_directive_path = "$source_dir/src/js/plugin-directive.js";
|
||||
my $plugin_directive = read_file($plugin_directive_path);
|
||||
$plugin_directive =~ s{
|
||||
\$scope\.plugin\.visible\s*=\s*!settings\.noembed\s*&&\s*!\$scope\.plugin\.nsfw;
|
||||
}{\$scope.plugin.visible = !\$scope.plugin.nsfw &&
|
||||
(\$scope.plugin.name === 'NeoDrop media' || !settings.noembed);}x or
|
||||
die "could not enable automatic NeoDrop media previews\n";
|
||||
write_file($plugin_directive_path, $plugin_directive);
|
||||
|
||||
my $plugins_path = "$source_dir/src/js/plugins.js";
|
||||
my $plugins = read_file($plugins_path);
|
||||
$plugins =~ s{(\s+return \{\s*plugins: \[)}{$drop_plugin$1neoDropMediaPlugin, neoDropTextPlugin, } or
|
||||
die "could not register NeoDrop preview plugin\n";
|
||||
write_file($plugins_path, $plugins);
|
||||
|
||||
my $main_path = "$source_dir/src/main.js";
|
||||
my $main = read_file($main_path);
|
||||
$main =~ s{(import "\./js/glowingbear\.js";)}{$1
|
||||
import "./js/context-menu.js";
|
||||
import "./js/server-manager.js";} or
|
||||
die "could not import NeoBear UI modules\n";
|
||||
$main .= q!
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('serviceworker.js');
|
||||
});
|
||||
}
|
||||
!;
|
||||
write_file($main_path, $main);
|
||||
|
||||
my $webpack_path = "$source_dir/webpack.config.js";
|
||||
my $webpack = read_file($webpack_path);
|
||||
$webpack =~ s{
|
||||
(output:\s*\{\s*path:\s*path\.resolve\(__dirname,\s*'build'\),)
|
||||
}{$1\n filename: 'main.neorelay.27.js',}x or
|
||||
die "could not version JavaScript bundle\n";
|
||||
$webpack =~ s{"\*\*/\*\.png",}{"**/*.png",\n "**/*.jpg",} or
|
||||
die "could not enable JPEG asset copying\n";
|
||||
$webpack =~ s{"\*\*/\*\.jpg",}{"**/*.jpg",\n "**/*.woff2",\n "assets/fonts/OFL.txt",} or
|
||||
die "could not enable OpenDyslexic asset copying\n";
|
||||
write_file($webpack_path, $webpack);
|
||||
|
||||
for my $manifest (qw(webapp.manifest.json manifest.json manifest.webapp)) {
|
||||
my $path = "$source_dir/src/$manifest";
|
||||
next unless -f $path;
|
||||
my $content = read_file($path);
|
||||
$content =~ s{assets/img/glowing_bear_(?:60x60|90x90|128x128)\.png}{assets/neorelay-avatar-icon.png}g;
|
||||
$content =~ s{assets/img/favicon\.png}{assets/neorelay-avatar-icon.png}g;
|
||||
$content =~ s/"theme_color"\s*:\s*"#[0-9a-fA-F]+"/"theme_color": "#150a24"/g;
|
||||
$content =~ s/"background_color"\s*:\s*"#[0-9a-fA-F]+"/"background_color": "#07030d"/g;
|
||||
write_file($path, $content);
|
||||
}
|
||||
|
||||
make_path("$source_dir/src/assets");
|
||||
make_path("$source_dir/src/assets/fonts");
|
||||
for my $font (qw(
|
||||
OpenDyslexic-Regular.woff2
|
||||
OpenDyslexic-Italic.woff2
|
||||
OpenDyslexic-Bold.woff2
|
||||
OpenDyslexic-BoldItalic.woff2
|
||||
OFL.txt
|
||||
)) {
|
||||
copy("$assets_dir/fonts/$font", "$source_dir/src/assets/fonts/$font") or
|
||||
die "copy OpenDyslexic asset $font: $!\n";
|
||||
}
|
||||
copy("$assets_dir/avatar-login.jpg", "$source_dir/src/assets/neorelay-avatar-login.jpg") or
|
||||
die "copy login avatar: $!\n";
|
||||
copy("$assets_dir/avatar-icon.png", "$source_dir/src/assets/neorelay-avatar-icon.png") or
|
||||
die "copy favicon: $!\n";
|
||||
copy("$assets_dir/cyberpunk.css", "$source_dir/src/css/neorelay-cyberpunk.css") or
|
||||
die "copy cyberpunk stylesheet: $!\n";
|
||||
copy("$assets_dir/neodrop.js", "$source_dir/src/js/imgur.js") or
|
||||
die "copy NeoDrop uploader: $!\n";
|
||||
copy("$assets_dir/neodrop-crypto.js", "$source_dir/src/js/neodrop-crypto.js") or
|
||||
die "copy NeoDrop crypto: $!\n";
|
||||
copy("$assets_dir/context-menu.js", "$source_dir/src/js/context-menu.js") or
|
||||
die "copy context menu: $!\n";
|
||||
copy("$assets_dir/context-menu.html", "$source_dir/src/directives/context-menu.html") or
|
||||
die "copy context menu template: $!\n";
|
||||
copy("$assets_dir/server-manager.js", "$source_dir/src/js/server-manager.js") or
|
||||
die "copy server manager: $!\n";
|
||||
copy("$assets_dir/server-manager.html", "$source_dir/src/directives/server-manager.html") or
|
||||
die "copy server manager template: $!\n";
|
||||
Reference in New Issue
Block a user