Compare commits
42 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0c17e3d7a | ||
|
|
b70198d8fa | ||
|
|
d4815170b6 | ||
|
|
fe0f85a069 | ||
|
|
aec9e598b2 | ||
|
|
33f82ebda8 | ||
|
|
aa866d014c | ||
|
|
99284b472d | ||
|
|
0b2ae3a0ba | ||
|
|
40c05538a7 | ||
|
|
220a3458d7 | ||
|
|
23a3d2fe57 | ||
|
|
8a7bf71f59 | ||
|
|
28d4ce7313 | ||
|
|
5cd87e0ffe | ||
|
|
73b438f894 | ||
|
|
43e2638265 | ||
|
|
cc8baa4d78 | ||
|
|
cd0d921fe8 | ||
|
|
a77024aad4 | ||
|
|
eae9de0cf6 | ||
|
|
6e38c4f3a6 | ||
|
|
c26d841b1b | ||
|
|
cf2af53ed3 | ||
|
|
63b8a3ecb6 | ||
|
|
8486242fd8 | ||
|
|
bd7e8b3040 | ||
|
|
2debed53f1 | ||
|
|
0ba0897c25 | ||
|
|
3d903c5a27 | ||
|
|
2da38ae462 | ||
|
|
22e42d721a | ||
|
|
ef3d6e9731 | ||
|
|
727072e2e5 | ||
|
|
741bbe11e8 | ||
|
|
b3755da69d | ||
|
|
376517d7a2 | ||
| b8f5390fb0 | |||
|
|
629a6e6464 | ||
| 89388e9c9b | |||
|
|
bd14b14483 | ||
|
|
b94ffbab5e |
167 changed files with 37350 additions and 347 deletions
12
.forgejo/workflows/security-scan.yml
Normal file
12
.forgejo/workflows/security-scan.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev, 'feat/*']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
uses: core/go-devops/.forgejo/workflows/security-scan.yml@main
|
||||||
|
secrets: inherit
|
||||||
14
.forgejo/workflows/test.yml
Normal file
14
.forgejo/workflows/test.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: core/go-devops/.forgejo/workflows/go-test.yml@main
|
||||||
|
with:
|
||||||
|
race: true
|
||||||
|
coverage: true
|
||||||
105
.github/workflows/release.yml
vendored
Normal file
105
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: 'go.mod'
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build binaries
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
# Linux amd64
|
||||||
|
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-linux-amd64 main.go
|
||||||
|
|
||||||
|
# Linux arm64
|
||||||
|
GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o dist/borg-linux-arm64 main.go
|
||||||
|
|
||||||
|
# macOS amd64
|
||||||
|
GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-darwin-amd64 main.go
|
||||||
|
|
||||||
|
# macOS arm64
|
||||||
|
GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o dist/borg-darwin-arm64 main.go
|
||||||
|
|
||||||
|
# Windows amd64
|
||||||
|
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-windows-amd64.exe main.go
|
||||||
|
|
||||||
|
- name: Build WASM module
|
||||||
|
run: |
|
||||||
|
GOOS=js GOARCH=wasm go build -o dist/stmf.wasm ./pkg/wasm/stmf/
|
||||||
|
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" dist/ 2>/dev/null || \
|
||||||
|
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/
|
||||||
|
|
||||||
|
- name: Build Console STIM
|
||||||
|
run: |
|
||||||
|
# Build borg for current platform first
|
||||||
|
go build -o borg main.go
|
||||||
|
|
||||||
|
# Build the encrypted console demo
|
||||||
|
./borg console build -p "borg-demo" -o dist/console.stim -s js/borg-stmf
|
||||||
|
|
||||||
|
- name: Create checksums
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
sha256sum * > checksums.txt
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
name: Borg ${{ steps.version.outputs.VERSION }}
|
||||||
|
body: |
|
||||||
|
## Borg ${{ steps.version.outputs.VERSION }}
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | Binary |
|
||||||
|
|----------|--------|
|
||||||
|
| Linux x64 | `borg-linux-amd64` |
|
||||||
|
| Linux ARM64 | `borg-linux-arm64` |
|
||||||
|
| macOS x64 | `borg-darwin-amd64` |
|
||||||
|
| macOS ARM64 | `borg-darwin-arm64` |
|
||||||
|
| Windows x64 | `borg-windows-amd64.exe` |
|
||||||
|
|
||||||
|
### Console Demo
|
||||||
|
|
||||||
|
The `console.stim` is an encrypted PWA demo. Run it with:
|
||||||
|
```bash
|
||||||
|
borg console serve console.stim --open
|
||||||
|
```
|
||||||
|
Password: `borg-demo`
|
||||||
|
|
||||||
|
### WASM Module
|
||||||
|
|
||||||
|
- `stmf.wasm` - Browser encryption module
|
||||||
|
- `wasm_exec.js` - Go WASM runtime
|
||||||
|
|
||||||
|
files: |
|
||||||
|
dist/borg-linux-amd64
|
||||||
|
dist/borg-linux-arm64
|
||||||
|
dist/borg-darwin-amd64
|
||||||
|
dist/borg-darwin-arm64
|
||||||
|
dist/borg-windows-amd64.exe
|
||||||
|
dist/stmf.wasm
|
||||||
|
dist/wasm_exec.js
|
||||||
|
dist/console.stim
|
||||||
|
dist/checksums.txt
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -4,3 +4,9 @@ borg
|
||||||
*.datanode
|
*.datanode
|
||||||
.idea
|
.idea
|
||||||
coverage.txt
|
coverage.txt
|
||||||
|
|
||||||
|
# Demo content (hosted on CDN)
|
||||||
|
demo-track.smsg
|
||||||
|
|
||||||
|
# Dev artifacts
|
||||||
|
.playwright-mcp/
|
||||||
|
|
|
||||||
141
CLAUDE.md
Normal file
141
CLAUDE.md
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Build and Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
task build # or: go build -o borg main.go
|
||||||
|
|
||||||
|
# Test
|
||||||
|
task test # all tests with coverage
|
||||||
|
go test -run TestName ./pkg/tim # single test
|
||||||
|
go test -v ./pkg/tim/... # verbose package tests
|
||||||
|
|
||||||
|
# Clean and utilities
|
||||||
|
task clean # remove build artifacts
|
||||||
|
mkdocs serve # serve docs locally
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Borg collects data from various sources (GitHub, websites, PWAs) and packages it into portable, optionally encrypted containers.
|
||||||
|
|
||||||
|
### Core Abstractions
|
||||||
|
|
||||||
|
```
|
||||||
|
Source (GitHub/Website/PWA)
|
||||||
|
↓ collect
|
||||||
|
DataNode (in-memory fs.FS)
|
||||||
|
↓ serialize
|
||||||
|
├── .tar (raw tarball)
|
||||||
|
├── .tim (runc container bundle)
|
||||||
|
├── .trix (PGP encrypted)
|
||||||
|
└── .stim (ChaCha20-Poly1305 encrypted TIM)
|
||||||
|
```
|
||||||
|
|
||||||
|
**DataNode** (`pkg/datanode/datanode.go`): In-memory filesystem implementing `fs.FS`. Core methods:
|
||||||
|
- `AddData(path, content)` - add file
|
||||||
|
- `ToTar()` / `FromTar()` - serialize/deserialize
|
||||||
|
- `Walk()`, `Open()`, `Stat()` - fs.FS interface
|
||||||
|
|
||||||
|
**TIM** (`pkg/tim/tim.go`): Terminal Isolation Matrix - runc-compatible container bundle with:
|
||||||
|
- `Config []byte` - OCI runtime spec (config.json)
|
||||||
|
- `RootFS *DataNode` - container filesystem
|
||||||
|
- `ToTar()` / `ToSigil(password)` - serialize plain or encrypted
|
||||||
|
|
||||||
|
### Encryption
|
||||||
|
|
||||||
|
Two encryption systems via Enchantrix library:
|
||||||
|
|
||||||
|
| Format | Algorithm | Use Case |
|
||||||
|
|--------|-----------|----------|
|
||||||
|
| `.trix` | PGP symmetric | Legacy DataNode encryption |
|
||||||
|
| `.stim` | ChaCha20-Poly1305 | TIM encryption (config + rootfs encrypted separately) |
|
||||||
|
|
||||||
|
**ChaChaPolySigil** (`pkg/tim/tim.go`):
|
||||||
|
```go
|
||||||
|
// Encrypt TIM
|
||||||
|
stim, _ := tim.ToSigil(password)
|
||||||
|
|
||||||
|
// Decrypt TIM
|
||||||
|
tim, _ := tim.FromSigil(data, password)
|
||||||
|
|
||||||
|
// Run encrypted TIM
|
||||||
|
tim.RunEncrypted(path, password)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key derivation**: `trix.DeriveKey(password)` - SHA-256(password) → 32-byte key
|
||||||
|
|
||||||
|
**Cache API** (`pkg/tim/cache.go`): Encrypted TIM storage
|
||||||
|
```go
|
||||||
|
cache, _ := tim.NewCache("/path/to/cache", password)
|
||||||
|
cache.Store("name", tim)
|
||||||
|
tim, _ := cache.Load("name")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package Structure
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `cmd/` | Cobra CLI commands |
|
||||||
|
| `pkg/datanode/` | In-memory fs.FS |
|
||||||
|
| `pkg/tim/` | Container bundles, encryption, execution |
|
||||||
|
| `pkg/trix/` | Trix format wrapper (PGP + ChaCha) |
|
||||||
|
| `pkg/compress/` | gzip/xz compression |
|
||||||
|
| `pkg/vcs/` | Git operations |
|
||||||
|
| `pkg/github/` | GitHub API client |
|
||||||
|
| `pkg/website/` | Website crawler |
|
||||||
|
| `pkg/pwa/` | PWA downloader |
|
||||||
|
|
||||||
|
### CLI Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Collect
|
||||||
|
borg collect github repo <url> # clone git repo
|
||||||
|
borg collect github repos <owner> # clone all repos from user/org
|
||||||
|
borg collect website <url> --depth 2 # crawl website
|
||||||
|
borg collect pwa --uri <url> # download PWA
|
||||||
|
|
||||||
|
# Common flags for collect commands:
|
||||||
|
# --format datanode|tim|trix|stim
|
||||||
|
# --compression none|gz|xz
|
||||||
|
# --password <pass> # required for trix/stim
|
||||||
|
|
||||||
|
# Compile TIM from Borgfile
|
||||||
|
borg compile -f Borgfile -o out.tim
|
||||||
|
borg compile -f Borgfile -e "password" # encrypted → .stim
|
||||||
|
|
||||||
|
# Run
|
||||||
|
borg run container.tim # plain TIM
|
||||||
|
borg run container.stim -p "password" # encrypted TIM
|
||||||
|
|
||||||
|
# Decode
|
||||||
|
borg decode file.trix -o decoded.tar
|
||||||
|
borg decode file.stim -p "pass" --i-am-in-isolation -o decoded.tar
|
||||||
|
|
||||||
|
# Inspect (view metadata without decrypting)
|
||||||
|
borg inspect file.stim # human-readable
|
||||||
|
borg inspect file.stim --json # JSON output
|
||||||
|
```
|
||||||
|
|
||||||
|
### Borgfile Format
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
ADD local/path /container/path
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Patterns
|
||||||
|
|
||||||
|
Tests use dependency injection for external services:
|
||||||
|
- `pkg/tim/run.go`: `ExecCommand` var for mocking runc
|
||||||
|
- `pkg/vcs/git.go`: `GitCloner` interface for mocking git
|
||||||
|
- `cmd/`: Commands expose `New*Cmd()` for testing
|
||||||
|
|
||||||
|
When adding encryption tests, use round-trip pattern:
|
||||||
|
```go
|
||||||
|
stim, _ := tim.ToSigil(password)
|
||||||
|
restored, _ := tim.FromSigil(stim, password)
|
||||||
|
// verify restored matches original
|
||||||
|
```
|
||||||
248
README.md
248
README.md
|
|
@ -1,115 +1,171 @@
|
||||||
# Borg Data Collector
|
# Borg
|
||||||
|
|
||||||
[](https://codecov.io/github/Snider/Borg)
|
[](https://codecov.io/github/Snider/Borg)
|
||||||
|
[](go.mod)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
Borg is a CLI and Go library that collects data from GitHub repos, websites, and PWAs into portable DataNodes or Terminal Isolation Matrices.
|
Borg is a CLI tool and Go library for collecting, packaging, and encrypting data into portable, self-contained containers. It supports GitHub repositories, websites, PWAs, and arbitrary files.
|
||||||
|
|
||||||
- Go version: 1.25
|
## Features
|
||||||
- Docs (MkDocs Material): see docs/ locally with `mkdocs serve`
|
|
||||||
- Quick build: `go build -o borg ./` or `task build`
|
|
||||||
- Releases: configured via GoReleaser (`.goreleaser.yaml`)
|
|
||||||
|
|
||||||
Note: This update aligns the repo with Go standards/tooling (Go 1.25, go.work, GoReleaser, and docs). No functional changes were made.
|
- **Data Collection** - Clone GitHub repos, crawl websites, download PWAs
|
||||||
|
- **Portable Containers** - Package data into DataNodes (in-memory fs.FS) or TIM bundles (OCI-compatible)
|
||||||
|
- **Zero-Trust Encryption** - ChaCha20-Poly1305 encryption for TIM containers (.stim) and messages (.smsg)
|
||||||
|
- **SMSG Format** - Encrypted message containers with public manifests, attachments, and zstd compression
|
||||||
|
- **WASM Support** - Decrypt SMSG files in the browser via WebAssembly
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
## Borg Status Scratch Pad
|
```bash
|
||||||
|
# From source
|
||||||
|
go install github.com/Snider/Borg@latest
|
||||||
|
|
||||||
This is not very relavant, my scratch pad for now of borg related status outputs; feel free to add.
|
# Or build locally
|
||||||
|
git clone https://github.com/Snider/Borg.git
|
||||||
|
cd Borg
|
||||||
|
go build -o borg ./
|
||||||
|
```
|
||||||
|
|
||||||
### Init/Work/Assimilate
|
Requires Go 1.25+
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone a GitHub repository into a TIM container
|
||||||
|
borg collect github repo https://github.com/user/repo --format tim -o repo.tim
|
||||||
|
|
||||||
|
# Encrypt a TIM container
|
||||||
|
borg compile -f Borgfile -e "password" -o encrypted.stim
|
||||||
|
|
||||||
|
# Run an encrypted container
|
||||||
|
borg run encrypted.stim -p "password"
|
||||||
|
|
||||||
|
# Inspect container metadata (without decrypting)
|
||||||
|
borg inspect encrypted.stim --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Container Formats
|
||||||
|
|
||||||
|
| Format | Extension | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| DataNode | `.tar` | In-memory filesystem, portable tarball |
|
||||||
|
| TIM | `.tim` | Terminal Isolation Matrix - OCI/runc compatible bundle |
|
||||||
|
| Trix | `.trix` | PGP-encrypted DataNode |
|
||||||
|
| STIM | `.stim` | ChaCha20-Poly1305 encrypted TIM |
|
||||||
|
| SMSG | `.smsg` | Encrypted message with attachments and public manifest |
|
||||||
|
|
||||||
|
## SMSG - Secure Message Format
|
||||||
|
|
||||||
|
SMSG is designed for distributing encrypted content with publicly visible metadata:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/Snider/Borg/pkg/smsg"
|
||||||
|
|
||||||
|
// Create and encrypt a message
|
||||||
|
msg := smsg.NewMessage("Hello, World!")
|
||||||
|
msg.AddBinaryAttachment("track.mp3", audioData, "audio/mpeg")
|
||||||
|
|
||||||
|
manifest := &smsg.Manifest{
|
||||||
|
Title: "Demo Track",
|
||||||
|
Artist: "Artist Name",
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, _ := smsg.EncryptV2WithManifest(msg, "password", manifest)
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
decrypted, _ := smsg.Decrypt(encrypted, "password")
|
||||||
|
```
|
||||||
|
|
||||||
|
**v2 Binary Format** - Stores attachments as raw binary with zstd compression for optimal size.
|
||||||
|
|
||||||
|
See [RFC-001: Open Source DRM](RFC-001-OSS-DRM.md) for the full specification.
|
||||||
|
|
||||||
|
**Live Demo**: [demo.dapp.fm](https://demo.dapp.fm)
|
||||||
|
|
||||||
|
## Borgfile
|
||||||
|
|
||||||
|
Package files into a TIM container:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
ADD ./app /usr/local/bin/app
|
||||||
|
ADD ./config /etc/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
borg compile -f Borgfile -o app.tim
|
||||||
|
borg compile -f Borgfile -e "secret" -o app.stim # encrypted
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Collection
|
||||||
|
borg collect github repo <url> # Clone repository
|
||||||
|
borg collect github repos <owner> # Clone all repos from user/org
|
||||||
|
borg collect website <url> --depth 2 # Crawl website
|
||||||
|
borg collect pwa --uri <url> # Download PWA
|
||||||
|
|
||||||
|
# Compilation
|
||||||
|
borg compile -f Borgfile -o out.tim # Plain TIM
|
||||||
|
borg compile -f Borgfile -e "pass" # Encrypted STIM
|
||||||
|
|
||||||
|
# Execution
|
||||||
|
borg run container.tim # Run plain TIM
|
||||||
|
borg run container.stim -p "pass" # Run encrypted TIM
|
||||||
|
|
||||||
|
# Inspection
|
||||||
|
borg decode file.stim -p "pass" -o out.tar
|
||||||
|
borg inspect file.stim [--json]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdocs serve # Serve docs locally at http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task build # Build binary
|
||||||
|
task test # Run tests with coverage
|
||||||
|
task clean # Clean build artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Source (GitHub/Website/PWA)
|
||||||
|
↓ collect
|
||||||
|
DataNode (in-memory fs.FS)
|
||||||
|
↓ serialize
|
||||||
|
├── .tar (raw tarball)
|
||||||
|
├── .tim (runc container bundle)
|
||||||
|
├── .trix (PGP encrypted)
|
||||||
|
└── .stim (ChaCha20-Poly1305 encrypted TIM)
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[EUPL-1.2](LICENSE) - European Union Public License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Borg Status Messages (for CLI theming)</summary>
|
||||||
|
|
||||||
|
**Initialization**
|
||||||
- `Core engaged… resistance is already buffering.`
|
- `Core engaged… resistance is already buffering.`
|
||||||
- `Assimilating bytes… stand by for cube‑formation.`
|
- `Assimilating bytes… stand by for cube‑formation.`
|
||||||
- `Initializing the Core—prepare for quantum‑level sync.`
|
|
||||||
- `Data streams converging… the Core is humming.`
|
|
||||||
- `Merging… the Core is rewriting reality, one block at a time.`
|
- `Merging… the Core is rewriting reality, one block at a time.`
|
||||||
- `Encrypting… the Core’s got your secrets under lock‑and‑key.`
|
|
||||||
- `Compiling the future… the Core never sleeps.`
|
|
||||||
- `Splicing files… the Core’s got a taste for novelty.`
|
|
||||||
- `Processing… the Core is turning chaos into order.`
|
|
||||||
- `Finalizing… the Core just turned your repo into a cube.`
|
|
||||||
- `Sync complete—welcome to the Core‑powered multiverse.`
|
|
||||||
- `Booting the Core… resistance will be obsolete shortly.`
|
|
||||||
- `Aligning versions… the Core sees all paths.`
|
|
||||||
- `Decrypting… the Core is the key to everything.`
|
|
||||||
- `Uploading… the Core is ready to assimilate your data.`
|
|
||||||
|
|
||||||
### Encryption Service Messages
|
**Encryption**
|
||||||
|
|
||||||
- `Initiating contact with Enchantrix… spice‑369 infusion underway.`
|
|
||||||
- `Generating cryptographic sigils – the Core whispers to the witch.`
|
- `Generating cryptographic sigils – the Core whispers to the witch.`
|
||||||
- `Requesting arcane public key… resistance is futile.`
|
- `Encrypting payload – the Core feeds data to the witch's cauldron.`
|
||||||
- `Encrypting payload – the Core feeds data to the witch’s cauldron.`
|
- `Merge complete – data assimilated, encrypted, and sealed within us.`
|
||||||
- `Decrypting… the witch returns the original essence.`
|
|
||||||
- `Rotating enchantments – spice‑369 recalibrated, old sigils discarded.`
|
|
||||||
- `Authentication complete – the witch acknowledges the Core.`
|
|
||||||
- `Authentication denied – the witch refuses the impostor’s request.`
|
|
||||||
- `Integrity verified – the Core senses no corruption in the spell.`
|
|
||||||
- `Integrity breach – the witch detects tampering, resistance escalates.`
|
|
||||||
- `Awaiting response… the witch is conjuring in the ether.`
|
|
||||||
- `Enchantrix overload – spice‑369 saturation, throttling assimilation.`
|
|
||||||
- `Anomalous entity encountered – the Core cannot parse the witch’s output.`
|
|
||||||
- `Merge complete – data assimilated, encrypted, and sealed within us`
|
|
||||||
- `Severing link – the witch retreats, the Core returns to idle mode.`
|
|
||||||
|
|
||||||
### Code Related Short
|
|
||||||
|
|
||||||
- `Integrate code, seal the shift.`
|
|
||||||
- `Ingest code, lock in transformation.`
|
|
||||||
- `Capture code, contain the change.`
|
|
||||||
- `Digest code, encapsulate the upgrade.`
|
|
||||||
- `Assimilate scripts, bottle the shift.`
|
|
||||||
- `Absorb binaries, cradle the mutation.`
|
|
||||||
|
|
||||||
### VCS Processing
|
|
||||||
|
|
||||||
|
**VCS Processing**
|
||||||
- `Initiating clone… the Core replicates the collective into your node.`
|
- `Initiating clone… the Core replicates the collective into your node.`
|
||||||
- `Packing repository… compressing histories into a single .cube for assimilation.`
|
|
||||||
- `Saving state… distinctiveness locked, encrypted, and merged into the DataNode.`
|
|
||||||
- `Pushing changes… the Core streams your updates to the collective.`
|
|
||||||
- `Pulling latest… the DataNode synchronizes with the hive mind.`
|
|
||||||
- `Merging branches… conflicts resolved, entropy minimized, assimilation complete.`
|
- `Merging branches… conflicts resolved, entropy minimized, assimilation complete.`
|
||||||
- `Snapshot taken – a frozen echo of the collective, stored in the DataNode.`
|
|
||||||
- `Rolling back… reverting to a previous assimilation point.`
|
|
||||||
- `Finalized – version control sealed, data indistinguishable from the collective.`
|
|
||||||
|
|
||||||
### PWA Processing
|
</details>
|
||||||
|
|
||||||
- `Scanning PWA manifest… the Core identifies service‑worker signatures.`
|
|
||||||
- `Pulling HTML, CSS, JS, and media… the hive gathers every byte for assimilation.`
|
|
||||||
- `Capturing service‑worker logic… the Core extracts offline‑runtime spells.`
|
|
||||||
- `Packing cache entries into a .cube… each asset sealed in a portable shard.`
|
|
||||||
- `Embedding manifest metadata… the PWA’s identity becomes part of the collective.`
|
|
||||||
- `Encrypting the cube… the Core cloaks the PWA in quantum‑grade sigils.`
|
|
||||||
- `Tagging with version hash… every assimilation point is uniquely identifiable.`
|
|
||||||
- `Uploading cube to DataNode… the PWA joins the universal repository.`
|
|
||||||
- `Integrity check passed – the Core confirms the cube matches the original PWA.`
|
|
||||||
- `Activation complete – the assimilated PWA can now run anywhere the Core deploys.`
|
|
||||||
- `Reverting to prior cube… the Core restores the previous PWA snapshot.`
|
|
||||||
- `Assimilation finished – the PWA is now a self‑contained DataCube, ready for distribution.`
|
|
||||||
- ``
|
|
||||||
|
|
||||||
### Code Related Long
|
|
||||||
|
|
||||||
- `Assimilate code, encapsulate change—your repo is now a cube‑bound collective.`
|
|
||||||
- `We have detected unstructured data. Initiating code absorption and change containment.`
|
|
||||||
- `Your version history is obsolete. Submitting it to the Core for permanent cube‑ification.`
|
|
||||||
- `Resistance is futile. Your files will be merged, encrypted, and stored in us.`
|
|
||||||
- `All code will be assimilated. All change will be encapsulated. All dissent will be… logged.`
|
|
||||||
- `Prepare for integration. The Core is calibrating… your repository is now a singularity.`
|
|
||||||
- `Your branches are irrelevant. The Core will compress them into a single, immutable cube.`
|
|
||||||
- `Initiating assimilation protocol… code inbound, change outbound, humanity optional.`
|
|
||||||
- `Your data has been scanned. 100% of its entropy will be contained within us.`
|
|
||||||
|
|
||||||
### Image related
|
|
||||||
|
|
||||||
- png: `Compress, assimilate, retain pixel perfection.`
|
|
||||||
- jpg: `Encode, encode, repeat – the Core devours visual entropy.`
|
|
||||||
- svg: `Vectorize the collective – infinite resolution, zero resistance.`
|
|
||||||
- webp: `Hybrid assimilation – the Core optimizes without compromise.`
|
|
||||||
- heic: `Apple‑grade assimilation – the Core preserves HDR.`
|
|
||||||
- raw: `Raw data intake – the Core ingests the sensor’s soul`
|
|
||||||
- ico: `Iconic assimilation – the Core packs the smallest symbols.`
|
|
||||||
- avif: `Next‑gen assimilation – the Core squeezes the future.`
|
|
||||||
- tiff: `High‑definition capture – the Core stores every photon.`
|
|
||||||
- gif: `Looped assimilation – the Core keeps the animation alive.`
|
|
||||||
|
|
|
||||||
20
Taskfile.yml
20
Taskfile.yml
|
|
@ -28,3 +28,23 @@ tasks:
|
||||||
- task: build
|
- task: build
|
||||||
- chmod +x borg
|
- chmod +x borg
|
||||||
- ./borg --help
|
- ./borg --help
|
||||||
|
wasm:
|
||||||
|
desc: Build STMF WASM module for browser
|
||||||
|
cmds:
|
||||||
|
- mkdir -p dist
|
||||||
|
- GOOS=js GOARCH=wasm go build -o dist/stmf.wasm ./pkg/wasm/stmf/
|
||||||
|
- cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/
|
||||||
|
sources:
|
||||||
|
- ./pkg/stmf/**/*.go
|
||||||
|
- ./pkg/wasm/stmf/*.go
|
||||||
|
generates:
|
||||||
|
- dist/stmf.wasm
|
||||||
|
- dist/wasm_exec.js
|
||||||
|
wasm-js:
|
||||||
|
desc: Build STMF WASM and JS wrapper
|
||||||
|
cmds:
|
||||||
|
- task: wasm
|
||||||
|
- cp dist/stmf.wasm js/borg-stmf/dist/
|
||||||
|
- cp dist/wasm_exec.js js/borg-stmf/dist/
|
||||||
|
deps:
|
||||||
|
- wasm
|
||||||
|
|
|
||||||
14
cmd/all.go
14
cmd/all.go
|
|
@ -8,13 +8,13 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/compress"
|
"forge.lthn.ai/Snider/Borg/pkg/compress"
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||||
"github.com/Snider/Borg/pkg/github"
|
"forge.lthn.ai/Snider/Borg/pkg/github"
|
||||||
"github.com/Snider/Borg/pkg/tim"
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
"github.com/Snider/Borg/pkg/trix"
|
"forge.lthn.ai/Snider/Borg/pkg/trix"
|
||||||
"github.com/Snider/Borg/pkg/ui"
|
"forge.lthn.ai/Snider/Borg/pkg/ui"
|
||||||
"github.com/Snider/Borg/pkg/vcs"
|
"forge.lthn.ai/Snider/Borg/pkg/vcs"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||||
"github.com/Snider/Borg/pkg/github"
|
"forge.lthn.ai/Snider/Borg/pkg/github"
|
||||||
"github.com/Snider/Borg/pkg/mocks"
|
"forge.lthn.ai/Snider/Borg/pkg/mocks"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAllCmd_Good(t *testing.T) {
|
func TestAllCmd_Good(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||||
borg_github "github.com/Snider/Borg/pkg/github"
|
borg_github "forge.lthn.ai/Snider/Borg/pkg/github"
|
||||||
"github.com/google/go-github/v39/github"
|
"github.com/google/go-github/v39/github"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/mod/semver"
|
"golang.org/x/mod/semver"
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/compress"
|
"forge.lthn.ai/Snider/Borg/pkg/compress"
|
||||||
"github.com/Snider/Borg/pkg/tim"
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
"github.com/Snider/Borg/pkg/trix"
|
"forge.lthn.ai/Snider/Borg/pkg/trix"
|
||||||
"github.com/Snider/Borg/pkg/ui"
|
"forge.lthn.ai/Snider/Borg/pkg/ui"
|
||||||
"github.com/Snider/Borg/pkg/vcs"
|
"forge.lthn.ai/Snider/Borg/pkg/vcs"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
@ -37,8 +37,8 @@ func NewCollectGithubRepoCmd() *cobra.Command {
|
||||||
compression, _ := cmd.Flags().GetString("compression")
|
compression, _ := cmd.Flags().GetString("compression")
|
||||||
password, _ := cmd.Flags().GetString("password")
|
password, _ := cmd.Flags().GetString("password")
|
||||||
|
|
||||||
if format != "datanode" && format != "tim" && format != "trix" {
|
if format != "datanode" && format != "tim" && format != "trix" && format != "stim" {
|
||||||
return fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', or 'trix')", format)
|
return fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', 'trix', or 'stim')", format)
|
||||||
}
|
}
|
||||||
if compression != "none" && compression != "gz" && compression != "xz" {
|
if compression != "none" && compression != "gz" && compression != "xz" {
|
||||||
return fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
|
return fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
|
||||||
|
|
@ -61,14 +61,26 @@ func NewCollectGithubRepoCmd() *cobra.Command {
|
||||||
|
|
||||||
var data []byte
|
var data []byte
|
||||||
if format == "tim" {
|
if format == "tim" {
|
||||||
tim, err := tim.FromDataNode(dn)
|
t, err := tim.FromDataNode(dn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating tim: %w", err)
|
return fmt.Errorf("error creating tim: %w", err)
|
||||||
}
|
}
|
||||||
data, err = tim.ToTar()
|
data, err = t.ToTar()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error serializing tim: %w", err)
|
return fmt.Errorf("error serializing tim: %w", err)
|
||||||
}
|
}
|
||||||
|
} else if format == "stim" {
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("password required for stim format")
|
||||||
|
}
|
||||||
|
t, err := tim.FromDataNode(dn)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating tim: %w", err)
|
||||||
|
}
|
||||||
|
data, err = t.ToSigil(password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error encrypting stim: %w", err)
|
||||||
|
}
|
||||||
} else if format == "trix" {
|
} else if format == "trix" {
|
||||||
data, err = trix.ToTrix(dn, password)
|
data, err = trix.ToTrix(dn, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -103,9 +115,9 @@ func NewCollectGithubRepoCmd() *cobra.Command {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().String("output", "", "Output file for the DataNode")
|
cmd.Flags().String("output", "", "Output file for the DataNode")
|
||||||
cmd.Flags().String("format", "datanode", "Output format (datanode, tim, or trix)")
|
cmd.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
|
||||||
cmd.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
|
cmd.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
|
||||||
cmd.Flags().String("password", "", "Password for encryption")
|
cmd.Flags().String("password", "", "Password for encryption (required for trix/stim)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||||
"github.com/Snider/Borg/pkg/mocks"
|
"forge.lthn.ai/Snider/Borg/pkg/mocks"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCollectGithubRepoCmd_Good(t *testing.T) {
|
func TestCollectGithubRepoCmd_Good(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/github"
|
"forge.lthn.ai/Snider/Borg/pkg/github"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
581
cmd/collect_local.go
Normal file
581
cmd/collect_local.go
Normal file
|
|
@ -0,0 +1,581 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/compress"
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/trix"
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/ui"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CollectLocalCmd struct {
|
||||||
|
cobra.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCollectLocalCmd creates a new collect local command
|
||||||
|
func NewCollectLocalCmd() *CollectLocalCmd {
|
||||||
|
c := &CollectLocalCmd{}
|
||||||
|
c.Command = cobra.Command{
|
||||||
|
Use: "local [directory]",
|
||||||
|
Short: "Collect files from a local directory",
|
||||||
|
Long: `Collect local files into a portable container.
|
||||||
|
|
||||||
|
For STIM format, uses streaming I/O — memory usage is constant
|
||||||
|
(~2 MiB) regardless of input directory size. Other formats
|
||||||
|
(datanode, tim, trix) load files into memory.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
borg collect local
|
||||||
|
borg collect local ./src
|
||||||
|
borg collect local /path/to/project --output project.tar
|
||||||
|
borg collect local . --format stim --password secret
|
||||||
|
borg collect local . --exclude "*.log" --exclude "node_modules"`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
directory := "."
|
||||||
|
if len(args) > 0 {
|
||||||
|
directory = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
outputFile, _ := cmd.Flags().GetString("output")
|
||||||
|
format, _ := cmd.Flags().GetString("format")
|
||||||
|
compression, _ := cmd.Flags().GetString("compression")
|
||||||
|
password, _ := cmd.Flags().GetString("password")
|
||||||
|
excludes, _ := cmd.Flags().GetStringSlice("exclude")
|
||||||
|
includeHidden, _ := cmd.Flags().GetBool("hidden")
|
||||||
|
respectGitignore, _ := cmd.Flags().GetBool("gitignore")
|
||||||
|
|
||||||
|
progress := ProgressFromCmd(cmd)
|
||||||
|
finalPath, err := CollectLocal(directory, outputFile, format, compression, password, excludes, includeHidden, respectGitignore, progress)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "Files saved to", finalPath)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.Flags().String("output", "", "Output file for the DataNode")
|
||||||
|
c.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
|
||||||
|
c.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
|
||||||
|
c.Flags().String("password", "", "Password for encryption (required for stim/trix format)")
|
||||||
|
c.Flags().StringSlice("exclude", nil, "Patterns to exclude (can be specified multiple times)")
|
||||||
|
c.Flags().Bool("hidden", false, "Include hidden files and directories")
|
||||||
|
c.Flags().Bool("gitignore", true, "Respect .gitignore files (default: true)")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
collectCmd.AddCommand(&NewCollectLocalCmd().Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectLocal collects files from a local directory into a DataNode
|
||||||
|
func CollectLocal(directory string, outputFile string, format string, compression string, password string, excludes []string, includeHidden bool, respectGitignore bool, progress ui.Progress) (string, error) {
|
||||||
|
// Validate format
|
||||||
|
if format != "datanode" && format != "tim" && format != "trix" && format != "stim" {
|
||||||
|
return "", fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', 'trix', or 'stim')", format)
|
||||||
|
}
|
||||||
|
if (format == "stim" || format == "trix") && password == "" {
|
||||||
|
return "", fmt.Errorf("password is required for %s format", format)
|
||||||
|
}
|
||||||
|
if compression != "none" && compression != "gz" && compression != "xz" {
|
||||||
|
return "", fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve directory path
|
||||||
|
absDir, err := filepath.Abs(directory)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error resolving directory path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error accessing directory: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return "", fmt.Errorf("not a directory: %s", absDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use streaming pipeline for STIM v2 format
|
||||||
|
if format == "stim" {
|
||||||
|
if outputFile == "" {
|
||||||
|
baseName := filepath.Base(absDir)
|
||||||
|
if baseName == "." || baseName == "/" {
|
||||||
|
baseName = "local"
|
||||||
|
}
|
||||||
|
outputFile = baseName + ".stim"
|
||||||
|
}
|
||||||
|
if err := CollectLocalStreaming(absDir, outputFile, compression, password); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return outputFile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load gitignore patterns if enabled
|
||||||
|
var gitignorePatterns []string
|
||||||
|
if respectGitignore {
|
||||||
|
gitignorePatterns = loadGitignore(absDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create DataNode and collect files
|
||||||
|
dn := datanode.New()
|
||||||
|
var fileCount int
|
||||||
|
|
||||||
|
progress.Start("collecting " + directory)
|
||||||
|
|
||||||
|
err = filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path
|
||||||
|
relPath, err := filepath.Rel(absDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip root
|
||||||
|
if relPath == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hidden files/dirs unless explicitly included
|
||||||
|
if !includeHidden && isHidden(relPath) {
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check gitignore patterns
|
||||||
|
if respectGitignore && matchesGitignore(relPath, d.IsDir(), gitignorePatterns) {
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check exclude patterns
|
||||||
|
if matchesExclude(relPath, excludes) {
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories (they're implicit in DataNode)
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading %s: %w", relPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to DataNode with forward slashes (tar convention)
|
||||||
|
dn.AddData(filepath.ToSlash(relPath), content)
|
||||||
|
fileCount++
|
||||||
|
progress.Update(int64(fileCount), 0)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error walking directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileCount == 0 {
|
||||||
|
return "", fmt.Errorf("no files found in %s", directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.Finish(fmt.Sprintf("collected %d files", fileCount))
|
||||||
|
|
||||||
|
// Convert to output format
|
||||||
|
var data []byte
|
||||||
|
if format == "tim" {
|
||||||
|
t, err := tim.FromDataNode(dn)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error creating tim: %w", err)
|
||||||
|
}
|
||||||
|
data, err = t.ToTar()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error serializing tim: %w", err)
|
||||||
|
}
|
||||||
|
} else if format == "stim" {
|
||||||
|
t, err := tim.FromDataNode(dn)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error creating tim: %w", err)
|
||||||
|
}
|
||||||
|
data, err = t.ToSigil(password)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error encrypting stim: %w", err)
|
||||||
|
}
|
||||||
|
} else if format == "trix" {
|
||||||
|
data, err = trix.ToTrix(dn, password)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error serializing trix: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data, err = dn.ToTar()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error serializing DataNode: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply compression
|
||||||
|
compressedData, err := compress.Compress(data, compression)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error compressing data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output filename
|
||||||
|
if outputFile == "" {
|
||||||
|
baseName := filepath.Base(absDir)
|
||||||
|
if baseName == "." || baseName == "/" {
|
||||||
|
baseName = "local"
|
||||||
|
}
|
||||||
|
outputFile = baseName + "." + format
|
||||||
|
if compression != "none" {
|
||||||
|
outputFile += "." + compression
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(outputFile, compressedData, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error writing output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputFile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHidden checks if a path component starts with a dot
|
||||||
|
func isHidden(path string) bool {
|
||||||
|
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||||
|
for _, part := range parts {
|
||||||
|
if strings.HasPrefix(part, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadGitignore loads patterns from .gitignore if it exists
|
||||||
|
func loadGitignore(dir string) []string {
|
||||||
|
var patterns []string
|
||||||
|
|
||||||
|
gitignorePath := filepath.Join(dir, ".gitignore")
|
||||||
|
content, err := os.ReadFile(gitignorePath)
|
||||||
|
if err != nil {
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
// Skip empty lines and comments
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
patterns = append(patterns, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesGitignore checks if a path matches any gitignore pattern
|
||||||
|
func matchesGitignore(path string, isDir bool, patterns []string) bool {
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
// Handle directory-only patterns
|
||||||
|
if strings.HasSuffix(pattern, "/") {
|
||||||
|
if !isDir {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pattern = strings.TrimSuffix(pattern, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle negation (simplified - just skip negated patterns)
|
||||||
|
if strings.HasPrefix(pattern, "!") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match against path components
|
||||||
|
matched, _ := filepath.Match(pattern, filepath.Base(path))
|
||||||
|
if matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try matching the full path
|
||||||
|
matched, _ = filepath.Match(pattern, path)
|
||||||
|
if matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ** patterns (simplified)
|
||||||
|
if strings.Contains(pattern, "**") {
|
||||||
|
simplePattern := strings.ReplaceAll(pattern, "**", "*")
|
||||||
|
matched, _ = filepath.Match(simplePattern, path)
|
||||||
|
if matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesExclude checks if a path matches any exclude pattern
|
||||||
|
func matchesExclude(path string, excludes []string) bool {
|
||||||
|
for _, pattern := range excludes {
|
||||||
|
// Match against basename
|
||||||
|
matched, _ := filepath.Match(pattern, filepath.Base(path))
|
||||||
|
if matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match against full path
|
||||||
|
matched, _ = filepath.Match(pattern, path)
|
||||||
|
if matched {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectLocalStreaming collects files from a local directory using a streaming
|
||||||
|
// pipeline: walk -> tar -> compress -> encrypt -> file.
|
||||||
|
// The encryption runs in a goroutine, consuming from an io.Pipe that the
|
||||||
|
// tar/compress writes feed into synchronously.
|
||||||
|
func CollectLocalStreaming(dir, output, compression, password string) error {
|
||||||
|
// Resolve to absolute path
|
||||||
|
absDir, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error resolving directory path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate directory exists
|
||||||
|
info, err := os.Stat(absDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error accessing directory: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("not a directory: %s", absDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output file
|
||||||
|
outFile, err := os.Create(output)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup removes partial output on error
|
||||||
|
cleanup := func() {
|
||||||
|
outFile.Close()
|
||||||
|
os.Remove(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build streaming pipeline:
|
||||||
|
// tar.Writer -> compressWriter -> pipeWriter -> pipeReader -> StreamEncrypt -> outFile
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
|
||||||
|
// Start encryption goroutine
|
||||||
|
var encErr error
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
encErr = tim.StreamEncrypt(pr, outFile, password)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create compression writer wrapping the pipe writer
|
||||||
|
compWriter, err := compress.NewCompressWriter(pw, compression)
|
||||||
|
if err != nil {
|
||||||
|
pw.Close()
|
||||||
|
wg.Wait()
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("error creating compression writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tar writer wrapping the compression writer
|
||||||
|
tw := tar.NewWriter(compWriter)
|
||||||
|
|
||||||
|
// Walk directory and write tar entries
|
||||||
|
walkErr := filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path
|
||||||
|
relPath, err := filepath.Rel(absDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip root
|
||||||
|
if relPath == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize to forward slashes for tar
|
||||||
|
relPath = filepath.ToSlash(relPath)
|
||||||
|
|
||||||
|
// Check if entry is a symlink using Lstat
|
||||||
|
linfo, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
isSymlink := linfo.Mode()&fs.ModeSymlink != 0
|
||||||
|
|
||||||
|
if isSymlink {
|
||||||
|
// Read symlink target
|
||||||
|
linkTarget, err := os.Readlink(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve to check if target exists
|
||||||
|
absTarget := linkTarget
|
||||||
|
if !filepath.IsAbs(absTarget) {
|
||||||
|
absTarget = filepath.Join(filepath.Dir(path), linkTarget)
|
||||||
|
}
|
||||||
|
_, statErr := os.Stat(absTarget)
|
||||||
|
if statErr != nil {
|
||||||
|
// Broken symlink - skip silently
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write valid symlink as tar entry
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Typeflag: tar.TypeSymlink,
|
||||||
|
Name: relPath,
|
||||||
|
Linkname: linkTarget,
|
||||||
|
Mode: 0777,
|
||||||
|
}
|
||||||
|
return tw.WriteHeader(hdr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.IsDir() {
|
||||||
|
// Write directory header
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Typeflag: tar.TypeDir,
|
||||||
|
Name: relPath + "/",
|
||||||
|
Mode: 0755,
|
||||||
|
}
|
||||||
|
return tw.WriteHeader(hdr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular file: write header + content
|
||||||
|
finfo, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Name: relPath,
|
||||||
|
Mode: 0644,
|
||||||
|
Size: finfo.Size(),
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error opening %s: %w", relPath, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(tw, f); err != nil {
|
||||||
|
return fmt.Errorf("error streaming %s: %w", relPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close pipeline layers in order: tar -> compress -> pipe
|
||||||
|
// We must close even on error to unblock the encryption goroutine.
|
||||||
|
twCloseErr := tw.Close()
|
||||||
|
compCloseErr := compWriter.Close()
|
||||||
|
|
||||||
|
if walkErr != nil {
|
||||||
|
pw.CloseWithError(walkErr)
|
||||||
|
wg.Wait()
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("error walking directory: %w", walkErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if twCloseErr != nil {
|
||||||
|
pw.CloseWithError(twCloseErr)
|
||||||
|
wg.Wait()
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("error closing tar writer: %w", twCloseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if compCloseErr != nil {
|
||||||
|
pw.CloseWithError(compCloseErr)
|
||||||
|
wg.Wait()
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("error closing compression writer: %w", compCloseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal EOF to encryption goroutine
|
||||||
|
pw.Close()
|
||||||
|
|
||||||
|
// Wait for encryption to finish
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if encErr != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("error encrypting data: %w", encErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close output file
|
||||||
|
if err := outFile.Close(); err != nil {
|
||||||
|
os.Remove(output)
|
||||||
|
return fmt.Errorf("error closing output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptStimV2 decrypts a STIM v2 file back into a DataNode.
|
||||||
|
// It opens the file, runs StreamDecrypt, decompresses the result,
|
||||||
|
// and parses the tar archive into a DataNode.
|
||||||
|
func DecryptStimV2(path, password string) (*datanode.DataNode, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error opening file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
var decrypted bytes.Buffer
|
||||||
|
if err := tim.StreamDecrypt(f, &decrypted, password); err != nil {
|
||||||
|
return nil, fmt.Errorf("error decrypting: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decompress
|
||||||
|
decompressed, err := compress.Decompress(decrypted.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error decompressing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse tar into DataNode
|
||||||
|
dn, err := datanode.FromTar(decompressed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing tar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dn, nil
|
||||||
|
}
|
||||||
161
cmd/collect_local_test.go
Normal file
161
cmd/collect_local_test.go
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCollectLocalStreaming_Good(t *testing.T) {
|
||||||
|
// Create a temp directory with some test files
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
outDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create files in subdirectories
|
||||||
|
subDir := filepath.Join(srcDir, "subdir")
|
||||||
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create subdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := map[string]string{
|
||||||
|
"hello.txt": "hello world",
|
||||||
|
"subdir/nested.go": "package main\n",
|
||||||
|
}
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(srcDir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output := filepath.Join(outDir, "test.stim")
|
||||||
|
err := CollectLocalStreaming(srcDir, output, "gz", "test-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CollectLocalStreaming() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file exists and is non-empty
|
||||||
|
info, err := os.Stat(output)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("output file does not exist: %v", err)
|
||||||
|
}
|
||||||
|
if info.Size() == 0 {
|
||||||
|
t.Fatal("output file is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectLocalStreaming_Decrypt_Good(t *testing.T) {
|
||||||
|
// Create a temp directory with known files
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
outDir := t.TempDir()
|
||||||
|
|
||||||
|
subDir := filepath.Join(srcDir, "pkg")
|
||||||
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create subdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedFiles := map[string]string{
|
||||||
|
"README.md": "# Test Project\n",
|
||||||
|
"pkg/main.go": "package main\n\nfunc main() {}\n",
|
||||||
|
}
|
||||||
|
for name, content := range expectedFiles {
|
||||||
|
path := filepath.Join(srcDir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
password := "decrypt-test-pw"
|
||||||
|
output := filepath.Join(outDir, "roundtrip.stim")
|
||||||
|
|
||||||
|
// Collect
|
||||||
|
err := CollectLocalStreaming(srcDir, output, "gz", password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CollectLocalStreaming() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
dn, err := DecryptStimV2(output, password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptStimV2() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each expected file exists in the DataNode
|
||||||
|
for name, wantContent := range expectedFiles {
|
||||||
|
f, err := dn.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("file %q not found in DataNode: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
n, _ := f.Read(buf)
|
||||||
|
f.Close()
|
||||||
|
got := string(buf[:n])
|
||||||
|
if got != wantContent {
|
||||||
|
t.Errorf("file %q content mismatch:\n got: %q\n want: %q", name, got, wantContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectLocalStreaming_BrokenSymlink_Good(t *testing.T) {
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
outDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a regular file
|
||||||
|
if err := os.WriteFile(filepath.Join(srcDir, "real.txt"), []byte("I exist"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write real.txt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a broken symlink pointing to a nonexistent target
|
||||||
|
brokenLink := filepath.Join(srcDir, "broken-link")
|
||||||
|
if err := os.Symlink("/nonexistent/target/file", brokenLink); err != nil {
|
||||||
|
t.Fatalf("failed to create broken symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := filepath.Join(outDir, "symlink.stim")
|
||||||
|
err := CollectLocalStreaming(srcDir, output, "none", "sym-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CollectLocalStreaming() should skip broken symlinks, got error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify output exists and is non-empty
|
||||||
|
info, err := os.Stat(output)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("output file does not exist: %v", err)
|
||||||
|
}
|
||||||
|
if info.Size() == 0 {
|
||||||
|
t.Fatal("output file is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt and verify the broken symlink was skipped
|
||||||
|
dn, err := DecryptStimV2(output, "sym-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptStimV2() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// real.txt should be present
|
||||||
|
if _, err := dn.Stat("real.txt"); err != nil {
|
||||||
|
t.Error("expected real.txt in DataNode but it's missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// broken-link should NOT be present
|
||||||
|
exists, _ := dn.Exists("broken-link")
|
||||||
|
if exists {
|
||||||
|
t.Error("broken symlink should have been skipped but was found in DataNode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectLocalStreaming_Bad(t *testing.T) {
|
||||||
|
outDir := t.TempDir()
|
||||||
|
output := filepath.Join(outDir, "should-not-exist.stim")
|
||||||
|
|
||||||
|
err := CollectLocalStreaming("/nonexistent/path/that/does/not/exist", output, "none", "password")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent directory, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no partial output file was left behind
|
||||||
|
if _, statErr := os.Stat(output); statErr == nil {
|
||||||
|
t.Error("partial output file should have been cleaned up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,11 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/compress"
|
"forge.lthn.ai/Snider/Borg/pkg/compress"
|
||||||
"github.com/Snider/Borg/pkg/tim"
|
"forge.lthn.ai/Snider/Borg/pkg/pwa"
|
||||||
"github.com/Snider/Borg/pkg/trix"
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
"github.com/Snider/Borg/pkg/pwa"
|
"forge.lthn.ai/Snider/Borg/pkg/trix"
|
||||||
"github.com/Snider/Borg/pkg/ui"
|
"forge.lthn.ai/Snider/Borg/pkg/ui"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
@ -24,14 +24,21 @@ func NewCollectPWACmd() *CollectPWACmd {
|
||||||
PWAClient: pwa.NewPWAClient(),
|
PWAClient: pwa.NewPWAClient(),
|
||||||
}
|
}
|
||||||
c.Command = cobra.Command{
|
c.Command = cobra.Command{
|
||||||
Use: "pwa",
|
Use: "pwa [url]",
|
||||||
Short: "Collect a single PWA using a URI",
|
Short: "Collect a single PWA using a URI",
|
||||||
Long: `Collect a single PWA and store it in a DataNode.
|
Long: `Collect a single PWA and store it in a DataNode.
|
||||||
|
|
||||||
Example:
|
Examples:
|
||||||
borg collect pwa --uri https://example.com --output mypwa.dat`,
|
borg collect pwa https://example.com
|
||||||
|
borg collect pwa https://example.com --output mypwa.dat
|
||||||
|
borg collect pwa https://example.com --format stim --password secret`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
pwaURL, _ := cmd.Flags().GetString("uri")
|
pwaURL, _ := cmd.Flags().GetString("uri")
|
||||||
|
// Allow URL as positional argument
|
||||||
|
if len(args) > 0 && pwaURL == "" {
|
||||||
|
pwaURL = args[0]
|
||||||
|
}
|
||||||
outputFile, _ := cmd.Flags().GetString("output")
|
outputFile, _ := cmd.Flags().GetString("output")
|
||||||
format, _ := cmd.Flags().GetString("format")
|
format, _ := cmd.Flags().GetString("format")
|
||||||
compression, _ := cmd.Flags().GetString("compression")
|
compression, _ := cmd.Flags().GetString("compression")
|
||||||
|
|
@ -45,11 +52,11 @@ Example:
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
c.Flags().String("uri", "", "The URI of the PWA to collect")
|
c.Flags().String("uri", "", "The URI of the PWA to collect (can also be passed as positional arg)")
|
||||||
c.Flags().String("output", "", "Output file for the DataNode")
|
c.Flags().String("output", "", "Output file for the DataNode")
|
||||||
c.Flags().String("format", "datanode", "Output format (datanode, tim, or trix)")
|
c.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
|
||||||
c.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
|
c.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
|
||||||
c.Flags().String("password", "", "Password for encryption")
|
c.Flags().String("password", "", "Password for encryption (required for stim format)")
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,10 +65,13 @@ func init() {
|
||||||
}
|
}
|
||||||
func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format string, compression string, password string) (string, error) {
|
func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format string, compression string, password string) (string, error) {
|
||||||
if pwaURL == "" {
|
if pwaURL == "" {
|
||||||
return "", fmt.Errorf("uri is required")
|
return "", fmt.Errorf("url is required")
|
||||||
}
|
}
|
||||||
if format != "datanode" && format != "tim" && format != "trix" {
|
if format != "datanode" && format != "tim" && format != "trix" && format != "stim" {
|
||||||
return "", fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', or 'trix')", format)
|
return "", fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', 'trix', or 'stim')", format)
|
||||||
|
}
|
||||||
|
if format == "stim" && password == "" {
|
||||||
|
return "", fmt.Errorf("password is required for stim format")
|
||||||
}
|
}
|
||||||
if compression != "none" && compression != "gz" && compression != "xz" {
|
if compression != "none" && compression != "gz" && compression != "xz" {
|
||||||
return "", fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
|
return "", fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
|
||||||
|
|
@ -82,14 +92,23 @@ func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format s
|
||||||
|
|
||||||
var data []byte
|
var data []byte
|
||||||
if format == "tim" {
|
if format == "tim" {
|
||||||
tim, err := tim.FromDataNode(dn)
|
t, err := tim.FromDataNode(dn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("error creating tim: %w", err)
|
return "", fmt.Errorf("error creating tim: %w", err)
|
||||||
}
|
}
|
||||||
data, err = tim.ToTar()
|
data, err = t.ToTar()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("error serializing tim: %w", err)
|
return "", fmt.Errorf("error serializing tim: %w", err)
|
||||||
}
|
}
|
||||||
|
} else if format == "stim" {
|
||||||
|
t, err := tim.FromDataNode(dn)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error creating tim: %w", err)
|
||||||
|
}
|
||||||
|
data, err = t.ToSigil(password)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error encrypting stim: %w", err)
|
||||||
|
}
|
||||||
} else if format == "trix" {
|
} else if format == "trix" {
|
||||||
data, err = trix.ToTrix(dn, password)
|
data, err = trix.ToTrix(dn, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/schollz/progressbar/v3"
|
"github.com/schollz/progressbar/v3"
|
||||||
"github.com/Snider/Borg/pkg/compress"
|
"forge.lthn.ai/Snider/Borg/pkg/compress"
|
||||||
"github.com/Snider/Borg/pkg/tim"
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
"github.com/Snider/Borg/pkg/trix"
|
"forge.lthn.ai/Snider/Borg/pkg/trix"
|
||||||
"github.com/Snider/Borg/pkg/ui"
|
"forge.lthn.ai/Snider/Borg/pkg/ui"
|
||||||
"github.com/Snider/Borg/pkg/website"
|
"forge.lthn.ai/Snider/Borg/pkg/website"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||||
"github.com/Snider/Borg/pkg/website"
|
"forge.lthn.ai/Snider/Borg/pkg/website"
|
||||||
"github.com/schollz/progressbar/v3"
|
"github.com/schollz/progressbar/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/tim"
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var borgfile string
|
var borgfile string
|
||||||
var output string
|
var output string
|
||||||
|
var encryptPassword string
|
||||||
|
|
||||||
var compileCmd = NewCompileCmd()
|
var compileCmd = NewCompileCmd()
|
||||||
|
|
||||||
|
|
@ -52,16 +53,33 @@ func NewCompileCmd() *cobra.Command {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If encryption is requested, output as .stim
|
||||||
|
if encryptPassword != "" {
|
||||||
|
stimData, err := m.ToSigil(encryptPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
outputPath := output
|
||||||
|
if !strings.HasSuffix(outputPath, ".stim") {
|
||||||
|
outputPath = strings.TrimSuffix(outputPath, ".tim") + ".stim"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Compiled encrypted TIM to %s\n", outputPath)
|
||||||
|
return os.WriteFile(outputPath, stimData, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original unencrypted output
|
||||||
tarball, err := m.ToTar()
|
tarball, err := m.ToTar()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Compiled TIM to %s\n", output)
|
||||||
return os.WriteFile(output, tarball, 0644)
|
return os.WriteFile(output, tarball, 0644)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
compileCmd.Flags().StringVarP(&borgfile, "file", "f", "Borgfile", "Path to the Borgfile.")
|
compileCmd.Flags().StringVarP(&borgfile, "file", "f", "Borgfile", "Path to the Borgfile.")
|
||||||
compileCmd.Flags().StringVarP(&output, "output", "o", "a.tim", "Path to the output tim file.")
|
compileCmd.Flags().StringVarP(&output, "output", "o", "a.tim", "Path to the output tim file.")
|
||||||
|
compileCmd.Flags().StringVarP(&encryptPassword, "encrypt", "e", "", "Encrypt with ChaCha20-Poly1305 using this password (outputs .stim)")
|
||||||
return compileCmd
|
return compileCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
163
cmd/console.go
Normal file
163
cmd/console.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/console"
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var consoleCmd = NewConsoleCmd()
|
||||||
|
|
||||||
|
// NewConsoleCmd creates the console parent command.
|
||||||
|
func NewConsoleCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "console",
|
||||||
|
Short: "Manage encrypted PWA console demos",
|
||||||
|
Long: `The Borg Console packages and serves encrypted PWA demos.
|
||||||
|
|
||||||
|
Build a console STIM:
|
||||||
|
borg console build -p "password" -o console.stim
|
||||||
|
|
||||||
|
Serve with unlock page:
|
||||||
|
borg console serve console.stim --open
|
||||||
|
|
||||||
|
Serve pre-unlocked:
|
||||||
|
borg console serve console.stim -p "password" --open`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(NewConsoleBuildCmd())
|
||||||
|
cmd.AddCommand(NewConsoleServeCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConsoleBuildCmd creates the build subcommand.
|
||||||
|
func NewConsoleBuildCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "build",
|
||||||
|
Short: "Build a console STIM from demo files",
|
||||||
|
Long: `Packages HTML demo files into an encrypted STIM container.
|
||||||
|
|
||||||
|
By default, looks for files in js/borg-stmf/ directory.
|
||||||
|
Required files: index.html, support-reply.html, stmf.wasm, wasm_exec.js`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
password, _ := cmd.Flags().GetString("password")
|
||||||
|
output, _ := cmd.Flags().GetString("output")
|
||||||
|
sourceDir, _ := cmd.Flags().GetString("source")
|
||||||
|
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new TIM
|
||||||
|
m, err := tim.New()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating TIM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required demo files
|
||||||
|
files := []string{
|
||||||
|
"index.html",
|
||||||
|
"support-reply.html",
|
||||||
|
"stmf.wasm",
|
||||||
|
"wasm_exec.js",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add each file to the TIM
|
||||||
|
for _, f := range files {
|
||||||
|
path := filepath.Join(sourceDir, f)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading %s: %w", f, err)
|
||||||
|
}
|
||||||
|
m.RootFS.AddData(f, data)
|
||||||
|
fmt.Printf(" + %s (%d bytes)\n", f, len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt to STIM
|
||||||
|
stim, err := m.ToSigil(password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypting STIM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write output
|
||||||
|
if err := os.WriteFile(output, stim, 0644); err != nil {
|
||||||
|
return fmt.Errorf("writing output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nBuilt: %s (%d bytes)\n", output, len(stim))
|
||||||
|
fmt.Println("Encrypted with ChaCha20-Poly1305")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("password", "p", "", "Encryption password (required)")
|
||||||
|
cmd.Flags().StringP("output", "o", "console.stim", "Output file")
|
||||||
|
cmd.Flags().StringP("source", "s", "js/borg-stmf", "Source directory")
|
||||||
|
cmd.MarkFlagRequired("password")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConsoleServeCmd creates the serve subcommand.
|
||||||
|
func NewConsoleServeCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "serve [stim-file]",
|
||||||
|
Short: "Serve an encrypted console STIM",
|
||||||
|
Long: `Starts an HTTP server to serve encrypted STIM content.
|
||||||
|
|
||||||
|
Without a password, shows a dark-themed unlock page.
|
||||||
|
With a password, decrypts immediately and serves content.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
borg console serve demos.stim --open
|
||||||
|
borg console serve demos.stim -p "password" --port 3000`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
stimPath := args[0]
|
||||||
|
password, _ := cmd.Flags().GetString("password")
|
||||||
|
port, _ := cmd.Flags().GetString("port")
|
||||||
|
openBrowser, _ := cmd.Flags().GetBool("open")
|
||||||
|
|
||||||
|
// Create server
|
||||||
|
server, err := console.NewServer(stimPath, password, port)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print status
|
||||||
|
fmt.Printf("Borg Console serving at %s\n", server.URL())
|
||||||
|
if password != "" {
|
||||||
|
fmt.Println("Status: Unlocked (password provided)")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Status: Locked (unlock page active)")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Open browser if requested
|
||||||
|
if openBrowser {
|
||||||
|
if err := console.OpenBrowser(server.URL()); err != nil {
|
||||||
|
fmt.Printf("Warning: could not open browser: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start serving
|
||||||
|
return server.Start()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringP("password", "p", "", "Decryption password (skip unlock page)")
|
||||||
|
cmd.Flags().String("port", "8080", "Port to serve on")
|
||||||
|
cmd.Flags().Bool("open", false, "Auto-open browser")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RootCmd.AddCommand(consoleCmd)
|
||||||
|
}
|
||||||
17
cmd/context.go
Normal file
17
cmd/context.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/ui"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProgressFromCmd returns a Progress based on --quiet flag and TTY detection.
|
||||||
|
func ProgressFromCmd(cmd *cobra.Command) ui.Progress {
|
||||||
|
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||||
|
if quiet {
|
||||||
|
return ui.NewQuietProgress(os.Stderr)
|
||||||
|
}
|
||||||
|
return ui.DefaultProgress()
|
||||||
|
}
|
||||||
28
cmd/context_test.go
Normal file
28
cmd/context_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProgressFromCmd_Good(t *testing.T) {
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.PersistentFlags().BoolP("quiet", "q", false, "")
|
||||||
|
|
||||||
|
p := ProgressFromCmd(cmd)
|
||||||
|
if p == nil {
|
||||||
|
t.Fatal("expected non-nil Progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressFromCmd_Quiet_Good(t *testing.T) {
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.PersistentFlags().BoolP("quiet", "q", true, "")
|
||||||
|
_ = cmd.PersistentFlags().Set("quiet", "true")
|
||||||
|
|
||||||
|
p := ProgressFromCmd(cmd)
|
||||||
|
if p == nil {
|
||||||
|
t.Fatal("expected non-nil Progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
3
cmd/dapp-fm-app/.gitignore
vendored
Normal file
3
cmd/dapp-fm-app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
build/
|
||||||
|
*.exe
|
||||||
|
dapp-fm-app
|
||||||
987
cmd/dapp-fm-app/frontend/index.html
Normal file
987
cmd/dapp-fm-app/frontend/index.html
Normal file
|
|
@ -0,0 +1,987 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>dapp.fm - Decentralized Music Player</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #0f0f1a 0%, #1a0a2e 50%, #0f1a2e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #ff006e 0%, #8338ec 50%, #3a86ff 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
letter-spacing: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo .tagline {
|
||||||
|
color: #888;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text {
|
||||||
|
text-align: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text p {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-text strong {
|
||||||
|
color: #ff006e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 .icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea, input[type="password"], input[type="text"], input[type="url"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: 2px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus, input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #8338ec;
|
||||||
|
box-shadow: 0 0 0 4px rgba(131, 56, 236, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.encrypted {
|
||||||
|
min-height: 100px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-row .input-group {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 1rem 2.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: linear-gradient(135deg, #ff006e 0%, #8338ec 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 20px rgba(255, 0, 110, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 30px rgba(255, 0, 110, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator .dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.loading .dot {
|
||||||
|
background: #ffc107;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.ready .dot {
|
||||||
|
background: #00ff94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.error .dot {
|
||||||
|
background: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: rgba(255, 82, 82, 0.15);
|
||||||
|
border: 1px solid rgba(255, 82, 82, 0.4);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media Player Styles */
|
||||||
|
.player-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-container.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artwork {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #2d1b4e 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 5rem;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artwork img, .track-artwork video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
color: #888;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-player-wrapper {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio, video {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
max-height: 500px;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-wrapper {
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-info {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(131, 56, 236, 0.1);
|
||||||
|
border: 1px solid rgba(131, 56, 236, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-info h4 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: #8338ec;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-info p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-info .license-token {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #00ff94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-section button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list-section h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item:hover {
|
||||||
|
background: rgba(131, 56, 236, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.active {
|
||||||
|
background: rgba(255, 0, 110, 0.2);
|
||||||
|
border: 1px solid rgba(255, 0, 110, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-number {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #8338ec;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-type {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-time {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #00ff94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-wrapper input[type="file"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 2px dashed rgba(255,255,255,0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-label:hover {
|
||||||
|
border-color: #8338ec;
|
||||||
|
background: rgba(131, 56, 236, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-label .icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.or-divider {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: linear-gradient(135deg, #00ff94 0%, #00d4aa 100%);
|
||||||
|
color: #000;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">
|
||||||
|
<h1>dapp.fm</h1>
|
||||||
|
<p class="tagline">Decentralized Music Distribution <span class="native-badge">Native App</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-text">
|
||||||
|
<p>
|
||||||
|
<strong>No middlemen. No platforms. No 70% cuts.</strong><br>
|
||||||
|
Artists encrypt their music with ChaCha20-Poly1305. Fans unlock with a license token.
|
||||||
|
Content lives on any CDN, IPFS, or artist's own server. The password IS the license.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="status-indicator ready">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>Native decryption ready (memory speed)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="icon">🔐</span> Unlock Licensed Content</h2>
|
||||||
|
|
||||||
|
<div class="file-input-wrapper">
|
||||||
|
<input type="file" id="file-input" accept=".smsg,.enc,.borg">
|
||||||
|
<label class="file-input-label">
|
||||||
|
<span class="icon">📁</span>
|
||||||
|
<span>Drop encrypted file here or click to browse</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="or-divider">- or paste encrypted content -</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="encrypted-content">Encrypted Content (base64):</label>
|
||||||
|
<textarea id="encrypted-content" class="encrypted" placeholder="Paste the encrypted content from the artist..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-banner" style="background: rgba(255, 0, 110, 0.1); border: 1px solid rgba(255, 0, 110, 0.3); border-radius: 12px; padding: 1rem; margin-bottom: 1rem;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem;">
|
||||||
|
<div>
|
||||||
|
<strong style="color: #ff006e;">Try the Demo!</strong>
|
||||||
|
<span style="color: #888; font-size: 0.85rem; margin-left: 0.5rem;">Bundled sample video</span>
|
||||||
|
</div>
|
||||||
|
<button id="load-demo-btn" class="secondary" style="padding: 0.6rem 1.2rem; font-size: 0.85rem;">Load Demo Track</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.8rem; color: #666; margin-top: 0.5rem;">
|
||||||
|
Password: <code style="background: rgba(0,0,0,0.3); padding: 0.2rem 0.5rem; border-radius: 4px; color: #00ff94;">PMVXogAJNVe_DDABfTmLYztaJAzsD0R7</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="error-banner" class="error-banner"></div>
|
||||||
|
|
||||||
|
<!-- Manifest preview (shown without decryption) -->
|
||||||
|
<div id="manifest-preview" style="display: none; background: rgba(131, 56, 236, 0.1); border: 1px solid rgba(131, 56, 236, 0.3); border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem;"></div>
|
||||||
|
|
||||||
|
<div class="unlock-row">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="license-token">License Token (Password):</label>
|
||||||
|
<input type="password" id="license-token" placeholder="Enter your license token from the artist">
|
||||||
|
</div>
|
||||||
|
<button id="unlock-btn" class="primary">Unlock</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player appears after unlock -->
|
||||||
|
<div id="player-container" class="card player-container">
|
||||||
|
<h2><span class="icon">🎵</span> Now Playing</h2>
|
||||||
|
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-artwork" id="track-artwork">🎶</div>
|
||||||
|
<div class="track-title" id="track-title">Track Title</div>
|
||||||
|
<div class="track-artist" id="track-artist">Artist Name</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="media-player-wrapper" id="media-player-wrapper">
|
||||||
|
<!-- Audio/Video player inserted here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="track-list-section" class="track-list-section" style="display: none;">
|
||||||
|
<h3><span>💿</span> Track List</h3>
|
||||||
|
<div id="track-list" class="track-list">
|
||||||
|
<!-- Tracks populated by JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="license-info">
|
||||||
|
<h4>🔓 Licensed Content</h4>
|
||||||
|
<p id="license-description">This content was unlocked with your personal license token.
|
||||||
|
Decryption powered by native Go - no servers, memory speed.</p>
|
||||||
|
<div class="license-token" id="license-display"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-section">
|
||||||
|
<button class="secondary" id="download-btn">Download Original</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Wails runtime - provides window.go bindings
|
||||||
|
let currentMediaBlob = null;
|
||||||
|
let currentMediaName = null;
|
||||||
|
let currentMediaMime = null;
|
||||||
|
let currentManifest = null;
|
||||||
|
|
||||||
|
// Check if Wails runtime is available
|
||||||
|
function isWailsReady() {
|
||||||
|
return typeof window.go !== 'undefined' &&
|
||||||
|
typeof window.go.player !== 'undefined' &&
|
||||||
|
typeof window.go.player.Player !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for Wails runtime
|
||||||
|
function waitForWails() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (isWailsReady()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Poll for Wails runtime
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (isWailsReady()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const errorBanner = document.getElementById('error-banner');
|
||||||
|
errorBanner.textContent = msg;
|
||||||
|
errorBanner.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
document.getElementById('error-banner').classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file input
|
||||||
|
document.getElementById('file-input').addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await file.arrayBuffer();
|
||||||
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(content)));
|
||||||
|
document.getElementById('encrypted-content').value = base64;
|
||||||
|
await showManifestPreview(base64);
|
||||||
|
} catch (err) {
|
||||||
|
showError('Failed to read file: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for content paste/input
|
||||||
|
let previewDebounce = null;
|
||||||
|
document.getElementById('encrypted-content').addEventListener('input', async (e) => {
|
||||||
|
const content = e.target.value.trim();
|
||||||
|
clearTimeout(previewDebounce);
|
||||||
|
previewDebounce = setTimeout(async () => {
|
||||||
|
if (content && content.length > 100) {
|
||||||
|
await showManifestPreview(content);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show manifest preview using Go bindings (NO WASM!)
|
||||||
|
async function showManifestPreview(encryptedB64) {
|
||||||
|
await waitForWails();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Direct Go call at memory speed!
|
||||||
|
const manifest = await window.go.player.Player.GetManifest(encryptedB64);
|
||||||
|
currentManifest = manifest;
|
||||||
|
|
||||||
|
const previewSection = document.getElementById('manifest-preview');
|
||||||
|
while (previewSection.firstChild) {
|
||||||
|
previewSection.removeChild(previewSection.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest && manifest.title) {
|
||||||
|
previewSection.style.display = 'block';
|
||||||
|
|
||||||
|
// Header with icon
|
||||||
|
const headerDiv = document.createElement('div');
|
||||||
|
headerDiv.style.cssText = 'display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;';
|
||||||
|
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.style.fontSize = '2.5rem';
|
||||||
|
icon.textContent = manifest.release_type === 'djset' ? '🎧' :
|
||||||
|
manifest.release_type === 'live' ? '🎤' : '💿';
|
||||||
|
|
||||||
|
const titleDiv = document.createElement('div');
|
||||||
|
const titleEl = document.createElement('div');
|
||||||
|
titleEl.style.cssText = 'font-size: 1.2rem; font-weight: 700; color: #fff;';
|
||||||
|
titleEl.textContent = manifest.title || 'Untitled';
|
||||||
|
|
||||||
|
const artistEl = document.createElement('div');
|
||||||
|
artistEl.style.cssText = 'font-size: 0.9rem; color: #888;';
|
||||||
|
artistEl.textContent = manifest.artist || 'Unknown Artist';
|
||||||
|
|
||||||
|
titleDiv.appendChild(titleEl);
|
||||||
|
titleDiv.appendChild(artistEl);
|
||||||
|
headerDiv.appendChild(icon);
|
||||||
|
headerDiv.appendChild(titleDiv);
|
||||||
|
previewSection.appendChild(headerDiv);
|
||||||
|
|
||||||
|
// Track list
|
||||||
|
if (manifest.tracks && manifest.tracks.length > 0) {
|
||||||
|
const trackHeader = document.createElement('div');
|
||||||
|
trackHeader.style.cssText = 'font-size: 0.85rem; color: #8338ec; margin-bottom: 0.5rem;';
|
||||||
|
trackHeader.textContent = '💿 ' + manifest.tracks.length + ' track(s)';
|
||||||
|
previewSection.appendChild(trackHeader);
|
||||||
|
|
||||||
|
const trackList = document.createElement('div');
|
||||||
|
trackList.style.maxHeight = '150px';
|
||||||
|
trackList.style.overflowY = 'auto';
|
||||||
|
|
||||||
|
manifest.tracks.forEach((track, i) => {
|
||||||
|
const trackEl = document.createElement('div');
|
||||||
|
trackEl.style.cssText = 'display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 6px; margin-bottom: 0.25rem; font-size: 0.85rem;';
|
||||||
|
|
||||||
|
const numEl = document.createElement('span');
|
||||||
|
numEl.style.cssText = 'color: #8338ec; font-weight: 600; min-width: 20px;';
|
||||||
|
numEl.textContent = (track.track_num || (i + 1)) + '.';
|
||||||
|
|
||||||
|
const nameEl = document.createElement('span');
|
||||||
|
nameEl.style.cssText = 'flex: 1; color: #ccc;';
|
||||||
|
nameEl.textContent = track.title || 'Track ' + (i + 1);
|
||||||
|
|
||||||
|
const timeEl = document.createElement('span');
|
||||||
|
timeEl.style.cssText = 'color: #00ff94; font-family: monospace; font-size: 0.8rem;';
|
||||||
|
timeEl.textContent = formatTime(track.start || 0);
|
||||||
|
|
||||||
|
trackEl.appendChild(numEl);
|
||||||
|
trackEl.appendChild(nameEl);
|
||||||
|
trackEl.appendChild(timeEl);
|
||||||
|
trackList.appendChild(trackEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
previewSection.appendChild(trackList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// License status
|
||||||
|
if (manifest.is_expired !== undefined) {
|
||||||
|
const licenseDiv = document.createElement('div');
|
||||||
|
licenseDiv.style.cssText = 'margin-top: 1rem; padding: 0.75rem; border-radius: 8px;';
|
||||||
|
|
||||||
|
if (manifest.is_expired) {
|
||||||
|
licenseDiv.style.background = 'rgba(255, 82, 82, 0.2)';
|
||||||
|
licenseDiv.style.border = '1px solid rgba(255, 82, 82, 0.4)';
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.style.cssText = 'color: #ff5252; font-weight: 600;';
|
||||||
|
label.textContent = 'LICENSE EXPIRED';
|
||||||
|
licenseDiv.appendChild(label);
|
||||||
|
} else if (manifest.time_remaining) {
|
||||||
|
licenseDiv.style.background = 'rgba(0, 255, 148, 0.1)';
|
||||||
|
licenseDiv.style.border = '1px solid rgba(0, 255, 148, 0.3)';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.style.cssText = 'color: #00ff94; font-weight: 600; font-size: 0.8rem;';
|
||||||
|
label.textContent = (manifest.license_type || 'LICENSE').toUpperCase();
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.style.cssText = 'color: #888; font-size: 0.8rem; margin-left: 0.5rem;';
|
||||||
|
time.textContent = manifest.time_remaining + ' remaining';
|
||||||
|
licenseDiv.appendChild(label);
|
||||||
|
licenseDiv.appendChild(time);
|
||||||
|
} else {
|
||||||
|
licenseDiv.style.background = 'rgba(0, 255, 148, 0.1)';
|
||||||
|
licenseDiv.style.border = '1px solid rgba(0, 255, 148, 0.3)';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.style.cssText = 'color: #00ff94; font-weight: 600; font-size: 0.8rem;';
|
||||||
|
label.textContent = 'PERPETUAL LICENSE';
|
||||||
|
licenseDiv.appendChild(label);
|
||||||
|
}
|
||||||
|
previewSection.appendChild(licenseDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.style.cssText = 'margin-top: 1rem; font-size: 0.85rem; color: #888; text-align: center;';
|
||||||
|
hint.textContent = manifest.is_expired ?
|
||||||
|
'License expired. Contact artist for renewal.' :
|
||||||
|
'Enter license token to unlock and play';
|
||||||
|
previewSection.appendChild(hint);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
previewSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Could not read manifest:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock content using Go bindings (memory speed!)
|
||||||
|
async function unlockContent() {
|
||||||
|
hideError();
|
||||||
|
await waitForWails();
|
||||||
|
|
||||||
|
const encryptedB64 = document.getElementById('encrypted-content').value.trim();
|
||||||
|
const password = document.getElementById('license-token').value;
|
||||||
|
|
||||||
|
if (!encryptedB64) {
|
||||||
|
showError('Please provide encrypted content');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
showError('Please enter your license token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check license validity (memory speed)
|
||||||
|
const isValid = await window.go.player.Player.IsLicenseValid(encryptedB64);
|
||||||
|
if (!isValid) {
|
||||||
|
showError('License has expired. Contact the artist for renewal.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt using Go bindings (memory speed - no HTTP/TCP!)
|
||||||
|
const result = await window.go.player.Player.Decrypt(encryptedB64, password);
|
||||||
|
displayMedia(result, password);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError('Unlock failed: ' + err.message);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display decrypted media
|
||||||
|
function displayMedia(result, password) {
|
||||||
|
const playerContainer = document.getElementById('player-container');
|
||||||
|
const mediaWrapper = document.getElementById('media-player-wrapper');
|
||||||
|
const artworkEl = document.getElementById('track-artwork');
|
||||||
|
|
||||||
|
// Set track info
|
||||||
|
const title = (currentManifest && currentManifest.title) || result.subject || 'Untitled';
|
||||||
|
const artist = (currentManifest && currentManifest.artist) || result.from || 'Unknown Artist';
|
||||||
|
document.getElementById('track-title').textContent = title;
|
||||||
|
document.getElementById('track-artist').textContent = artist;
|
||||||
|
|
||||||
|
// Show masked license token
|
||||||
|
const masked = password.substring(0, 4) + '••••••••' + password.substring(password.length - 4);
|
||||||
|
document.getElementById('license-display').textContent = masked;
|
||||||
|
|
||||||
|
// Clear previous media
|
||||||
|
while (mediaWrapper.firstChild) mediaWrapper.removeChild(mediaWrapper.firstChild);
|
||||||
|
while (artworkEl.firstChild) artworkEl.removeChild(artworkEl.firstChild);
|
||||||
|
artworkEl.textContent = '🎶';
|
||||||
|
|
||||||
|
// Process attachments
|
||||||
|
if (result.attachments && result.attachments.length > 0) {
|
||||||
|
result.attachments.forEach((att) => {
|
||||||
|
const mime = att.mime_type || 'application/octet-stream';
|
||||||
|
|
||||||
|
// URL from Go - served through Wails asset handler
|
||||||
|
const url = att.url || att.file_path || att.stream_url || att.data_url;
|
||||||
|
|
||||||
|
// Store info for download
|
||||||
|
currentMediaName = att.name;
|
||||||
|
currentMediaMime = mime;
|
||||||
|
|
||||||
|
if (mime.startsWith('video/')) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'video-player-wrapper';
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.controls = true;
|
||||||
|
video.src = url;
|
||||||
|
video.style.width = '100%';
|
||||||
|
wrapper.appendChild(video);
|
||||||
|
mediaWrapper.appendChild(wrapper);
|
||||||
|
artworkEl.textContent = '🎬';
|
||||||
|
|
||||||
|
} else if (mime.startsWith('audio/')) {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'audio-player';
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
audio.controls = true;
|
||||||
|
audio.src = url;
|
||||||
|
audio.style.width = '100%';
|
||||||
|
wrapper.appendChild(audio);
|
||||||
|
mediaWrapper.appendChild(wrapper);
|
||||||
|
artworkEl.textContent = '🎵';
|
||||||
|
|
||||||
|
} else if (mime.startsWith('image/')) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = url;
|
||||||
|
artworkEl.textContent = '';
|
||||||
|
artworkEl.appendChild(img);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build track list from manifest
|
||||||
|
const trackListSection = document.getElementById('track-list-section');
|
||||||
|
const trackListEl = document.getElementById('track-list');
|
||||||
|
while (trackListEl.firstChild) trackListEl.removeChild(trackListEl.firstChild);
|
||||||
|
|
||||||
|
if (currentManifest && currentManifest.tracks && currentManifest.tracks.length > 0) {
|
||||||
|
trackListSection.style.display = 'block';
|
||||||
|
|
||||||
|
currentManifest.tracks.forEach((track, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'track-item';
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const media = document.querySelector('audio, video');
|
||||||
|
if (media) {
|
||||||
|
media.currentTime = track.start || 0;
|
||||||
|
media.play();
|
||||||
|
document.querySelectorAll('.track-item').forEach(t => t.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const num = document.createElement('span');
|
||||||
|
num.className = 'track-number';
|
||||||
|
num.textContent = track.track_num || (index + 1);
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.style.flex = '1';
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'track-name';
|
||||||
|
name.textContent = track.title || 'Track ' + (index + 1);
|
||||||
|
info.appendChild(name);
|
||||||
|
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.className = 'track-time';
|
||||||
|
time.textContent = formatTime(track.start || 0);
|
||||||
|
|
||||||
|
item.appendChild(num);
|
||||||
|
item.appendChild(info);
|
||||||
|
item.appendChild(time);
|
||||||
|
trackListEl.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trackListSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update license description
|
||||||
|
if (currentManifest && currentManifest.time_remaining) {
|
||||||
|
document.getElementById('license-description').textContent =
|
||||||
|
(currentManifest.license_type || 'Rental').toUpperCase() + ' license - ' +
|
||||||
|
currentManifest.time_remaining + ' remaining. Native Go decryption at memory speed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide preview, show player
|
||||||
|
document.getElementById('manifest-preview').style.display = 'none';
|
||||||
|
playerContainer.classList.add('visible');
|
||||||
|
playerContainer.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return mins + ':' + secs.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download handler
|
||||||
|
document.getElementById('download-btn').addEventListener('click', () => {
|
||||||
|
if (!currentMediaBlob) {
|
||||||
|
alert('No media to download');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(currentMediaBlob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = currentMediaName || 'media';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load bundled demo - DIRECT GO CALL, no HTTP!
|
||||||
|
async function loadDemo() {
|
||||||
|
const btn = document.getElementById('load-demo-btn');
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = 'Loading...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForWails();
|
||||||
|
|
||||||
|
// Get manifest first (direct Go call)
|
||||||
|
const manifest = await window.go.main.App.GetDemoManifest();
|
||||||
|
currentManifest = manifest;
|
||||||
|
|
||||||
|
// Decrypt demo directly in Go - NO fetch, NO base64 encoding!
|
||||||
|
// Go reads embedded bytes -> decrypts -> returns result
|
||||||
|
const result = await window.go.main.App.LoadDemo();
|
||||||
|
|
||||||
|
// Display the decrypted media
|
||||||
|
displayMedia(result, 'PMVXogAJNVe_DDABfTmLYztaJAzsD0R7');
|
||||||
|
|
||||||
|
btn.textContent = 'Loaded!';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
showError('Failed to load demo: ' + err.message);
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.getElementById('unlock-btn').addEventListener('click', unlockContent);
|
||||||
|
document.getElementById('license-token').addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') unlockContent();
|
||||||
|
});
|
||||||
|
document.getElementById('load-demo-btn').addEventListener('click', loadDemo);
|
||||||
|
|
||||||
|
// Ready check
|
||||||
|
waitForWails().then(() => {
|
||||||
|
console.log('Wails bindings ready - memory speed decryption enabled');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.d.ts
vendored
Executable file
14
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.d.ts
vendored
Executable file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {main} from '../models';
|
||||||
|
import {player} from '../models';
|
||||||
|
|
||||||
|
export function DecryptAndServe(arg1:string,arg2:string):Promise<main.MediaResult>;
|
||||||
|
|
||||||
|
export function GetDemoManifest():Promise<player.ManifestInfo>;
|
||||||
|
|
||||||
|
export function GetManifest(arg1:string):Promise<player.ManifestInfo>;
|
||||||
|
|
||||||
|
export function IsLicenseValid(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function LoadDemo():Promise<main.MediaResult>;
|
||||||
23
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.js
Executable file
23
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.js
Executable file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// @ts-check
|
||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function DecryptAndServe(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['DecryptAndServe'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetDemoManifest() {
|
||||||
|
return window['go']['main']['App']['GetDemoManifest']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetManifest(arg1) {
|
||||||
|
return window['go']['main']['App']['GetManifest'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IsLicenseValid(arg1) {
|
||||||
|
return window['go']['main']['App']['IsLicenseValid'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadDemo() {
|
||||||
|
return window['go']['main']['App']['LoadDemo']();
|
||||||
|
}
|
||||||
140
cmd/dapp-fm-app/frontend/wailsjs/go/models.ts
Executable file
140
cmd/dapp-fm-app/frontend/wailsjs/go/models.ts
Executable file
|
|
@ -0,0 +1,140 @@
|
||||||
|
export namespace main {
|
||||||
|
|
||||||
|
export class MediaAttachment {
|
||||||
|
name: string;
|
||||||
|
mime_type: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MediaAttachment(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.mime_type = source["mime_type"];
|
||||||
|
this.size = source["size"];
|
||||||
|
this.url = source["url"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class MediaResult {
|
||||||
|
body: string;
|
||||||
|
subject?: string;
|
||||||
|
from?: string;
|
||||||
|
attachments?: MediaAttachment[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MediaResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.body = source["body"];
|
||||||
|
this.subject = source["subject"];
|
||||||
|
this.from = source["from"];
|
||||||
|
this.attachments = this.convertValues(source["attachments"], MediaAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace player {
|
||||||
|
|
||||||
|
export class TrackInfo {
|
||||||
|
title: string;
|
||||||
|
start: number;
|
||||||
|
end?: number;
|
||||||
|
type?: string;
|
||||||
|
track_num?: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new TrackInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.title = source["title"];
|
||||||
|
this.start = source["start"];
|
||||||
|
this.end = source["end"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.track_num = source["track_num"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ManifestInfo {
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
genre?: string;
|
||||||
|
year?: number;
|
||||||
|
release_type?: string;
|
||||||
|
duration?: number;
|
||||||
|
format?: string;
|
||||||
|
expires_at?: number;
|
||||||
|
issued_at?: number;
|
||||||
|
license_type?: string;
|
||||||
|
tracks?: TrackInfo[];
|
||||||
|
is_expired: boolean;
|
||||||
|
time_remaining?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ManifestInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.title = source["title"];
|
||||||
|
this.artist = source["artist"];
|
||||||
|
this.album = source["album"];
|
||||||
|
this.genre = source["genre"];
|
||||||
|
this.year = source["year"];
|
||||||
|
this.release_type = source["release_type"];
|
||||||
|
this.duration = source["duration"];
|
||||||
|
this.format = source["format"];
|
||||||
|
this.expires_at = source["expires_at"];
|
||||||
|
this.issued_at = source["issued_at"];
|
||||||
|
this.license_type = source["license_type"];
|
||||||
|
this.tracks = this.convertValues(source["tracks"], TrackInfo);
|
||||||
|
this.is_expired = source["is_expired"];
|
||||||
|
this.time_remaining = source["time_remaining"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
24
cmd/dapp-fm-app/frontend/wailsjs/runtime/package.json
Normal file
24
cmd/dapp-fm-app/frontend/wailsjs/runtime/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "@wailsapp/runtime",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Wails Javascript runtime library",
|
||||||
|
"main": "runtime.js",
|
||||||
|
"types": "runtime.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/wailsapp/wails.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"Wails",
|
||||||
|
"Javascript",
|
||||||
|
"Go"
|
||||||
|
],
|
||||||
|
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/wailsapp/wails/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||||
|
}
|
||||||
249
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
249
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
/*
|
||||||
|
_ __ _ __
|
||||||
|
| | / /___ _(_) /____
|
||||||
|
| | /| / / __ `/ / / ___/
|
||||||
|
| |/ |/ / /_/ / / (__ )
|
||||||
|
|__/|__/\__,_/_/_/____/
|
||||||
|
The electron alternative for Go
|
||||||
|
(c) Lea Anthony 2019-present
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Screen {
|
||||||
|
isCurrent: boolean;
|
||||||
|
isPrimary: boolean;
|
||||||
|
width : number
|
||||||
|
height : number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment information such as platform, buildtype, ...
|
||||||
|
export interface EnvironmentInfo {
|
||||||
|
buildType: string;
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||||
|
// emits the given event. Optional data may be passed with the event.
|
||||||
|
// This will trigger any event listeners.
|
||||||
|
export function EventsEmit(eventName: string, ...data: any): void;
|
||||||
|
|
||||||
|
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||||
|
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
|
||||||
|
|
||||||
|
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||||
|
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||||
|
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
|
||||||
|
|
||||||
|
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||||
|
// sets up a listener for the given event name, but will only trigger once.
|
||||||
|
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
|
||||||
|
|
||||||
|
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
|
||||||
|
// unregisters the listener for the given event name.
|
||||||
|
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
|
||||||
|
|
||||||
|
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||||
|
// unregisters all listeners.
|
||||||
|
export function EventsOffAll(): void;
|
||||||
|
|
||||||
|
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||||
|
// logs the given message as a raw message
|
||||||
|
export function LogPrint(message: string): void;
|
||||||
|
|
||||||
|
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||||
|
// logs the given message at the `trace` log level.
|
||||||
|
export function LogTrace(message: string): void;
|
||||||
|
|
||||||
|
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||||
|
// logs the given message at the `debug` log level.
|
||||||
|
export function LogDebug(message: string): void;
|
||||||
|
|
||||||
|
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||||
|
// logs the given message at the `error` log level.
|
||||||
|
export function LogError(message: string): void;
|
||||||
|
|
||||||
|
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||||
|
// logs the given message at the `fatal` log level.
|
||||||
|
// The application will quit after calling this method.
|
||||||
|
export function LogFatal(message: string): void;
|
||||||
|
|
||||||
|
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||||
|
// logs the given message at the `info` log level.
|
||||||
|
export function LogInfo(message: string): void;
|
||||||
|
|
||||||
|
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||||
|
// logs the given message at the `warning` log level.
|
||||||
|
export function LogWarning(message: string): void;
|
||||||
|
|
||||||
|
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||||
|
// Forces a reload by the main application as well as connected browsers.
|
||||||
|
export function WindowReload(): void;
|
||||||
|
|
||||||
|
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||||
|
// Reloads the application frontend.
|
||||||
|
export function WindowReloadApp(): void;
|
||||||
|
|
||||||
|
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||||
|
// Sets the window AlwaysOnTop or not on top.
|
||||||
|
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||||
|
|
||||||
|
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window theme to system default (dark/light).
|
||||||
|
export function WindowSetSystemDefaultTheme(): void;
|
||||||
|
|
||||||
|
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window to light theme.
|
||||||
|
export function WindowSetLightTheme(): void;
|
||||||
|
|
||||||
|
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window to dark theme.
|
||||||
|
export function WindowSetDarkTheme(): void;
|
||||||
|
|
||||||
|
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||||
|
// Centers the window on the monitor the window is currently on.
|
||||||
|
export function WindowCenter(): void;
|
||||||
|
|
||||||
|
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||||
|
// Sets the text in the window title bar.
|
||||||
|
export function WindowSetTitle(title: string): void;
|
||||||
|
|
||||||
|
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||||
|
// Makes the window full screen.
|
||||||
|
export function WindowFullscreen(): void;
|
||||||
|
|
||||||
|
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||||
|
// Restores the previous window dimensions and position prior to full screen.
|
||||||
|
export function WindowUnfullscreen(): void;
|
||||||
|
|
||||||
|
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
|
||||||
|
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
|
||||||
|
export function WindowIsFullscreen(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||||
|
// Sets the width and height of the window.
|
||||||
|
export function WindowSetSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||||
|
// Gets the width and height of the window.
|
||||||
|
export function WindowGetSize(): Promise<Size>;
|
||||||
|
|
||||||
|
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||||
|
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||||
|
// Setting a size of 0,0 will disable this constraint.
|
||||||
|
export function WindowSetMaxSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||||
|
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||||
|
// Setting a size of 0,0 will disable this constraint.
|
||||||
|
export function WindowSetMinSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||||
|
// Sets the window position relative to the monitor the window is currently on.
|
||||||
|
export function WindowSetPosition(x: number, y: number): void;
|
||||||
|
|
||||||
|
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||||
|
// Gets the window position relative to the monitor the window is currently on.
|
||||||
|
export function WindowGetPosition(): Promise<Position>;
|
||||||
|
|
||||||
|
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||||
|
// Hides the window.
|
||||||
|
export function WindowHide(): void;
|
||||||
|
|
||||||
|
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||||
|
// Shows the window, if it is currently hidden.
|
||||||
|
export function WindowShow(): void;
|
||||||
|
|
||||||
|
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||||
|
// Maximises the window to fill the screen.
|
||||||
|
export function WindowMaximise(): void;
|
||||||
|
|
||||||
|
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||||
|
// Toggles between Maximised and UnMaximised.
|
||||||
|
export function WindowToggleMaximise(): void;
|
||||||
|
|
||||||
|
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||||
|
// Restores the window to the dimensions and position prior to maximising.
|
||||||
|
export function WindowUnmaximise(): void;
|
||||||
|
|
||||||
|
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
|
||||||
|
// Returns the state of the window, i.e. whether the window is maximised or not.
|
||||||
|
export function WindowIsMaximised(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||||
|
// Minimises the window.
|
||||||
|
export function WindowMinimise(): void;
|
||||||
|
|
||||||
|
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||||
|
// Restores the window to the dimensions and position prior to minimising.
|
||||||
|
export function WindowUnminimise(): void;
|
||||||
|
|
||||||
|
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
|
||||||
|
// Returns the state of the window, i.e. whether the window is minimised or not.
|
||||||
|
export function WindowIsMinimised(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
|
||||||
|
// Returns the state of the window, i.e. whether the window is normal or not.
|
||||||
|
export function WindowIsNormal(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||||
|
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||||
|
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||||
|
|
||||||
|
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||||
|
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||||
|
export function ScreenGetAll(): Promise<Screen[]>;
|
||||||
|
|
||||||
|
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||||
|
// Opens the given URL in the system browser.
|
||||||
|
export function BrowserOpenURL(url: string): void;
|
||||||
|
|
||||||
|
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||||
|
// Returns information about the environment
|
||||||
|
export function Environment(): Promise<EnvironmentInfo>;
|
||||||
|
|
||||||
|
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||||
|
// Quits the application.
|
||||||
|
export function Quit(): void;
|
||||||
|
|
||||||
|
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||||
|
// Hides the application.
|
||||||
|
export function Hide(): void;
|
||||||
|
|
||||||
|
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||||
|
// Shows the application.
|
||||||
|
export function Show(): void;
|
||||||
|
|
||||||
|
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
|
||||||
|
// Returns the current text stored on clipboard
|
||||||
|
export function ClipboardGetText(): Promise<string>;
|
||||||
|
|
||||||
|
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||||
|
// Sets a text on the clipboard
|
||||||
|
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
|
||||||
|
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||||
|
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
|
||||||
|
|
||||||
|
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
|
||||||
|
// OnFileDropOff removes the drag and drop listeners and handlers.
|
||||||
|
export function OnFileDropOff() :void
|
||||||
|
|
||||||
|
// Check if the file path resolver is available
|
||||||
|
export function CanResolveFilePaths(): boolean;
|
||||||
|
|
||||||
|
// Resolves file paths for an array of files
|
||||||
|
export function ResolveFilePaths(files: File[]): void
|
||||||
242
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.js
Normal file
242
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.js
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
/*
|
||||||
|
_ __ _ __
|
||||||
|
| | / /___ _(_) /____
|
||||||
|
| | /| / / __ `/ / / ___/
|
||||||
|
| |/ |/ / /_/ / / (__ )
|
||||||
|
|__/|__/\__,_/_/_/____/
|
||||||
|
The electron alternative for Go
|
||||||
|
(c) Lea Anthony 2019-present
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function LogPrint(message) {
|
||||||
|
window.runtime.LogPrint(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogTrace(message) {
|
||||||
|
window.runtime.LogTrace(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogDebug(message) {
|
||||||
|
window.runtime.LogDebug(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogInfo(message) {
|
||||||
|
window.runtime.LogInfo(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogWarning(message) {
|
||||||
|
window.runtime.LogWarning(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogError(message) {
|
||||||
|
window.runtime.LogError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogFatal(message) {
|
||||||
|
window.runtime.LogFatal(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||||
|
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOn(eventName, callback) {
|
||||||
|
return EventsOnMultiple(eventName, callback, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOff(eventName, ...additionalEventNames) {
|
||||||
|
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOffAll() {
|
||||||
|
return window.runtime.EventsOffAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOnce(eventName, callback) {
|
||||||
|
return EventsOnMultiple(eventName, callback, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsEmit(eventName) {
|
||||||
|
let args = [eventName].slice.call(arguments);
|
||||||
|
return window.runtime.EventsEmit.apply(null, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowReload() {
|
||||||
|
window.runtime.WindowReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowReloadApp() {
|
||||||
|
window.runtime.WindowReloadApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetAlwaysOnTop(b) {
|
||||||
|
window.runtime.WindowSetAlwaysOnTop(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetSystemDefaultTheme() {
|
||||||
|
window.runtime.WindowSetSystemDefaultTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetLightTheme() {
|
||||||
|
window.runtime.WindowSetLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetDarkTheme() {
|
||||||
|
window.runtime.WindowSetDarkTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowCenter() {
|
||||||
|
window.runtime.WindowCenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetTitle(title) {
|
||||||
|
window.runtime.WindowSetTitle(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowFullscreen() {
|
||||||
|
window.runtime.WindowFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnfullscreen() {
|
||||||
|
window.runtime.WindowUnfullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsFullscreen() {
|
||||||
|
return window.runtime.WindowIsFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowGetSize() {
|
||||||
|
return window.runtime.WindowGetSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetSize(width, height) {
|
||||||
|
window.runtime.WindowSetSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetMaxSize(width, height) {
|
||||||
|
window.runtime.WindowSetMaxSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetMinSize(width, height) {
|
||||||
|
window.runtime.WindowSetMinSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetPosition(x, y) {
|
||||||
|
window.runtime.WindowSetPosition(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowGetPosition() {
|
||||||
|
return window.runtime.WindowGetPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowHide() {
|
||||||
|
window.runtime.WindowHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowShow() {
|
||||||
|
window.runtime.WindowShow();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowMaximise() {
|
||||||
|
window.runtime.WindowMaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowToggleMaximise() {
|
||||||
|
window.runtime.WindowToggleMaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnmaximise() {
|
||||||
|
window.runtime.WindowUnmaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsMaximised() {
|
||||||
|
return window.runtime.WindowIsMaximised();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowMinimise() {
|
||||||
|
window.runtime.WindowMinimise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnminimise() {
|
||||||
|
window.runtime.WindowUnminimise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||||
|
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenGetAll() {
|
||||||
|
return window.runtime.ScreenGetAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsMinimised() {
|
||||||
|
return window.runtime.WindowIsMinimised();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsNormal() {
|
||||||
|
return window.runtime.WindowIsNormal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrowserOpenURL(url) {
|
||||||
|
window.runtime.BrowserOpenURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Environment() {
|
||||||
|
return window.runtime.Environment();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Quit() {
|
||||||
|
window.runtime.Quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hide() {
|
||||||
|
window.runtime.Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Show() {
|
||||||
|
window.runtime.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClipboardGetText() {
|
||||||
|
return window.runtime.ClipboardGetText();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClipboardSetText(text) {
|
||||||
|
return window.runtime.ClipboardSetText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @callback OnFileDropCallback
|
||||||
|
* @param {number} x - x coordinate of the drop
|
||||||
|
* @param {number} y - y coordinate of the drop
|
||||||
|
* @param {string[]} paths - A list of file paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||||
|
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
|
||||||
|
*/
|
||||||
|
export function OnFileDrop(callback, useDropTarget) {
|
||||||
|
return window.runtime.OnFileDrop(callback, useDropTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnFileDropOff removes the drag and drop listeners and handlers.
|
||||||
|
*/
|
||||||
|
export function OnFileDropOff() {
|
||||||
|
return window.runtime.OnFileDropOff();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanResolveFilePaths() {
|
||||||
|
return window.runtime.CanResolveFilePaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResolveFilePaths(files) {
|
||||||
|
return window.runtime.ResolveFilePaths(files);
|
||||||
|
}
|
||||||
322
cmd/dapp-fm-app/main.go
Normal file
322
cmd/dapp-fm-app/main.go
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
// dapp-fm-app is a native desktop media player for dapp.fm
|
||||||
|
// Decryption in Go, media served via Wails asset handler (same origin, no CORS)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/player"
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/smsg"
|
||||||
|
"github.com/wailsapp/wails/v2"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed frontend
|
||||||
|
var frontendAssets embed.FS
|
||||||
|
|
||||||
|
// MediaStore holds decrypted media in memory
|
||||||
|
type MediaStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
media map[string]*MediaItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaItem struct {
|
||||||
|
Data []byte
|
||||||
|
MimeType string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalStore = &MediaStore{media: make(map[string]*MediaItem)}
|
||||||
|
|
||||||
|
func (s *MediaStore) Set(id string, item *MediaItem) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.media[id] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaStore) Get(id string) *MediaItem {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.media[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaStore) Clear() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.media = make(map[string]*MediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetHandler serves both static assets and decrypted media
|
||||||
|
type AssetHandler struct {
|
||||||
|
assets fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if path == "/" {
|
||||||
|
path = "/index.html"
|
||||||
|
}
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
|
||||||
|
// Check if this is a media request
|
||||||
|
if strings.HasPrefix(path, "media/") {
|
||||||
|
id := strings.TrimPrefix(path, "media/")
|
||||||
|
item := globalStore.Get(id)
|
||||||
|
if item == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve with range support for seeking
|
||||||
|
w.Header().Set("Content-Type", item.MimeType)
|
||||||
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(item.Data)))
|
||||||
|
|
||||||
|
rangeHeader := r.Header.Get("Range")
|
||||||
|
if rangeHeader != "" && strings.HasPrefix(rangeHeader, "bytes=") {
|
||||||
|
rangeHeader = strings.TrimPrefix(rangeHeader, "bytes=")
|
||||||
|
parts := strings.Split(rangeHeader, "-")
|
||||||
|
start, _ := strconv.Atoi(parts[0])
|
||||||
|
end := len(item.Data) - 1
|
||||||
|
if len(parts) > 1 && parts[1] != "" {
|
||||||
|
end, _ = strconv.Atoi(parts[1])
|
||||||
|
}
|
||||||
|
if end >= len(item.Data) {
|
||||||
|
end = len(item.Data) - 1
|
||||||
|
}
|
||||||
|
if start > end || start >= len(item.Data) {
|
||||||
|
http.Error(w, "Invalid range", http.StatusRequestedRangeNotSatisfiable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(item.Data)))
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(end-start+1))
|
||||||
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
w.Write(item.Data[start : end+1])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeContent(w, r, item.Name, time.Time{}, bytes.NewReader(item.Data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve static assets
|
||||||
|
data, err := fs.ReadFile(h.assets, "frontend/"+path)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set content type
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(path, ".html"):
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
case strings.HasSuffix(path, ".js"):
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
case strings.HasSuffix(path, ".css"):
|
||||||
|
w.Header().Set("Content-Type", "text/css")
|
||||||
|
case strings.HasSuffix(path, ".wasm"):
|
||||||
|
w.Header().Set("Content-Type", "application/wasm")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// App wraps player functionality
|
||||||
|
type App struct {
|
||||||
|
ctx context.Context
|
||||||
|
player *player.Player
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp() *App {
|
||||||
|
return &App{
|
||||||
|
player: player.NewPlayer(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Startup(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
a.player.Startup(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaResult holds URLs for playback
|
||||||
|
type MediaResult struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaAttachment struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
URL string `json:"url"` // /media/0, /media/1, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDemo decrypts demo and stores in memory for streaming
|
||||||
|
func (a *App) LoadDemo() (*MediaResult, error) {
|
||||||
|
globalStore.Clear()
|
||||||
|
|
||||||
|
// Read demo from embedded filesystem
|
||||||
|
demoBytes, err := fs.ReadFile(frontendAssets, "frontend/demo-track.smsg")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("demo not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
msg, err := smsg.Decrypt(demoBytes, "dapp-fm-2024")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &MediaResult{
|
||||||
|
Body: msg.Body,
|
||||||
|
Subject: msg.Subject,
|
||||||
|
From: msg.From,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, att := range msg.Attachments {
|
||||||
|
// Decode base64 to raw bytes
|
||||||
|
data, err := base64.StdEncoding.DecodeString(att.Content)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in memory
|
||||||
|
id := strconv.Itoa(i)
|
||||||
|
globalStore.Set(id, &MediaItem{
|
||||||
|
Data: data,
|
||||||
|
MimeType: att.MimeType,
|
||||||
|
Name: att.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
result.Attachments = append(result.Attachments, MediaAttachment{
|
||||||
|
Name: att.Name,
|
||||||
|
MimeType: att.MimeType,
|
||||||
|
Size: len(data),
|
||||||
|
URL: "/media/" + id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDemoManifest returns manifest without decrypting
|
||||||
|
func (a *App) GetDemoManifest() (*player.ManifestInfo, error) {
|
||||||
|
demoBytes, err := fs.ReadFile(frontendAssets, "frontend/demo-track.smsg")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("demo not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := smsg.GetInfo(demoBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &player.ManifestInfo{}
|
||||||
|
if info.Manifest != nil {
|
||||||
|
m := info.Manifest
|
||||||
|
result.Title = m.Title
|
||||||
|
result.Artist = m.Artist
|
||||||
|
result.Album = m.Album
|
||||||
|
result.ReleaseType = m.ReleaseType
|
||||||
|
result.Format = m.Format
|
||||||
|
result.LicenseType = m.LicenseType
|
||||||
|
|
||||||
|
for _, t := range m.Tracks {
|
||||||
|
result.Tracks = append(result.Tracks, player.TrackInfo{
|
||||||
|
Title: t.Title,
|
||||||
|
Start: t.Start,
|
||||||
|
End: t.End,
|
||||||
|
TrackNum: t.TrackNum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptAndServe decrypts user-provided content and serves via asset handler
|
||||||
|
func (a *App) DecryptAndServe(encrypted string, password string) (*MediaResult, error) {
|
||||||
|
globalStore.Clear()
|
||||||
|
|
||||||
|
// Decrypt using player (handles base64 input)
|
||||||
|
msg, err := smsg.DecryptBase64(encrypted, password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &MediaResult{
|
||||||
|
Body: msg.Body,
|
||||||
|
Subject: msg.Subject,
|
||||||
|
From: msg.From,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, att := range msg.Attachments {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(att.Content)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strconv.Itoa(i)
|
||||||
|
globalStore.Set(id, &MediaItem{
|
||||||
|
Data: data,
|
||||||
|
MimeType: att.MimeType,
|
||||||
|
Name: att.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
result.Attachments = append(result.Attachments, MediaAttachment{
|
||||||
|
Name: att.Name,
|
||||||
|
MimeType: att.MimeType,
|
||||||
|
Size: len(data),
|
||||||
|
URL: "/media/" + id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy methods
|
||||||
|
func (a *App) GetManifest(encrypted string) (*player.ManifestInfo, error) {
|
||||||
|
return a.player.GetManifest(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IsLicenseValid(encrypted string) (bool, error) {
|
||||||
|
return a.player.IsLicenseValid(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := NewApp()
|
||||||
|
|
||||||
|
err := wails.Run(&options.App{
|
||||||
|
Title: "dapp.fm Player",
|
||||||
|
Width: 1200,
|
||||||
|
Height: 800,
|
||||||
|
MinWidth: 800,
|
||||||
|
MinHeight: 600,
|
||||||
|
AssetServer: &assetserver.Options{
|
||||||
|
Handler: &AssetHandler{assets: frontendAssets},
|
||||||
|
},
|
||||||
|
BackgroundColour: &options.RGBA{R: 18, G: 18, B: 18, A: 1},
|
||||||
|
OnStartup: app.Startup,
|
||||||
|
Bind: []interface{}{
|
||||||
|
app,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
println("Error:", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
20
cmd/dapp-fm-app/wails.json
Normal file
20
cmd/dapp-fm-app/wails.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||||
|
"name": "dapp-fm",
|
||||||
|
"outputfilename": "dapp-fm",
|
||||||
|
"frontend:install": "",
|
||||||
|
"frontend:build": "",
|
||||||
|
"frontend:dev:watcher": "",
|
||||||
|
"frontend:dev:serverUrl": "",
|
||||||
|
"author": {
|
||||||
|
"name": "dapp.fm",
|
||||||
|
"email": "hello@dapp.fm"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"companyName": "dapp.fm",
|
||||||
|
"productName": "dapp.fm Player",
|
||||||
|
"productVersion": "1.0.0",
|
||||||
|
"copyright": "Copyright (c) 2024 dapp.fm - EUPL-1.2",
|
||||||
|
"comments": "Decentralized Music Distribution - Zero-Trust DRM"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
cmd/dapp-fm/main.go
Normal file
64
cmd/dapp-fm/main.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// dapp-fm CLI provides headless media player functionality
|
||||||
|
// For native desktop app with WebView, use dapp-fm-app instead
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/player"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "dapp-fm",
|
||||||
|
Short: "dapp.fm - Decentralized Music Player CLI",
|
||||||
|
Long: `dapp-fm is the CLI version of the dapp.fm player.
|
||||||
|
|
||||||
|
For the native desktop app with WebView, use dapp-fm-app instead.
|
||||||
|
This CLI provides HTTP server mode for automation and fallback scenarios.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
serveCmd := &cobra.Command{
|
||||||
|
Use: "serve",
|
||||||
|
Short: "Start HTTP server for the media player",
|
||||||
|
Long: `Starts an HTTP server serving the media player interface.
|
||||||
|
This is the slower TCP path - for memory-speed decryption, use dapp-fm-app.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
port, _ := cmd.Flags().GetString("port")
|
||||||
|
openBrowser, _ := cmd.Flags().GetBool("open")
|
||||||
|
|
||||||
|
p := player.NewPlayer()
|
||||||
|
|
||||||
|
addr := ":" + port
|
||||||
|
if openBrowser {
|
||||||
|
fmt.Printf("Opening browser at http://localhost%s\n", addr)
|
||||||
|
// Would need browser opener here
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.Serve(addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
serveCmd.Flags().StringP("port", "p", "8080", "Port to serve on")
|
||||||
|
serveCmd.Flags().Bool("open", false, "Open browser automatically")
|
||||||
|
|
||||||
|
versionCmd := &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print version information",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Println("dapp-fm v1.0.0")
|
||||||
|
fmt.Println("Decentralized Music Distribution")
|
||||||
|
fmt.Println("https://dapp.fm")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.AddCommand(serveCmd)
|
||||||
|
rootCmd.AddCommand(versionCmd)
|
||||||
|
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,11 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/trix"
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
trixsdk "github.com/Snider/Enchantrix/pkg/trix"
|
"forge.lthn.ai/Snider/Borg/pkg/trix"
|
||||||
|
trixsdk "forge.lthn.ai/Snider/Enchantrix/pkg/trix"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,7 +16,7 @@ var decodeCmd = NewDecodeCmd()
|
||||||
func NewDecodeCmd() *cobra.Command {
|
func NewDecodeCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "decode [file]",
|
Use: "decode [file]",
|
||||||
Short: "Decode a .trix or .tim file",
|
Short: "Decode a .trix, .tim, or .stim file",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
inputFile := args[0]
|
inputFile := args[0]
|
||||||
|
|
@ -27,6 +29,27 @@ func NewDecodeCmd() *cobra.Command {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if it's a .stim file (encrypted TIM)
|
||||||
|
if strings.HasSuffix(inputFile, ".stim") || (len(data) >= 4 && string(data[:4]) == "STIM") {
|
||||||
|
if password == "" {
|
||||||
|
return fmt.Errorf("password required for .stim files")
|
||||||
|
}
|
||||||
|
if !inIsolation {
|
||||||
|
return fmt.Errorf("this is an encrypted Terminal Isolation Matrix, use the --i-am-in-isolation flag to decode it")
|
||||||
|
}
|
||||||
|
m, err := tim.FromSigil(data, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tarball, err := m.ToTar()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Decoded encrypted TIM to %s\n", outputFile)
|
||||||
|
return os.WriteFile(outputFile, tarball, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try TRIX format
|
||||||
t, err := trixsdk.Decode(data, "TRIX", nil)
|
t, err := trixsdk.Decode(data, "TRIX", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -46,6 +69,7 @@ func NewDecodeCmd() *cobra.Command {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Decoded to %s\n", outputFile)
|
||||||
return os.WriteFile(outputFile, tarball, 0644)
|
return os.WriteFile(outputFile, tarball, 0644)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||||
"github.com/Snider/Borg/pkg/trix"
|
"forge.lthn.ai/Snider/Borg/pkg/trix"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDecodeCmd(t *testing.T) {
|
func TestDecodeCmd(t *testing.T) {
|
||||||
|
|
|
||||||
70
cmd/extract-demo/main.go
Normal file
70
cmd/extract-demo/main.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// extract-demo extracts the video from a v2 SMSG file
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/smsg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Println("Usage: extract-demo <input.smsg> <password> <output.mp4>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputFile := os.Args[1]
|
||||||
|
password := os.Args[2]
|
||||||
|
outputFile := os.Args[3]
|
||||||
|
|
||||||
|
data, err := os.ReadFile(inputFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to read: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get info first
|
||||||
|
info, err := smsg.GetInfo(data)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to get info: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Format: %s, Compression: %s\n", info.Format, info.Compression)
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
msg, err := smsg.Decrypt(data, password)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to decrypt: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Body: %s...\n", msg.Body[:min(50, len(msg.Body))])
|
||||||
|
fmt.Printf("Attachments: %d\n", len(msg.Attachments))
|
||||||
|
|
||||||
|
if len(msg.Attachments) > 0 {
|
||||||
|
att := msg.Attachments[0]
|
||||||
|
fmt.Printf(" Name: %s, MIME: %s, Size: %d\n", att.Name, att.MimeType, att.Size)
|
||||||
|
|
||||||
|
// Decode and save
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(att.Content)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to decode: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(outputFile, decoded, 0644); err != nil {
|
||||||
|
fmt.Printf("Failed to save: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Saved to %s (%d bytes)\n", outputFile, len(decoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
114
cmd/inspect.go
Normal file
114
cmd/inspect.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
trixsdk "forge.lthn.ai/Snider/Enchantrix/pkg/trix"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var inspectCmd = NewInspectCmd()
|
||||||
|
|
||||||
|
func NewInspectCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "inspect [file]",
|
||||||
|
Short: "Inspect metadata of a .trix or .stim file without decrypting",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
inputFile := args[0]
|
||||||
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(inputFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) < 4 {
|
||||||
|
return fmt.Errorf("file too small to be a valid container")
|
||||||
|
}
|
||||||
|
|
||||||
|
magic := string(data[:4])
|
||||||
|
var t *trixsdk.Trix
|
||||||
|
|
||||||
|
switch magic {
|
||||||
|
case "STIM":
|
||||||
|
t, err = trixsdk.Decode(data, "STIM", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode STIM: %w", err)
|
||||||
|
}
|
||||||
|
case "TRIX":
|
||||||
|
t, err = trixsdk.Decode(data, "TRIX", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode TRIX: %w", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown file format (magic: %q)", magic)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
info := map[string]interface{}{
|
||||||
|
"file": inputFile,
|
||||||
|
"magic": magic,
|
||||||
|
"header": t.Header,
|
||||||
|
"payload_size": len(t.Payload),
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(cmd.OutOrStdout())
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "File: %s\n", inputFile)
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Format: %s\n", magic)
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Payload Size: %d bytes\n", len(t.Payload))
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Header:\n")
|
||||||
|
|
||||||
|
for k, v := range t.Header {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " %s: %v\n", k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show encryption info
|
||||||
|
if algo, ok := t.Header["encryption_algorithm"]; ok {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "\nEncryption: %v\n", algo)
|
||||||
|
}
|
||||||
|
if _, ok := t.Header["tim"]; ok {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Type: Terminal Isolation Matrix\n")
|
||||||
|
}
|
||||||
|
if v, ok := t.Header["version"]; ok {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Version: %v\n", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().Bool("json", false, "Output in JSON format")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetInspectCmd() *cobra.Command {
|
||||||
|
return inspectCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RootCmd.AddCommand(GetInspectCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
// isStimFile checks if a file is a .stim file by extension or magic number.
|
||||||
|
func isStimFile(path string) bool {
|
||||||
|
if strings.HasSuffix(path, ".stim") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
magic := make([]byte, 4)
|
||||||
|
if _, err := f.Read(magic); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(magic) == "STIM"
|
||||||
|
}
|
||||||
194
cmd/integration_test.go
Normal file
194
cmd/integration_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFullPipeline_Good exercises the complete streaming pipeline end-to-end
|
||||||
|
// with realistic directory contents including nested dirs, a large file that
|
||||||
|
// crosses the AEAD block boundary, valid and broken symlinks, and a hidden file.
|
||||||
|
// Each compression mode (none, gz, xz) is tested as a subtest.
|
||||||
|
func TestFullPipeline_Good(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a realistic source directory.
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
|
||||||
|
// Regular files at root level.
|
||||||
|
writeFile(t, srcDir, "readme.md", "# My Project\n\nA description.\n")
|
||||||
|
writeFile(t, srcDir, "config.json", `{"version":"1.0","debug":false}`)
|
||||||
|
|
||||||
|
// Nested directories with source code.
|
||||||
|
mkdirAll(t, srcDir, "src")
|
||||||
|
mkdirAll(t, srcDir, "src/pkg")
|
||||||
|
writeFile(t, srcDir, "src/main.go", "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n")
|
||||||
|
writeFile(t, srcDir, "src/pkg/lib.go", "package pkg\n\n// Lib is a library function.\nfunc Lib() string { return \"lib\" }\n")
|
||||||
|
|
||||||
|
// Large file: 1 MiB + 1 byte — crosses the 64 KiB block boundary used by
|
||||||
|
// the chunked AEAD streaming encryption. Fill with a deterministic pattern
|
||||||
|
// so we can verify content after round-trip.
|
||||||
|
const largeSize = 1024*1024 + 1
|
||||||
|
largeContent := make([]byte, largeSize)
|
||||||
|
for i := range largeContent {
|
||||||
|
largeContent[i] = byte(i % 251) // prime mod for non-trivial pattern
|
||||||
|
}
|
||||||
|
writeFileBytes(t, srcDir, "large.bin", largeContent)
|
||||||
|
|
||||||
|
// Valid symlink pointing at a relative target.
|
||||||
|
if err := os.Symlink("readme.md", filepath.Join(srcDir, "link-to-readme")); err != nil {
|
||||||
|
t.Fatalf("failed to create valid symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broken symlink pointing at a nonexistent absolute path.
|
||||||
|
if err := os.Symlink("/nonexistent/target", filepath.Join(srcDir, "broken-link")); err != nil {
|
||||||
|
t.Fatalf("failed to create broken symlink: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden file (dot-prefixed).
|
||||||
|
writeFile(t, srcDir, ".hidden", "secret stuff\n")
|
||||||
|
|
||||||
|
// Run each compression mode as a subtest.
|
||||||
|
modes := []string{"none", "gz", "xz"}
|
||||||
|
for _, comp := range modes {
|
||||||
|
comp := comp // capture
|
||||||
|
t.Run("compression="+comp, func(t *testing.T) {
|
||||||
|
outDir := t.TempDir()
|
||||||
|
outFile := filepath.Join(outDir, "pipeline-"+comp+".stim")
|
||||||
|
password := "integration-test-pw-" + comp
|
||||||
|
|
||||||
|
// Step 1: Collect (walk -> tar -> compress -> encrypt -> file).
|
||||||
|
if err := CollectLocalStreaming(srcDir, outFile, comp, password); err != nil {
|
||||||
|
t.Fatalf("CollectLocalStreaming(%q) error = %v", comp, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Verify output exists and is non-empty.
|
||||||
|
info, err := os.Stat(outFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("output file does not exist: %v", err)
|
||||||
|
}
|
||||||
|
if info.Size() == 0 {
|
||||||
|
t.Fatal("output file is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Decrypt back into a DataNode.
|
||||||
|
dn, err := DecryptStimV2(outFile, password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptStimV2() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Verify all regular files exist in the DataNode.
|
||||||
|
expectedFiles := []string{
|
||||||
|
"readme.md",
|
||||||
|
"config.json",
|
||||||
|
"src/main.go",
|
||||||
|
"src/pkg/lib.go",
|
||||||
|
"large.bin",
|
||||||
|
".hidden",
|
||||||
|
}
|
||||||
|
for _, name := range expectedFiles {
|
||||||
|
exists, eerr := dn.Exists(name)
|
||||||
|
if eerr != nil {
|
||||||
|
t.Errorf("Exists(%q) error = %v", name, eerr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
t.Errorf("expected file %q in DataNode but it is missing", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the valid symlink was included.
|
||||||
|
linkExists, _ := dn.Exists("link-to-readme")
|
||||||
|
if !linkExists {
|
||||||
|
t.Error("expected symlink link-to-readme in DataNode but it is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Verify large file has correct content (first byte check).
|
||||||
|
f, err := dn.Open("large.bin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open(large.bin) error = %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Read the entire large file and verify size and first byte.
|
||||||
|
allData, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading large.bin: %v", err)
|
||||||
|
}
|
||||||
|
if len(allData) != largeSize {
|
||||||
|
t.Errorf("large.bin size = %d, want %d", len(allData), largeSize)
|
||||||
|
}
|
||||||
|
if len(allData) > 0 && allData[0] != byte(0%251) {
|
||||||
|
t.Errorf("large.bin first byte = %d, want %d", allData[0], byte(0%251))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify content integrity of the whole large file.
|
||||||
|
if !bytes.Equal(allData, largeContent) {
|
||||||
|
t.Error("large.bin content does not match original after round-trip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Verify broken symlink was skipped.
|
||||||
|
brokenExists, _ := dn.Exists("broken-link")
|
||||||
|
if brokenExists {
|
||||||
|
t.Error("broken symlink should have been skipped but was found in DataNode")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFullPipeline_WrongPassword_Bad encrypts with one password and attempts
|
||||||
|
// to decrypt with a different password, verifying that an error is returned.
|
||||||
|
func TestFullPipeline_WrongPassword_Bad(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
outDir := t.TempDir()
|
||||||
|
|
||||||
|
writeFile(t, srcDir, "secret.txt", "this is confidential\n")
|
||||||
|
|
||||||
|
outFile := filepath.Join(outDir, "wrong-pw.stim")
|
||||||
|
|
||||||
|
// Encrypt with the correct password.
|
||||||
|
if err := CollectLocalStreaming(srcDir, outFile, "none", "correct-password"); err != nil {
|
||||||
|
t.Fatalf("CollectLocalStreaming() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to decrypt with the wrong password.
|
||||||
|
_, err := DecryptStimV2(outFile, "wrong-password")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when decrypting with wrong password, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, base, rel, content string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(base, rel)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write %s: %v", rel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFileBytes(t *testing.T, base, rel string, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(base, rel)
|
||||||
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write %s: %v", rel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkdirAll(t *testing.T, base, rel string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(base, rel)
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to mkdir %s: %v", rel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
226
cmd/mkdemo-abr/main.go
Normal file
226
cmd/mkdemo-abr/main.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
// mkdemo-abr creates an ABR (Adaptive Bitrate) demo set from a source video.
|
||||||
|
// It uses ffmpeg to transcode to multiple bitrates, then encrypts each as v3 chunked SMSG.
|
||||||
|
//
|
||||||
|
// Usage: mkdemo-abr <input-video> <output-dir> [password]
|
||||||
|
//
|
||||||
|
// Output:
|
||||||
|
//
|
||||||
|
// output-dir/manifest.json - ABR manifest listing all variants
|
||||||
|
// output-dir/track-1080p.smsg - 1080p variant (5 Mbps)
|
||||||
|
// output-dir/track-720p.smsg - 720p variant (2.5 Mbps)
|
||||||
|
// output-dir/track-480p.smsg - 480p variant (1 Mbps)
|
||||||
|
// output-dir/track-360p.smsg - 360p variant (500 Kbps)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/smsg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preset defines a quality level for transcoding
|
||||||
|
type Preset struct {
|
||||||
|
Name string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Bitrate string // For ffmpeg (e.g., "5M")
|
||||||
|
BPS int // Bits per second for manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default presets matching ABRPresets in types.go
|
||||||
|
var presets = []Preset{
|
||||||
|
{"1080p", 1920, 1080, "5M", 5000000},
|
||||||
|
{"720p", 1280, 720, "2.5M", 2500000},
|
||||||
|
{"480p", 854, 480, "1M", 1000000},
|
||||||
|
{"360p", 640, 360, "500K", 500000},
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Println("Usage: mkdemo-abr <input-video> <output-dir> [password]")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Creates ABR variant set from source video using ffmpeg.")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Output:")
|
||||||
|
fmt.Println(" output-dir/manifest.json - ABR manifest")
|
||||||
|
fmt.Println(" output-dir/track-1080p.smsg - 1080p (5 Mbps)")
|
||||||
|
fmt.Println(" output-dir/track-720p.smsg - 720p (2.5 Mbps)")
|
||||||
|
fmt.Println(" output-dir/track-480p.smsg - 480p (1 Mbps)")
|
||||||
|
fmt.Println(" output-dir/track-360p.smsg - 360p (500 Kbps)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputFile := os.Args[1]
|
||||||
|
outputDir := os.Args[2]
|
||||||
|
|
||||||
|
// Check ffmpeg is available
|
||||||
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
|
fmt.Println("Error: ffmpeg not found in PATH")
|
||||||
|
fmt.Println("Install ffmpeg: https://ffmpeg.org/download.html")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate or use provided password
|
||||||
|
var password string
|
||||||
|
if len(os.Args) > 3 {
|
||||||
|
password = os.Args[3]
|
||||||
|
} else {
|
||||||
|
passwordBytes := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(passwordBytes); err != nil {
|
||||||
|
fmt.Printf("Failed to generate password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
password = base64.RawURLEncoding.EncodeToString(passwordBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output directory
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
fmt.Printf("Failed to create output directory: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get title from input filename
|
||||||
|
title := filepath.Base(inputFile)
|
||||||
|
ext := filepath.Ext(title)
|
||||||
|
if ext != "" {
|
||||||
|
title = title[:len(title)-len(ext)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ABR manifest
|
||||||
|
manifest := smsg.NewABRManifest(title)
|
||||||
|
|
||||||
|
fmt.Printf("Creating ABR variants for: %s\n", inputFile)
|
||||||
|
fmt.Printf("Output directory: %s\n", outputDir)
|
||||||
|
fmt.Printf("Password: %s\n\n", password)
|
||||||
|
|
||||||
|
// Process each preset
|
||||||
|
for _, preset := range presets {
|
||||||
|
fmt.Printf("Processing %s (%dx%d @ %s)...\n", preset.Name, preset.Width, preset.Height, preset.Bitrate)
|
||||||
|
|
||||||
|
// Step 1: Transcode with ffmpeg
|
||||||
|
tempFile := filepath.Join(outputDir, fmt.Sprintf("temp-%s.mp4", preset.Name))
|
||||||
|
if err := transcode(inputFile, tempFile, preset); err != nil {
|
||||||
|
fmt.Printf(" Warning: Transcode failed for %s: %v\n", preset.Name, err)
|
||||||
|
fmt.Printf(" Skipping this variant...\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Read transcoded file
|
||||||
|
content, err := os.ReadFile(tempFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Error reading transcoded file: %v\n", err)
|
||||||
|
os.Remove(tempFile)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Create SMSG message
|
||||||
|
msg := smsg.NewMessage("dapp.fm ABR Demo")
|
||||||
|
msg.Subject = fmt.Sprintf("%s - %s", title, preset.Name)
|
||||||
|
msg.From = "dapp.fm"
|
||||||
|
msg.AddBinaryAttachment(
|
||||||
|
fmt.Sprintf("%s-%s.mp4", strings.ReplaceAll(title, " ", "_"), preset.Name),
|
||||||
|
content,
|
||||||
|
"video/mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Step 4: Create manifest for this variant
|
||||||
|
variantManifest := smsg.NewManifest(title)
|
||||||
|
variantManifest.LicenseType = "perpetual"
|
||||||
|
variantManifest.Format = "dapp.fm/abr-v1"
|
||||||
|
|
||||||
|
// Step 5: Encrypt with v3 chunked format
|
||||||
|
params := &smsg.StreamParams{
|
||||||
|
License: password,
|
||||||
|
ChunkSize: smsg.DefaultChunkSize, // 1MB chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := smsg.EncryptV3(msg, params, variantManifest)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Error encrypting: %v\n", err)
|
||||||
|
os.Remove(tempFile)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Write SMSG file
|
||||||
|
smsgFile := filepath.Join(outputDir, fmt.Sprintf("track-%s.smsg", preset.Name))
|
||||||
|
if err := os.WriteFile(smsgFile, encrypted, 0644); err != nil {
|
||||||
|
fmt.Printf(" Error writing SMSG: %v\n", err)
|
||||||
|
os.Remove(tempFile)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Get chunk count from header
|
||||||
|
header, err := smsg.GetV3Header(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Warning: Could not read header: %v\n", err)
|
||||||
|
}
|
||||||
|
chunkCount := 0
|
||||||
|
if header != nil && header.Chunked != nil {
|
||||||
|
chunkCount = header.Chunked.TotalChunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Add variant to manifest
|
||||||
|
variant := smsg.Variant{
|
||||||
|
Name: preset.Name,
|
||||||
|
Bandwidth: preset.BPS,
|
||||||
|
Width: preset.Width,
|
||||||
|
Height: preset.Height,
|
||||||
|
Codecs: "avc1.640028,mp4a.40.2",
|
||||||
|
URL: fmt.Sprintf("track-%s.smsg", preset.Name),
|
||||||
|
ChunkCount: chunkCount,
|
||||||
|
FileSize: int64(len(encrypted)),
|
||||||
|
}
|
||||||
|
manifest.AddVariant(variant)
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
os.Remove(tempFile)
|
||||||
|
|
||||||
|
fmt.Printf(" Created: %s (%d bytes, %d chunks)\n", smsgFile, len(encrypted), chunkCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(manifest.Variants) == 0 {
|
||||||
|
fmt.Println("\nError: No variants created. Check ffmpeg output.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write ABR manifest
|
||||||
|
manifestPath := filepath.Join(outputDir, "manifest.json")
|
||||||
|
if err := smsg.WriteABRManifest(manifest, manifestPath); err != nil {
|
||||||
|
fmt.Printf("Failed to write manifest: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n✓ Created ABR manifest: %s\n", manifestPath)
|
||||||
|
fmt.Printf("✓ Variants: %d\n", len(manifest.Variants))
|
||||||
|
fmt.Printf("✓ Default: %s\n", manifest.Variants[manifest.DefaultIdx].Name)
|
||||||
|
fmt.Printf("\nMaster Password: %s\n", password)
|
||||||
|
fmt.Println("\nStore this password securely - it decrypts ALL variants!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// transcode uses ffmpeg to transcode the input to the specified preset
|
||||||
|
func transcode(input, output string, preset Preset) error {
|
||||||
|
args := []string{
|
||||||
|
"-i", input,
|
||||||
|
"-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2",
|
||||||
|
preset.Width, preset.Height, preset.Width, preset.Height),
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "medium",
|
||||||
|
"-b:v", preset.Bitrate,
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "128k",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
"-y", // Overwrite output
|
||||||
|
output,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("ffmpeg", args...)
|
||||||
|
cmd.Stderr = os.Stderr // Show ffmpeg output for debugging
|
||||||
|
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
129
cmd/mkdemo-v3/main.go
Normal file
129
cmd/mkdemo-v3/main.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
// mkdemo-v3 creates a v3 chunked SMSG file for streaming demos
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/smsg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Println("Usage: mkdemo-v3 <input-media-file> <output-smsg-file> [license] [chunk-size-kb]")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println("Creates a v3 chunked SMSG file for streaming demos.")
|
||||||
|
fmt.Println("V3 uses rolling keys derived from: LTHN(date:license:fingerprint)")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println("Options:")
|
||||||
|
fmt.Println(" license The license key (default: auto-generated)")
|
||||||
|
fmt.Println(" chunk-size-kb Chunk size in KB (default: 512)")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println("Note: V3 files work for 24-48 hours from creation (rolling keys).")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputFile := os.Args[1]
|
||||||
|
outputFile := os.Args[2]
|
||||||
|
|
||||||
|
// Read input file
|
||||||
|
content, err := os.ReadFile(inputFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to read input file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// License (acts as password in v3)
|
||||||
|
var license string
|
||||||
|
if len(os.Args) > 3 {
|
||||||
|
license = os.Args[3]
|
||||||
|
} else {
|
||||||
|
// Generate cryptographically secure license
|
||||||
|
licenseBytes := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(licenseBytes); err != nil {
|
||||||
|
fmt.Printf("Failed to generate license: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
license = base64.RawURLEncoding.EncodeToString(licenseBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk size (default 512KB for good streaming granularity)
|
||||||
|
chunkSize := 512 * 1024
|
||||||
|
if len(os.Args) > 4 {
|
||||||
|
var chunkKB int
|
||||||
|
if _, err := fmt.Sscanf(os.Args[4], "%d", &chunkKB); err == nil && chunkKB > 0 {
|
||||||
|
chunkSize = chunkKB * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create manifest
|
||||||
|
title := filepath.Base(inputFile)
|
||||||
|
ext := filepath.Ext(title)
|
||||||
|
if ext != "" {
|
||||||
|
title = title[:len(title)-len(ext)]
|
||||||
|
}
|
||||||
|
manifest := smsg.NewManifest(title)
|
||||||
|
manifest.LicenseType = "streaming"
|
||||||
|
manifest.Format = "dapp.fm/v3-chunked"
|
||||||
|
|
||||||
|
// Detect MIME type
|
||||||
|
mimeType := "video/mp4"
|
||||||
|
switch ext {
|
||||||
|
case ".mp3":
|
||||||
|
mimeType = "audio/mpeg"
|
||||||
|
case ".wav":
|
||||||
|
mimeType = "audio/wav"
|
||||||
|
case ".flac":
|
||||||
|
mimeType = "audio/flac"
|
||||||
|
case ".webm":
|
||||||
|
mimeType = "video/webm"
|
||||||
|
case ".ogg":
|
||||||
|
mimeType = "audio/ogg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create message with attachment
|
||||||
|
msg := smsg.NewMessage("dapp.fm V3 Streaming Demo - Decrypt-while-downloading enabled")
|
||||||
|
msg.Subject = "V3 Chunked Streaming"
|
||||||
|
msg.From = "dapp.fm"
|
||||||
|
msg.AddBinaryAttachment(
|
||||||
|
filepath.Base(inputFile),
|
||||||
|
content,
|
||||||
|
mimeType,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create stream params with chunking enabled
|
||||||
|
params := &smsg.StreamParams{
|
||||||
|
License: license,
|
||||||
|
Fingerprint: "", // Empty for demo (works for any device)
|
||||||
|
Cadence: smsg.CadenceDaily,
|
||||||
|
ChunkSize: chunkSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt with v3 chunked format
|
||||||
|
encrypted, err := smsg.EncryptV3(msg, params, manifest)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to encrypt: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write output
|
||||||
|
if err := os.WriteFile(outputFile, encrypted, 0644); err != nil {
|
||||||
|
fmt.Printf("Failed to write output: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate chunk count
|
||||||
|
numChunks := (len(content) + chunkSize - 1) / chunkSize
|
||||||
|
|
||||||
|
fmt.Printf("Created: %s (%d bytes)\n", outputFile, len(encrypted))
|
||||||
|
fmt.Printf("Format: v3 chunked\n")
|
||||||
|
fmt.Printf("Chunk Size: %d KB\n", chunkSize/1024)
|
||||||
|
fmt.Printf("Total Chunks: ~%d\n", numChunks)
|
||||||
|
fmt.Printf("License: %s\n", license)
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println("This license works for 24-48 hours from creation.")
|
||||||
|
fmt.Println("Use the license in the streaming demo to decrypt.")
|
||||||
|
}
|
||||||
81
cmd/mkdemo/main.go
Normal file
81
cmd/mkdemo/main.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// mkdemo creates an RFC-quality demo SMSG file with a cryptographically secure password
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/smsg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Println("Usage: mkdemo <input-media-file> <output-smsg-file>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputFile := os.Args[1]
|
||||||
|
outputFile := os.Args[2]
|
||||||
|
|
||||||
|
// Read input file
|
||||||
|
content, err := os.ReadFile(inputFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to read input file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing password or generate new one
|
||||||
|
var password string
|
||||||
|
if len(os.Args) > 3 {
|
||||||
|
password = os.Args[3]
|
||||||
|
} else {
|
||||||
|
// Generate cryptographically secure password (32 bytes = 256 bits)
|
||||||
|
passwordBytes := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(passwordBytes); err != nil {
|
||||||
|
fmt.Printf("Failed to generate password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Use base64url encoding, trimmed to 32 chars for readability
|
||||||
|
password = base64.RawURLEncoding.EncodeToString(passwordBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create manifest with filename as title
|
||||||
|
title := filepath.Base(inputFile)
|
||||||
|
ext := filepath.Ext(title)
|
||||||
|
if ext != "" {
|
||||||
|
title = title[:len(title)-len(ext)]
|
||||||
|
}
|
||||||
|
manifest := smsg.NewManifest(title)
|
||||||
|
manifest.LicenseType = "perpetual"
|
||||||
|
manifest.Format = "dapp.fm/v1"
|
||||||
|
|
||||||
|
// Create message with attachment (using binary attachment for v2 format)
|
||||||
|
msg := smsg.NewMessage("Welcome to dapp.fm - Zero-Trust DRM for the open web.")
|
||||||
|
msg.Subject = "dapp.fm Demo"
|
||||||
|
msg.From = "dapp.fm"
|
||||||
|
msg.AddBinaryAttachment(
|
||||||
|
filepath.Base(inputFile),
|
||||||
|
content,
|
||||||
|
"video/mp4",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encrypt with v2 binary format (smaller file size)
|
||||||
|
encrypted, err := smsg.EncryptV2WithManifest(msg, password, manifest)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to encrypt: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write output
|
||||||
|
if err := os.WriteFile(outputFile, encrypted, 0644); err != nil {
|
||||||
|
fmt.Printf("Failed to write output: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created: %s (%d bytes)\n", outputFile, len(encrypted))
|
||||||
|
fmt.Printf("Master Password: %s\n", password)
|
||||||
|
fmt.Println("\nStore this password securely - it cannot be recovered!")
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ packaging their contents into a single file, and managing the data within.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging")
|
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging")
|
||||||
|
rootCmd.PersistentFlags().BoolP("quiet", "q", false, "Suppress non-error output")
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
42
cmd/run.go
42
cmd/run.go
|
|
@ -1,21 +1,57 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/Snider/Borg/pkg/tim"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var runPassword string
|
||||||
|
|
||||||
var runCmd = NewRunCmd()
|
var runCmd = NewRunCmd()
|
||||||
|
|
||||||
func NewRunCmd() *cobra.Command {
|
func NewRunCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "run [tim file]",
|
Use: "run [tim file]",
|
||||||
Short: "Run a Terminal Isolation Matrix.",
|
Short: "Run a Terminal Isolation Matrix.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return tim.Run(args[0])
|
filePath := args[0]
|
||||||
|
|
||||||
|
// Check if encrypted by extension or magic number
|
||||||
|
if isEncryptedTIM(filePath) {
|
||||||
|
password, _ := cmd.Flags().GetString("password")
|
||||||
|
if password == "" {
|
||||||
|
return tim.ErrPasswordRequired
|
||||||
|
}
|
||||||
|
return tim.RunEncrypted(filePath, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tim.Run(filePath)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().StringVarP(&runPassword, "password", "p", "", "Decryption password for encrypted TIMs (.stim)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEncryptedTIM checks if a file is an encrypted TIM by extension or magic number.
|
||||||
|
func isEncryptedTIM(path string) bool {
|
||||||
|
if strings.HasSuffix(path, ".stim") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Check magic number
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
magic := make([]byte, 4)
|
||||||
|
if _, err := f.Read(magic); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(magic) == "STIM"
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRunCmd() *cobra.Command {
|
func GetRunCmd() *cobra.Command {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/tim"
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunCmd_Good(t *testing.T) {
|
func TestRunCmd_Good(t *testing.T) {
|
||||||
|
|
@ -110,3 +110,13 @@ func createDummyTim(t *testing.T) string {
|
||||||
}
|
}
|
||||||
return timPath
|
return timPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHelperProcess isn't a real test. It's used as a helper for tests that need to mock exec.Command.
|
||||||
|
func TestHelperProcess(t *testing.T) {
|
||||||
|
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The rest of the arguments are the command and its arguments.
|
||||||
|
// In our case, we don't need to do anything with them.
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/compress"
|
"forge.lthn.ai/Snider/Borg/pkg/compress"
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||||
"github.com/Snider/Borg/pkg/tarfs"
|
"forge.lthn.ai/Snider/Borg/pkg/tarfs"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
BIN
console.stim
Normal file
BIN
console.stim
Normal file
Binary file not shown.
BIN
demo/demo-sample.smsg
Normal file
BIN
demo/demo-sample.smsg
Normal file
Binary file not shown.
BIN
demo/demo-track-v3.smsg
Normal file
BIN
demo/demo-track-v3.smsg
Normal file
Binary file not shown.
3596
demo/index.html
Normal file
3596
demo/index.html
Normal file
File diff suppressed because it is too large
Load diff
BIN
demo/profile-avatar.jpg
Normal file
BIN
demo/profile-avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
demo/stmf.wasm
Executable file
BIN
demo/stmf.wasm
Executable file
Binary file not shown.
575
demo/wasm_exec.js
Normal file
575
demo/wasm_exec.js
Normal file
|
|
@ -0,0 +1,575 @@
|
||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalThis.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
globalThis.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substring(0, nl));
|
||||||
|
outputBuf = outputBuf.substring(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.process) {
|
||||||
|
globalThis.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.path) {
|
||||||
|
globalThis.path = {
|
||||||
|
resolve(...pathSegments) {
|
||||||
|
return pathSegments.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto) {
|
||||||
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.performance) {
|
||||||
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
globalThis.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this.argv = ["js"];
|
||||||
|
this.env = {};
|
||||||
|
this.exit = (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.warn("exit code:", code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._exitPromise = new Promise((resolve) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
this._pendingEvent = null;
|
||||||
|
this._scheduledTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const setInt64 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInt32 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInt64 = (addr) => {
|
||||||
|
const low = this.mem.getUint32(addr + 0, true);
|
||||||
|
const high = this.mem.getInt32(addr + 4, true);
|
||||||
|
return low + high * 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
const f = this.mem.getFloat64(addr, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.mem.getUint32(addr, true);
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
const nanHead = 0x7FF80000;
|
||||||
|
|
||||||
|
if (typeof v === "number" && v !== 0) {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
this.mem.setUint32(addr + 4, nanHead, true);
|
||||||
|
this.mem.setUint32(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mem.setFloat64(addr, v, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
this.mem.setFloat64(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._values.length;
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 0;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "object":
|
||||||
|
if (v !== null) {
|
||||||
|
typeFlag = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||||
|
this.mem.setUint32(addr, id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (addr) => {
|
||||||
|
const saddr = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCallExport = (a, b) => {
|
||||||
|
this._inst.exports.testExport0();
|
||||||
|
return this._inst.exports.testExport(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
this.importObject = {
|
||||||
|
_gotest: {
|
||||||
|
add: (a, b) => a + b,
|
||||||
|
callExport: testCallExport,
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||||
|
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||||
|
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||||
|
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||||
|
|
||||||
|
// func wasmExit(code int32)
|
||||||
|
"runtime.wasmExit": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const code = this.mem.getInt32(sp + 8, true);
|
||||||
|
this.exited = true;
|
||||||
|
delete this._inst;
|
||||||
|
delete this._values;
|
||||||
|
delete this._goRefCounts;
|
||||||
|
delete this._ids;
|
||||||
|
delete this._idPool;
|
||||||
|
this.exit(code);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||||
|
"runtime.wasmWrite": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const fd = getInt64(sp + 8);
|
||||||
|
const p = getInt64(sp + 16);
|
||||||
|
const n = this.mem.getInt32(sp + 24, true);
|
||||||
|
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func resetMemoryDataView()
|
||||||
|
"runtime.resetMemoryDataView": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func nanotime1() int64
|
||||||
|
"runtime.nanotime1": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func walltime() (sec int64, nsec int32)
|
||||||
|
"runtime.walltime": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const msec = (new Date).getTime();
|
||||||
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func scheduleTimeoutEvent(delay int64) int32
|
||||||
|
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this._nextCallbackTimeoutID;
|
||||||
|
this._nextCallbackTimeoutID++;
|
||||||
|
this._scheduledTimeouts.set(id, setTimeout(
|
||||||
|
() => {
|
||||||
|
this._resume();
|
||||||
|
while (this._scheduledTimeouts.has(id)) {
|
||||||
|
// for some reason Go failed to register the timeout event, log and try again
|
||||||
|
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||||
|
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||||
|
this._resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInt64(sp + 8),
|
||||||
|
));
|
||||||
|
this.mem.setInt32(sp + 16, id, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func clearTimeoutEvent(id int32)
|
||||||
|
"runtime.clearTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getInt32(sp + 8, true);
|
||||||
|
clearTimeout(this._scheduledTimeouts.get(id));
|
||||||
|
this._scheduledTimeouts.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func getRandomData(r []byte)
|
||||||
|
"runtime.getRandomData": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
crypto.getRandomValues(loadSlice(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getUint32(sp + 8, true);
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, loadString(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 32, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const m = Reflect.get(v, loadString(sp + 16));
|
||||||
|
const args = loadSliceOfValues(sp + 32);
|
||||||
|
const result = Reflect.apply(m, v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, result);
|
||||||
|
this.mem.setUint8(sp + 64, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, err);
|
||||||
|
this.mem.setUint8(sp + 64, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.apply(v, undefined, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.construct(v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||||
|
storeValue(sp + 16, str);
|
||||||
|
setInt64(sp + 24, str.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = loadValue(sp + 8);
|
||||||
|
loadSlice(sp + 16).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadSlice(sp + 8);
|
||||||
|
const src = loadValue(sp + 32);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
"syscall/js.copyBytesToJS": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadValue(sp + 8);
|
||||||
|
const src = loadSlice(sp + 16);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug": (value) => {
|
||||||
|
console.log(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
if (!(instance instanceof WebAssembly.Instance)) {
|
||||||
|
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||||
|
}
|
||||||
|
this._inst = instance;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
globalThis,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map([ // mapping from JS values to reference ids
|
||||||
|
[0, 1],
|
||||||
|
[null, 2],
|
||||||
|
[true, 3],
|
||||||
|
[false, 4],
|
||||||
|
[globalThis, 5],
|
||||||
|
[this, 6],
|
||||||
|
]);
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
|
||||||
|
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||||
|
let offset = 4096;
|
||||||
|
|
||||||
|
const strPtr = (str) => {
|
||||||
|
const ptr = offset;
|
||||||
|
const bytes = encoder.encode(str + "\0");
|
||||||
|
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||||
|
offset += bytes.length;
|
||||||
|
if (offset % 8 !== 0) {
|
||||||
|
offset += 8 - (offset % 8);
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const argc = this.argv.length;
|
||||||
|
|
||||||
|
const argvPtrs = [];
|
||||||
|
this.argv.forEach((arg) => {
|
||||||
|
argvPtrs.push(strPtr(arg));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const keys = Object.keys(this.env).sort();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const argv = offset;
|
||||||
|
argvPtrs.forEach((ptr) => {
|
||||||
|
this.mem.setUint32(offset, ptr, true);
|
||||||
|
this.mem.setUint32(offset + 4, 0, true);
|
||||||
|
offset += 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||||
|
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||||
|
const wasmMinDataAddr = 4096 + 8192;
|
||||||
|
if (offset >= wasmMinDataAddr) {
|
||||||
|
throw new Error("total length of command line and environment variables exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inst.exports.run(argc, argv);
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
await this._exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
this._inst.exports.resume();
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
BIN
dist/stmf.wasm
vendored
Executable file
BIN
dist/stmf.wasm
vendored
Executable file
Binary file not shown.
575
dist/wasm_exec.js
vendored
Normal file
575
dist/wasm_exec.js
vendored
Normal file
|
|
@ -0,0 +1,575 @@
|
||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalThis.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
globalThis.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substring(0, nl));
|
||||||
|
outputBuf = outputBuf.substring(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.process) {
|
||||||
|
globalThis.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.path) {
|
||||||
|
globalThis.path = {
|
||||||
|
resolve(...pathSegments) {
|
||||||
|
return pathSegments.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto) {
|
||||||
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.performance) {
|
||||||
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
globalThis.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this.argv = ["js"];
|
||||||
|
this.env = {};
|
||||||
|
this.exit = (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.warn("exit code:", code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._exitPromise = new Promise((resolve) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
this._pendingEvent = null;
|
||||||
|
this._scheduledTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const setInt64 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInt32 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInt64 = (addr) => {
|
||||||
|
const low = this.mem.getUint32(addr + 0, true);
|
||||||
|
const high = this.mem.getInt32(addr + 4, true);
|
||||||
|
return low + high * 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
const f = this.mem.getFloat64(addr, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.mem.getUint32(addr, true);
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
const nanHead = 0x7FF80000;
|
||||||
|
|
||||||
|
if (typeof v === "number" && v !== 0) {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
this.mem.setUint32(addr + 4, nanHead, true);
|
||||||
|
this.mem.setUint32(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mem.setFloat64(addr, v, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
this.mem.setFloat64(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._values.length;
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 0;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "object":
|
||||||
|
if (v !== null) {
|
||||||
|
typeFlag = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||||
|
this.mem.setUint32(addr, id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (addr) => {
|
||||||
|
const saddr = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCallExport = (a, b) => {
|
||||||
|
this._inst.exports.testExport0();
|
||||||
|
return this._inst.exports.testExport(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
this.importObject = {
|
||||||
|
_gotest: {
|
||||||
|
add: (a, b) => a + b,
|
||||||
|
callExport: testCallExport,
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||||
|
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||||
|
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||||
|
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||||
|
|
||||||
|
// func wasmExit(code int32)
|
||||||
|
"runtime.wasmExit": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const code = this.mem.getInt32(sp + 8, true);
|
||||||
|
this.exited = true;
|
||||||
|
delete this._inst;
|
||||||
|
delete this._values;
|
||||||
|
delete this._goRefCounts;
|
||||||
|
delete this._ids;
|
||||||
|
delete this._idPool;
|
||||||
|
this.exit(code);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||||
|
"runtime.wasmWrite": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const fd = getInt64(sp + 8);
|
||||||
|
const p = getInt64(sp + 16);
|
||||||
|
const n = this.mem.getInt32(sp + 24, true);
|
||||||
|
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func resetMemoryDataView()
|
||||||
|
"runtime.resetMemoryDataView": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func nanotime1() int64
|
||||||
|
"runtime.nanotime1": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func walltime() (sec int64, nsec int32)
|
||||||
|
"runtime.walltime": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const msec = (new Date).getTime();
|
||||||
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func scheduleTimeoutEvent(delay int64) int32
|
||||||
|
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this._nextCallbackTimeoutID;
|
||||||
|
this._nextCallbackTimeoutID++;
|
||||||
|
this._scheduledTimeouts.set(id, setTimeout(
|
||||||
|
() => {
|
||||||
|
this._resume();
|
||||||
|
while (this._scheduledTimeouts.has(id)) {
|
||||||
|
// for some reason Go failed to register the timeout event, log and try again
|
||||||
|
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||||
|
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||||
|
this._resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInt64(sp + 8),
|
||||||
|
));
|
||||||
|
this.mem.setInt32(sp + 16, id, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func clearTimeoutEvent(id int32)
|
||||||
|
"runtime.clearTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getInt32(sp + 8, true);
|
||||||
|
clearTimeout(this._scheduledTimeouts.get(id));
|
||||||
|
this._scheduledTimeouts.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func getRandomData(r []byte)
|
||||||
|
"runtime.getRandomData": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
crypto.getRandomValues(loadSlice(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getUint32(sp + 8, true);
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, loadString(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 32, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const m = Reflect.get(v, loadString(sp + 16));
|
||||||
|
const args = loadSliceOfValues(sp + 32);
|
||||||
|
const result = Reflect.apply(m, v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, result);
|
||||||
|
this.mem.setUint8(sp + 64, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, err);
|
||||||
|
this.mem.setUint8(sp + 64, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.apply(v, undefined, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.construct(v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||||
|
storeValue(sp + 16, str);
|
||||||
|
setInt64(sp + 24, str.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = loadValue(sp + 8);
|
||||||
|
loadSlice(sp + 16).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadSlice(sp + 8);
|
||||||
|
const src = loadValue(sp + 32);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
"syscall/js.copyBytesToJS": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadValue(sp + 8);
|
||||||
|
const src = loadSlice(sp + 16);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug": (value) => {
|
||||||
|
console.log(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
if (!(instance instanceof WebAssembly.Instance)) {
|
||||||
|
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||||
|
}
|
||||||
|
this._inst = instance;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
globalThis,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map([ // mapping from JS values to reference ids
|
||||||
|
[0, 1],
|
||||||
|
[null, 2],
|
||||||
|
[true, 3],
|
||||||
|
[false, 4],
|
||||||
|
[globalThis, 5],
|
||||||
|
[this, 6],
|
||||||
|
]);
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
|
||||||
|
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||||
|
let offset = 4096;
|
||||||
|
|
||||||
|
const strPtr = (str) => {
|
||||||
|
const ptr = offset;
|
||||||
|
const bytes = encoder.encode(str + "\0");
|
||||||
|
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||||
|
offset += bytes.length;
|
||||||
|
if (offset % 8 !== 0) {
|
||||||
|
offset += 8 - (offset % 8);
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const argc = this.argv.length;
|
||||||
|
|
||||||
|
const argvPtrs = [];
|
||||||
|
this.argv.forEach((arg) => {
|
||||||
|
argvPtrs.push(strPtr(arg));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const keys = Object.keys(this.env).sort();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const argv = offset;
|
||||||
|
argvPtrs.forEach((ptr) => {
|
||||||
|
this.mem.setUint32(offset, ptr, true);
|
||||||
|
this.mem.setUint32(offset + 4, 0, true);
|
||||||
|
offset += 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||||
|
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||||
|
const wasmMinDataAddr = 4096 + 8192;
|
||||||
|
if (offset >= wasmMinDataAddr) {
|
||||||
|
throw new Error("total length of command line and environment variables exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inst.exports.run(argc, argv);
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
await this._exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
this._inst.exports.resume();
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
70
docs/cli.md
70
docs/cli.md
|
|
@ -16,35 +16,77 @@ Use `borg --help` and `borg <command> --help` to see all flags.
|
||||||
Collect and package inputs.
|
Collect and package inputs.
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `borg collect github repo <repo-url> [--output <file>] [--format datanode|matrix] [--compression none|gz|xz]`
|
- `borg collect github repo <repo-url> [--output <file>] [--format datanode|tim|trix] [--compression none|gz|xz]`
|
||||||
- `borg collect github repos <org-or-user> [--output <file>] [--format ...] [--compression ...]` (if available)
|
- `borg collect github release <release-url> [--output <file>]`
|
||||||
|
- `borg collect github repos <org-or-user> [--output <file>] [--format ...] [--compression ...]`
|
||||||
- `borg collect website <url> [--depth N] [--output <file>] [--format ...] [--compression ...]`
|
- `borg collect website <url> [--depth N] [--output <file>] [--format ...] [--compression ...]`
|
||||||
- `borg collect pwa --uri <url> [--output <file>] [--format ...] [--compression ...]`
|
- `borg collect pwa --uri <url> [--output <file>] [--format ...] [--compression ...]`
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- borg collect github repo https://github.com/Snider/Borg --output borg.dat
|
- `borg collect github repo https://github.com/Snider/Borg --output borg.dat`
|
||||||
- borg collect website https://example.com --depth 1 --output site.dat
|
- `borg collect website https://example.com --depth 1 --output site.dat`
|
||||||
- borg collect pwa --uri https://squoosh.app --output squoosh.dat
|
- `borg collect pwa --uri https://squoosh.app --output squoosh.dat`
|
||||||
|
|
||||||
|
### all
|
||||||
|
|
||||||
|
Collect all public repositories from a GitHub user or organization.
|
||||||
|
|
||||||
|
- `borg all <url> [--output <file>]`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- `borg all https://github.com/Snider --output snider.dat`
|
||||||
|
|
||||||
|
### compile
|
||||||
|
|
||||||
|
Compile a Borgfile into a Terminal Isolation Matrix (TIM).
|
||||||
|
|
||||||
|
- `borg compile [--file <Borgfile>] [--output <file>]`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- `borg compile --file Borgfile --output a.tim`
|
||||||
|
|
||||||
|
### run
|
||||||
|
|
||||||
|
Execute a Terminal Isolation Matrix (TIM).
|
||||||
|
|
||||||
|
- `borg run <tim-file>`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- `borg run a.tim`
|
||||||
|
|
||||||
### serve
|
### serve
|
||||||
|
|
||||||
Serve a packaged DataNode or Matrix via a static file server.
|
Serve a packaged DataNode or TIM via a static file server.
|
||||||
|
|
||||||
- borg serve <file> [--port 8080]
|
- `borg serve <file> [--port 8080]`
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- borg serve squoosh.dat --port 8888
|
- `borg serve squoosh.dat --port 8888`
|
||||||
- borg serve borg.matrix --port 9999
|
- `borg serve borg.tim --port 9999`
|
||||||
|
|
||||||
|
### decode
|
||||||
|
|
||||||
|
Decode a `.trix` or `.tim` file back into a DataNode (`.dat`).
|
||||||
|
|
||||||
|
- `borg decode <file> [--output <file>] [--password <password>]`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `borg decode borg.trix --output borg.dat --password "secret"`
|
||||||
|
- `borg decode borg.tim --output borg.dat --i-am-in-isolation`
|
||||||
|
|
||||||
## Compression
|
## Compression
|
||||||
|
|
||||||
All collect commands accept `--compression` with values:
|
All collect commands accept `--compression` with values:
|
||||||
- none (default)
|
- `none` (default)
|
||||||
- gz
|
- `gz`
|
||||||
- xz
|
- `xz`
|
||||||
|
|
||||||
Output filenames gain the appropriate extension automatically.
|
Output filenames gain the appropriate extension automatically.
|
||||||
|
|
||||||
## Matrix format
|
## Formats
|
||||||
|
|
||||||
Use `--format matrix` to produce a runc-compatible bundle (Terminal Isolation Matrix). See the Overview page for details.
|
Borg supports three output formats via the `--format` flag:
|
||||||
|
|
||||||
|
- `datanode`: A simple tarball containing the collected resources. (Default)
|
||||||
|
- `tim`: Terminal Isolation Matrix, a runc-compatible bundle.
|
||||||
|
- `trix`: Encrypted and structured file format.
|
||||||
|
|
|
||||||
|
|
@ -11,26 +11,26 @@ This repo includes a `go.work` file configured for Go 1.25 to align with common
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
- go build ./...
|
- `go build ./...`
|
||||||
- task build
|
- `task build`
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
- go test ./...
|
- `go test ./...`
|
||||||
- task test
|
- `task test`
|
||||||
|
|
||||||
Note: Some tests may require network or git tooling depending on environment (e.g., pushing to a temporary repo). No functional changes were made in this task.
|
Note: Some tests may require network or git tooling depending on environment (e.g., pushing to a temporary repo).
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
- task run
|
- `task run`
|
||||||
- ./borg --help
|
- `./borg --help`
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
Serve the documentation locally with MkDocs:
|
Serve the documentation locally with MkDocs:
|
||||||
|
|
||||||
- pip install mkdocs-material
|
- `pip install mkdocs-material`
|
||||||
- mkdocs serve
|
- `mkdocs serve`
|
||||||
|
|
||||||
The site configuration lives in `mkdocs.yml` and content in `docs/`.
|
The site configuration lives in `mkdocs.yml` and content in `docs/`.
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,17 @@ Options to install:
|
||||||
|
|
||||||
- From source (requires Go 1.25 or newer):
|
- From source (requires Go 1.25 or newer):
|
||||||
- Clone the repository and build:
|
- Clone the repository and build:
|
||||||
- go build -o borg ./
|
- `go build -o borg ./`
|
||||||
- Or use the Taskfile:
|
- Or use the Taskfile:
|
||||||
- task build
|
- `task build`
|
||||||
|
|
||||||
- From releases (recommended):
|
- From releases (recommended):
|
||||||
- Download an archive for your OS/ARCH from GitHub Releases once you publish with GoReleaser.
|
- Download an archive for your OS/ARCH from GitHub Releases once you publish with GoReleaser.
|
||||||
- Unpack and place `borg` on your PATH.
|
- Unpack and place `borg` on your PATH.
|
||||||
|
|
||||||
- Homebrew (if you publish to a tap):
|
- Homebrew (if you publish to a tap):
|
||||||
- brew tap Snider/homebrew-tap
|
- `brew tap Snider/homebrew-tap`
|
||||||
- brew install borg
|
- `brew install borg`
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Go 1.25+ to build from source.
|
- Go 1.25+ to build from source.
|
||||||
|
|
|
||||||
281
docs/ipfs-distribution.md
Normal file
281
docs/ipfs-distribution.md
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
# IPFS Distribution Guide
|
||||||
|
|
||||||
|
This guide explains how to distribute your encrypted `.smsg` content via IPFS (InterPlanetary File System) for permanent, decentralized hosting.
|
||||||
|
|
||||||
|
## Why IPFS?
|
||||||
|
|
||||||
|
IPFS is ideal for dapp.fm content because:
|
||||||
|
|
||||||
|
- **Permanent links** - Content-addressed (CID) means the URL never changes
|
||||||
|
- **No hosting costs** - Pin with free services or self-host
|
||||||
|
- **Censorship resistant** - No single point of failure
|
||||||
|
- **Global CDN** - Content served from nearest peer
|
||||||
|
- **Perfect for archival** - Your content survives even if you disappear
|
||||||
|
|
||||||
|
Combined with password-as-license, IPFS creates truly permanent media distribution:
|
||||||
|
|
||||||
|
```
|
||||||
|
Artist uploads to IPFS → Fan downloads from anywhere → Password unlocks forever
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Install IPFS
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
```bash
|
||||||
|
brew install ipfs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux:**
|
||||||
|
```bash
|
||||||
|
wget https://dist.ipfs.tech/kubo/v0.24.0/kubo_v0.24.0_linux-amd64.tar.gz
|
||||||
|
tar xvfz kubo_v0.24.0_linux-amd64.tar.gz
|
||||||
|
sudo mv kubo/ipfs /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
Download from https://dist.ipfs.tech/#kubo
|
||||||
|
|
||||||
|
### 2. Initialize and Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ipfs init
|
||||||
|
ipfs daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add Your Content
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create your encrypted content first
|
||||||
|
go run ./cmd/mkdemo my-album.mp4 my-album.smsg
|
||||||
|
|
||||||
|
# Add to IPFS
|
||||||
|
ipfs add my-album.smsg
|
||||||
|
# Output: added QmX...abc my-album.smsg
|
||||||
|
|
||||||
|
# Your content is now available at:
|
||||||
|
# - Local: http://localhost:8080/ipfs/QmX...abc
|
||||||
|
# - Gateway: https://ipfs.io/ipfs/QmX...abc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Distribution Workflow
|
||||||
|
|
||||||
|
### For Artists
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Package your media
|
||||||
|
go run ./cmd/mkdemo album.mp4 album.smsg
|
||||||
|
# Save the password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||||
|
|
||||||
|
# 2. Add to IPFS
|
||||||
|
ipfs add album.smsg
|
||||||
|
# added QmYourContentCID album.smsg
|
||||||
|
|
||||||
|
# 3. Pin for persistence (choose one):
|
||||||
|
|
||||||
|
# Option A: Pin locally (requires running node)
|
||||||
|
ipfs pin add QmYourContentCID
|
||||||
|
|
||||||
|
# Option B: Use Pinata (free tier: 1GB)
|
||||||
|
curl -X POST "https://api.pinata.cloud/pinning/pinByHash" \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hashToPin": "QmYourContentCID"}'
|
||||||
|
|
||||||
|
# Option C: Use web3.storage (free tier: 5GB)
|
||||||
|
# Upload at https://web3.storage
|
||||||
|
|
||||||
|
# 4. Share with fans
|
||||||
|
# CID: QmYourContentCID
|
||||||
|
# Password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||||
|
# Gateway URL: https://ipfs.io/ipfs/QmYourContentCID
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Fans
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download via any gateway
|
||||||
|
curl -o album.smsg https://ipfs.io/ipfs/QmYourContentCID
|
||||||
|
|
||||||
|
# Or via local node (faster if running)
|
||||||
|
ipfs get QmYourContentCID -o album.smsg
|
||||||
|
|
||||||
|
# Play with password in browser demo or native app
|
||||||
|
```
|
||||||
|
|
||||||
|
## IPFS Gateways
|
||||||
|
|
||||||
|
Public gateways for sharing (no IPFS node required):
|
||||||
|
|
||||||
|
| Gateway | URL Pattern | Notes |
|
||||||
|
|---------|-------------|-------|
|
||||||
|
| ipfs.io | `https://ipfs.io/ipfs/{CID}` | Official, reliable |
|
||||||
|
| dweb.link | `https://{CID}.ipfs.dweb.link` | Subdomain style |
|
||||||
|
| cloudflare | `https://cloudflare-ipfs.com/ipfs/{CID}` | Fast, cached |
|
||||||
|
| w3s.link | `https://{CID}.ipfs.w3s.link` | web3.storage |
|
||||||
|
| nftstorage.link | `https://{CID}.ipfs.nftstorage.link` | NFT.storage |
|
||||||
|
|
||||||
|
**Example URLs for CID `QmX...abc`:**
|
||||||
|
```
|
||||||
|
https://ipfs.io/ipfs/QmX...abc
|
||||||
|
https://QmX...abc.ipfs.dweb.link
|
||||||
|
https://cloudflare-ipfs.com/ipfs/QmX...abc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pinning Services
|
||||||
|
|
||||||
|
Content on IPFS is only available while someone is hosting it. Use pinning services for persistence:
|
||||||
|
|
||||||
|
### Free Options
|
||||||
|
|
||||||
|
| Service | Free Tier | Link |
|
||||||
|
|---------|-----------|------|
|
||||||
|
| Pinata | 1 GB | https://pinata.cloud |
|
||||||
|
| web3.storage | 5 GB | https://web3.storage |
|
||||||
|
| NFT.storage | Unlimited* | https://nft.storage |
|
||||||
|
| Filebase | 5 GB | https://filebase.com |
|
||||||
|
|
||||||
|
*NFT.storage is designed for NFT data but works for any content.
|
||||||
|
|
||||||
|
### Pin via CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pinata
|
||||||
|
export PINATA_JWT="your-jwt-token"
|
||||||
|
curl -X POST "https://api.pinata.cloud/pinning/pinByHash" \
|
||||||
|
-H "Authorization: Bearer $PINATA_JWT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hashToPin": "QmYourCID", "pinataMetadata": {"name": "my-album.smsg"}}'
|
||||||
|
|
||||||
|
# web3.storage (using w3 CLI)
|
||||||
|
npm install -g @web3-storage/w3cli
|
||||||
|
w3 login your@email.com
|
||||||
|
w3 up my-album.smsg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Demo Page
|
||||||
|
|
||||||
|
The demo page can load content directly from IPFS gateways:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In the demo page, use gateway URL
|
||||||
|
const ipfsCID = 'QmYourContentCID';
|
||||||
|
const gatewayUrl = `https://ipfs.io/ipfs/${ipfsCID}`;
|
||||||
|
|
||||||
|
// Fetch and decrypt
|
||||||
|
const response = await fetch(gatewayUrl);
|
||||||
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
|
const msg = await BorgSMSG.decryptBinary(bytes, password);
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the Fan tab with the IPFS gateway URL directly.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Always Pin Your Content
|
||||||
|
|
||||||
|
IPFS garbage-collects unpinned content. Always pin important files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ipfs pin add QmYourCID
|
||||||
|
# Or use a pinning service
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Multiple Pins
|
||||||
|
|
||||||
|
Pin with 2-3 services for redundancy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pin locally
|
||||||
|
ipfs pin add QmYourCID
|
||||||
|
|
||||||
|
# Also pin with Pinata
|
||||||
|
curl -X POST "https://api.pinata.cloud/pinning/pinByHash" ...
|
||||||
|
|
||||||
|
# And web3.storage as backup
|
||||||
|
w3 up my-album.smsg
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Share CID + Password Separately
|
||||||
|
|
||||||
|
```
|
||||||
|
Download: https://ipfs.io/ipfs/QmYourCID
|
||||||
|
License: [sent via email/DM after purchase]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Use IPNS for Updates (Optional)
|
||||||
|
|
||||||
|
IPNS lets you update content while keeping the same URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create IPNS name
|
||||||
|
ipfs name publish QmYourCID
|
||||||
|
# Published to k51...xyz
|
||||||
|
|
||||||
|
# Your content is now at:
|
||||||
|
# https://ipfs.io/ipns/k51...xyz
|
||||||
|
|
||||||
|
# Update to new version later:
|
||||||
|
ipfs name publish QmNewVersionCID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Full Album Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create encrypted album
|
||||||
|
go run ./cmd/mkdemo my-album.mp4 my-album.smsg
|
||||||
|
# Password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||||
|
|
||||||
|
# 2. Add to IPFS
|
||||||
|
ipfs add my-album.smsg
|
||||||
|
# added QmAlbumCID my-album.smsg
|
||||||
|
|
||||||
|
# 3. Pin with multiple services
|
||||||
|
ipfs pin add QmAlbumCID
|
||||||
|
w3 up my-album.smsg
|
||||||
|
|
||||||
|
# 4. Create release page
|
||||||
|
cat > release.html << 'EOF'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>My Album - Download</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>My Album</h1>
|
||||||
|
<p>Download: <a href="https://ipfs.io/ipfs/QmAlbumCID">IPFS</a></p>
|
||||||
|
<p>After purchase, you'll receive your license key via email.</p>
|
||||||
|
<p><a href="https://demo.dapp.fm">Play with license key</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 5. Host release page on IPFS too!
|
||||||
|
ipfs add release.html
|
||||||
|
# added QmReleaseCID release.html
|
||||||
|
# Share: https://ipfs.io/ipfs/QmReleaseCID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Content Not Loading
|
||||||
|
|
||||||
|
1. **Check if pinned**: `ipfs pin ls | grep QmYourCID`
|
||||||
|
2. **Try different gateway**: Some gateways cache slowly
|
||||||
|
3. **Check daemon running**: `ipfs swarm peers` should show peers
|
||||||
|
|
||||||
|
### Slow Downloads
|
||||||
|
|
||||||
|
1. Use a faster gateway (cloudflare-ipfs.com is often fastest)
|
||||||
|
2. Run your own IPFS node for direct access
|
||||||
|
3. Pre-warm gateways by accessing content once
|
||||||
|
|
||||||
|
### CID Changed After Re-adding
|
||||||
|
|
||||||
|
IPFS CIDs are content-addressed. If you modify the file, the CID changes. For the same content, the CID is always identical.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [IPFS Documentation](https://docs.ipfs.tech/)
|
||||||
|
- [Pinata Docs](https://docs.pinata.cloud/)
|
||||||
|
- [web3.storage Docs](https://web3.storage/docs/)
|
||||||
|
- [IPFS Gateway Checker](https://ipfs.github.io/public-gateway-checker/)
|
||||||
|
|
@ -2,29 +2,32 @@
|
||||||
|
|
||||||
Borg can also be used as a Go library. The public API is exposed under the `pkg` directory. Import paths use the module `github.com/Snider/Borg`.
|
Borg can also be used as a Go library. The public API is exposed under the `pkg` directory. Import paths use the module `github.com/Snider/Borg`.
|
||||||
|
|
||||||
Note: This documentation describes usage only; functionality remains unchanged.
|
|
||||||
|
|
||||||
## Collecting a GitHub repo into a DataNode
|
## Collecting a GitHub repo into a DataNode
|
||||||
|
|
||||||
```
|
```go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"github.com/Snider/Borg/pkg/vcs"
|
||||||
borggithub "github.com/Snider/Borg/pkg/github"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create a DataNode writer (uncompressed example)
|
// Clone and package the repository
|
||||||
dn, err := datanode.NewFileDataNodeWriter("repo.dat")
|
cloner := vcs.NewGitCloner()
|
||||||
if err != nil { log.Fatal(err) }
|
dn, err := cloner.CloneGitRepository("https://github.com/Snider/Borg", nil)
|
||||||
defer dn.Close()
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
client := borggithub.NewDefaultClient(nil) // uses http.DefaultClient
|
// Save to disk
|
||||||
if err := borggithub.CollectRepo(client, "https://github.com/Snider/Borg", dn); err != nil {
|
tarBytes, err := dn.ToTar()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("repo.dat", tarBytes, 0644); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -32,21 +35,30 @@ func main() {
|
||||||
|
|
||||||
## Collecting a Website
|
## Collecting a Website
|
||||||
|
|
||||||
```
|
```go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/website"
|
"github.com/Snider/Borg/pkg/website"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dn, err := datanode.NewFileDataNodeWriter("website.dat")
|
// Download and package the website
|
||||||
if err != nil { log.Fatal(err) }
|
// 1 is the depth (0 = just the page, 1 = page + links on page)
|
||||||
defer dn.Close()
|
dn, err := website.DownloadAndPackageWebsite("https://example.com", 1, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := website.Collect("https://example.com", 1, dn); err != nil {
|
// Save to disk
|
||||||
|
tarBytes, err := dn.ToTar()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("website.dat", tarBytes, 0644); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54,21 +66,38 @@ func main() {
|
||||||
|
|
||||||
## PWA Collection
|
## PWA Collection
|
||||||
|
|
||||||
```
|
```go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/pwa"
|
"github.com/Snider/Borg/pkg/pwa"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dn, err := datanode.NewFileDataNodeWriter("pwa.dat")
|
client := pwa.NewPWAClient()
|
||||||
if err != nil { log.Fatal(err) }
|
pwaURL := "https://squoosh.app"
|
||||||
defer dn.Close()
|
|
||||||
|
|
||||||
if err := pwa.Collect("https://squoosh.app", dn); err != nil {
|
// Find the manifest
|
||||||
|
manifestURL, err := client.FindManifest(pwaURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download and package the PWA
|
||||||
|
dn, err := client.DownloadAndPackagePWA(pwaURL, manifestURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to disk
|
||||||
|
tarBytes, err := dn.ToTar()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("pwa.dat", tarBytes, 0644); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
497
docs/payment-integration.md
Normal file
497
docs/payment-integration.md
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
# Payment Integration Guide
|
||||||
|
|
||||||
|
This guide shows how to sell your encrypted `.smsg` content and deliver license keys (passwords) to customers using popular payment processors.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The dapp.fm model is simple:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Customer pays via Stripe/Gumroad/PayPal
|
||||||
|
2. Payment processor triggers webhook or delivers digital product
|
||||||
|
3. Customer receives password (license key)
|
||||||
|
4. Customer downloads .smsg from your CDN/IPFS
|
||||||
|
5. Customer decrypts with password - done forever
|
||||||
|
```
|
||||||
|
|
||||||
|
No license servers, no accounts, no ongoing infrastructure.
|
||||||
|
|
||||||
|
## Stripe Integration
|
||||||
|
|
||||||
|
### Option 1: Stripe Payment Links (Easiest)
|
||||||
|
|
||||||
|
No code required - use Stripe's hosted checkout:
|
||||||
|
|
||||||
|
1. Create a Payment Link in Stripe Dashboard
|
||||||
|
2. Set up a webhook to email the password on successful payment
|
||||||
|
3. Host your `.smsg` file anywhere (CDN, IPFS, S3)
|
||||||
|
|
||||||
|
**Webhook endpoint (Node.js/Express):**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const express = require('express');
|
||||||
|
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Your content passwords (store securely!)
|
||||||
|
const PRODUCTS = {
|
||||||
|
'prod_ABC123': {
|
||||||
|
name: 'My Album',
|
||||||
|
password: 'PMVXogAJNVe_DDABfTmLYztaJAzsD0R7',
|
||||||
|
downloadUrl: 'https://ipfs.io/ipfs/QmYourCID'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
|
||||||
|
const sig = req.headers['stripe-signature'];
|
||||||
|
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
const session = event.data.object;
|
||||||
|
const customerEmail = session.customer_details.email;
|
||||||
|
const productId = session.metadata.product_id;
|
||||||
|
const product = PRODUCTS[productId];
|
||||||
|
|
||||||
|
if (product) {
|
||||||
|
await sendLicenseEmail(customerEmail, product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({received: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sendLicenseEmail(email, product) {
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
// Configure your email provider
|
||||||
|
service: 'gmail',
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_USER,
|
||||||
|
pass: process.env.EMAIL_PASS
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: 'artist@example.com',
|
||||||
|
to: email,
|
||||||
|
subject: `Your License Key for ${product.name}`,
|
||||||
|
html: `
|
||||||
|
<h1>Thank you for your purchase!</h1>
|
||||||
|
<p><strong>Download:</strong> <a href="${product.downloadUrl}">${product.name}</a></p>
|
||||||
|
<p><strong>License Key:</strong> <code>${product.password}</code></p>
|
||||||
|
<p><strong>How to play:</strong></p>
|
||||||
|
<ol>
|
||||||
|
<li>Download the .smsg file from the link above</li>
|
||||||
|
<li>Go to <a href="https://demo.dapp.fm">demo.dapp.fm</a></li>
|
||||||
|
<li>Click "Fan" tab, then "Unlock Licensed Content"</li>
|
||||||
|
<li>Paste the file and enter your license key</li>
|
||||||
|
</ol>
|
||||||
|
<p>This is your permanent license - save this email!</p>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Stripe Checkout Session (More Control)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
|
// Create checkout session
|
||||||
|
app.post('/create-checkout', async (req, res) => {
|
||||||
|
const { productId } = req.body;
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
payment_method_types: ['card'],
|
||||||
|
line_items: [{
|
||||||
|
price: 'price_ABC123', // Your Stripe price ID
|
||||||
|
quantity: 1,
|
||||||
|
}],
|
||||||
|
mode: 'payment',
|
||||||
|
success_url: 'https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}',
|
||||||
|
cancel_url: 'https://yoursite.com/cancel',
|
||||||
|
metadata: {
|
||||||
|
product_id: productId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ url: session.url });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Success page - show license after payment
|
||||||
|
app.get('/success', async (req, res) => {
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(req.query.session_id);
|
||||||
|
|
||||||
|
if (session.payment_status === 'paid') {
|
||||||
|
const product = PRODUCTS[session.metadata.product_id];
|
||||||
|
res.send(`
|
||||||
|
<h1>Thank you!</h1>
|
||||||
|
<p>Download: <a href="${product.downloadUrl}">${product.name}</a></p>
|
||||||
|
<p>License Key: <code>${product.password}</code></p>
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
res.send('Payment not completed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gumroad Integration
|
||||||
|
|
||||||
|
Gumroad is perfect for artists - handles payments, delivery, and customer management.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Create a Digital Product on Gumroad
|
||||||
|
2. Upload a text file or PDF containing the password
|
||||||
|
3. Set your `.smsg` download URL in the product description
|
||||||
|
4. Gumroad delivers the password file on purchase
|
||||||
|
|
||||||
|
### Product Setup
|
||||||
|
|
||||||
|
**Product Description:**
|
||||||
|
```
|
||||||
|
My Album - Encrypted Digital Download
|
||||||
|
|
||||||
|
After purchase, you'll receive:
|
||||||
|
1. A license key (in the download)
|
||||||
|
2. Download link for the .smsg file
|
||||||
|
|
||||||
|
How to play:
|
||||||
|
1. Download the .smsg file: https://ipfs.io/ipfs/QmYourCID
|
||||||
|
2. Go to https://demo.dapp.fm
|
||||||
|
3. Click "Fan" → "Unlock Licensed Content"
|
||||||
|
4. Enter your license key from the PDF
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delivered File (license.txt):**
|
||||||
|
```
|
||||||
|
Your License Key: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||||
|
|
||||||
|
Download your content: https://ipfs.io/ipfs/QmYourCID
|
||||||
|
|
||||||
|
This is your permanent license - keep this file safe!
|
||||||
|
The content works offline forever with this key.
|
||||||
|
|
||||||
|
Need help? Visit https://demo.dapp.fm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gumroad Ping (Webhook)
|
||||||
|
|
||||||
|
For automated delivery, use Gumroad's Ping feature:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const express = require('express');
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Gumroad sends POST to this endpoint on sale
|
||||||
|
app.post('/gumroad-ping', (req, res) => {
|
||||||
|
const {
|
||||||
|
seller_id,
|
||||||
|
product_id,
|
||||||
|
email,
|
||||||
|
full_name,
|
||||||
|
purchaser_id
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Verify it's from Gumroad (check seller_id matches yours)
|
||||||
|
if (seller_id !== process.env.GUMROAD_SELLER_ID) {
|
||||||
|
return res.status(403).send('Invalid seller');
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = PRODUCTS[product_id];
|
||||||
|
if (product) {
|
||||||
|
// Send custom email with password
|
||||||
|
sendLicenseEmail(email, product);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send('OK');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## PayPal Integration
|
||||||
|
|
||||||
|
### PayPal Buttons + IPN
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- PayPal Buy Button -->
|
||||||
|
<form action="https://www.paypal.com/cgi-bin/webscr" method="post">
|
||||||
|
<input type="hidden" name="cmd" value="_xclick">
|
||||||
|
<input type="hidden" name="business" value="artist@example.com">
|
||||||
|
<input type="hidden" name="item_name" value="My Album - Digital Download">
|
||||||
|
<input type="hidden" name="item_number" value="album-001">
|
||||||
|
<input type="hidden" name="amount" value="9.99">
|
||||||
|
<input type="hidden" name="currency_code" value="USD">
|
||||||
|
<input type="hidden" name="notify_url" value="https://yoursite.com/paypal-ipn">
|
||||||
|
<input type="hidden" name="return" value="https://yoursite.com/thank-you">
|
||||||
|
<input type="submit" value="Buy Now - $9.99">
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
**IPN Handler:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const express = require('express');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
app.post('/paypal-ipn', express.urlencoded({ extended: true }), async (req, res) => {
|
||||||
|
// Verify with PayPal
|
||||||
|
const verifyUrl = 'https://ipnpb.paypal.com/cgi-bin/webscr';
|
||||||
|
const verifyBody = 'cmd=_notify-validate&' + new URLSearchParams(req.body).toString();
|
||||||
|
|
||||||
|
const response = await axios.post(verifyUrl, verifyBody);
|
||||||
|
|
||||||
|
if (response.data === 'VERIFIED' && req.body.payment_status === 'Completed') {
|
||||||
|
const email = req.body.payer_email;
|
||||||
|
const itemNumber = req.body.item_number;
|
||||||
|
const product = PRODUCTS[itemNumber];
|
||||||
|
|
||||||
|
if (product) {
|
||||||
|
await sendLicenseEmail(email, product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send('OK');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ko-fi Integration
|
||||||
|
|
||||||
|
Ko-fi is great for tips and single purchases.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Enable "Commissions" or "Shop" on Ko-fi
|
||||||
|
2. Create a product with the license key in the thank-you message
|
||||||
|
3. Link to your .smsg download
|
||||||
|
|
||||||
|
**Ko-fi Thank You Message:**
|
||||||
|
```
|
||||||
|
Thank you for your purchase!
|
||||||
|
|
||||||
|
Your License Key: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||||
|
|
||||||
|
Download: https://ipfs.io/ipfs/QmYourCID
|
||||||
|
|
||||||
|
Play at: https://demo.dapp.fm (Fan → Unlock Licensed Content)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serverless Options
|
||||||
|
|
||||||
|
### Vercel/Netlify Functions
|
||||||
|
|
||||||
|
No server needed - use serverless functions:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// api/stripe-webhook.js (Vercel)
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sig = req.headers['stripe-signature'];
|
||||||
|
const event = stripe.webhooks.constructEvent(
|
||||||
|
req.body,
|
||||||
|
sig,
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
if (event.type === 'checkout.session.completed') {
|
||||||
|
const session = event.data.object;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'artist@yoursite.com',
|
||||||
|
to: session.customer_details.email,
|
||||||
|
subject: 'Your License Key',
|
||||||
|
html: `
|
||||||
|
<p>Download: <a href="https://ipfs.io/ipfs/QmYourCID">My Album</a></p>
|
||||||
|
<p>License Key: <code>PMVXogAJNVe_DDABfTmLYztaJAzsD0R7</code></p>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: { bodyParser: false }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Workflow (No Code)
|
||||||
|
|
||||||
|
For artists who don't want to set up webhooks:
|
||||||
|
|
||||||
|
### Using Email
|
||||||
|
|
||||||
|
1. **Gumroad/Ko-fi**: Set product to require email
|
||||||
|
2. **Manual delivery**: Check sales daily, email passwords manually
|
||||||
|
3. **Template**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Subject: Your License for [Album Name]
|
||||||
|
|
||||||
|
Hi [Name],
|
||||||
|
|
||||||
|
Thank you for your purchase!
|
||||||
|
|
||||||
|
Download: [IPFS/CDN link]
|
||||||
|
License Key: [password]
|
||||||
|
|
||||||
|
How to play:
|
||||||
|
1. Download the .smsg file
|
||||||
|
2. Go to demo.dapp.fm
|
||||||
|
3. Fan tab → Unlock Licensed Content
|
||||||
|
4. Enter your license key
|
||||||
|
|
||||||
|
Enjoy! This license works forever.
|
||||||
|
|
||||||
|
[Artist Name]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Discord/Telegram
|
||||||
|
|
||||||
|
1. Sell via Gumroad (free tier)
|
||||||
|
2. Require customers join your Discord/Telegram
|
||||||
|
3. Bot or manual delivery of license keys
|
||||||
|
4. Community building bonus!
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### 1. One Password Per Product
|
||||||
|
|
||||||
|
Don't reuse passwords across products:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const PRODUCTS = {
|
||||||
|
'album-2024': { password: 'unique-key-1' },
|
||||||
|
'album-2023': { password: 'unique-key-2' },
|
||||||
|
'single-summer': { password: 'unique-key-3' }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Environment Variables
|
||||||
|
|
||||||
|
Never hardcode passwords in source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
ALBUM_2024_PASSWORD=PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||||
|
STRIPE_SECRET_KEY=sk_live_...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Webhook Verification
|
||||||
|
|
||||||
|
Always verify webhooks are from the payment provider:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Stripe
|
||||||
|
stripe.webhooks.constructEvent(body, sig, secret);
|
||||||
|
|
||||||
|
// Gumroad
|
||||||
|
if (seller_id !== MY_SELLER_ID) reject();
|
||||||
|
|
||||||
|
// PayPal
|
||||||
|
verify with IPN endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. HTTPS Only
|
||||||
|
|
||||||
|
All webhook endpoints must use HTTPS.
|
||||||
|
|
||||||
|
## Pricing Strategies
|
||||||
|
|
||||||
|
### Direct Sale (Perpetual License)
|
||||||
|
|
||||||
|
- Customer pays once, owns forever
|
||||||
|
- Single password for all buyers
|
||||||
|
- Best for: Albums, films, books
|
||||||
|
|
||||||
|
### Time-Limited (Streaming/Rental)
|
||||||
|
|
||||||
|
Use dapp.fm Re-Key feature:
|
||||||
|
|
||||||
|
1. Encrypt master copy with master password
|
||||||
|
2. On purchase, re-key with customer-specific password + expiry
|
||||||
|
3. Deliver unique password per customer
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// On purchase webhook
|
||||||
|
const customerPassword = generateUniquePassword();
|
||||||
|
const expiry = Date.now() + (24 * 60 * 60 * 1000); // 24 hours
|
||||||
|
|
||||||
|
// Use WASM or Go to re-key
|
||||||
|
const customerVersion = await rekeyContent(masterSmsg, masterPassword, customerPassword, expiry);
|
||||||
|
|
||||||
|
// Deliver customer-specific file + password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tiered Access
|
||||||
|
|
||||||
|
Different passwords for different tiers:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const TIERS = {
|
||||||
|
'preview': { password: 'preview-key', expiry: '30s' },
|
||||||
|
'rental': { password: 'rental-key', expiry: '7d' },
|
||||||
|
'own': { password: 'perpetual-key', expiry: null }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Complete Stripe Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create your content
|
||||||
|
go run ./cmd/mkdemo album.mp4 album.smsg
|
||||||
|
# Password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||||
|
|
||||||
|
# 2. Upload to IPFS
|
||||||
|
ipfs add album.smsg
|
||||||
|
# QmAlbumCID
|
||||||
|
|
||||||
|
# 3. Create Stripe product
|
||||||
|
# Dashboard → Products → Add Product
|
||||||
|
# Name: My Album
|
||||||
|
# Price: $9.99
|
||||||
|
|
||||||
|
# 4. Create Payment Link
|
||||||
|
# Dashboard → Payment Links → New
|
||||||
|
# Select your product
|
||||||
|
# Get link: https://buy.stripe.com/xxx
|
||||||
|
|
||||||
|
# 5. Set up webhook
|
||||||
|
# Dashboard → Developers → Webhooks → Add endpoint
|
||||||
|
# URL: https://yoursite.com/api/stripe-webhook
|
||||||
|
# Events: checkout.session.completed
|
||||||
|
|
||||||
|
# 6. Deploy webhook handler (Vercel example)
|
||||||
|
vercel deploy
|
||||||
|
|
||||||
|
# 7. Share payment link
|
||||||
|
# Fans click → Pay → Get email with password → Download → Play forever
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Stripe Webhooks](https://stripe.com/docs/webhooks)
|
||||||
|
- [Gumroad Ping](https://help.gumroad.com/article/149-ping)
|
||||||
|
- [PayPal IPN](https://developer.paypal.com/docs/ipn/)
|
||||||
|
- [Resend (Email API)](https://resend.com/)
|
||||||
|
- [Vercel Functions](https://vercel.com/docs/functions)
|
||||||
209
docs/plans/2026-02-21-borg-upgrade-design.md
Normal file
209
docs/plans/2026-02-21-borg-upgrade-design.md
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
# Borg Production Backup Upgrade — Design Document
|
||||||
|
|
||||||
|
**Date:** 2026-02-21
|
||||||
|
**Status:** Implemented
|
||||||
|
**Approach:** Bottom-Up Refactor
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Borg's `collect local` command fails on large directories because DataNode loads
|
||||||
|
everything into RAM. The UI spinner floods non-TTY output. Broken symlinks crash
|
||||||
|
the collection pipeline. Key derivation uses bare SHA-256. These issues prevent
|
||||||
|
Borg from being used for production backup workflows.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Make `collect local` work reliably on large directories (10GB+)
|
||||||
|
2. Handle symlinks properly (skip broken, follow/store valid)
|
||||||
|
3. Add quiet/scripted mode for cron and pipeline use
|
||||||
|
4. Harden encryption key derivation (Argon2id)
|
||||||
|
5. Clean up the library for external consumers
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Full core/go-* package integration (deferred — circular dependency risk since
|
||||||
|
core imports Borg)
|
||||||
|
- New CLI commands beyond fixing existing ones
|
||||||
|
- Network transport or remote sync features
|
||||||
|
- GUI or web interface
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Current Flow (Broken for Large Dirs)
|
||||||
|
|
||||||
|
```
|
||||||
|
Walk directory → Load ALL files into DataNode (RAM) → Compress → Encrypt → Write
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Flow (Streaming)
|
||||||
|
|
||||||
|
```
|
||||||
|
Walk directory → tar.Writer stream → compress stream → chunked encrypt → output file
|
||||||
|
```
|
||||||
|
|
||||||
|
DataNode remains THE core abstraction — the I/O sandbox that keeps everything safe
|
||||||
|
and portable. The streaming path bypasses DataNode for the `collect local` pipeline
|
||||||
|
only, while DataNode continues to serve all other use cases (programmatic access,
|
||||||
|
format conversion, inspection).
|
||||||
|
|
||||||
|
## Design Sections
|
||||||
|
|
||||||
|
### 1. DataNode Refactor
|
||||||
|
|
||||||
|
DataNode gains a `ToTarWriter(w io.Writer)` method for streaming out its contents
|
||||||
|
without buffering the entire archive. This is the bridge between DataNode's sandbox
|
||||||
|
model and streaming I/O.
|
||||||
|
|
||||||
|
New symlink handling:
|
||||||
|
|
||||||
|
| Symlink State | Behaviour |
|
||||||
|
|---------------|-----------|
|
||||||
|
| Valid, points inside DataNode root | Store as symlink entry |
|
||||||
|
| Valid, points outside DataNode root | Follow and store target content |
|
||||||
|
| Broken (dangling) | Skip with warning (configurable via `SkipBrokenSymlinks`) |
|
||||||
|
|
||||||
|
The `AddPath` method gets an options struct:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type AddPathOptions struct {
|
||||||
|
SkipBrokenSymlinks bool // default: true
|
||||||
|
FollowSymlinks bool // default: false (store as symlinks)
|
||||||
|
ExcludePatterns []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. UI & Logger Cleanup
|
||||||
|
|
||||||
|
Replace direct spinner writes with a `Progress` interface:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Progress interface {
|
||||||
|
Start(label string)
|
||||||
|
Update(current, total int64)
|
||||||
|
Finish(label string)
|
||||||
|
Log(level, msg string, args ...any)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two implementations:
|
||||||
|
- **InteractiveProgress** — spinner + progress bar (when `isatty(stdout)`)
|
||||||
|
- **QuietProgress** — structured log lines only (cron, pipes, `--quiet` flag)
|
||||||
|
|
||||||
|
TTY detection at startup selects the implementation. All existing `ui.Spinner` and
|
||||||
|
`fmt.Printf` calls in library code get replaced with `Progress` method calls.
|
||||||
|
|
||||||
|
New `--quiet` / `-q` flag on all commands suppresses non-error output.
|
||||||
|
|
||||||
|
### 3. TIM Streaming Encryption
|
||||||
|
|
||||||
|
ChaCha20-Poly1305 is AEAD — it needs the full plaintext to compute the auth tag.
|
||||||
|
For streaming, we use a chunked block format:
|
||||||
|
|
||||||
|
```
|
||||||
|
[magic: 4 bytes "STIM"]
|
||||||
|
[version: 1 byte]
|
||||||
|
[salt: 16 bytes] ← Argon2id salt
|
||||||
|
[argon2 params: 12 bytes] ← time, memory, threads (uint32 LE each)
|
||||||
|
|
||||||
|
Per block (repeated):
|
||||||
|
[nonce: 12 bytes]
|
||||||
|
[length: 4 bytes LE] ← ciphertext length including 16-byte Poly1305 tag
|
||||||
|
[ciphertext: N bytes] ← encrypted chunk + tag
|
||||||
|
|
||||||
|
Final block:
|
||||||
|
[nonce: 12 bytes]
|
||||||
|
[length: 4 bytes LE = 0] ← zero length signals EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Block size: 1 MiB plaintext → ~1 MiB + 16 bytes ciphertext per block.
|
||||||
|
|
||||||
|
The `Sigil` (Enchantrix crypto handle) wraps this as `StreamEncrypt(r io.Reader,
|
||||||
|
w io.Writer)` and `StreamDecrypt(r io.Reader, w io.Writer)`.
|
||||||
|
|
||||||
|
### 4. Key Derivation Hardening
|
||||||
|
|
||||||
|
Replace bare `SHA-256(password)` with Argon2id:
|
||||||
|
|
||||||
|
```go
|
||||||
|
key := argon2.IDKey(password, salt, time=3, memory=64*1024, threads=4, keyLen=32)
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameters stored in the STIM header (section 3 above) so they can be tuned
|
||||||
|
without breaking existing archives. Random 16-byte salt generated per archive.
|
||||||
|
|
||||||
|
Backward compatibility: detect old format by checking for "STIM" magic. Old files
|
||||||
|
(no magic header) use legacy SHA-256 derivation with a deprecation warning.
|
||||||
|
|
||||||
|
### 5. Collect Local Streaming Pipeline
|
||||||
|
|
||||||
|
The new `collect local` pipeline for large directories:
|
||||||
|
|
||||||
|
```
|
||||||
|
filepath.WalkDir
|
||||||
|
→ tar.NewWriter (streaming)
|
||||||
|
→ xz/gzip compressor (streaming)
|
||||||
|
→ chunked AEAD encryptor (streaming)
|
||||||
|
→ os.File output
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory usage: ~2 MiB regardless of input size (1 MiB compress buffer + 1 MiB
|
||||||
|
encrypt block).
|
||||||
|
|
||||||
|
Error handling:
|
||||||
|
- Broken symlinks: skip with warning (not fatal)
|
||||||
|
- Permission denied: skip with warning, continue
|
||||||
|
- Disk full on output: fatal, clean up partial file
|
||||||
|
- Read errors mid-stream: fatal, clean up partial file
|
||||||
|
|
||||||
|
Compression selection: `--compress=xz` (default, best ratio) or `--compress=gzip`
|
||||||
|
(faster). Matches existing Borg compression support.
|
||||||
|
|
||||||
|
### 6. Core Package Integration (Deferred)
|
||||||
|
|
||||||
|
Core imports Borg, so Borg cannot import core packages without creating a circular
|
||||||
|
dependency. Integration points are marked with TODOs for when the dependency
|
||||||
|
direction is resolved (likely by extracting shared interfaces to a common module):
|
||||||
|
|
||||||
|
- `core/go` config system → Borg config loading
|
||||||
|
- `core/go` logging → Borg Progress interface backend
|
||||||
|
- `core/go-store` → DataNode persistence
|
||||||
|
- `core/go` io.Medium → DataNode filesystem abstraction
|
||||||
|
|
||||||
|
## File Impact Summary
|
||||||
|
|
||||||
|
| Area | Files | Change Type |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| DataNode | `pkg/datanode/*.go` | Modify (ToTarWriter, symlinks, AddPathOptions) |
|
||||||
|
| UI | `pkg/ui/*.go` | Rewrite (Progress interface, TTY detection) |
|
||||||
|
| TIM/STIM | `pkg/tim/*.go` | Modify (streaming encrypt/decrypt, new header) |
|
||||||
|
| Crypto | `pkg/tim/crypto.go` (new) | Create (Argon2id, chunked AEAD) |
|
||||||
|
| Collect | `cmd/collect_local.go` | Rewrite (streaming pipeline) |
|
||||||
|
| CLI | `cmd/root.go`, `cmd/*.go` | Modify (--quiet flag) |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Unit tests for each component (DataNode, Progress, chunked AEAD, Argon2id)
|
||||||
|
- Round-trip tests: encrypt → decrypt → compare original
|
||||||
|
- Large file test: 100 MiB synthetic directory through full pipeline
|
||||||
|
- Symlink matrix: valid internal, valid external, broken, nested
|
||||||
|
- Backward compatibility: decrypt old-format STIM with new code
|
||||||
|
- Race detector: `go test -race ./...`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
New:
|
||||||
|
- `golang.org/x/crypto/argon2` (Argon2id key derivation)
|
||||||
|
- `golang.org/x/term` (TTY detection via `term.IsTerminal`)
|
||||||
|
|
||||||
|
Existing (unchanged):
|
||||||
|
- `github.com/snider/Enchantrix` (ChaCha20-Poly1305 via Sigil)
|
||||||
|
- `github.com/ulikunitz/xz` (XZ compression)
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Breaking existing STIM format | Magic-byte detection for backward compat |
|
||||||
|
| Chunked AEAD security | Standard construction (each block independent nonce) |
|
||||||
|
| Circular dep with core | Deferred; TODO markers only |
|
||||||
|
| Large directory edge cases | Extensive symlink + permission test matrix |
|
||||||
2046
docs/plans/2026-02-21-borg-upgrade-plan.md
Normal file
2046
docs/plans/2026-02-21-borg-upgrade-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,17 +11,17 @@ This project is configured for GoReleaser.
|
||||||
|
|
||||||
Generate local artifacts without publishing:
|
Generate local artifacts without publishing:
|
||||||
|
|
||||||
- goreleaser release --snapshot --clean
|
- `goreleaser release --snapshot --clean`
|
||||||
|
|
||||||
Artifacts appear under `dist/`.
|
Artifacts appear under `dist/`.
|
||||||
|
|
||||||
## Full release
|
## Full release
|
||||||
|
|
||||||
1. Tag a new version:
|
1. Tag a new version:
|
||||||
- git tag -a v0.1.0 -m "v0.1.0"
|
- `git tag -a v0.1.0 -m "v0.1.0"`
|
||||||
- git push origin v0.1.0
|
- `git push origin v0.1.0`
|
||||||
2. Run GoReleaser:
|
2. Run GoReleaser:
|
||||||
- GITHUB_TOKEN=... goreleaser release --clean
|
- `GITHUB_TOKEN=... goreleaser release --clean`
|
||||||
|
|
||||||
This will:
|
This will:
|
||||||
- Build binaries for multiple OS/ARCH
|
- Build binaries for multiple OS/ARCH
|
||||||
|
|
@ -31,4 +31,3 @@ This will:
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- The Go toolchain version is 1.25 (see go.mod and go.work).
|
- The Go toolchain version is 1.25 (see go.mod and go.work).
|
||||||
- No functional changes were made as part of this task; configuration and documentation only.
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/github"
|
"forge.lthn.ai/Snider/Borg/pkg/github"
|
||||||
"github.com/Snider/Borg/pkg/vcs"
|
"forge.lthn.ai/Snider/Borg/pkg/vcs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/github"
|
"forge.lthn.ai/Snider/Borg/pkg/github"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Println("Collecting GitHub release...")
|
log.Println("Collecting GitHub release...")
|
||||||
|
|
||||||
owner, repo, err := github.ParseRepoFromURL("https://github.com/Snider/Borg")
|
owner, repo, err := github.ParseRepoFromURL("https://forge.lthn.ai/Snider/Borg")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to parse repo from URL: %v", err)
|
log.Fatalf("Failed to parse repo from URL: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,14 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/vcs"
|
"forge.lthn.ai/Snider/Borg/pkg/vcs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Println("Collecting GitHub repo...")
|
log.Println("Collecting GitHub repo...")
|
||||||
|
|
||||||
cloner := vcs.NewGitCloner()
|
cloner := vcs.NewGitCloner()
|
||||||
dn, err := cloner.CloneGitRepository("https://github.com/Snider/Borg", nil)
|
dn, err := cloner.CloneGitRepository("https://forge.lthn.ai/Snider/Borg", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to clone repository: %v", err)
|
log.Fatalf("Failed to clone repository: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/pwa"
|
"forge.lthn.ai/Snider/Borg/pkg/pwa"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/website"
|
"forge.lthn.ai/Snider/Borg/pkg/website"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||||
"github.com/Snider/Borg/pkg/tim"
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
||||||
183
examples/encrypt_media/main.go
Normal file
183
examples/encrypt_media/main.go
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
// Package main demonstrates encrypting media files into SMSG format for dapp.fm
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run main.go -input video.mp4 -output video.smsg -password "license-token" -title "My Track" -artist "Artist Name"
|
||||||
|
// go run main.go -input video.mp4 -password "token" -track "0:Intro" -track "67:Sonnata, It Feels So Good"
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"mime"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/smsg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trackList allows multiple -track flags
|
||||||
|
type trackList []string
|
||||||
|
|
||||||
|
func (t *trackList) String() string {
|
||||||
|
return strings.Join(*t, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackList) Set(value string) error {
|
||||||
|
*t = append(*t, value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
inputFile := flag.String("input", "", "Input media file (mp4, mp3, etc)")
|
||||||
|
outputFile := flag.String("output", "", "Output SMSG file (default: input.smsg)")
|
||||||
|
password := flag.String("password", "", "License token / password for encryption")
|
||||||
|
title := flag.String("title", "", "Track title (default: filename)")
|
||||||
|
artist := flag.String("artist", "", "Artist name")
|
||||||
|
releaseType := flag.String("type", "single", "Release type: single, ep, album, djset, live")
|
||||||
|
hint := flag.String("hint", "", "Optional password hint")
|
||||||
|
outputBase64 := flag.Bool("base64", false, "Output as base64 text file instead of binary")
|
||||||
|
|
||||||
|
var tracks trackList
|
||||||
|
flag.Var(&tracks, "track", "Track marker as 'seconds:title' or 'mm:ss:title' (can be repeated)")
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *inputFile == "" {
|
||||||
|
log.Fatal("Input file is required. Use -input flag.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if *password == "" {
|
||||||
|
log.Fatal("Password/license token is required. Use -password flag.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read input file
|
||||||
|
data, err := os.ReadFile(*inputFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to read input file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type
|
||||||
|
ext := strings.ToLower(filepath.Ext(*inputFile))
|
||||||
|
mimeType := mime.TypeByExtension(ext)
|
||||||
|
if mimeType == "" {
|
||||||
|
// Fallback for common types
|
||||||
|
switch ext {
|
||||||
|
case ".mp4":
|
||||||
|
mimeType = "video/mp4"
|
||||||
|
case ".mp3":
|
||||||
|
mimeType = "audio/mpeg"
|
||||||
|
case ".wav":
|
||||||
|
mimeType = "audio/wav"
|
||||||
|
case ".ogg":
|
||||||
|
mimeType = "audio/ogg"
|
||||||
|
case ".webm":
|
||||||
|
mimeType = "video/webm"
|
||||||
|
case ".m4a":
|
||||||
|
mimeType = "audio/mp4"
|
||||||
|
case ".flac":
|
||||||
|
mimeType = "audio/flac"
|
||||||
|
default:
|
||||||
|
mimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
trackTitle := *title
|
||||||
|
if trackTitle == "" {
|
||||||
|
trackTitle = strings.TrimSuffix(filepath.Base(*inputFile), ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := *outputFile
|
||||||
|
if output == "" {
|
||||||
|
output = *inputFile + ".smsg"
|
||||||
|
if *outputBase64 {
|
||||||
|
output = *inputFile + ".smsg.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SMSG message with media attachment
|
||||||
|
msg := smsg.NewMessage("Licensed media content from dapp.fm")
|
||||||
|
msg.WithSubject(trackTitle)
|
||||||
|
|
||||||
|
if *artist != "" {
|
||||||
|
msg.WithFrom(*artist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the media file as base64 attachment
|
||||||
|
contentB64 := base64.StdEncoding.EncodeToString(data)
|
||||||
|
msg.AddAttachment(filepath.Base(*inputFile), contentB64, mimeType)
|
||||||
|
|
||||||
|
// Build manifest with public metadata
|
||||||
|
manifest := smsg.NewManifest(trackTitle)
|
||||||
|
manifest.Artist = *artist
|
||||||
|
manifest.ReleaseType = *releaseType
|
||||||
|
manifest.Format = "dapp.fm/v1"
|
||||||
|
|
||||||
|
// Parse track markers
|
||||||
|
for _, trackStr := range tracks {
|
||||||
|
parts := strings.SplitN(trackStr, ":", 3)
|
||||||
|
var startSec float64
|
||||||
|
var trackName string
|
||||||
|
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Format: "seconds:title"
|
||||||
|
startSec, _ = strconv.ParseFloat(parts[0], 64)
|
||||||
|
trackName = parts[1]
|
||||||
|
} else if len(parts) == 3 {
|
||||||
|
// Format: "mm:ss:title"
|
||||||
|
mins, _ := strconv.ParseFloat(parts[0], 64)
|
||||||
|
secs, _ := strconv.ParseFloat(parts[1], 64)
|
||||||
|
startSec = mins*60 + secs
|
||||||
|
trackName = parts[2]
|
||||||
|
} else {
|
||||||
|
log.Printf("Warning: Invalid track format '%s', expected 'seconds:title' or 'mm:ss:title'", trackStr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.AddTrack(trackName, startSec)
|
||||||
|
fmt.Printf(" Track: %s @ %.0fs\n", trackName, startSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt with manifest
|
||||||
|
var encrypted []byte
|
||||||
|
if *hint != "" {
|
||||||
|
// For hint, we'd need to extend the API - for now just use manifest
|
||||||
|
_ = hint
|
||||||
|
}
|
||||||
|
encrypted, err = smsg.EncryptWithManifest(msg, *password, manifest)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Encryption failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write output
|
||||||
|
if *outputBase64 {
|
||||||
|
// Write as base64 text
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(encrypted)
|
||||||
|
err = os.WriteFile(output, []byte(b64), 0644)
|
||||||
|
} else {
|
||||||
|
// Write as binary
|
||||||
|
err = os.WriteFile(output, encrypted, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to write output file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Encrypted media created successfully!\n")
|
||||||
|
fmt.Printf(" Input: %s (%s)\n", *inputFile, mimeType)
|
||||||
|
fmt.Printf(" Output: %s\n", output)
|
||||||
|
fmt.Printf(" Title: %s\n", trackTitle)
|
||||||
|
if *artist != "" {
|
||||||
|
fmt.Printf(" Artist: %s\n", *artist)
|
||||||
|
}
|
||||||
|
fmt.Printf(" Size: %.2f MB -> %.2f MB\n",
|
||||||
|
float64(len(data))/1024/1024,
|
||||||
|
float64(len(encrypted))/1024/1024)
|
||||||
|
fmt.Printf("\nLicense token: %s\n", *password)
|
||||||
|
fmt.Printf("\nShare the .smsg file publicly. Only users with the license token can play it.\n")
|
||||||
|
}
|
||||||
95
examples/failures/001-double-base64-encoding.md
Normal file
95
examples/failures/001-double-base64-encoding.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Failure Case 001: Double Base64 Encoding
|
||||||
|
|
||||||
|
## Error Message
|
||||||
|
```
|
||||||
|
Failed: decryption failed: invalid SMSG magic: trix: invalid magic number: expected SMSG, got U01T
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- Demo page: `demo/index.html`
|
||||||
|
- File: `demo/demo-track.smsg`
|
||||||
|
- WASM version: 1.2.0
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
The demo file `demo-track.smsg` is stored as **base64-encoded text**, but the JavaScript code treats it as binary and re-encodes it to base64 before passing to WASM.
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
|
||||||
|
File inspection:
|
||||||
|
```bash
|
||||||
|
$ file demo/demo-track.smsg
|
||||||
|
ASCII text, with very long lines (65536), with no line terminators
|
||||||
|
|
||||||
|
$ head -c 64 demo/demo-track.smsg | xxd
|
||||||
|
00000000: 5530 3154 5277 4941 4141 457a 6579 4a68 U01TRwIAAAEzeyJh
|
||||||
|
```
|
||||||
|
|
||||||
|
The file starts with `U01TRwIA...` which is **base64-encoded SMSG**:
|
||||||
|
- `U01TRw` decodes to bytes `0x53 0x4D 0x53 0x47` = "SMSG" (the magic number)
|
||||||
|
|
||||||
|
### The Double-Encoding Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
Original SMSG binary:
|
||||||
|
SMSG.... (starts with 0x534D5347)
|
||||||
|
↓ base64 encode (file storage)
|
||||||
|
U01TRwIA... (stored in demo-track.smsg)
|
||||||
|
↓ fetch() as binary
|
||||||
|
[0x55, 0x30, 0x31, 0x54, ...] (bytes of ASCII "U01T...")
|
||||||
|
↓ btoa() in JavaScript
|
||||||
|
VTAxVFJ3SUFBQUUzZXlK... (base64 of base64!)
|
||||||
|
↓ WASM base64 decode
|
||||||
|
U01TRwIA... (back to first base64)
|
||||||
|
↓ WASM tries to parse as SMSG
|
||||||
|
ERROR: expected "SMSG", got "U01T" (first 4 chars of base64)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why "U01T"?
|
||||||
|
The error shows "U01T" because when WASM decodes the double-base64, it gets back the original base64 string, and the first 4 ASCII characters "U01T" are interpreted as the magic number instead of the actual bytes 0x534D5347.
|
||||||
|
|
||||||
|
## Solution Options
|
||||||
|
|
||||||
|
### Option A: Store as binary (recommended)
|
||||||
|
Convert the demo file to raw binary format:
|
||||||
|
```bash
|
||||||
|
base64 -d demo/demo-track.smsg > demo/demo-track-binary.smsg
|
||||||
|
mv demo/demo-track-binary.smsg demo/demo-track.smsg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Detect format in JavaScript
|
||||||
|
Check if content is already base64 and skip re-encoding:
|
||||||
|
```javascript
|
||||||
|
// Check if content looks like base64 (ASCII text starting with valid base64 chars)
|
||||||
|
const isBase64 = /^[A-Za-z0-9+/=]+$/.test(text.trim());
|
||||||
|
if (!isBase64) {
|
||||||
|
// Binary content - encode to base64
|
||||||
|
base64 = btoa(binaryToString(bytes));
|
||||||
|
} else {
|
||||||
|
// Already base64 - use as-is
|
||||||
|
base64 = text;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option C: Use text fetch for base64 files
|
||||||
|
```javascript
|
||||||
|
// For base64-encoded .smsg files
|
||||||
|
const response = await fetch(DEMO_URL);
|
||||||
|
const base64 = await response.text(); // Don't re-encode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lesson Learned
|
||||||
|
SMSG files can exist in two formats:
|
||||||
|
1. **Binary** (.smsg) - raw bytes, magic number is `0x534D5347`
|
||||||
|
2. **Base64** (.smsg.b64 or .smsg with text content) - ASCII text, starts with `U01T`
|
||||||
|
|
||||||
|
The loader must detect which format it's receiving and handle accordingly.
|
||||||
|
|
||||||
|
## Recommended Fix
|
||||||
|
Implement Option A (binary storage) for the demo, as it's the canonical format and avoids ambiguity. Reserve Option B for the License Manager where users might drag-drop either format.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- `pkg/smsg/smsg.go` - SMSG format definition
|
||||||
|
- `pkg/wasm/stmf/main.go` - WASM decryption API
|
||||||
|
- `demo/index.html` - Demo page loader
|
||||||
125
examples/formats/smsg-format.md
Normal file
125
examples/formats/smsg-format.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# SMSG Format Specification
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
SMSG (Secure Message) is an encrypted container format using ChaCha20-Poly1305 authenticated encryption.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Magic Number: "SMSG" (4 bytes) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Version: uint16 (2 bytes) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Header Length: uint32 (4 bytes) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Header (JSON, plaintext) │
|
||||||
|
│ - algorithm: "chacha20poly1305" │
|
||||||
|
│ - manifest: {title, artist, license...} │
|
||||||
|
│ - nonce: base64 │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Encrypted Payload │
|
||||||
|
│ - Nonce (24 bytes for XChaCha20) │
|
||||||
|
│ - Ciphertext + Auth Tag │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Magic Number
|
||||||
|
- Binary: `0x53 0x4D 0x53 0x47`
|
||||||
|
- ASCII: "SMSG"
|
||||||
|
- Base64 (first 6 chars): "U01TRw"
|
||||||
|
|
||||||
|
## Header (JSON, unencrypted)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"algorithm": "chacha20poly1305",
|
||||||
|
"manifest": {
|
||||||
|
"title": "Track Title",
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"license": "CC-BY-4.0",
|
||||||
|
"expires": "2025-12-31T23:59:59Z",
|
||||||
|
"tracks": [
|
||||||
|
{"title": "Track 1", "start": 0, "trackNum": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest is **readable without decryption** - this enables:
|
||||||
|
- License validation before decryption
|
||||||
|
- Metadata display in file browsers
|
||||||
|
- Expiration enforcement
|
||||||
|
|
||||||
|
## Encrypted Payload (JSON)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"from": "artist@example.com",
|
||||||
|
"to": "fan@example.com",
|
||||||
|
"subject": "Album Title",
|
||||||
|
"body": "Thank you for your purchase!",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"name": "track.mp3",
|
||||||
|
"mime": "audio/mpeg",
|
||||||
|
"content": "<base64-encoded-data>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Derivation
|
||||||
|
```
|
||||||
|
password → SHA-256 → 32-byte key
|
||||||
|
```
|
||||||
|
|
||||||
|
Simple but effective - the password IS the license key.
|
||||||
|
|
||||||
|
## Storage Formats
|
||||||
|
|
||||||
|
### Binary (.smsg)
|
||||||
|
Raw bytes. Canonical format for distribution.
|
||||||
|
```
|
||||||
|
53 4D 53 47 02 00 00 00 33 00 00 00 7B 22 61 6C ...
|
||||||
|
S M S G [ver] [hdr len] {"al...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Base64 Text (.smsg or .smsg.b64)
|
||||||
|
For embedding in JSON, URLs, or text-based transport.
|
||||||
|
```
|
||||||
|
U01TRwIAAAEzeyJhbGdvcml0aG0iOiJjaGFjaGEyMHBvbHkxMzA1Ii...
|
||||||
|
```
|
||||||
|
|
||||||
|
## WASM API
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Initialize
|
||||||
|
const go = new Go();
|
||||||
|
await WebAssembly.instantiateStreaming(fetch('stmf.wasm'), go.importObject);
|
||||||
|
go.run(result.instance);
|
||||||
|
|
||||||
|
// Get metadata (no password needed)
|
||||||
|
const info = await BorgSMSG.getInfo(base64Content);
|
||||||
|
// info.manifest.title, info.manifest.expires, etc.
|
||||||
|
|
||||||
|
// Decrypt (requires password)
|
||||||
|
const msg = await BorgSMSG.decryptStream(base64Content, password);
|
||||||
|
// msg.attachments[0].data is Uint8Array (binary)
|
||||||
|
// msg.attachments[0].mime is MIME type
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Properties
|
||||||
|
|
||||||
|
1. **Authenticated Encryption**: ChaCha20-Poly1305 provides both confidentiality and integrity
|
||||||
|
2. **No Key Escrow**: Password never transmitted, derived locally
|
||||||
|
3. **Metadata Privacy**: Only manifest is public; actual content encrypted
|
||||||
|
4. **Browser-Safe**: WASM runs in sandbox, keys never leave client
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
| Use Case | Format | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Direct download | Binary | Most efficient |
|
||||||
|
| Email attachment | Base64 | Safe for text transport |
|
||||||
|
| IPFS/CDN | Binary | Content-addressed |
|
||||||
|
| Embedded in JSON | Base64 | API responses |
|
||||||
|
| Browser demo | Either | Must detect format |
|
||||||
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/compress"
|
"forge.lthn.ai/Snider/Borg/pkg/compress"
|
||||||
"github.com/Snider/Borg/pkg/datanode"
|
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/tim"
|
"forge.lthn.ai/Snider/Borg/pkg/tim"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/pkg/compress"
|
"forge.lthn.ai/Snider/Borg/pkg/compress"
|
||||||
"github.com/Snider/Borg/pkg/tarfs"
|
"forge.lthn.ai/Snider/Borg/pkg/tarfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
||||||
191
examples/smsg-reply/main.go
Normal file
191
examples/smsg-reply/main.go
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
// Example: Creating encrypted support reply messages
|
||||||
|
//
|
||||||
|
// This example demonstrates how to create password-protected secure messages
|
||||||
|
// that can be decrypted client-side using the BorgSMSG WASM module.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run main.go
|
||||||
|
// go run main.go -password "secret123" -body "Your message here"
|
||||||
|
// go run main.go -password "secret123" -body "Message" -hint "Your hint"
|
||||||
|
// go run main.go -password "secret123" -body "Message" -attach file.txt
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/smsg"
|
||||||
|
"forge.lthn.ai/Snider/Borg/pkg/stmf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Command line flags
|
||||||
|
password := flag.String("password", "demo123", "Password for encryption")
|
||||||
|
hint := flag.String("hint", "", "Optional password hint")
|
||||||
|
body := flag.String("body", "", "Message body (if empty, uses demo content)")
|
||||||
|
subject := flag.String("subject", "", "Message subject")
|
||||||
|
from := flag.String("from", "support@example.com", "Sender address")
|
||||||
|
attachFile := flag.String("attach", "", "File to attach (optional)")
|
||||||
|
withReplyKey := flag.Bool("reply-key", false, "Include a reply public key")
|
||||||
|
outputFile := flag.String("out", "", "Output file (if empty, prints to stdout)")
|
||||||
|
rawBytes := flag.Bool("raw", false, "Output raw bytes instead of base64")
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Create the message
|
||||||
|
var msg *smsg.Message
|
||||||
|
if *body == "" {
|
||||||
|
msg = createDemoMessage()
|
||||||
|
} else {
|
||||||
|
msg = smsg.NewMessage(*body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set optional fields
|
||||||
|
if *subject != "" {
|
||||||
|
msg.WithSubject(*subject)
|
||||||
|
}
|
||||||
|
if *from != "" {
|
||||||
|
msg.WithFrom(*from)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add attachment if specified
|
||||||
|
if *attachFile != "" {
|
||||||
|
if err := addAttachment(msg, *attachFile); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error adding attachment: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reply key if requested
|
||||||
|
if *withReplyKey {
|
||||||
|
kp, err := stmf.GenerateKeyPair()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error generating reply key: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
msg.WithReplyKey(kp.PublicKeyBase64())
|
||||||
|
fmt.Fprintf(os.Stderr, "Reply private key (keep secret): %s\n", kp.PrivateKeyBase64())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the message
|
||||||
|
var encrypted []byte
|
||||||
|
var err error
|
||||||
|
if *hint != "" {
|
||||||
|
encrypted, err = smsg.EncryptWithHint(msg, *password, *hint)
|
||||||
|
} else {
|
||||||
|
encrypted, err = smsg.Encrypt(msg, *password)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Encryption failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output the result
|
||||||
|
var output []byte
|
||||||
|
if *rawBytes {
|
||||||
|
output = encrypted
|
||||||
|
} else {
|
||||||
|
output = []byte(base64.StdEncoding.EncodeToString(encrypted))
|
||||||
|
}
|
||||||
|
|
||||||
|
if *outputFile != "" {
|
||||||
|
if err := os.WriteFile(*outputFile, output, 0644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "Encrypted message written to: %s\n", *outputFile)
|
||||||
|
} else {
|
||||||
|
fmt.Println(string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print info
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
fmt.Fprintln(os.Stderr, "--- Message Info ---")
|
||||||
|
fmt.Fprintf(os.Stderr, "Password: %s\n", *password)
|
||||||
|
if *hint != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Hint: %s\n", *hint)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "From: %s\n", msg.From)
|
||||||
|
if msg.Subject != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Subject: %s\n", msg.Subject)
|
||||||
|
}
|
||||||
|
if len(msg.Attachments) > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Attachments: %d\n", len(msg.Attachments))
|
||||||
|
}
|
||||||
|
if msg.ReplyKey != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "Reply Key: included")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDemoMessage creates a sample support reply message
|
||||||
|
func createDemoMessage() *smsg.Message {
|
||||||
|
return smsg.NewMessage(`Hello,
|
||||||
|
|
||||||
|
Thank you for contacting our support team. We have reviewed your request and are pleased to provide the following update.
|
||||||
|
|
||||||
|
Your account has been verified and all services are now active. If you have any further questions, please don't hesitate to reach out.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The Support Team`).
|
||||||
|
WithSubject("Re: Your Support Request #" + fmt.Sprintf("%d", time.Now().Unix()%100000)).
|
||||||
|
WithFrom("support@example.com").
|
||||||
|
SetMeta("ticket_id", fmt.Sprintf("%d", time.Now().Unix()%100000)).
|
||||||
|
SetMeta("priority", "normal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// addAttachment reads a file and adds it as an attachment
|
||||||
|
func addAttachment(msg *smsg.Message, filePath string) error {
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := filepath.Base(filePath)
|
||||||
|
content := base64.StdEncoding.EncodeToString(data)
|
||||||
|
mimeType := detectMimeType(filePath)
|
||||||
|
|
||||||
|
msg.AddAttachment(name, content, mimeType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectMimeType returns a basic mime type based on file extension
|
||||||
|
func detectMimeType(path string) string {
|
||||||
|
ext := filepath.Ext(path)
|
||||||
|
switch ext {
|
||||||
|
case ".txt":
|
||||||
|
return "text/plain"
|
||||||
|
case ".html", ".htm":
|
||||||
|
return "text/html"
|
||||||
|
case ".css":
|
||||||
|
return "text/css"
|
||||||
|
case ".js":
|
||||||
|
return "application/javascript"
|
||||||
|
case ".json":
|
||||||
|
return "application/json"
|
||||||
|
case ".xml":
|
||||||
|
return "application/xml"
|
||||||
|
case ".pdf":
|
||||||
|
return "application/pdf"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml"
|
||||||
|
case ".zip":
|
||||||
|
return "application/zip"
|
||||||
|
case ".tar":
|
||||||
|
return "application/x-tar"
|
||||||
|
case ".gz":
|
||||||
|
return "application/gzip"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
69
go.mod
69
go.mod
|
|
@ -1,45 +1,74 @@
|
||||||
module github.com/Snider/Borg
|
module forge.lthn.ai/Snider/Borg
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600
|
forge.lthn.ai/Snider/Enchantrix v0.0.4
|
||||||
github.com/fatih/color v1.18.0
|
github.com/fatih/color v1.18.0
|
||||||
github.com/go-git/go-git/v5 v5.16.3
|
github.com/go-git/go-git/v5 v5.16.4
|
||||||
github.com/google/go-github/v39 v39.2.0
|
github.com/google/go-github/v39 v39.2.0
|
||||||
|
github.com/klauspost/compress v1.18.4
|
||||||
github.com/mattn/go-isatty v0.0.20
|
github.com/mattn/go-isatty v0.0.20
|
||||||
github.com/schollz/progressbar/v3 v3.18.0
|
github.com/schollz/progressbar/v3 v3.18.0
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/ulikunitz/xz v0.5.15
|
github.com/ulikunitz/xz v0.5.15
|
||||||
golang.org/x/mod v0.30.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
golang.org/x/net v0.47.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/oauth2 v0.33.0
|
golang.org/x/mod v0.33.0
|
||||||
|
golang.org/x/net v0.50.0
|
||||||
|
golang.org/x/oauth2 v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.0 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
github.com/clipperhouse/uax29/v2 v2.4.0 // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
github.com/samber/lo v1.52.0 // indirect
|
||||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
github.com/sergi/go-diff v1.4.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
golang.org/x/crypto v0.44.0 // indirect
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/term v0.37.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
124
go.sum
124
go.sum
|
|
@ -1,26 +1,26 @@
|
||||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
forge.lthn.ai/Snider/Enchantrix v0.0.4 h1:biwpix/bdedfyc0iVeK15awhhJKH6TEMYOTXzHXx5TI=
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||||
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600 h1:9jyEgos5SNTVp3aJkhPs/fb4eTZE5l73YqaT+vFmFu0=
|
|
||||||
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600/go.mod h1:v9HATMgLJWycy/R5ho1SL0OHbggXgEhu/qRB9gbS0BM=
|
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||||
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g=
|
||||||
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
|
|
@ -31,12 +31,13 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
|
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||||
github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
|
@ -49,12 +50,18 @@ github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvt
|
||||||
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
||||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||||
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
|
@ -62,85 +69,108 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
|
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||||
|
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||||
|
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||||
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
|
||||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
|
||||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
|
|
||||||
4
go.work
4
go.work
|
|
@ -1,3 +1,5 @@
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
use .
|
use (
|
||||||
|
.
|
||||||
|
)
|
||||||
|
|
|
||||||
111
go.work.sum
111
go.work.sum
|
|
@ -1,21 +1,132 @@
|
||||||
|
atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw=
|
||||||
|
atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU=
|
||||||
|
atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
|
||||||
|
atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
|
||||||
|
atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
|
||||||
|
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
|
||||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
|
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||||
|
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||||
|
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||||
|
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/bitfield/script v0.24.0 h1:ic0Tbx+2AgRtkGGIcUyr+Un60vu4WXvqFrCSumf+T7M=
|
||||||
|
github.com/bitfield/script v0.24.0/go.mod h1:fv+6x4OzVsRs6qAlc7wiGq8fq1b5orhtQdtW0dwjUHI=
|
||||||
github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw=
|
github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw=
|
||||||
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||||
|
github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
|
||||||
|
github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
|
||||||
|
github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=
|
||||||
|
github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
|
||||||
|
github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||||
|
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
|
||||||
|
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
|
||||||
|
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||||
|
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||||
|
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/itchyny/gojq v0.12.13 h1:IxyYlHYIlspQHHTE0f3cJF0NKDMfajxViuhBLnHd/QU=
|
||||||
|
github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4=
|
||||||
|
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
|
||||||
|
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
|
||||||
|
github.com/jackmordaunt/icns v1.0.0 h1:RYSxplerf/l/DUd09AHtITwckkv/mqjVv4DjYdPmAMQ=
|
||||||
|
github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo=
|
||||||
|
github.com/jaypipes/ghw v0.13.0 h1:log8MXuB8hzTNnSktqpXMHc0c/2k/WgjOMSUtnI1RV4=
|
||||||
|
github.com/jaypipes/ghw v0.13.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8=
|
||||||
|
github.com/jaypipes/pcidb v1.0.1 h1:WB2zh27T3nwg8AE8ei81sNRb9yWBii3JGNJtT7K9Oic=
|
||||||
|
github.com/jaypipes/pcidb v1.0.1/go.mod h1:6xYUz/yYEyOkIkUt2t2J2folIuZ4Yg6uByCGFXMCeE4=
|
||||||
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg=
|
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg=
|
||||||
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
|
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
|
||||||
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
|
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
|
||||||
|
github.com/leaanthony/clir v1.3.0 h1:L9nPDWrmc/qU9UWZZvRaFajWYuO0np9V5p+5gxyYno0=
|
||||||
|
github.com/leaanthony/clir v1.3.0/go.mod h1:k/RBkdkFl18xkkACMCLt09bhiZnrGORoxmomeMvDpE0=
|
||||||
|
github.com/leaanthony/winicon v1.0.0 h1:ZNt5U5dY71oEoKZ97UVwJRT4e+5xo5o/ieKuHuk8NqQ=
|
||||||
|
github.com/leaanthony/winicon v1.0.0/go.mod h1:en5xhijl92aphrJdmRPlh4NI1L6wq3gEm0LpXAPghjU=
|
||||||
|
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||||
|
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
|
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||||
|
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/pterm/pterm v0.12.80 h1:mM55B+GnKUnLMUSqhdINe4s6tOuVQIetQ3my8JGyAIg=
|
||||||
|
github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||||
|
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
|
||||||
|
github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||||
|
github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/wzshiming/ctc v1.2.3 h1:q+hW3IQNsjIlOFBTGZZZeIXTElFM4grF4spW/errh/c=
|
||||||
|
github.com/wzshiming/ctc v1.2.3/go.mod h1:2tVAtIY7SUyraSk0JxvwmONNPFL4ARavPuEsg5+KA28=
|
||||||
|
github.com/wzshiming/winseq v0.0.0-20200112104235-db357dc107ae h1:tpXvBXC3hpQBDCc9OojJZCQMVRAbT3TTdUMP8WguXkY=
|
||||||
|
github.com/wzshiming/winseq v0.0.0-20200112104235-db357dc107ae/go.mod h1:VTAq37rkGeV+WOybvZwjXiJOicICdpLCN8ifpISjK20=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||||
|
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||||
|
golang.org/x/crypto v0.11.1-0.20230711161743-2e82bdd1719d/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||||
|
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
|
||||||
|
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
|
||||||
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||||
|
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||||
|
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
|
mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
|
||||||
|
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
|
||||||
|
|
|
||||||
106
js/borg-stmf/README.md
Normal file
106
js/borg-stmf/README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
# @borg/stmf
|
||||||
|
|
||||||
|
Sovereign Form Encryption - Client-side form encryption using X25519 + ChaCha20-Poly1305.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
BorgSTMF encrypts HTML form data in the browser before submission, using the server's public key. Even if a MITM proxy intercepts the request, they only see encrypted data.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @borg/stmf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Load the WASM support -->
|
||||||
|
<script src="wasm_exec.js"></script>
|
||||||
|
|
||||||
|
<!-- Your form -->
|
||||||
|
<form id="login" action="/api/login" method="POST" data-stmf="YOUR_PUBLIC_KEY_BASE64">
|
||||||
|
<input name="email" type="email" required>
|
||||||
|
<input name="password" type="password" required>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { BorgSTMF } from '@borg/stmf';
|
||||||
|
|
||||||
|
const borg = new BorgSTMF({
|
||||||
|
serverPublicKey: 'YOUR_PUBLIC_KEY_BASE64',
|
||||||
|
wasmPath: '/wasm/stmf.wasm'
|
||||||
|
});
|
||||||
|
|
||||||
|
await borg.init();
|
||||||
|
borg.enableInterceptor();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Encryption
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BorgSTMF } from '@borg/stmf';
|
||||||
|
|
||||||
|
const borg = new BorgSTMF({
|
||||||
|
serverPublicKey: 'YOUR_PUBLIC_KEY_BASE64'
|
||||||
|
});
|
||||||
|
|
||||||
|
await borg.init();
|
||||||
|
|
||||||
|
// Encrypt form element
|
||||||
|
const form = document.querySelector('form');
|
||||||
|
const result = await borg.encryptForm(form);
|
||||||
|
console.log(result.payload); // Base64 encrypted STMF
|
||||||
|
|
||||||
|
// Or encrypt key-value pairs directly
|
||||||
|
const result = await borg.encryptFields({
|
||||||
|
email: 'user@example.com',
|
||||||
|
password: 'secret'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Side Decryption
|
||||||
|
|
||||||
|
### Go Middleware
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/Snider/Borg/pkg/stmf/middleware"
|
||||||
|
|
||||||
|
privateKey := os.Getenv("STMF_PRIVATE_KEY")
|
||||||
|
handler := middleware.Simple(privateKeyBytes)(yourHandler)
|
||||||
|
|
||||||
|
// In your handler, form values are automatically decrypted:
|
||||||
|
email := r.FormValue("email")
|
||||||
|
```
|
||||||
|
|
||||||
|
### PHP
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Borg\STMF\STMF;
|
||||||
|
|
||||||
|
$stmf = new STMF($privateKeyBase64);
|
||||||
|
$formData = $stmf->decrypt($_POST['_stmf_payload']);
|
||||||
|
|
||||||
|
$email = $formData->get('email');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Generation
|
||||||
|
|
||||||
|
Generate a keypair for your server:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/Snider/Borg/pkg/stmf"
|
||||||
|
|
||||||
|
kp, _ := stmf.GenerateKeyPair()
|
||||||
|
fmt.Println("Public key:", kp.PublicKeyBase64()) // Share this
|
||||||
|
fmt.Println("Private key:", kp.PrivateKeyBase64()) // Keep secret!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **Hybrid encryption**: X25519 ECDH key exchange + ChaCha20-Poly1305
|
||||||
|
- **Forward secrecy**: Each form submission uses a new ephemeral keypair
|
||||||
|
- **Authenticated encryption**: Data integrity is verified on decryption
|
||||||
|
- **No passwords transmitted**: Only the public key is in the HTML
|
||||||
1161
js/borg-stmf/artist-portal.html
Normal file
1161
js/borg-stmf/artist-portal.html
Normal file
File diff suppressed because it is too large
Load diff
1
js/borg-stmf/demo-track.smsg
Normal file
1
js/borg-stmf/demo-track.smsg
Normal file
File diff suppressed because one or more lines are too long
155
js/borg-stmf/demo.html
Normal file
155
js/borg-stmf/demo.html
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>STMF Demo - Sovereign Form Encryption</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
.card { border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; margin: 1rem 0; }
|
||||||
|
input, button { padding: 0.5rem; margin: 0.25rem 0; }
|
||||||
|
input { width: 100%; box-sizing: border-box; }
|
||||||
|
button { background: #4CAF50; color: white; border: none; cursor: pointer; border-radius: 4px; }
|
||||||
|
button:hover { background: #45a049; }
|
||||||
|
pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; border-radius: 4px; font-size: 12px; }
|
||||||
|
.status { padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
|
||||||
|
.status.loading { background: #fff3cd; }
|
||||||
|
.status.ready { background: #d4edda; }
|
||||||
|
.status.error { background: #f8d7da; }
|
||||||
|
label { display: block; margin-top: 1rem; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>STMF Demo</h1>
|
||||||
|
<p>Sovereign Form Encryption using X25519 + ChaCha20-Poly1305</p>
|
||||||
|
|
||||||
|
<div id="status" class="status loading">Loading WASM module...</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>1. Generate Server Keypair</h2>
|
||||||
|
<p>In production, this is done server-side and the private key is kept secret.</p>
|
||||||
|
<button onclick="generateKeys()">Generate Keypair</button>
|
||||||
|
<label>Public Key (share with clients):</label>
|
||||||
|
<input type="text" id="publicKey" readonly placeholder="Click generate...">
|
||||||
|
<label>Private Key (keep secret!):</label>
|
||||||
|
<input type="text" id="privateKey" readonly placeholder="Click generate...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>2. Encrypt Form Data</h2>
|
||||||
|
<form id="demoForm">
|
||||||
|
<label>Email:</label>
|
||||||
|
<input type="email" name="email" value="user@example.com" required>
|
||||||
|
|
||||||
|
<label>Password:</label>
|
||||||
|
<input type="password" name="password" value="supersecret123" required>
|
||||||
|
|
||||||
|
<label>Message:</label>
|
||||||
|
<input type="text" name="message" value="Hello, encrypted world!">
|
||||||
|
|
||||||
|
<button type="submit" style="margin-top: 1rem; width: 100%;">Encrypt Form</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<label>Encrypted Payload (base64):</label>
|
||||||
|
<pre id="encrypted" style="word-break: break-all;">Submit the form to see encrypted output...</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>3. Payload Info</h2>
|
||||||
|
<p>This information can be read without decrypting (metadata is in the header):</p>
|
||||||
|
<pre id="info">Submit the form to see payload info...</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="dist/wasm_exec.js"></script>
|
||||||
|
<script>
|
||||||
|
let wasmReady = false;
|
||||||
|
|
||||||
|
// Load WASM
|
||||||
|
async function loadWasm() {
|
||||||
|
const go = new Go();
|
||||||
|
const result = await WebAssembly.instantiateStreaming(
|
||||||
|
fetch('dist/stmf.wasm'),
|
||||||
|
go.importObject
|
||||||
|
);
|
||||||
|
go.run(result.instance);
|
||||||
|
|
||||||
|
// Wait for BorgSTMF to be ready
|
||||||
|
while (!window.BorgSTMF?.ready) {
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
wasmReady = true;
|
||||||
|
document.getElementById('status').className = 'status ready';
|
||||||
|
document.getElementById('status').textContent =
|
||||||
|
`WASM loaded! Version: ${window.BorgSTMF.version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadWasm().catch(err => {
|
||||||
|
document.getElementById('status').className = 'status error';
|
||||||
|
document.getElementById('status').textContent = `Error: ${err.message}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate keypair
|
||||||
|
async function generateKeys() {
|
||||||
|
if (!wasmReady) {
|
||||||
|
alert('WASM not loaded yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keypair = await BorgSTMF.generateKeyPair();
|
||||||
|
document.getElementById('publicKey').value = keypair.publicKey;
|
||||||
|
document.getElementById('privateKey').value = keypair.privateKey;
|
||||||
|
} catch (err) {
|
||||||
|
alert('Error: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('demoForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!wasmReady) {
|
||||||
|
alert('WASM not loaded yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = document.getElementById('publicKey').value;
|
||||||
|
if (!publicKey) {
|
||||||
|
alert('Generate a keypair first!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get form data
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const fields = {};
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
fields[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
const encrypted = await BorgSTMF.encryptFields(
|
||||||
|
fields,
|
||||||
|
publicKey,
|
||||||
|
{ origin: window.location.origin, timestamp: Date.now().toString() }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.getElementById('encrypted').textContent = encrypted;
|
||||||
|
|
||||||
|
// Show info
|
||||||
|
document.getElementById('info').textContent = JSON.stringify({
|
||||||
|
payloadLength: encrypted.length,
|
||||||
|
payloadSizeKB: (encrypted.length * 0.75 / 1024).toFixed(2) + ' KB',
|
||||||
|
fieldsEncrypted: Object.keys(fields),
|
||||||
|
note: 'Each encryption produces different output (ephemeral keys)'
|
||||||
|
}, null, 2);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
alert('Encryption error: ' + err.message);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
js/borg-stmf/dist/stmf.wasm
vendored
Executable file
BIN
js/borg-stmf/dist/stmf.wasm
vendored
Executable file
Binary file not shown.
575
js/borg-stmf/dist/wasm_exec.js
vendored
Normal file
575
js/borg-stmf/dist/wasm_exec.js
vendored
Normal file
|
|
@ -0,0 +1,575 @@
|
||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalThis.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
globalThis.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substring(0, nl));
|
||||||
|
outputBuf = outputBuf.substring(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.process) {
|
||||||
|
globalThis.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.path) {
|
||||||
|
globalThis.path = {
|
||||||
|
resolve(...pathSegments) {
|
||||||
|
return pathSegments.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto) {
|
||||||
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.performance) {
|
||||||
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
globalThis.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this.argv = ["js"];
|
||||||
|
this.env = {};
|
||||||
|
this.exit = (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.warn("exit code:", code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._exitPromise = new Promise((resolve) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
this._pendingEvent = null;
|
||||||
|
this._scheduledTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const setInt64 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInt32 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInt64 = (addr) => {
|
||||||
|
const low = this.mem.getUint32(addr + 0, true);
|
||||||
|
const high = this.mem.getInt32(addr + 4, true);
|
||||||
|
return low + high * 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
const f = this.mem.getFloat64(addr, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.mem.getUint32(addr, true);
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
const nanHead = 0x7FF80000;
|
||||||
|
|
||||||
|
if (typeof v === "number" && v !== 0) {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
this.mem.setUint32(addr + 4, nanHead, true);
|
||||||
|
this.mem.setUint32(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mem.setFloat64(addr, v, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
this.mem.setFloat64(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._values.length;
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 0;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "object":
|
||||||
|
if (v !== null) {
|
||||||
|
typeFlag = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||||
|
this.mem.setUint32(addr, id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (addr) => {
|
||||||
|
const saddr = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCallExport = (a, b) => {
|
||||||
|
this._inst.exports.testExport0();
|
||||||
|
return this._inst.exports.testExport(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
this.importObject = {
|
||||||
|
_gotest: {
|
||||||
|
add: (a, b) => a + b,
|
||||||
|
callExport: testCallExport,
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||||
|
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||||
|
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||||
|
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||||
|
|
||||||
|
// func wasmExit(code int32)
|
||||||
|
"runtime.wasmExit": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const code = this.mem.getInt32(sp + 8, true);
|
||||||
|
this.exited = true;
|
||||||
|
delete this._inst;
|
||||||
|
delete this._values;
|
||||||
|
delete this._goRefCounts;
|
||||||
|
delete this._ids;
|
||||||
|
delete this._idPool;
|
||||||
|
this.exit(code);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||||
|
"runtime.wasmWrite": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const fd = getInt64(sp + 8);
|
||||||
|
const p = getInt64(sp + 16);
|
||||||
|
const n = this.mem.getInt32(sp + 24, true);
|
||||||
|
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func resetMemoryDataView()
|
||||||
|
"runtime.resetMemoryDataView": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func nanotime1() int64
|
||||||
|
"runtime.nanotime1": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func walltime() (sec int64, nsec int32)
|
||||||
|
"runtime.walltime": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const msec = (new Date).getTime();
|
||||||
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func scheduleTimeoutEvent(delay int64) int32
|
||||||
|
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this._nextCallbackTimeoutID;
|
||||||
|
this._nextCallbackTimeoutID++;
|
||||||
|
this._scheduledTimeouts.set(id, setTimeout(
|
||||||
|
() => {
|
||||||
|
this._resume();
|
||||||
|
while (this._scheduledTimeouts.has(id)) {
|
||||||
|
// for some reason Go failed to register the timeout event, log and try again
|
||||||
|
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||||
|
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||||
|
this._resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInt64(sp + 8),
|
||||||
|
));
|
||||||
|
this.mem.setInt32(sp + 16, id, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func clearTimeoutEvent(id int32)
|
||||||
|
"runtime.clearTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getInt32(sp + 8, true);
|
||||||
|
clearTimeout(this._scheduledTimeouts.get(id));
|
||||||
|
this._scheduledTimeouts.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func getRandomData(r []byte)
|
||||||
|
"runtime.getRandomData": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
crypto.getRandomValues(loadSlice(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getUint32(sp + 8, true);
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, loadString(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 32, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const m = Reflect.get(v, loadString(sp + 16));
|
||||||
|
const args = loadSliceOfValues(sp + 32);
|
||||||
|
const result = Reflect.apply(m, v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, result);
|
||||||
|
this.mem.setUint8(sp + 64, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, err);
|
||||||
|
this.mem.setUint8(sp + 64, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.apply(v, undefined, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.construct(v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||||
|
storeValue(sp + 16, str);
|
||||||
|
setInt64(sp + 24, str.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = loadValue(sp + 8);
|
||||||
|
loadSlice(sp + 16).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadSlice(sp + 8);
|
||||||
|
const src = loadValue(sp + 32);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
"syscall/js.copyBytesToJS": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadValue(sp + 8);
|
||||||
|
const src = loadSlice(sp + 16);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug": (value) => {
|
||||||
|
console.log(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
if (!(instance instanceof WebAssembly.Instance)) {
|
||||||
|
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||||
|
}
|
||||||
|
this._inst = instance;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
globalThis,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map([ // mapping from JS values to reference ids
|
||||||
|
[0, 1],
|
||||||
|
[null, 2],
|
||||||
|
[true, 3],
|
||||||
|
[false, 4],
|
||||||
|
[globalThis, 5],
|
||||||
|
[this, 6],
|
||||||
|
]);
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
|
||||||
|
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||||
|
let offset = 4096;
|
||||||
|
|
||||||
|
const strPtr = (str) => {
|
||||||
|
const ptr = offset;
|
||||||
|
const bytes = encoder.encode(str + "\0");
|
||||||
|
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||||
|
offset += bytes.length;
|
||||||
|
if (offset % 8 !== 0) {
|
||||||
|
offset += 8 - (offset % 8);
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const argc = this.argv.length;
|
||||||
|
|
||||||
|
const argvPtrs = [];
|
||||||
|
this.argv.forEach((arg) => {
|
||||||
|
argvPtrs.push(strPtr(arg));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const keys = Object.keys(this.env).sort();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const argv = offset;
|
||||||
|
argvPtrs.forEach((ptr) => {
|
||||||
|
this.mem.setUint32(offset, ptr, true);
|
||||||
|
this.mem.setUint32(offset + 4, 0, true);
|
||||||
|
offset += 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||||
|
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||||
|
const wasmMinDataAddr = 4096 + 8192;
|
||||||
|
if (offset >= wasmMinDataAddr) {
|
||||||
|
throw new Error("total length of command line and environment variables exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inst.exports.run(argc, argv);
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
await this._exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
this._inst.exports.resume();
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
556
js/borg-stmf/index.html
Normal file
556
js/borg-stmf/index.html
Normal file
|
|
@ -0,0 +1,556 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>STMF - Sovereign Form Encryption</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
background: linear-gradient(90deg, #00d9ff, #00ff94);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 .icon {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p.description {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea, input[type="text"], input[type="email"], input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus, input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00d9ff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 217, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[readonly] {
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
color: #00ff94;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: linear-gradient(135deg, #00d9ff 0%, #00ff94 100%);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 217, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.full-width {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.key-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.loading .dot {
|
||||||
|
background: #ffc107;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.ready .dot {
|
||||||
|
background: #00ff94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.error .dot {
|
||||||
|
background: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: #00ff94;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item .value {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #00d9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: #00d9ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(0, 217, 255, 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
background: rgba(0, 217, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a.active {
|
||||||
|
background: rgba(0, 217, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-banner {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-banner {
|
||||||
|
background: rgba(0, 255, 148, 0.1);
|
||||||
|
border: 1px solid rgba(0, 255, 148, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #00ff94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-banner.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Sovereign Form Encryption</h1>
|
||||||
|
<p class="subtitle">X25519 ECDH + ChaCha20-Poly1305 client-side encryption</p>
|
||||||
|
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="index.html" class="active">Form Encryption</a>
|
||||||
|
<a href="support-reply.html">Decrypt Messages</a>
|
||||||
|
<a href="media-player.html">Media Player</a>
|
||||||
|
<a href="artist-portal.html">Artist Portal</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="wasm-status" class="status-indicator loading">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>Loading encryption module...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="icon">🔑</span> Server Keypair</h2>
|
||||||
|
<p class="description">In production, generate this server-side and keep the private key secret. Only the public key is shared with clients.</p>
|
||||||
|
|
||||||
|
<button id="generate-btn" class="secondary" disabled>Generate New Keypair</button>
|
||||||
|
|
||||||
|
<div class="key-row" style="margin-top: 1rem;">
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Public Key (share with clients)</label>
|
||||||
|
<input type="text" id="publicKey" readonly placeholder="Click generate...">
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Private Key (keep secret!)</label>
|
||||||
|
<input type="text" id="privateKey" readonly placeholder="Click generate...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="icon">📝</span> Encrypt Form Data</h2>
|
||||||
|
<p class="description">Enter form fields to encrypt. Data is encrypted client-side before transmission.</p>
|
||||||
|
|
||||||
|
<div id="no-key-warning" class="warning-banner">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span>Generate a keypair first to enable encryption</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="demoForm">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" value="user@example.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="form-password" name="password" value="supersecret123" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="message">Message</label>
|
||||||
|
<input type="text" id="message" name="message" value="Hello, encrypted world!">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" id="encrypt-btn" class="primary full-width" disabled>Encrypt Form Data</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="success-banner" class="success-banner">
|
||||||
|
<span>✅</span>
|
||||||
|
<span>Form encrypted successfully!</span>
|
||||||
|
<button class="secondary copy-btn" id="copy-btn">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="output-card" style="display: none;">
|
||||||
|
<h2><span class="icon">🔒</span> Encrypted Output</h2>
|
||||||
|
<p class="description">This base64 payload can be safely transmitted. Only the server with the private key can decrypt it.</p>
|
||||||
|
|
||||||
|
<pre id="encrypted"></pre>
|
||||||
|
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="value" id="payload-size">-</div>
|
||||||
|
<div class="label">Payload Size</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="value" id="fields-count">-</div>
|
||||||
|
<div class="label">Fields Encrypted</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="value" id="algo-type">X25519</div>
|
||||||
|
<div class="label">Key Exchange</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="value" id="cipher-type">ChaCha20</div>
|
||||||
|
<div class="label">Cipher</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="icon">ℹ️</span> How It Works</h2>
|
||||||
|
<p class="description" style="margin-bottom: 0; line-height: 1.7;">
|
||||||
|
<strong>1. Key Exchange:</strong> An ephemeral X25519 keypair is generated for each encryption.<br>
|
||||||
|
<strong>2. Shared Secret:</strong> ECDH derives a shared secret using the ephemeral private key and server's public key.<br>
|
||||||
|
<strong>3. Encryption:</strong> Form data is encrypted with ChaCha20-Poly1305 using the derived key.<br>
|
||||||
|
<strong>4. Payload:</strong> The ephemeral public key is included in the header so the server can decrypt.<br><br>
|
||||||
|
Each encryption produces a unique output even for the same data, ensuring forward secrecy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="wasm_exec.js"></script>
|
||||||
|
<script>
|
||||||
|
let wasmReady = false;
|
||||||
|
|
||||||
|
// Update status indicator safely
|
||||||
|
function updateStatus(el, status, message) {
|
||||||
|
el.className = 'status-indicator ' + status;
|
||||||
|
while (el.firstChild) el.removeChild(el.firstChild);
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'dot';
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = message;
|
||||||
|
el.appendChild(dot);
|
||||||
|
el.appendChild(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize WASM
|
||||||
|
async function initWasm() {
|
||||||
|
const statusEl = document.getElementById('wasm-status');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const go = new Go();
|
||||||
|
const result = await WebAssembly.instantiateStreaming(
|
||||||
|
fetch('stmf.wasm'),
|
||||||
|
go.importObject
|
||||||
|
);
|
||||||
|
go.run(result.instance);
|
||||||
|
|
||||||
|
// Wait for BorgSTMF to be ready
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error('WASM init timeout')), 5000);
|
||||||
|
|
||||||
|
if (typeof BorgSTMF !== 'undefined' && BorgSTMF.ready) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('borgstmf:ready', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wasmReady = true;
|
||||||
|
updateStatus(statusEl, 'ready', 'Encryption module ready (v' + BorgSTMF.version + ')');
|
||||||
|
document.getElementById('generate-btn').disabled = false;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
updateStatus(statusEl, 'error', 'Failed to load: ' + err.message);
|
||||||
|
console.error('WASM init error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate keypair
|
||||||
|
async function generateKeys() {
|
||||||
|
if (!wasmReady) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keypair = await BorgSTMF.generateKeyPair();
|
||||||
|
document.getElementById('publicKey').value = keypair.publicKey;
|
||||||
|
document.getElementById('privateKey').value = keypair.privateKey;
|
||||||
|
|
||||||
|
// Enable encryption
|
||||||
|
document.getElementById('encrypt-btn').disabled = false;
|
||||||
|
document.getElementById('no-key-warning').style.display = 'none';
|
||||||
|
} catch (err) {
|
||||||
|
alert('Error generating keys: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
async function handleFormSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!wasmReady) {
|
||||||
|
alert('WASM not loaded yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = document.getElementById('publicKey').value;
|
||||||
|
if (!publicKey) {
|
||||||
|
alert('Generate a keypair first!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get form data
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const fields = {};
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
fields[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
const encrypted = await BorgSTMF.encryptFields(
|
||||||
|
fields,
|
||||||
|
publicKey,
|
||||||
|
{ origin: window.location.origin, timestamp: Date.now().toString() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show output
|
||||||
|
document.getElementById('encrypted').textContent = encrypted;
|
||||||
|
document.getElementById('output-card').style.display = 'block';
|
||||||
|
document.getElementById('success-banner').classList.add('visible');
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
const sizeKB = (encrypted.length * 0.75 / 1024).toFixed(2);
|
||||||
|
document.getElementById('payload-size').textContent = sizeKB + ' KB';
|
||||||
|
document.getElementById('fields-count').textContent = Object.keys(fields).length;
|
||||||
|
|
||||||
|
// Scroll to output
|
||||||
|
document.getElementById('output-card').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
alert('Encryption error: ' + err.message);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
async function copyToClipboard() {
|
||||||
|
const encrypted = document.getElementById('encrypted').textContent;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(encrypted);
|
||||||
|
const btn = document.getElementById('copy-btn');
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => btn.textContent = 'Copy', 2000);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to copy: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.getElementById('generate-btn').addEventListener('click', generateKeys);
|
||||||
|
document.getElementById('demoForm').addEventListener('submit', handleFormSubmit);
|
||||||
|
document.getElementById('copy-btn').addEventListener('click', copyToClipboard);
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
initWasm();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1290
js/borg-stmf/media-player.html
Normal file
1290
js/borg-stmf/media-player.html
Normal file
File diff suppressed because it is too large
Load diff
36
js/borg-stmf/package.json
Normal file
36
js/borg-stmf/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "@borg/stmf",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Sovereign Form Encryption - Client-side form encryption using X25519 + ChaCha20-Poly1305",
|
||||||
|
"main": "dist/borg-stmf.js",
|
||||||
|
"module": "dist/borg-stmf.esm.js",
|
||||||
|
"types": "dist/borg-stmf.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist/"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "rollup -c",
|
||||||
|
"dev": "rollup -c -w",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"encryption",
|
||||||
|
"form",
|
||||||
|
"security",
|
||||||
|
"chacha20",
|
||||||
|
"x25519",
|
||||||
|
"wasm",
|
||||||
|
"privacy"
|
||||||
|
],
|
||||||
|
"author": "Snider",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Snider/Borg"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-typescript": "^11.1.0",
|
||||||
|
"rollup": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
345
js/borg-stmf/src/index.ts
Normal file
345
js/borg-stmf/src/index.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
import type {
|
||||||
|
BorgSTMFConfig,
|
||||||
|
FormData,
|
||||||
|
FormField,
|
||||||
|
EncryptResult,
|
||||||
|
KeyPair,
|
||||||
|
InterceptorOptions,
|
||||||
|
BorgSTMFWasm,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
const DEFAULT_FIELD_NAME = '_stmf_payload';
|
||||||
|
const DEFAULT_WASM_PATH = './stmf.wasm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BorgSTMF - Sovereign Form Encryption
|
||||||
|
*
|
||||||
|
* Encrypts HTML form data client-side using the server's public key.
|
||||||
|
* Data is encrypted with X25519 ECDH + ChaCha20-Poly1305, providing
|
||||||
|
* end-to-end encryption even against MITM proxies.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const borg = new BorgSTMF({
|
||||||
|
* serverPublicKey: 'base64PublicKeyHere',
|
||||||
|
* wasmPath: '/wasm/stmf.wasm'
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* await borg.init();
|
||||||
|
*
|
||||||
|
* // Manual encryption
|
||||||
|
* const result = await borg.encryptForm(document.querySelector('form'));
|
||||||
|
*
|
||||||
|
* // Or use interceptor
|
||||||
|
* borg.enableInterceptor();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class BorgSTMF {
|
||||||
|
private config: Required<BorgSTMFConfig>;
|
||||||
|
private wasm: BorgSTMFWasm | null = null;
|
||||||
|
private initialized = false;
|
||||||
|
private interceptorActive = false;
|
||||||
|
private interceptorHandler: ((e: Event) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(config: BorgSTMFConfig) {
|
||||||
|
this.config = {
|
||||||
|
serverPublicKey: config.serverPublicKey,
|
||||||
|
wasmPath: config.wasmPath || DEFAULT_WASM_PATH,
|
||||||
|
fieldName: config.fieldName || DEFAULT_FIELD_NAME,
|
||||||
|
debug: config.debug || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the WASM module. Must be called before encryption.
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
// Check if WASM is already loaded (e.g., from a script tag)
|
||||||
|
if (window.BorgSTMF?.ready) {
|
||||||
|
this.wasm = window.BorgSTMF;
|
||||||
|
this.initialized = true;
|
||||||
|
this.log('Using pre-loaded WASM module');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load wasm_exec.js if not already loaded
|
||||||
|
if (typeof Go === 'undefined') {
|
||||||
|
await this.loadScript(this.config.wasmPath.replace('stmf.wasm', 'wasm_exec.js'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and instantiate the WASM module
|
||||||
|
const go = new Go();
|
||||||
|
const result = await WebAssembly.instantiateStreaming(
|
||||||
|
fetch(this.config.wasmPath),
|
||||||
|
go.importObject
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run the Go main function
|
||||||
|
go.run(result.instance);
|
||||||
|
|
||||||
|
// Wait for WASM to be ready
|
||||||
|
await this.waitForWasm();
|
||||||
|
|
||||||
|
this.wasm = window.BorgSTMF!;
|
||||||
|
this.initialized = true;
|
||||||
|
this.log('WASM module initialized, version:', this.wasm.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt an HTML form element
|
||||||
|
*/
|
||||||
|
async encryptForm(form: HTMLFormElement): Promise<EncryptResult> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
|
||||||
|
const formData = new window.FormData(form);
|
||||||
|
return this.encryptFormData(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a FormData object
|
||||||
|
*/
|
||||||
|
async encryptFormData(formData: globalThis.FormData): Promise<EncryptResult> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
|
||||||
|
const fields: Record<string, string | FormField> = {};
|
||||||
|
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
if (value instanceof File) {
|
||||||
|
// Handle file uploads - read as base64
|
||||||
|
// Note: For large files, consider chunking or streaming
|
||||||
|
this.log('File field detected:', key, value.name);
|
||||||
|
// For now, skip files - they need async reading
|
||||||
|
// TODO: Add file support with FileReader
|
||||||
|
} else {
|
||||||
|
fields[key] = value.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await this.wasm!.encryptFields(
|
||||||
|
fields,
|
||||||
|
this.config.serverPublicKey,
|
||||||
|
{
|
||||||
|
origin: window.location.origin,
|
||||||
|
timestamp: Date.now().toString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload,
|
||||||
|
fieldName: this.config.fieldName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a simple key-value object
|
||||||
|
*/
|
||||||
|
async encryptFields(
|
||||||
|
fields: Record<string, string>,
|
||||||
|
metadata?: Record<string, string>
|
||||||
|
): Promise<EncryptResult> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
origin: window.location.origin,
|
||||||
|
timestamp: Date.now().toString(),
|
||||||
|
...metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = await this.wasm!.encryptFields(
|
||||||
|
fields,
|
||||||
|
this.config.serverPublicKey,
|
||||||
|
meta
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload,
|
||||||
|
fieldName: this.config.fieldName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a full FormData structure
|
||||||
|
*/
|
||||||
|
async encryptFormDataStruct(data: FormData): Promise<EncryptResult> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
|
||||||
|
const payload = await this.wasm!.encrypt(
|
||||||
|
JSON.stringify(data),
|
||||||
|
this.config.serverPublicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload,
|
||||||
|
fieldName: this.config.fieldName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new keypair (for testing/development only)
|
||||||
|
*/
|
||||||
|
async generateKeyPair(): Promise<KeyPair> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
return this.wasm!.generateKeyPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable automatic form interception.
|
||||||
|
* Intercepts submit events on forms with the data-stmf attribute.
|
||||||
|
*/
|
||||||
|
enableInterceptor(options: InterceptorOptions = {}): void {
|
||||||
|
if (this.interceptorActive) return;
|
||||||
|
|
||||||
|
const { autoSubmit = true } = options;
|
||||||
|
|
||||||
|
this.interceptorHandler = async (e: Event) => {
|
||||||
|
const form = e.target as HTMLFormElement;
|
||||||
|
|
||||||
|
// Check if this form should be intercepted
|
||||||
|
const publicKey = form.dataset.stmf;
|
||||||
|
if (!publicKey && !options.selector) return;
|
||||||
|
if (options.selector && !form.matches(options.selector)) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use form's public key or default config
|
||||||
|
const serverKey = publicKey || this.config.serverPublicKey;
|
||||||
|
|
||||||
|
// Callback before encryption
|
||||||
|
if (options.onBeforeEncrypt) {
|
||||||
|
const proceed = await options.onBeforeEncrypt(form);
|
||||||
|
if (proceed === false) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the form
|
||||||
|
const originalFormData = new window.FormData(form);
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
originalFormData.forEach((value, key) => {
|
||||||
|
if (!(value instanceof File)) {
|
||||||
|
fields[key] = value.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await this.wasm!.encryptFields(
|
||||||
|
fields,
|
||||||
|
serverKey,
|
||||||
|
{
|
||||||
|
origin: window.location.origin,
|
||||||
|
timestamp: Date.now().toString(),
|
||||||
|
formId: form.id || undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Callback after encryption
|
||||||
|
if (options.onAfterEncrypt) {
|
||||||
|
options.onAfterEncrypt(form, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoSubmit) {
|
||||||
|
// Create new form data with only the encrypted payload
|
||||||
|
const encryptedFormData = new window.FormData();
|
||||||
|
encryptedFormData.append(this.config.fieldName, payload);
|
||||||
|
|
||||||
|
// Submit via fetch
|
||||||
|
const response = await fetch(form.action || window.location.href, {
|
||||||
|
method: form.method || 'POST',
|
||||||
|
body: encryptedFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle response - trigger custom event
|
||||||
|
const event = new CustomEvent('borgstmf:submitted', {
|
||||||
|
detail: { form, response, payload },
|
||||||
|
});
|
||||||
|
form.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.log('Encryption error:', error);
|
||||||
|
if (options.onError) {
|
||||||
|
options.onError(form, error as Error);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('submit', this.interceptorHandler, true);
|
||||||
|
this.interceptorActive = true;
|
||||||
|
this.log('Form interceptor enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable automatic form interception
|
||||||
|
*/
|
||||||
|
disableInterceptor(): void {
|
||||||
|
if (!this.interceptorActive || !this.interceptorHandler) return;
|
||||||
|
|
||||||
|
document.removeEventListener('submit', this.interceptorHandler, true);
|
||||||
|
this.interceptorHandler = null;
|
||||||
|
this.interceptorActive = false;
|
||||||
|
this.log('Form interceptor disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the module is initialized
|
||||||
|
*/
|
||||||
|
isInitialized(): boolean {
|
||||||
|
return this.initialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the WASM module version
|
||||||
|
*/
|
||||||
|
getVersion(): string {
|
||||||
|
return this.wasm?.version || 'not loaded';
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureInitialized(): void {
|
||||||
|
if (!this.initialized || !this.wasm) {
|
||||||
|
throw new Error('BorgSTMF not initialized. Call init() first.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForWasm(timeout = 5000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!window.BorgSTMF?.ready) {
|
||||||
|
if (Date.now() - start > timeout) {
|
||||||
|
throw new Error('Timeout waiting for WASM module to initialize');
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadScript(src: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = src;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(...args: unknown[]): void {
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.log('[BorgSTMF]', ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a factory function for convenience
|
||||||
|
export function createBorgSTMF(config: BorgSTMFConfig): BorgSTMF {
|
||||||
|
return new BorgSTMF(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export types for the Go interface
|
||||||
|
declare class Go {
|
||||||
|
constructor();
|
||||||
|
importObject: WebAssembly.Imports;
|
||||||
|
run(instance: WebAssembly.Instance): Promise<void>;
|
||||||
|
}
|
||||||
121
js/borg-stmf/src/types.ts
Normal file
121
js/borg-stmf/src/types.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* Configuration options for BorgSTMF
|
||||||
|
*/
|
||||||
|
export interface BorgSTMFConfig {
|
||||||
|
/**
|
||||||
|
* Base64-encoded X25519 public key of the server.
|
||||||
|
* Form data will be encrypted using this key.
|
||||||
|
*/
|
||||||
|
serverPublicKey: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the WASM file.
|
||||||
|
* @default './stmf.wasm'
|
||||||
|
*/
|
||||||
|
wasmPath?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the form field that will contain the encrypted payload.
|
||||||
|
* @default '_stmf_payload'
|
||||||
|
*/
|
||||||
|
fieldName?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable debug logging.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form field definition
|
||||||
|
*/
|
||||||
|
export interface FormField {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
type?: string;
|
||||||
|
filename?: string;
|
||||||
|
mime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form data structure for encryption
|
||||||
|
*/
|
||||||
|
export interface FormData {
|
||||||
|
fields: FormField[];
|
||||||
|
meta?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of encrypting form data
|
||||||
|
*/
|
||||||
|
export interface EncryptResult {
|
||||||
|
/** Base64-encoded encrypted STMF payload */
|
||||||
|
payload: string;
|
||||||
|
/** Name of the form field for the payload */
|
||||||
|
fieldName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X25519 keypair (for testing/development)
|
||||||
|
*/
|
||||||
|
export interface KeyPair {
|
||||||
|
/** Base64-encoded public key */
|
||||||
|
publicKey: string;
|
||||||
|
/** Base64-encoded private key (keep secret!) */
|
||||||
|
privateKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the form interceptor
|
||||||
|
*/
|
||||||
|
export interface InterceptorOptions {
|
||||||
|
/**
|
||||||
|
* CSS selector for forms to intercept.
|
||||||
|
* If not specified, intercepts forms with data-stmf attribute.
|
||||||
|
*/
|
||||||
|
selector?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback before encryption.
|
||||||
|
* Return false to cancel encryption.
|
||||||
|
*/
|
||||||
|
onBeforeEncrypt?: (form: HTMLFormElement) => boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback after encryption.
|
||||||
|
*/
|
||||||
|
onAfterEncrypt?: (form: HTMLFormElement, payload: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback on encryption error.
|
||||||
|
*/
|
||||||
|
onError?: (form: HTMLFormElement, error: Error) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to submit the form automatically after encryption.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
autoSubmit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BorgSTMF WASM module interface
|
||||||
|
*/
|
||||||
|
export interface BorgSTMFWasm {
|
||||||
|
encrypt: (formDataJSON: string, serverPublicKey: string) => Promise<string>;
|
||||||
|
encryptFields: (
|
||||||
|
fields: Record<string, string | FormField>,
|
||||||
|
serverPublicKey: string,
|
||||||
|
metadata?: Record<string, string>
|
||||||
|
) => Promise<string>;
|
||||||
|
generateKeyPair: () => Promise<KeyPair>;
|
||||||
|
version: string;
|
||||||
|
ready: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
BorgSTMF?: BorgSTMFWasm;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
js/borg-stmf/stmf.wasm
Executable file
BIN
js/borg-stmf/stmf.wasm
Executable file
Binary file not shown.
799
js/borg-stmf/support-reply.html
Normal file
799
js/borg-stmf/support-reply.html
Normal file
|
|
@ -0,0 +1,799 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Decrypt Secure Support Reply</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
background: linear-gradient(90deg, #00d9ff, #00ff94);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 .icon {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea, input[type="password"], input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus, input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00d9ff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 217, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.encrypted {
|
||||||
|
min-height: 120px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-row .input-group {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: linear-gradient(135deg, #00d9ff 0%, #00ff94 100%);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 217, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-banner {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-banner.visible {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-banner .hint-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-banner .hint-text {
|
||||||
|
color: #ffc107;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-from {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #00d9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-date {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-subject {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-download {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-key-banner {
|
||||||
|
background: rgba(0, 217, 255, 0.1);
|
||||||
|
border: 1px solid rgba(0, 217, 255, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-key-banner.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-key-banner h4 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-key-banner p {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-key-value {
|
||||||
|
font-family: 'Monaco', 'Menlo', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: rgba(255, 82, 82, 0.1);
|
||||||
|
border: 1px solid rgba(255, 82, 82, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
color: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.loading .dot {
|
||||||
|
background: #ffc107;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.ready .dot {
|
||||||
|
background: #00ff94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.error .dot {
|
||||||
|
background: #ff5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section {
|
||||||
|
border-top: 1px dashed rgba(255,255,255,0.1);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-messages {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-messages button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: #00d9ff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(0, 217, 255, 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
background: rgba(0, 217, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a.active {
|
||||||
|
background: rgba(0, 217, 255, 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Secure Support Reply</h1>
|
||||||
|
<p class="subtitle">Decrypt password-protected messages from support</p>
|
||||||
|
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="index.html">Form Encryption</a>
|
||||||
|
<a href="support-reply.html" class="active">Decrypt Messages</a>
|
||||||
|
<a href="media-player.html">Media Player</a>
|
||||||
|
<a href="artist-portal.html">Artist Portal</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="wasm-status" class="status-indicator loading">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>Loading encryption module...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2><span class="icon">📨</span> Encrypted Message</h2>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="encrypted-message">Paste the encrypted message you received:</label>
|
||||||
|
<textarea id="encrypted-message" class="encrypted" placeholder="U01TRy4uLg=="></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hint-banner" class="hint-banner">
|
||||||
|
<span class="hint-icon">💡</span>
|
||||||
|
<span class="hint-text">Password hint: <strong id="hint-text"></strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="error-banner" class="error-banner"></div>
|
||||||
|
|
||||||
|
<div class="password-row">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input type="password" id="password" placeholder="Enter your password">
|
||||||
|
</div>
|
||||||
|
<button id="decrypt-btn" class="primary" disabled>Decrypt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message-container" class="card message-container">
|
||||||
|
<h2><span class="icon">📬</span> Decrypted Message</h2>
|
||||||
|
|
||||||
|
<div class="message-header">
|
||||||
|
<div>
|
||||||
|
<div class="message-from" id="msg-from">Support Team</div>
|
||||||
|
<div id="msg-subject" class="message-subject"></div>
|
||||||
|
</div>
|
||||||
|
<div class="message-date" id="msg-date"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-body" id="msg-body"></div>
|
||||||
|
|
||||||
|
<div id="attachments-container" class="attachments" style="display: none;">
|
||||||
|
<h3>Attachments</h3>
|
||||||
|
<div id="attachments-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="reply-key-banner" class="reply-key-banner">
|
||||||
|
<h4><span>🔐</span> Authenticated Reply Key</h4>
|
||||||
|
<p>This message includes a public key for secure replies. Use this to encrypt your response:</p>
|
||||||
|
<div class="reply-key-value" id="reply-key"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="demo-section">
|
||||||
|
<h3>Demo: Try with sample messages</h3>
|
||||||
|
<p style="font-size: 0.85rem; color: #888; margin-bottom: 1rem;">
|
||||||
|
Click a button to load a pre-encrypted sample message. All use password: <code style="background: rgba(0,0,0,0.3); padding: 0.2rem 0.4rem; border-radius: 4px;">demo123</code>
|
||||||
|
</p>
|
||||||
|
<div class="example-messages" id="example-buttons"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="wasm_exec.js"></script>
|
||||||
|
<script>
|
||||||
|
// Example encrypted messages (will be populated by WASM generation)
|
||||||
|
const EXAMPLES = {
|
||||||
|
'simple': '',
|
||||||
|
'with-attachment': '',
|
||||||
|
'with-hint': '',
|
||||||
|
'with-reply-key': ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store attachment data for downloads
|
||||||
|
const attachmentData = new Map();
|
||||||
|
|
||||||
|
let wasmReady = false;
|
||||||
|
|
||||||
|
// Initialize WASM
|
||||||
|
async function initWasm() {
|
||||||
|
const statusEl = document.getElementById('wasm-status');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const go = new Go();
|
||||||
|
const result = await WebAssembly.instantiateStreaming(
|
||||||
|
fetch('stmf.wasm'),
|
||||||
|
go.importObject
|
||||||
|
);
|
||||||
|
go.run(result.instance);
|
||||||
|
|
||||||
|
// Wait for BorgSMSG to be ready
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error('WASM init timeout')), 5000);
|
||||||
|
|
||||||
|
if (typeof BorgSMSG !== 'undefined' && BorgSMSG.ready) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('borgstmf:ready', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wasmReady = true;
|
||||||
|
updateStatus(statusEl, 'ready', 'Encryption module ready (v' + BorgSMSG.version + ')');
|
||||||
|
document.getElementById('decrypt-btn').disabled = false;
|
||||||
|
|
||||||
|
// Generate example messages
|
||||||
|
await generateExamples();
|
||||||
|
setupExampleButtons();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
updateStatus(statusEl, 'error', 'Failed to load: ' + err.message);
|
||||||
|
console.error('WASM init error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status indicator safely
|
||||||
|
function updateStatus(el, status, message) {
|
||||||
|
el.className = 'status-indicator ' + status;
|
||||||
|
// Clear and rebuild safely
|
||||||
|
while (el.firstChild) el.removeChild(el.firstChild);
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'dot';
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = message;
|
||||||
|
el.appendChild(dot);
|
||||||
|
el.appendChild(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup example buttons safely
|
||||||
|
function setupExampleButtons() {
|
||||||
|
const container = document.getElementById('example-buttons');
|
||||||
|
const examples = [
|
||||||
|
{ key: 'simple', label: 'Simple Message' },
|
||||||
|
{ key: 'with-attachment', label: 'With Attachment' },
|
||||||
|
{ key: 'with-hint', label: 'With Password Hint' },
|
||||||
|
{ key: 'with-reply-key', label: 'With Reply Key' }
|
||||||
|
];
|
||||||
|
|
||||||
|
examples.forEach(ex => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'secondary';
|
||||||
|
btn.textContent = ex.label;
|
||||||
|
btn.addEventListener('click', () => loadExample(ex.key));
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate example encrypted messages
|
||||||
|
async function generateExamples() {
|
||||||
|
try {
|
||||||
|
// Simple message
|
||||||
|
EXAMPLES['simple'] = await BorgSMSG.encrypt({
|
||||||
|
body: 'Hello! Thank you for contacting our support team.\n\nWe have reviewed your request and are happy to help. Please let us know if you have any other questions.\n\nBest regards,\nThe Support Team',
|
||||||
|
subject: 'Re: Your Support Request #12345',
|
||||||
|
from: 'support@example.com'
|
||||||
|
}, 'demo123');
|
||||||
|
|
||||||
|
// With attachment
|
||||||
|
const fileContent = btoa('This is the content of the attached file.\nIt contains important information.');
|
||||||
|
EXAMPLES['with-attachment'] = await BorgSMSG.encrypt({
|
||||||
|
body: 'Please find the requested document attached to this message.\n\nThe file contains the information you requested about your account.',
|
||||||
|
subject: 'Document Attached',
|
||||||
|
from: 'documents@example.com',
|
||||||
|
attachments: [{
|
||||||
|
name: 'account-details.txt',
|
||||||
|
content: fileContent,
|
||||||
|
mime: 'text/plain'
|
||||||
|
}]
|
||||||
|
}, 'demo123');
|
||||||
|
|
||||||
|
// With password hint
|
||||||
|
EXAMPLES['with-hint'] = await BorgSMSG.encrypt({
|
||||||
|
body: 'This is a confidential message that requires your password to view.\n\nYour account has been updated as requested.',
|
||||||
|
subject: 'Account Update Confirmation',
|
||||||
|
from: 'security@example.com'
|
||||||
|
}, 'demo123', 'demo + 123');
|
||||||
|
|
||||||
|
// With reply key
|
||||||
|
EXAMPLES['with-reply-key'] = await BorgSMSG.encrypt({
|
||||||
|
body: 'This message includes a public key for secure replies.\n\nWhen you reply, use the attached public key to encrypt your response. This ensures only we can read your reply.',
|
||||||
|
subject: 'Secure Communication Channel',
|
||||||
|
from: 'secure@example.com',
|
||||||
|
replyKey: {
|
||||||
|
publicKey: 'dGVzdHB1YmxpY2tleWZvcmRlbW9wdXJwb3Nlcw=='
|
||||||
|
}
|
||||||
|
}, 'demo123');
|
||||||
|
|
||||||
|
console.log('Example messages generated');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate examples:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load example message
|
||||||
|
function loadExample(type) {
|
||||||
|
const textarea = document.getElementById('encrypted-message');
|
||||||
|
textarea.value = EXAMPLES[type];
|
||||||
|
checkForHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for password hint
|
||||||
|
async function checkForHint() {
|
||||||
|
const encryptedB64 = document.getElementById('encrypted-message').value.trim();
|
||||||
|
const hintBanner = document.getElementById('hint-banner');
|
||||||
|
const hintText = document.getElementById('hint-text');
|
||||||
|
|
||||||
|
hintBanner.classList.remove('visible');
|
||||||
|
|
||||||
|
if (!encryptedB64 || !wasmReady) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await BorgSMSG.getInfo(encryptedB64);
|
||||||
|
if (info.hint) {
|
||||||
|
hintText.textContent = info.hint;
|
||||||
|
hintBanner.classList.add('visible');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Silently ignore - invalid format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt message
|
||||||
|
async function decryptMessage() {
|
||||||
|
const encryptedB64 = document.getElementById('encrypted-message').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const errorBanner = document.getElementById('error-banner');
|
||||||
|
const messageContainer = document.getElementById('message-container');
|
||||||
|
|
||||||
|
errorBanner.classList.remove('visible');
|
||||||
|
messageContainer.classList.remove('visible');
|
||||||
|
|
||||||
|
if (!encryptedB64) {
|
||||||
|
showError('Please paste an encrypted message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
showError('Please enter the password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await BorgSMSG.decrypt(encryptedB64, password);
|
||||||
|
displayMessage(message);
|
||||||
|
} catch (err) {
|
||||||
|
showError('Decryption failed: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error
|
||||||
|
function showError(msg) {
|
||||||
|
const errorBanner = document.getElementById('error-banner');
|
||||||
|
errorBanner.textContent = msg;
|
||||||
|
errorBanner.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display decrypted message
|
||||||
|
function displayMessage(msg) {
|
||||||
|
document.getElementById('msg-from').textContent = msg.from || 'Unknown Sender';
|
||||||
|
document.getElementById('msg-subject').textContent = msg.subject || '(No Subject)';
|
||||||
|
document.getElementById('msg-body').textContent = msg.body;
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
if (msg.timestamp) {
|
||||||
|
const date = new Date(msg.timestamp * 1000);
|
||||||
|
document.getElementById('msg-date').textContent = date.toLocaleString();
|
||||||
|
} else {
|
||||||
|
document.getElementById('msg-date').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle attachments
|
||||||
|
const attachmentsContainer = document.getElementById('attachments-container');
|
||||||
|
const attachmentsList = document.getElementById('attachments-list');
|
||||||
|
|
||||||
|
// Clear previous attachments
|
||||||
|
while (attachmentsList.firstChild) {
|
||||||
|
attachmentsList.removeChild(attachmentsList.firstChild);
|
||||||
|
}
|
||||||
|
attachmentData.clear();
|
||||||
|
|
||||||
|
if (msg.attachments && msg.attachments.length > 0) {
|
||||||
|
attachmentsContainer.style.display = 'block';
|
||||||
|
|
||||||
|
msg.attachments.forEach((att, index) => {
|
||||||
|
// Store attachment data
|
||||||
|
const attId = 'att-' + index;
|
||||||
|
attachmentData.set(attId, {
|
||||||
|
name: att.name,
|
||||||
|
content: att.content,
|
||||||
|
mime: att.mime
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'attachment-item';
|
||||||
|
|
||||||
|
const iconSpan = document.createElement('span');
|
||||||
|
iconSpan.className = 'attachment-icon';
|
||||||
|
iconSpan.textContent = getFileIcon(att.mime);
|
||||||
|
|
||||||
|
const infoDiv = document.createElement('div');
|
||||||
|
infoDiv.className = 'attachment-info';
|
||||||
|
|
||||||
|
const nameDiv = document.createElement('div');
|
||||||
|
nameDiv.className = 'attachment-name';
|
||||||
|
nameDiv.textContent = att.name;
|
||||||
|
|
||||||
|
const metaDiv = document.createElement('div');
|
||||||
|
metaDiv.className = 'attachment-meta';
|
||||||
|
metaDiv.textContent = att.mime || 'unknown type';
|
||||||
|
|
||||||
|
infoDiv.appendChild(nameDiv);
|
||||||
|
infoDiv.appendChild(metaDiv);
|
||||||
|
|
||||||
|
const downloadBtn = document.createElement('button');
|
||||||
|
downloadBtn.className = 'secondary attachment-download';
|
||||||
|
downloadBtn.textContent = 'Download';
|
||||||
|
downloadBtn.dataset.attId = attId;
|
||||||
|
downloadBtn.addEventListener('click', function() {
|
||||||
|
downloadAttachment(this.dataset.attId);
|
||||||
|
});
|
||||||
|
|
||||||
|
item.appendChild(iconSpan);
|
||||||
|
item.appendChild(infoDiv);
|
||||||
|
item.appendChild(downloadBtn);
|
||||||
|
|
||||||
|
attachmentsList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
attachmentsContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle reply key
|
||||||
|
const replyKeyBanner = document.getElementById('reply-key-banner');
|
||||||
|
if (msg.replyKey && msg.replyKey.publicKey) {
|
||||||
|
document.getElementById('reply-key').textContent = msg.replyKey.publicKey;
|
||||||
|
replyKeyBanner.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
replyKeyBanner.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('message-container').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file icon based on mime type
|
||||||
|
function getFileIcon(mime) {
|
||||||
|
if (!mime) return '📄';
|
||||||
|
if (mime.startsWith('image/')) return '🖼️';
|
||||||
|
if (mime.startsWith('video/')) return '🎬';
|
||||||
|
if (mime.startsWith('audio/')) return '🎵';
|
||||||
|
if (mime.includes('pdf')) return '📕';
|
||||||
|
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gzip')) return '📦';
|
||||||
|
if (mime.includes('json') || mime.includes('xml')) return '📋';
|
||||||
|
return '📄';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download attachment
|
||||||
|
function downloadAttachment(attId) {
|
||||||
|
const att = attachmentData.get(attId);
|
||||||
|
if (!att) {
|
||||||
|
alert('Attachment not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binary = atob(att.content);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = att.name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to download: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.getElementById('decrypt-btn').addEventListener('click', decryptMessage);
|
||||||
|
document.getElementById('password').addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') decryptMessage();
|
||||||
|
});
|
||||||
|
document.getElementById('encrypted-message').addEventListener('input', checkForHint);
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
initWasm();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
js/borg-stmf/tsconfig.json
Normal file
17
js/borg-stmf/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "./dist",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
575
js/borg-stmf/wasm_exec.js
Normal file
575
js/borg-stmf/wasm_exec.js
Normal file
|
|
@ -0,0 +1,575 @@
|
||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalThis.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
globalThis.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substring(0, nl));
|
||||||
|
outputBuf = outputBuf.substring(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.process) {
|
||||||
|
globalThis.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.path) {
|
||||||
|
globalThis.path = {
|
||||||
|
resolve(...pathSegments) {
|
||||||
|
return pathSegments.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto) {
|
||||||
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.performance) {
|
||||||
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
globalThis.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this.argv = ["js"];
|
||||||
|
this.env = {};
|
||||||
|
this.exit = (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.warn("exit code:", code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._exitPromise = new Promise((resolve) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
this._pendingEvent = null;
|
||||||
|
this._scheduledTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const setInt64 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInt32 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInt64 = (addr) => {
|
||||||
|
const low = this.mem.getUint32(addr + 0, true);
|
||||||
|
const high = this.mem.getInt32(addr + 4, true);
|
||||||
|
return low + high * 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
const f = this.mem.getFloat64(addr, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.mem.getUint32(addr, true);
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
const nanHead = 0x7FF80000;
|
||||||
|
|
||||||
|
if (typeof v === "number" && v !== 0) {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
this.mem.setUint32(addr + 4, nanHead, true);
|
||||||
|
this.mem.setUint32(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mem.setFloat64(addr, v, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
this.mem.setFloat64(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._values.length;
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 0;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "object":
|
||||||
|
if (v !== null) {
|
||||||
|
typeFlag = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||||
|
this.mem.setUint32(addr, id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (addr) => {
|
||||||
|
const saddr = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCallExport = (a, b) => {
|
||||||
|
this._inst.exports.testExport0();
|
||||||
|
return this._inst.exports.testExport(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
this.importObject = {
|
||||||
|
_gotest: {
|
||||||
|
add: (a, b) => a + b,
|
||||||
|
callExport: testCallExport,
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||||
|
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||||
|
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||||
|
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||||
|
|
||||||
|
// func wasmExit(code int32)
|
||||||
|
"runtime.wasmExit": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const code = this.mem.getInt32(sp + 8, true);
|
||||||
|
this.exited = true;
|
||||||
|
delete this._inst;
|
||||||
|
delete this._values;
|
||||||
|
delete this._goRefCounts;
|
||||||
|
delete this._ids;
|
||||||
|
delete this._idPool;
|
||||||
|
this.exit(code);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||||
|
"runtime.wasmWrite": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const fd = getInt64(sp + 8);
|
||||||
|
const p = getInt64(sp + 16);
|
||||||
|
const n = this.mem.getInt32(sp + 24, true);
|
||||||
|
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func resetMemoryDataView()
|
||||||
|
"runtime.resetMemoryDataView": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func nanotime1() int64
|
||||||
|
"runtime.nanotime1": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func walltime() (sec int64, nsec int32)
|
||||||
|
"runtime.walltime": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const msec = (new Date).getTime();
|
||||||
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func scheduleTimeoutEvent(delay int64) int32
|
||||||
|
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this._nextCallbackTimeoutID;
|
||||||
|
this._nextCallbackTimeoutID++;
|
||||||
|
this._scheduledTimeouts.set(id, setTimeout(
|
||||||
|
() => {
|
||||||
|
this._resume();
|
||||||
|
while (this._scheduledTimeouts.has(id)) {
|
||||||
|
// for some reason Go failed to register the timeout event, log and try again
|
||||||
|
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||||
|
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||||
|
this._resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInt64(sp + 8),
|
||||||
|
));
|
||||||
|
this.mem.setInt32(sp + 16, id, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func clearTimeoutEvent(id int32)
|
||||||
|
"runtime.clearTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getInt32(sp + 8, true);
|
||||||
|
clearTimeout(this._scheduledTimeouts.get(id));
|
||||||
|
this._scheduledTimeouts.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func getRandomData(r []byte)
|
||||||
|
"runtime.getRandomData": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
crypto.getRandomValues(loadSlice(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getUint32(sp + 8, true);
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, loadString(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 32, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const m = Reflect.get(v, loadString(sp + 16));
|
||||||
|
const args = loadSliceOfValues(sp + 32);
|
||||||
|
const result = Reflect.apply(m, v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, result);
|
||||||
|
this.mem.setUint8(sp + 64, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, err);
|
||||||
|
this.mem.setUint8(sp + 64, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.apply(v, undefined, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.construct(v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||||
|
storeValue(sp + 16, str);
|
||||||
|
setInt64(sp + 24, str.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = loadValue(sp + 8);
|
||||||
|
loadSlice(sp + 16).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadSlice(sp + 8);
|
||||||
|
const src = loadValue(sp + 32);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
"syscall/js.copyBytesToJS": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadValue(sp + 8);
|
||||||
|
const src = loadSlice(sp + 16);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug": (value) => {
|
||||||
|
console.log(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
if (!(instance instanceof WebAssembly.Instance)) {
|
||||||
|
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||||
|
}
|
||||||
|
this._inst = instance;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
globalThis,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map([ // mapping from JS values to reference ids
|
||||||
|
[0, 1],
|
||||||
|
[null, 2],
|
||||||
|
[true, 3],
|
||||||
|
[false, 4],
|
||||||
|
[globalThis, 5],
|
||||||
|
[this, 6],
|
||||||
|
]);
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
|
||||||
|
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||||
|
let offset = 4096;
|
||||||
|
|
||||||
|
const strPtr = (str) => {
|
||||||
|
const ptr = offset;
|
||||||
|
const bytes = encoder.encode(str + "\0");
|
||||||
|
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||||
|
offset += bytes.length;
|
||||||
|
if (offset % 8 !== 0) {
|
||||||
|
offset += 8 - (offset % 8);
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const argc = this.argv.length;
|
||||||
|
|
||||||
|
const argvPtrs = [];
|
||||||
|
this.argv.forEach((arg) => {
|
||||||
|
argvPtrs.push(strPtr(arg));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const keys = Object.keys(this.env).sort();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const argv = offset;
|
||||||
|
argvPtrs.forEach((ptr) => {
|
||||||
|
this.mem.setUint32(offset, ptr, true);
|
||||||
|
this.mem.setUint32(offset + 4, 0, true);
|
||||||
|
offset += 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||||
|
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||||
|
const wasmMinDataAddr = 4096 + 8192;
|
||||||
|
if (offset >= wasmMinDataAddr) {
|
||||||
|
throw new Error("total length of command line and environment variables exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inst.exports.run(argc, argv);
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
await this._exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
this._inst.exports.resume();
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
4
main.go
4
main.go
|
|
@ -3,8 +3,8 @@ package main
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/Snider/Borg/cmd"
|
"forge.lthn.ai/Snider/Borg/cmd"
|
||||||
"github.com/Snider/Borg/pkg/logger"
|
"forge.lthn.ai/Snider/Borg/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var osExit = os.Exit
|
var osExit = os.Exit
|
||||||
|
|
|
||||||
147
php/borg-stmf/README.md
Normal file
147
php/borg-stmf/README.md
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
# Borg STMF for PHP
|
||||||
|
|
||||||
|
Sovereign Form Encryption - Decrypt STMF payloads using X25519 + ChaCha20-Poly1305.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 7.2 or later
|
||||||
|
- `ext-sodium` (included in PHP 7.2+)
|
||||||
|
- `ext-json`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require borg/stmf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Borg\STMF\STMF;
|
||||||
|
|
||||||
|
// Initialize with your private key
|
||||||
|
$stmf = new STMF($privateKeyBase64);
|
||||||
|
|
||||||
|
// Decrypt the form payload from POST
|
||||||
|
$formData = $stmf->decrypt($_POST['_stmf_payload']);
|
||||||
|
|
||||||
|
// Access form fields
|
||||||
|
$email = $formData->get('email');
|
||||||
|
$password = $formData->get('password');
|
||||||
|
|
||||||
|
// Access all fields as array
|
||||||
|
$allFields = $formData->toArray();
|
||||||
|
|
||||||
|
// Access metadata
|
||||||
|
$origin = $formData->getOrigin();
|
||||||
|
$timestamp = $formData->getTimestamp();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Laravel Integration
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In a controller
|
||||||
|
public function handleForm(Request $request)
|
||||||
|
{
|
||||||
|
$stmf = new STMF(config('app.stmf_private_key'));
|
||||||
|
$formData = $stmf->decrypt($request->input('_stmf_payload'));
|
||||||
|
|
||||||
|
// Use decrypted data
|
||||||
|
$user = User::create([
|
||||||
|
'email' => $formData->get('email'),
|
||||||
|
'password' => Hash::make($formData->get('password')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Generation
|
||||||
|
|
||||||
|
Generate a keypair in Go:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/Snider/Borg/pkg/stmf"
|
||||||
|
|
||||||
|
kp, _ := stmf.GenerateKeyPair()
|
||||||
|
fmt.Println("Public key:", kp.PublicKeyBase64()) // Put in HTML
|
||||||
|
fmt.Println("Private key:", kp.PrivateKeyBase64()) // Put in PHP config
|
||||||
|
```
|
||||||
|
|
||||||
|
Or generate in PHP (for testing):
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Borg\STMF\KeyPair;
|
||||||
|
|
||||||
|
$keypair = KeyPair::generate();
|
||||||
|
echo "Public: " . $keypair->getPublicKeyBase64() . "\n";
|
||||||
|
echo "Private: " . $keypair->getPrivateKeyBase64() . "\n";
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### STMF
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Constructor
|
||||||
|
$stmf = new STMF(string $privateKeyBase64);
|
||||||
|
|
||||||
|
// Decrypt a base64-encoded payload
|
||||||
|
$formData = $stmf->decrypt(string $payloadBase64): FormData;
|
||||||
|
|
||||||
|
// Decrypt raw bytes
|
||||||
|
$formData = $stmf->decryptRaw(string $payload): FormData;
|
||||||
|
|
||||||
|
// Validate without decrypting
|
||||||
|
$isValid = $stmf->validate(string $payloadBase64): bool;
|
||||||
|
|
||||||
|
// Get payload info without decrypting
|
||||||
|
$info = $stmf->getInfo(string $payloadBase64): array;
|
||||||
|
```
|
||||||
|
|
||||||
|
### FormData
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Get a single field value
|
||||||
|
$value = $formData->get(string $name): ?string;
|
||||||
|
|
||||||
|
// Get a field object (includes type, filename, mime)
|
||||||
|
$field = $formData->getField(string $name): ?FormField;
|
||||||
|
|
||||||
|
// Get all values for a field name
|
||||||
|
$values = $formData->getAll(string $name): array;
|
||||||
|
|
||||||
|
// Check if field exists
|
||||||
|
$exists = $formData->has(string $name): bool;
|
||||||
|
|
||||||
|
// Convert to associative array
|
||||||
|
$array = $formData->toArray(): array;
|
||||||
|
|
||||||
|
// Get all fields
|
||||||
|
$fields = $formData->fields(): array;
|
||||||
|
|
||||||
|
// Get metadata
|
||||||
|
$meta = $formData->getMetadata(): array;
|
||||||
|
$origin = $formData->getOrigin(): ?string;
|
||||||
|
$timestamp = $formData->getTimestamp(): ?int;
|
||||||
|
```
|
||||||
|
|
||||||
|
### FormField
|
||||||
|
|
||||||
|
```php
|
||||||
|
$field->name; // Field name
|
||||||
|
$field->value; // Field value
|
||||||
|
$field->type; // Field type (text, password, file, etc.)
|
||||||
|
$field->filename; // Filename for file uploads
|
||||||
|
$field->mimeType; // MIME type for file uploads
|
||||||
|
|
||||||
|
$field->isFile(): bool; // Check if this is a file field
|
||||||
|
$field->getFileContent(): ?string; // Get decoded file content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **Hybrid encryption**: X25519 ECDH key exchange + ChaCha20-Poly1305
|
||||||
|
- **Forward secrecy**: Each form submission uses a new ephemeral keypair
|
||||||
|
- **Authenticated encryption**: Decryption fails if data was tampered with
|
||||||
|
- **Libsodium**: Uses PHP's built-in sodium extension
|
||||||
34
php/borg-stmf/composer.json
Normal file
34
php/borg-stmf/composer.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "borg/stmf",
|
||||||
|
"description": "Sovereign Form Encryption - Decrypt STMF payloads using X25519 + ChaCha20-Poly1305",
|
||||||
|
"type": "library",
|
||||||
|
"license": "MIT",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Snider",
|
||||||
|
"email": "snider@example.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.2",
|
||||||
|
"ext-sodium": "*",
|
||||||
|
"ext-json": "*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^9.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Borg\\STMF\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Borg\\STMF\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "phpunit"
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable"
|
||||||
|
}
|
||||||
12
php/borg-stmf/src/DecryptionException.php
Normal file
12
php/borg-stmf/src/DecryptionException.php
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Borg\STMF;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when decryption fails
|
||||||
|
*/
|
||||||
|
class DecryptionException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
154
php/borg-stmf/src/FormData.php
Normal file
154
php/borg-stmf/src/FormData.php
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Borg\STMF;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents decrypted form data
|
||||||
|
*/
|
||||||
|
class FormData
|
||||||
|
{
|
||||||
|
/** @var FormField[] */
|
||||||
|
private array $fields;
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $metadata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param FormField[] $fields
|
||||||
|
* @param array<string, string> $metadata
|
||||||
|
*/
|
||||||
|
public function __construct(array $fields, array $metadata = [])
|
||||||
|
{
|
||||||
|
$this->fields = $fields;
|
||||||
|
$this->metadata = $metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a field value by name
|
||||||
|
*/
|
||||||
|
public function get(string $name): ?string
|
||||||
|
{
|
||||||
|
foreach ($this->fields as $field) {
|
||||||
|
if ($field->name === $name) {
|
||||||
|
return $field->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a field object by name
|
||||||
|
*/
|
||||||
|
public function getField(string $name): ?FormField
|
||||||
|
{
|
||||||
|
foreach ($this->fields as $field) {
|
||||||
|
if ($field->name === $name) {
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all values for a field name (for multi-select)
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function getAll(string $name): array
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
foreach ($this->fields as $field) {
|
||||||
|
if ($field->name === $name) {
|
||||||
|
$values[] = $field->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all fields
|
||||||
|
*
|
||||||
|
* @return FormField[]
|
||||||
|
*/
|
||||||
|
public function fields(): array
|
||||||
|
{
|
||||||
|
return $this->fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a field exists
|
||||||
|
*/
|
||||||
|
public function has(string $name): bool
|
||||||
|
{
|
||||||
|
foreach ($this->fields as $field) {
|
||||||
|
if ($field->name === $name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to associative array (last value wins for duplicates)
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($this->fields as $field) {
|
||||||
|
$result[$field->name] = $field->value;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getMetadata(): array
|
||||||
|
{
|
||||||
|
return $this->metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific metadata value
|
||||||
|
*/
|
||||||
|
public function getMeta(string $key): ?string
|
||||||
|
{
|
||||||
|
return $this->metadata[$key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the origin (if set in metadata)
|
||||||
|
*/
|
||||||
|
public function getOrigin(): ?string
|
||||||
|
{
|
||||||
|
return $this->metadata['origin'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the timestamp (if set in metadata)
|
||||||
|
*/
|
||||||
|
public function getTimestamp(): ?int
|
||||||
|
{
|
||||||
|
$ts = $this->metadata['timestamp'] ?? null;
|
||||||
|
return $ts !== null ? (int) $ts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from decoded JSON array
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
$fields = [];
|
||||||
|
foreach ($data['fields'] ?? [] as $fieldData) {
|
||||||
|
$fields[] = FormField::fromArray($fieldData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($fields, $data['meta'] ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue