Compare commits

..

2 commits

Author SHA1 Message Date
google-labs-jules[bot]
3acd7a94b1 feat: Add comprehensive docstrings and refactor matrix to tim
Add comprehensive Go docstrings with examples to all packages to achieve 100% coverage.

Refactor the `matrix` package to `tim` (Terminal Isolation Matrix). Update all references to the old package and terminology across the codebase, including commands, tests, and examples.

Fix inconsistencies in command-line flags and help text related to the refactoring.
2025-11-14 22:26:21 +00:00
google-labs-jules[bot]
38fafbf639 feat: Add comprehensive docstrings and refactor matrix to tim
Add comprehensive Go docstrings with examples to all packages to achieve 100% coverage.

Refactor the `matrix` package to `tim` (Terminal Isolation Matrix). Update all references to the old package and terminology across the codebase, including commands, tests, and examples.

Fix inconsistencies in command-line flags and help text related to the refactoring.
2025-11-14 21:23:11 +00:00
153 changed files with 1498 additions and 33538 deletions

View file

@ -1,105 +0,0 @@
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
View file

@ -4,9 +4,3 @@ borg
*.datanode
.idea
coverage.txt
# Demo content (hosted on CDN)
demo-track.smsg
# Dev artifacts
.playwright-mcp/

141
CLAUDE.md
View file

@ -1,141 +0,0 @@
# 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
View file

@ -1,171 +1,115 @@
# Borg
# Borg Data Collector
[![codecov](https://codecov.io/github/Snider/Borg/branch/main/graph/badge.svg?token=XWWU0SBIR4)](https://codecov.io/github/Snider/Borg)
[![Go Version](https://img.shields.io/github/go-mod/go-version/Snider/Borg)](go.mod)
[![License](https://img.shields.io/badge/license-EUPL--1.2-blue)](LICENSE)
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.
Borg is a CLI and Go library that collects data from GitHub repos, websites, and PWAs into portable DataNodes or Terminal Isolation Matrices.
## Features
- Go version: 1.25
- Docs (MkDocs Material): see docs/ locally with `mkdocs serve`
- Quick build: `go build -o borg ./` or `task build`
- Releases: configured via GoReleaser (`.goreleaser.yaml`)
- **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
Note: This update aligns the repo with Go standards/tooling (Go 1.25, go.work, GoReleaser, and docs). No functional changes were made.
## Installation
```bash
# From source
go install github.com/Snider/Borg@latest
## Borg Status Scratch Pad
# Or build locally
git clone https://github.com/Snider/Borg.git
cd Borg
go build -o borg ./
```
This is not very relavant, my scratch pad for now of borg related status outputs; feel free to add.
Requires Go 1.25+
### Init/Work/Assimilate
## 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.`
- `Assimilating bytes… stand by for cubeformation.`
- `Initializing the Core—prepare for quantumlevel sync.`
- `Data streams converging… the Core is humming.`
- `Merging… the Core is rewriting reality, one block at a time.`
- `Encrypting… the Cores got your secrets under lockandkey.`
- `Compiling the future… the Core never sleeps.`
- `Splicing files… the Cores 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 Corepowered 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**
### Encryption Service Messages
- `Initiating contact with Enchantrix… spice369 infusion underway.`
- `Generating cryptographic sigils the Core whispers to the witch.`
- `Encrypting payload the Core feeds data to the witch's cauldron.`
- `Merge complete data assimilated, encrypted, and sealed within us.`
- `Requesting arcane public key… resistance is futile.`
- `Encrypting payload the Core feeds data to the witchs cauldron.`
- `Decrypting… the witch returns the original essence.`
- `Rotating enchantments spice369 recalibrated, old sigils discarded.`
- `Authentication complete the witch acknowledges the Core.`
- `Authentication denied the witch refuses the impostors 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 spice369 saturation, throttling assimilation.`
- `Anomalous entity encountered the Core cannot parse the witchs 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.`
- `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.`
- `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.`
</details>
### PWA Processing
- `Scanning PWA manifest… the Core identifies serviceworker signatures.`
- `Pulling HTML, CSS, JS, and media… the hive gathers every byte for assimilation.`
- `Capturing serviceworker logic… the Core extracts offlineruntime spells.`
- `Packing cache entries into a .cube… each asset sealed in a portable shard.`
- `Embedding manifest metadata… the PWAs identity becomes part of the collective.`
- `Encrypting the cube… the Core cloaks the PWA in quantumgrade 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 selfcontained DataCube, ready for distribution.`
- ``
### Code Related Long
- `Assimilate code, encapsulate change—your repo is now a cubebound collective.`
- `We have detected unstructured data. Initiating code absorption and change containment.`
- `Your version history is obsolete. Submitting it to the Core for permanent cubeification.`
- `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: `Applegrade assimilation the Core preserves HDR.`
- raw: `Raw data intake the Core ingests the sensors soul`
- ico: `Iconic assimilation the Core packs the smallest symbols.`
- avif: `Nextgen assimilation the Core squeezes the future.`
- tiff: `Highdefinition capture the Core stores every photon.`
- gif: `Looped assimilation the Core keeps the animation alive.`

View file

@ -28,23 +28,3 @@ tasks:
- task: build
- chmod +x borg
- ./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

View file

@ -12,7 +12,6 @@ import (
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/github"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/ui"
"github.com/Snider/Borg/pkg/vcs"
"github.com/spf13/cobra"
@ -31,10 +30,9 @@ func NewAllCmd() *cobra.Command {
outputFile, _ := cmd.Flags().GetString("output")
format, _ := cmd.Flags().GetString("format")
compression, _ := cmd.Flags().GetString("compression")
password, _ := cmd.Flags().GetString("password")
if format != "datanode" && format != "tim" && format != "trix" {
return fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', or 'trix')", format)
if format != "datanode" && format != "tim" {
return fmt.Errorf("invalid format: %s (must be 'datanode' or 'tim')", format)
}
owner, err := parseGithubOwner(url)
@ -105,18 +103,13 @@ func NewAllCmd() *cobra.Command {
var data []byte
if format == "tim" {
tim, err := tim.FromDataNode(allDataNodes)
t, err := tim.FromDataNode(allDataNodes)
if err != nil {
return fmt.Errorf("error creating tim: %w", err)
return fmt.Errorf("error creating matrix: %w", err)
}
data, err = tim.ToTar()
data, err = t.ToTar()
if err != nil {
return fmt.Errorf("error serializing tim: %w", err)
}
} else if format == "trix" {
data, err = trix.ToTrix(allDataNodes, password)
if err != nil {
return fmt.Errorf("error serializing trix: %w", err)
return fmt.Errorf("error serializing matrix: %w", err)
}
} else {
data, err = allDataNodes.ToTar()
@ -130,6 +123,13 @@ func NewAllCmd() *cobra.Command {
return fmt.Errorf("error compressing data: %w", err)
}
if outputFile == "" {
outputFile = "all." + format
if compression != "none" {
outputFile += "." + compression
}
}
err = os.WriteFile(outputFile, compressedData, 0644)
if err != nil {
return fmt.Errorf("error writing DataNode to file: %w", err)
@ -140,10 +140,9 @@ func NewAllCmd() *cobra.Command {
return nil
},
}
allCmd.PersistentFlags().String("output", "all.dat", "Output file for the DataNode")
allCmd.PersistentFlags().String("format", "datanode", "Output format (datanode, tim, or trix)")
allCmd.PersistentFlags().String("output", "", "Output file for the DataNode")
allCmd.PersistentFlags().String("format", "datanode", "Output format (datanode or tim)")
allCmd.PersistentFlags().String("compression", "none", "Compression format (none, gz, or xz)")
allCmd.PersistentFlags().String("password", "", "Password for encryption")
return allCmd
}

View file

@ -7,7 +7,6 @@ import (
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/ui"
"github.com/Snider/Borg/pkg/vcs"
@ -35,10 +34,9 @@ func NewCollectGithubRepoCmd() *cobra.Command {
outputFile, _ := cmd.Flags().GetString("output")
format, _ := cmd.Flags().GetString("format")
compression, _ := cmd.Flags().GetString("compression")
password, _ := cmd.Flags().GetString("password")
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 != "datanode" && format != "tim" {
return fmt.Errorf("invalid format: %s (must be 'datanode' or 'tim')", format)
}
if compression != "none" && compression != "gz" && compression != "xz" {
return fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
@ -63,28 +61,11 @@ func NewCollectGithubRepoCmd() *cobra.Command {
if format == "tim" {
t, err := tim.FromDataNode(dn)
if err != nil {
return fmt.Errorf("error creating tim: %w", err)
return fmt.Errorf("error creating matrix: %w", err)
}
data, err = t.ToTar()
if err != nil {
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" {
data, err = trix.ToTrix(dn, password)
if err != nil {
return fmt.Errorf("error serializing trix: %w", err)
return fmt.Errorf("error serializing matrix: %w", err)
}
} else {
data, err = dn.ToTar()
@ -115,9 +96,8 @@ func NewCollectGithubRepoCmd() *cobra.Command {
},
}
cmd.Flags().String("output", "", "Output file for the DataNode")
cmd.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
cmd.Flags().String("format", "datanode", "Output format (datanode or tim)")
cmd.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
cmd.Flags().String("password", "", "Password for encryption (required for trix/stim)")
return cmd
}

View file

@ -1,333 +0,0 @@
package cmd
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/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 files from a local directory and store them in a DataNode.
If no directory is specified, the current working directory is used.
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")
finalPath, err := CollectLocal(directory, outputFile, format, compression, password, excludes, includeHidden, respectGitignore)
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) (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)
}
// Load gitignore patterns if enabled
var gitignorePatterns []string
if respectGitignore {
gitignorePatterns = loadGitignore(absDir)
}
// Create DataNode and collect files
dn := datanode.New()
var fileCount int
bar := ui.NewProgressBar(-1, "Scanning files")
defer bar.Finish()
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++
bar.Describe(fmt.Sprintf("Collected %d files", fileCount))
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)
}
bar.Describe(fmt.Sprintf("Packaging %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
}

View file

@ -5,9 +5,8 @@ import (
"os"
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/pwa"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/pwa"
"github.com/Snider/Borg/pkg/ui"
"github.com/spf13/cobra"
@ -24,27 +23,19 @@ func NewCollectPWACmd() *CollectPWACmd {
PWAClient: pwa.NewPWAClient(),
}
c.Command = cobra.Command{
Use: "pwa [url]",
Use: "pwa",
Short: "Collect a single PWA using a URI",
Long: `Collect a single PWA and store it in a DataNode.
Examples:
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),
Example:
borg collect pwa --uri https://example.com --output mypwa.dat`,
RunE: func(cmd *cobra.Command, args []string) error {
pwaURL, _ := cmd.Flags().GetString("uri")
// Allow URL as positional argument
if len(args) > 0 && pwaURL == "" {
pwaURL = args[0]
}
outputFile, _ := cmd.Flags().GetString("output")
format, _ := cmd.Flags().GetString("format")
compression, _ := cmd.Flags().GetString("compression")
password, _ := cmd.Flags().GetString("password")
finalPath, err := CollectPWA(c.PWAClient, pwaURL, outputFile, format, compression, password)
finalPath, err := CollectPWA(c.PWAClient, pwaURL, outputFile, format, compression)
if err != nil {
return err
}
@ -52,26 +43,22 @@ Examples:
return nil
},
}
c.Flags().String("uri", "", "The URI of the PWA to collect (can also be passed as positional arg)")
c.Flags().String("uri", "", "The URI of the PWA to collect")
c.Flags().String("output", "", "Output file for the DataNode")
c.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
c.Flags().String("format", "datanode", "Output format (datanode or tim)")
c.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
c.Flags().String("password", "", "Password for encryption (required for stim format)")
return c
}
func init() {
collectCmd.AddCommand(&NewCollectPWACmd().Command)
}
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) (string, error) {
if pwaURL == "" {
return "", fmt.Errorf("url is required")
return "", fmt.Errorf("uri is required")
}
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" && password == "" {
return "", fmt.Errorf("password is required for stim format")
if format != "datanode" && format != "tim" {
return "", fmt.Errorf("invalid format: %s (must be 'datanode' or 'tim')", format)
}
if compression != "none" && compression != "gz" && compression != "xz" {
return "", fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
@ -94,25 +81,11 @@ func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format s
if format == "tim" {
t, err := tim.FromDataNode(dn)
if err != nil {
return "", fmt.Errorf("error creating tim: %w", err)
return "", fmt.Errorf("error creating matrix: %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)
return "", fmt.Errorf("error serializing matrix: %w", err)
}
} else {
data, err = dn.ToTar()

View file

@ -7,7 +7,6 @@ import (
"github.com/schollz/progressbar/v3"
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/ui"
"github.com/Snider/Borg/pkg/website"
@ -37,10 +36,9 @@ func NewCollectWebsiteCmd() *cobra.Command {
depth, _ := cmd.Flags().GetInt("depth")
format, _ := cmd.Flags().GetString("format")
compression, _ := cmd.Flags().GetString("compression")
password, _ := cmd.Flags().GetString("password")
if format != "datanode" && format != "tim" && format != "trix" {
return fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', or 'trix')", format)
if format != "datanode" && format != "tim" {
return fmt.Errorf("invalid format: %s (must be 'datanode' or 'tim')", format)
}
prompter := ui.NewNonInteractivePrompter(ui.GetWebsiteQuote)
@ -58,18 +56,13 @@ func NewCollectWebsiteCmd() *cobra.Command {
var data []byte
if format == "tim" {
tim, err := tim.FromDataNode(dn)
t, err := tim.FromDataNode(dn)
if err != nil {
return fmt.Errorf("error creating tim: %w", err)
return fmt.Errorf("error creating matrix: %w", err)
}
data, err = tim.ToTar()
data, err = t.ToTar()
if err != nil {
return fmt.Errorf("error serializing tim: %w", err)
}
} else if format == "trix" {
data, err = trix.ToTrix(dn, password)
if err != nil {
return fmt.Errorf("error serializing trix: %w", err)
return fmt.Errorf("error serializing matrix: %w", err)
}
} else {
data, err = dn.ToTar()
@ -101,8 +94,7 @@ func NewCollectWebsiteCmd() *cobra.Command {
}
collectWebsiteCmd.PersistentFlags().String("output", "", "Output file for the DataNode")
collectWebsiteCmd.PersistentFlags().Int("depth", 2, "Recursion depth for downloading")
collectWebsiteCmd.PersistentFlags().String("format", "datanode", "Output format (datanode, tim, or trix)")
collectWebsiteCmd.PersistentFlags().String("format", "datanode", "Output format (datanode or tim)")
collectWebsiteCmd.PersistentFlags().String("compression", "none", "Compression format (none, gz, or xz)")
collectWebsiteCmd.PersistentFlags().String("password", "", "Password for encryption")
return collectWebsiteCmd
}

View file

@ -11,14 +11,13 @@ import (
var borgfile string
var output string
var encryptPassword string
var compileCmd = NewCompileCmd()
func NewCompileCmd() *cobra.Command {
compileCmd := &cobra.Command{
Use: "compile",
Short: "Compile a Borgfile into a Terminal Isolation Matrix.",
Short: "Compile a Borgfile into a TIM.",
RunE: func(cmd *cobra.Command, args []string) error {
content, err := os.ReadFile(borgfile)
if err != nil {
@ -53,33 +52,16 @@ 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()
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Compiled TIM to %s\n", output)
return os.WriteFile(output, tarball, 0644)
},
}
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(&encryptPassword, "encrypt", "e", "", "Encrypt with ChaCha20-Poly1305 using this password (outputs .stim)")
compileCmd.Flags().StringVarP(&output, "output", "o", "a.tim", "Path to the output TIM file.")
return compileCmd
}

View file

@ -1,122 +1,130 @@
package cmd
import (
"archive/tar"
"io"
"os"
"path/filepath"
"testing"
)
func TestCompileCmd(t *testing.T) {
// t.Run("Good", func(t *testing.T) {
// tempDir := t.TempDir()
// outputTimPath := filepath.Join(tempDir, "test.tim")
// borgfilePath := filepath.Join(tempDir, "Borgfile")
// dummyFilePath := filepath.Join(tempDir, "dummy.txt")
func TestCompileCmd_Good(t *testing.T) {
tempDir := t.TempDir()
borgfilePath := filepath.Join(tempDir, "Borgfile")
outputMatrixPath := filepath.Join(tempDir, "test.matrix")
fileToAddPath := filepath.Join(tempDir, "test.txt")
// // Create a dummy file to add to the tim.
// err := os.WriteFile(dummyFilePath, []byte("dummy content"), 0644)
// if err != nil {
// t.Fatalf("failed to create dummy file: %v", err)
// }
// Create a dummy file to add to the matrix.
err := os.WriteFile(fileToAddPath, []byte("hello world"), 0644)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
// // Create a Borgfile.
// borgfileContent := "ADD " + dummyFilePath + " /dummy.txt"
// err = os.WriteFile(borgfilePath, []byte(borgfileContent), 0644)
// if err != nil {
// t.Fatalf("failed to create Borgfile: %v", err)
// }
// Create a dummy Borgfile.
borgfileContent := "ADD " + fileToAddPath + " /test.txt"
err = os.WriteFile(borgfilePath, []byte(borgfileContent), 0644)
if err != nil {
t.Fatalf("failed to create Borgfile: %v", err)
}
// // Execute the compile command.
// cmd := NewCompileCmd()
// cmd.SetArgs([]string{"--file", borgfilePath, "--output", outputTimPath})
// err = cmd.Execute()
// if err != nil {
// t.Fatalf("compile command failed: %v", err)
// }
// Run the compile command.
rootCmd := NewRootCmd()
rootCmd.AddCommand(GetCompileCmd())
_, err = executeCommand(rootCmd, "compile", "-f", borgfilePath, "-o", outputMatrixPath)
if err != nil {
t.Fatalf("compile command failed: %v", err)
}
// // Verify the output tim file.
// timFile, err := os.Open(outputTimPath)
// if err != nil {
// t.Fatalf("failed to open output tim file: %v", err)
// }
// defer timFile.Close()
// Verify the output matrix file.
matrixFile, err := os.Open(outputMatrixPath)
if err != nil {
t.Fatalf("failed to open output matrix file: %v", err)
}
defer matrixFile.Close()
// tr := tar.NewReader(timFile)
// files := []string{"config.json", "rootfs/", "rootfs/dummy.txt"}
// found := make(map[string]bool)
// for {
// hdr, err := tr.Next()
// if err != nil {
// break
// }
// found[hdr.Name] = true
// }
// for _, f := range files {
// if !found[f] {
// t.Errorf("%s not found in tim tarball", f)
// }
// }
// })
tr := tar.NewReader(matrixFile)
found := make(map[string]bool)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("failed to read tar header: %v", err)
}
found[header.Name] = true
}
t.Run("Bad_Borgfile", func(t *testing.T) {
expectedFiles := []string{"config.json", "rootfs/", "rootfs/test.txt"}
for _, f := range expectedFiles {
if !found[f] {
t.Errorf("%s not found in matrix tarball", f)
}
}
}
func TestCompileCmd_Bad(t *testing.T) {
t.Run("Invalid Borgfile instruction", func(t *testing.T) {
tempDir := t.TempDir()
outputTimPath := filepath.Join(tempDir, "test.tim")
borgfilePath := filepath.Join(tempDir, "Borgfile")
outputMatrixPath := filepath.Join(tempDir, "test.matrix")
// Create a Borgfile with an invalid instruction.
borgfileContent := "INVALID instruction"
// Create a dummy Borgfile with an invalid instruction.
borgfileContent := "INVALID_INSTRUCTION"
err := os.WriteFile(borgfilePath, []byte(borgfileContent), 0644)
if err != nil {
t.Fatalf("failed to create Borgfile: %v", err)
}
// Execute the compile command.
cmd := NewCompileCmd()
cmd.SetArgs([]string{"--file", borgfilePath, "--output", outputTimPath})
err = cmd.Execute()
// Run the compile command.
rootCmd := NewRootCmd()
rootCmd.AddCommand(GetCompileCmd())
_, err = executeCommand(rootCmd, "compile", "-f", borgfilePath, "-o", outputMatrixPath)
if err == nil {
t.Error("compile command should have failed but did not")
t.Fatal("compile command should have failed but did not")
}
})
t.Run("Bad_ADD", func(t *testing.T) {
t.Run("Missing input file", func(t *testing.T) {
tempDir := t.TempDir()
outputTimPath := filepath.Join(tempDir, "test.tim")
borgfilePath := filepath.Join(tempDir, "Borgfile")
outputMatrixPath := filepath.Join(tempDir, "test.matrix")
// Create a Borgfile with an invalid ADD instruction.
borgfileContent := "ADD dummy.txt"
// Create a dummy Borgfile that references a non-existent file.
borgfileContent := "ADD /non/existent/file /test.txt"
err := os.WriteFile(borgfilePath, []byte(borgfileContent), 0644)
if err != nil {
t.Fatalf("failed to create Borgfile: %v", err)
}
// Execute the compile command.
cmd := NewCompileCmd()
cmd.SetArgs([]string{"--file", borgfilePath, "--output", outputTimPath})
err = cmd.Execute()
// Run the compile command.
rootCmd := NewRootCmd()
rootCmd.AddCommand(GetCompileCmd())
_, err = executeCommand(rootCmd, "compile", "-f", borgfilePath, "-o", outputMatrixPath)
if err == nil {
t.Error("compile command should have failed but did not")
}
})
t.Run("Ugly_EmptyBorgfile", func(t *testing.T) {
tempDir := t.TempDir()
outputTimPath := filepath.Join(tempDir, "test.tim")
borgfilePath := filepath.Join(tempDir, "Borgfile")
// Create an empty Borgfile.
err := os.WriteFile(borgfilePath, []byte{}, 0644)
if err != nil {
t.Fatalf("failed to create Borgfile: %v", err)
}
// Execute the compile command.
cmd := NewCompileCmd()
cmd.SetArgs([]string{"--file", borgfilePath, "--output", outputTimPath})
err = cmd.Execute()
if err != nil {
t.Fatalf("compile command failed: %v", err)
t.Fatal("compile command should have failed but did not")
}
})
}
func TestCompileCmd_Ugly(t *testing.T) {
t.Run("Empty Borgfile", func(t *testing.T) {
tempDir := t.TempDir()
borgfilePath := filepath.Join(tempDir, "Borgfile")
outputMatrixPath := filepath.Join(tempDir, "test.matrix")
// Create an empty Borgfile.
err := os.WriteFile(borgfilePath, []byte(""), 0644)
if err != nil {
t.Fatalf("failed to create Borgfile: %v", err)
}
// Run the compile command.
rootCmd := NewRootCmd()
rootCmd.AddCommand(GetCompileCmd())
_, err = executeCommand(rootCmd, "compile", "-f", borgfilePath, "-o", outputMatrixPath)
if err != nil {
t.Fatalf("compile command failed for empty Borgfile: %v", err)
}
})
}

View file

@ -1,163 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/Snider/Borg/pkg/console"
"github.com/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)
}

View file

@ -1,3 +0,0 @@
build/
*.exe
dapp-fm-app

View file

@ -1,987 +0,0 @@
<!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>

View file

@ -1,14 +0,0 @@
// 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>;

View file

@ -1,23 +0,0 @@
// @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']();
}

View file

@ -1,140 +0,0 @@
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;
}
}
}

View file

@ -1,24 +0,0 @@
{
"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"
}

View file

@ -1,249 +0,0 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
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

View file

@ -1,242 +0,0 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
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);
}

View file

@ -1,322 +0,0 @@
// 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"
"github.com/Snider/Borg/pkg/player"
"github.com/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())
}
}

View file

@ -1,20 +0,0 @@
{
"$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"
}
}

View file

@ -1,64 +0,0 @@
// dapp-fm CLI provides headless media player functionality
// For native desktop app with WebView, use dapp-fm-app instead
package main
import (
"fmt"
"os"
"github.com/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)
}
}

View file

@ -1,88 +0,0 @@
package cmd
import (
"fmt"
"os"
"strings"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
trixsdk "github.com/Snider/Enchantrix/pkg/trix"
"github.com/spf13/cobra"
)
var decodeCmd = NewDecodeCmd()
func NewDecodeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "decode [file]",
Short: "Decode a .trix, .tim, or .stim file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inputFile := args[0]
outputFile, _ := cmd.Flags().GetString("output")
password, _ := cmd.Flags().GetString("password")
inIsolation, _ := cmd.Flags().GetBool("i-am-in-isolation")
data, err := os.ReadFile(inputFile)
if err != nil {
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)
if err != nil {
return err
}
if _, ok := t.Header["tim"]; ok && !inIsolation {
return fmt.Errorf("this is a Terminal Isolation Matrix, use the --i-am-in-isolation flag to decode it")
}
dn, err := trix.FromTrix(data, password)
if err != nil {
return err
}
tarball, err := dn.ToTar()
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Decoded to %s\n", outputFile)
return os.WriteFile(outputFile, tarball, 0644)
},
}
cmd.Flags().String("output", "decoded.dat", "Output file for the decoded data")
cmd.Flags().String("password", "", "Password for decryption")
cmd.Flags().Bool("i-am-in-isolation", false, "Required to decode a Terminal Isolation Matrix")
return cmd
}
func GetDecodeCmd() *cobra.Command {
return decodeCmd
}
func init() {
RootCmd.AddCommand(GetDecodeCmd())
}

View file

@ -1,44 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/trix"
)
func TestDecodeCmd(t *testing.T) {
t.Run("Good", func(t *testing.T) {
tempDir := t.TempDir()
outputFile := filepath.Join(tempDir, "decoded.dat")
inputFile := filepath.Join(tempDir, "test.trix")
// Create a dummy trix file.
dn := datanode.New()
dn.AddData("test.txt", []byte("hello"))
trixBytes, err := trix.ToTrix(dn, "")
if err != nil {
t.Fatalf("failed to create trix file: %v", err)
}
err = os.WriteFile(inputFile, trixBytes, 0644)
if err != nil {
t.Fatalf("failed to write trix file: %v", err)
}
// Execute the decode command.
cmd := NewDecodeCmd()
cmd.SetArgs([]string{inputFile, "--output", outputFile})
err = cmd.Execute()
if err != nil {
t.Fatalf("decode command failed: %v", err)
}
// Verify the output file.
_, err = os.Stat(outputFile)
if err != nil {
t.Fatalf("output file not found: %v", err)
}
})
}

View file

@ -1,70 +0,0 @@
// extract-demo extracts the video from a v2 SMSG file
package main
import (
"encoding/base64"
"fmt"
"os"
"github.com/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
}

View file

@ -1,114 +0,0 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
trixsdk "github.com/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"
}

View file

@ -1,226 +0,0 @@
// 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"
"github.com/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()
}

View file

@ -1,129 +0,0 @@
// mkdemo-v3 creates a v3 chunked SMSG file for streaming demos
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"github.com/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.")
}

View file

@ -1,81 +0,0 @@
// mkdemo creates an RFC-quality demo SMSG file with a cryptographically secure password
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"github.com/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!")
}

View file

@ -1,57 +1,21 @@
package cmd
import (
"os"
"strings"
"github.com/Snider/Borg/pkg/tim"
"github.com/spf13/cobra"
)
var runPassword string
var runCmd = NewRunCmd()
func NewRunCmd() *cobra.Command {
cmd := &cobra.Command{
return &cobra.Command{
Use: "run [tim file]",
Short: "Run a Terminal Isolation Matrix.",
Short: "Run a TIM.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
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)
return tim.Run(args[0])
},
}
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 {

View file

@ -12,7 +12,7 @@ import (
func TestRunCmd_Good(t *testing.T) {
// Create a dummy tim file.
timPath := createDummyTim(t)
timPath := createDummyTIM(t)
// Mock the exec.Command function in the tim package.
origExecCommand := tim.ExecCommand
@ -68,8 +68,8 @@ func TestRunCmd_Ugly(t *testing.T) {
})
}
// createDummyTim creates a valid, empty tim file for testing.
func createDummyTim(t *testing.T) string {
// createDummyTIM creates a valid, empty tim file for testing.
func createDummyTIM(t *testing.T) string {
t.Helper()
tempDir := t.TempDir()
timPath := filepath.Join(tempDir, "test.tim")
@ -110,13 +110,3 @@ func createDummyTim(t *testing.T) string {
}
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)
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

View file

@ -1,575 +0,0 @@
// 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

Binary file not shown.

575
dist/wasm_exec.js vendored
View file

@ -1,575 +0,0 @@
// 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;
};
}
}
})();

View file

@ -16,77 +16,35 @@ Use `borg --help` and `borg <command> --help` to see all flags.
Collect and package inputs.
Subcommands:
- `borg collect github repo <repo-url> [--output <file>] [--format datanode|tim|trix] [--compression none|gz|xz]`
- `borg collect github release <release-url> [--output <file>]`
- `borg collect github repos <org-or-user> [--output <file>] [--format ...] [--compression ...]`
- `borg collect github repo <repo-url> [--output <file>] [--format datanode|matrix] [--compression none|gz|xz]`
- `borg collect github repos <org-or-user> [--output <file>] [--format ...] [--compression ...]` (if available)
- `borg collect website <url> [--depth N] [--output <file>] [--format ...] [--compression ...]`
- `borg collect pwa --uri <url> [--output <file>] [--format ...] [--compression ...]`
Examples:
- `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 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`
- 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 pwa --uri https://squoosh.app --output squoosh.dat
### serve
Serve a packaged DataNode or TIM via a static file server.
Serve a packaged DataNode or Matrix via a static file server.
- `borg serve <file> [--port 8080]`
- borg serve <file> [--port 8080]
Examples:
- `borg serve squoosh.dat --port 8888`
- `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`
- borg serve squoosh.dat --port 8888
- borg serve borg.matrix --port 9999
## Compression
All collect commands accept `--compression` with values:
- `none` (default)
- `gz`
- `xz`
- none (default)
- gz
- xz
Output filenames gain the appropriate extension automatically.
## Formats
## Matrix format
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.
Use `--format matrix` to produce a runc-compatible bundle (Terminal Isolation Matrix). See the Overview page for details.

View file

@ -11,26 +11,26 @@ This repo includes a `go.work` file configured for Go 1.25 to align with common
## Build
- `go build ./...`
- `task build`
- go build ./...
- task build
## Test
- `go test ./...`
- `task test`
- go test ./...
- task test
Note: Some tests may require network or git tooling depending on environment (e.g., pushing to a temporary repo).
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.
## Run
- `task run`
- `./borg --help`
- task run
- ./borg --help
## Docs
Serve the documentation locally with MkDocs:
- `pip install mkdocs-material`
- `mkdocs serve`
- pip install mkdocs-material
- mkdocs serve
The site configuration lives in `mkdocs.yml` and content in `docs/`.

View file

@ -1,137 +1,265 @@
# Borg
# Borg Data Collector
Borg is a command-line tool for collecting resources from various URIs (like Git repositories and websites) into a unified format.
Borg Data Collector is a command-line tool for collecting and managing data from various sources.
## Installation
## Commands
You can install Borg using `go install`:
### `collect`
```bash
go install github.com/Snider/Borg@latest
This command is used to collect resources from different sources and store them in a DataNode.
#### `collect github repo`
Collects a single Git repository and stores it in a DataNode.
**Usage:**
```
borg collect github repo [repository-url] [flags]
```
## Usage
**Flags:**
- `--output string`: Output file for the DataNode (default "repo.dat")
- `--format string`: Output format (datanode or matrix) (default "datanode")
- `--compression string`: Compression format (none, gz, or xz) (default "none")
Borg provides several subcommands for collecting different types of resources.
### `borg collect`
The `collect` command is the main entry point for collecting resources. It has several subcommands for different resource types.
#### `borg collect github repo`
This command collects a single Git repository and stores it in a DataNode.
```bash
**Example:**
```
./borg collect github repo https://github.com/Snider/Borg --output borg.dat
```
#### `borg collect github release`
#### `collect website`
This command downloads and packages the assets from a GitHub release.
Collects a single website and stores it in a DataNode.
```bash
./borg collect github release https://github.com/Snider/Borg/releases/latest --output borg-release.dat
**Usage:**
```
borg collect website [url] [flags]
```
#### `borg collect pwa`
**Flags:**
- `--output string`: Output file for the DataNode (default "website.dat")
- `--depth int`: Recursion depth for downloading (default 2)
- `--format string`: Output format (datanode or matrix) (default "datanode")
- `--compression string`: Compression format (none, gz, or xz) (default "none")
This command collects a Progressive Web App (PWA) from a given URI.
**Example:**
```
./borg collect website https://google.com --output website.dat --depth 1
```
```bash
#### `collect github repos`
Collects all public repositories for a user or organization.
**Usage:**
```
borg collect github repos [user-or-org] [flags]
```
**Example:**
```
./borg collect github repos Snider
```
#### `collect github release`
Downloads the latest release of a file from GitHub releases.
**Usage:**
```
borg collect github release [repository-url] [flags]
```
**Flags:**
- `--output string`: Output directory for the downloaded file (default ".")
- `--pack`: Pack all assets into a DataNode
- `--file string`: The file to download from the release
- `--version string`: The version to check against
**Example:**
```
# Download the latest release of the 'borg' executable
./borg collect github release https://github.com/Snider/Borg --file borg
# Pack all assets from the latest release into a DataNode
./borg collect github release https://github.com/Snider/Borg --pack --output borg-release.dat
```
#### `collect pwa`
Collects a single PWA and stores it in a DataNode.
**Usage:**
```
borg collect pwa [flags]
```
**Flags:**
- `--uri string`: The URI of the PWA to collect
- `--output string`: Output file for the DataNode (default "pwa.dat")
- `--format string`: Output format (datanode or matrix) (default "datanode")
- `--compression string`: Compression format (none, gz, or xz) (default "none")
**Example:**
```
./borg collect pwa --uri https://squoosh.app --output squoosh.dat
```
#### `borg collect website`
### `compile`
This command collects a single website and stores it in a DataNode.
Compiles a `Borgfile` into a Terminal Isolation Matrix.
```bash
./borg collect website https://example.com --output example.dat
**Usage:**
```
borg compile [flags]
```
### `borg all`
**Flags:**
- `--file string`: Path to the Borgfile (default "Borgfile")
- `--output string`: Path to the output matrix file (default "a.matrix")
The `borg all` command collects all public repositories from a GitHub user or organization.
```bash
./borg all https://github.com/Snider --output snider.dat
**Example:**
```
./borg compile -f my-borgfile -o my-app.matrix
```
### `borg compile`
### `serve`
The `borg compile` command compiles a `Borgfile` into a Terminal Isolation Matrix.
Serves the contents of a packaged DataNode or Terminal Isolation Matrix file using a static file server.
```bash
./borg compile --file Borgfile --output a.tim
**Usage:**
```
borg serve [file] [flags]
```
### `borg run`
**Flags:**
- `--port string`: Port to serve the DataNode on (default "8080")
The `borg run` command executes a Terminal Isolation Matrix.
**Example:**
```
# Serve a DataNode
./borg serve squoosh.dat --port 8888
```bash
./borg run a.tim
# Serve a Terminal Isolation Matrix
./borg serve borg.matrix --port 9999
```
### `borg serve`
## Compression
The `borg serve` command serves a DataNode or Terminal Isolation Matrix using a static file server.
All `collect` commands support optional compression. The following compression formats are available:
```bash
./borg serve my-collected-data.dat --port 8080
- `none`: No compression (default)
- `gz`: Gzip compression
- `xz`: XZ compression
To use compression, specify the desired format with the `--compression` flag. The output filename will be automatically updated with the appropriate extension (e.g., `.gz`, `.xz`).
**Example:**
```
./borg collect github repo https://github.com/Snider/Borg --compression gz
```
### `borg decode`
The `serve` command can transparently serve compressed files.
The `borg decode` command decodes a `.trix` or `.tim` file.
## Terminal Isolation Matrix
```bash
./borg decode my-collected-data.trix --output my-collected-data.dat
The `matrix` format creates a `runc` compatible bundle. This bundle can be executed by `runc` to create a container with the collected files. This is useful for creating isolated environments for testing or analysis.
To create a Matrix, use the `--format matrix` flag with any of the `collect` subcommands.
**Example:**
```
./borg collect github repo https://github.com/Snider/Borg --output borg.matrix --format matrix
```
## Formats
The `borg run` command is used to execute a Terminal Isolation Matrix. This command handles the unpacking and execution of the matrix in a secure, isolated environment using `runc`. This ensures that the payload can be safely analyzed without affecting the host system.
Borg supports three output formats: `datanode`, `tim`, and `trix`.
### DataNode
The `datanode` format is a simple tarball containing the collected resources. This is the default format.
### Terminal Isolation Matrix (TIM)
The Terminal Isolation Matrix (`tim`) is a `runc` bundle that can be executed in an isolated environment. This is useful for analyzing potentially malicious code without affecting the host system. A `.tim` file is a specialized `.trix` file with the `tim` flag set in its header.
To create a TIM, use the `--format tim` flag with any of the `collect` subcommands.
```bash
./borg collect github repo https://github.com/Snider/Borg --output borg.tim --format tim
**Example:**
```
./borg run borg.matrix
```
### Trix
## Programmatic Usage
The `trix` format is an encrypted and structured file format. It is used as the underlying format for `.tim` files, but can also be used on its own for encrypting any `DataNode`.
The `examples` directory contains a number of Go programs that demonstrate how to use the `borg` package programmatically.
To create a `.trix` file, use the `--format trix` flag with any of the `collect` subcommands.
### Inspecting a DataNode
```bash
./borg collect github repo https://github.com/Snider/Borg --output borg.trix --format trix --password "my-secret-password"
The `inspect_datanode` example demonstrates how to read, decompress, and walk a `.dat` file.
**Usage:**
```
go run examples/inspect_datanode/main.go <path to .dat file>
```
## Encryption
### Creating a Matrix Programmatically
Both the `tim` and `trix` formats can be encrypted with a password by using the `--password` flag.
The `create_matrix_programmatically` example demonstrates how to create a Terminal Isolation Matrix from scratch.
## Decoding
To decode a `.trix` or `.tim` file, use the `decode` command. If the file is encrypted, you must provide the `--password` flag.
```bash
./borg decode borg.trix --output borg.dat --password "my-secret-password"
**Usage:**
```
go run examples/create_matrix_programmatically/main.go
```
If you are decoding a `.tim` file, you must also provide the `--i-am-in-isolation` flag. This is a safety measure to prevent you from accidentally executing potentially malicious code on your host system.
### Running a Matrix Programmatically
```bash
./borg decode borg.tim --output borg.dat --i-am-in-isolation
The `run_matrix_programmatically` example demonstrates how to run a Terminal Isolation Matrix using the `borg` package.
**Usage:**
```
go run examples/run_matrix_programmatically/main.go
```
### Collecting a Website
The `collect_website` example demonstrates how to collect a website and package it into a `.dat` file.
**Usage:**
```
go run examples/collect_website/main.go
```
### Collecting a GitHub Release
The `collect_github_release` example demonstrates how to collect the latest release of a GitHub repository.
**Usage:**
```
go run examples/collect_github_release/main.go
```
### Collecting All Repositories for a User
The `all` example demonstrates how to collect all public repositories for a GitHub user.
**Usage:**
```
go run examples/all/main.go
```
### Collecting a PWA
The `collect_pwa` example demonstrates how to collect a Progressive Web App and package it into a `.dat` file.
**Usage:**
```
go run examples/collect_pwa/main.go
```
### Collecting a GitHub Repository
The `collect_github_repo` example demonstrates how to clone a GitHub repository and package it into a `.dat` file.
**Usage:**
```
go run examples/collect_github_repo/main.go
```
### Serving a DataNode
The `serve` example demonstrates how to serve the contents of a `.dat` file over HTTP.
**Usage:**
```
go run examples/serve/main.go
```

View file

@ -6,17 +6,17 @@ Options to install:
- From source (requires Go 1.25 or newer):
- Clone the repository and build:
- `go build -o borg ./`
- go build -o borg ./
- Or use the Taskfile:
- `task build`
- task build
- From releases (recommended):
- Download an archive for your OS/ARCH from GitHub Releases once you publish with GoReleaser.
- Unpack and place `borg` on your PATH.
- Homebrew (if you publish to a tap):
- `brew tap Snider/homebrew-tap`
- `brew install borg`
- brew tap Snider/homebrew-tap
- brew install borg
Requirements:
- Go 1.25+ to build from source.

View file

@ -1,281 +0,0 @@
# 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/)

View file

@ -2,32 +2,29 @@
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
```go
```
package main
import (
"log"
"os"
"github.com/Snider/Borg/pkg/vcs"
"github.com/Snider/Borg/pkg/datanode"
borggithub "github.com/Snider/Borg/pkg/github"
)
func main() {
// Clone and package the repository
cloner := vcs.NewGitCloner()
dn, err := cloner.CloneGitRepository("https://github.com/Snider/Borg", nil)
if err != nil {
log.Fatal(err)
}
// Create a DataNode writer (uncompressed example)
dn, err := datanode.NewFileDataNodeWriter("repo.dat")
if err != nil { log.Fatal(err) }
defer dn.Close()
// Save to disk
tarBytes, err := dn.ToTar()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("repo.dat", tarBytes, 0644); err != nil {
client := borggithub.NewDefaultClient(nil) // uses http.DefaultClient
if err := borggithub.CollectRepo(client, "https://github.com/Snider/Borg", dn); err != nil {
log.Fatal(err)
}
}
@ -35,30 +32,21 @@ func main() {
## Collecting a Website
```go
```
package main
import (
"log"
"os"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/website"
)
func main() {
// Download and package the website
// 1 is the depth (0 = just the page, 1 = page + links on page)
dn, err := website.DownloadAndPackageWebsite("https://example.com", 1, nil)
if err != nil {
log.Fatal(err)
}
dn, err := datanode.NewFileDataNodeWriter("website.dat")
if err != nil { log.Fatal(err) }
defer dn.Close()
// Save to disk
tarBytes, err := dn.ToTar()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("website.dat", tarBytes, 0644); err != nil {
if err := website.Collect("https://example.com", 1, dn); err != nil {
log.Fatal(err)
}
}
@ -66,38 +54,21 @@ func main() {
## PWA Collection
```go
```
package main
import (
"log"
"os"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/pwa"
)
func main() {
client := pwa.NewPWAClient()
pwaURL := "https://squoosh.app"
dn, err := datanode.NewFileDataNodeWriter("pwa.dat")
if err != nil { log.Fatal(err) }
defer dn.Close()
// 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 {
if err := pwa.Collect("https://squoosh.app", dn); err != nil {
log.Fatal(err)
}
}

View file

@ -1,497 +0,0 @@
# 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)

View file

@ -11,17 +11,17 @@ This project is configured for GoReleaser.
Generate local artifacts without publishing:
- `goreleaser release --snapshot --clean`
- goreleaser release --snapshot --clean
Artifacts appear under `dist/`.
## Full release
1. Tag a new version:
- `git tag -a v0.1.0 -m "v0.1.0"`
- `git push origin v0.1.0`
- git tag -a v0.1.0 -m "v0.1.0"
- git push origin v0.1.0
2. Run GoReleaser:
- `GITHUB_TOKEN=... goreleaser release --clean`
- GITHUB_TOKEN=... goreleaser release --clean
This will:
- Build binaries for multiple OS/ARCH
@ -31,3 +31,4 @@ This will:
## Notes
- 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.

8
examples/create_matrix.sh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/bash
# Example of using the 'borg collect' command with the '--format matrix' flag.
# This script clones the specified Git repository and saves it as a .matrix file.
# The main executable 'borg' is built from the project's root.
# Make sure you have built the project by running 'go build -o borg main.go' in the root directory.
./borg collect github repo https://github.com/Snider/Borg --output borg.matrix --format matrix

View file

@ -9,26 +9,26 @@ import (
)
func main() {
// Create a new DataNode and add a file to it.
// Create a new DataNode to hold the root filesystem.
dn := datanode.New()
dn.AddData("hello.txt", []byte("Hello from within the tim!"))
dn.AddData("hello.txt", []byte("Hello from within the TIM!"))
// Create a new TerminalIsolationMatrix from the DataNode.
m, err := tim.FromDataNode(dn)
if err != nil {
log.Fatalf("Failed to create tim: %v", err)
log.Fatalf("Failed to create TIM: %v", err)
}
// Serialize the tim to a tarball.
// Serialize the TIM to a tarball.
tarball, err := m.ToTar()
if err != nil {
log.Fatalf("Failed to serialize tim to tar: %v", err)
log.Fatalf("Failed to serialize TIM to tar: %v", err)
}
// Write the tarball to a file.
err = os.WriteFile("programmatic.tim", tarball, 0644)
if err != nil {
log.Fatalf("Failed to write tim file: %v", err)
log.Fatalf("Failed to write TIM file: %v", err)
}
log.Println("Successfully created programmatic.tim")

View file

@ -1,8 +0,0 @@
#!/bin/bash
# Example of using the 'borg collect' command with the '--format tim' flag.
# This script clones the specified Git repository and saves it as a .tim file.
# Ensure the 'borg' executable is in the current directory or in the system's PATH.
# You can build it by running 'go build' in the project root.
./borg collect github repo https://github.com/Snider/Borg --output borg.tim --format tim

View file

@ -1,183 +0,0 @@
// 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"
"github.com/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")
}

View file

@ -1,95 +0,0 @@
# 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

View file

@ -1,125 +0,0 @@
# 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 |

View file

@ -0,0 +1,18 @@
package main
import (
"log"
"github.com/Snider/Borg/pkg/tim"
)
func main() {
log.Println("Executing TIM with Borg...")
// Execute the TIM using the Borg package.
if err := tim.Run("programmatic.tim"); err != nil {
log.Fatalf("Failed to run TIM: %v", err)
}
log.Println("TIM execution finished.")
}

View file

@ -1,16 +0,0 @@
package main
import (
"log"
"github.com/Snider/Borg/pkg/tim"
)
func main() {
log.Println("Executing tim with Borg...")
// Execute the tim using the Borg package.
if err := tim.Run("programmatic.tim"); err != nil {
log.Fatalf("Failed to run tim: %v", err)
}
}

12
examples/serve_matrix.sh Executable file
View file

@ -0,0 +1,12 @@
#!/bin/bash
# Example of using the 'borg serve' command with a .matrix file.
# This script serves the contents of a .matrix file using a static file server.
# The main executable 'borg' is built from the project's root.
# Make sure you have built the project by running 'go build -o borg main.go' in the root directory.
# First, create a .matrix file
./borg collect github repo https://github.com/Snider/Borg --output borg.matrix --format matrix
# Then, serve it
./borg serve borg.matrix --port 9999

View file

@ -1,12 +0,0 @@
#!/bin/bash
# Example of using the 'borg serve' command with a .tim file.
# This script serves the contents of a .tim file using a static file server.
# Ensure the 'borg' executable is in the current directory or in the system's PATH.
# You can build it by running 'go build' in the project root.
# First, create a .tim file
./borg collect github repo https://github.com/Snider/Borg --output borg.tim --format tim
# Now, serve the .tim file
./borg serve borg.tim --port 9999

View file

@ -1,191 +0,0 @@
// 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"
"github.com/Snider/Borg/pkg/smsg"
"github.com/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"
}
}

55
go.mod
View file

@ -1,68 +1,45 @@
module github.com/Snider/Borg
go 1.25.0
go 1.25
require (
github.com/Snider/Enchantrix v0.0.2
github.com/fatih/color v1.18.0
github.com/go-git/go-git/v5 v5.16.3
github.com/google/go-github/v39 v39.2.0
github.com/klauspost/compress v1.18.2
github.com/mattn/go-isatty v0.0.20
github.com/schollz/progressbar/v3 v3.18.0
github.com/spf13/cobra v1.10.1
github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0
golang.org/x/mod v0.30.0
golang.org/x/net v0.47.0
golang.org/x/oauth2 v0.33.0
golang.org/x/mod v0.29.0
golang.org/x/net v0.46.0
golang.org/x/oauth2 v0.32.0
)
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/ProtonMail/go-crypto v1.3.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/cyphar/filepath-securejoin v0.5.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/go-billy/v5 v5.6.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // 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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/kevinburke/ssh_config v1.2.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.13 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/pflag v1.0.9 // 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.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

112
go.sum
View file

@ -1,18 +1,18 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
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/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
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/Snider/Enchantrix v0.0.2 h1:ExZQiBhfS/p/AHFTKhY80TOd+BXZjK95EzByAEgwvjs=
github.com/Snider/Enchantrix v0.0.2/go.mod h1:CtFcLAvnDT1KcuF1JBb/DJj0KplY8jHryO06KzQ1hsQ=
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/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/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/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
@ -20,6 +20,8 @@ github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZ
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.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/cyphar/filepath-securejoin v0.5.1 h1:eYgfMq5yryL4fbWfkLpFFy2ukSELzaJOTaUTuh+oF48=
github.com/cyphar/filepath-securejoin v0.5.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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -39,10 +41,6 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
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.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.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -55,20 +53,16 @@ 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-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
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/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/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@ -76,26 +70,8 @@ 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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@ -106,92 +82,80 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
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.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/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
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/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/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
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/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
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.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
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.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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.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/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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/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.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
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/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-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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
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/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
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-20210505024714-0287a6fb4125/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.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
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.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.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-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-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-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-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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
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.3/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.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
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=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=

View file

@ -1,5 +1,3 @@
go 1.25.0
go 1.25
use (
.
)
use .

View file

@ -1,132 +1,21 @@
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/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/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/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/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/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
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/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/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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/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=
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/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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=

View file

@ -1,106 +0,0 @@
# @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

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,155 +0,0 @@
<!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>

Binary file not shown.

View file

@ -1,575 +0,0 @@
// 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;
};
}
}
})();

View file

@ -1,556 +0,0 @@
<!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>

File diff suppressed because it is too large Load diff

View file

@ -1,36 +0,0 @@
{
"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"
}
}

View file

@ -1,345 +0,0 @@
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>;
}

View file

@ -1,121 +0,0 @@
/**
* 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;
}
}

Binary file not shown.

View file

@ -1,799 +0,0 @@
<!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>

View file

@ -1,17 +0,0 @@
{
"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"]
}

View file

@ -1,575 +0,0 @@
// 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;
};
}
}
})();

View file

@ -1,147 +0,0 @@
# 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

View file

@ -1,34 +0,0 @@
{
"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"
}

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* Exception thrown when decryption fails
*/
class DecryptionException extends \RuntimeException
{
}

View file

@ -1,154 +0,0 @@
<?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'] ?? []);
}
}

View file

@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* Represents a single form field
*/
class FormField
{
public string $name;
public string $value;
public ?string $type;
public ?string $filename;
public ?string $mimeType;
public function __construct(
string $name,
string $value,
?string $type = null,
?string $filename = null,
?string $mimeType = null
) {
$this->name = $name;
$this->value = $value;
$this->type = $type;
$this->filename = $filename;
$this->mimeType = $mimeType;
}
/**
* Check if this is a file field
*/
public function isFile(): bool
{
return $this->type === 'file';
}
/**
* Get the file content decoded from base64
*/
public function getFileContent(): ?string
{
if (!$this->isFile()) {
return null;
}
return base64_decode($this->value, true) ?: null;
}
/**
* Create from array
*/
public static function fromArray(array $data): self
{
return new self(
$data['name'] ?? '',
$data['value'] ?? '',
$data['type'] ?? null,
$data['filename'] ?? null,
$data['mime'] ?? null
);
}
}

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* Exception thrown when the STMF payload is invalid
*/
class InvalidPayloadException extends \RuntimeException
{
}

View file

@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* X25519 keypair for STMF encryption/decryption
*/
class KeyPair
{
private string $publicKey;
private string $privateKey;
/**
* @param string $publicKey Raw public key bytes (32 bytes)
* @param string $privateKey Raw private key bytes (32 bytes)
*/
public function __construct(string $publicKey, string $privateKey)
{
if (strlen($publicKey) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) {
throw new \InvalidArgumentException(
'Public key must be ' . SODIUM_CRYPTO_BOX_PUBLICKEYBYTES . ' bytes'
);
}
if (strlen($privateKey) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES) {
throw new \InvalidArgumentException(
'Private key must be ' . SODIUM_CRYPTO_BOX_SECRETKEYBYTES . ' bytes'
);
}
$this->publicKey = $publicKey;
$this->privateKey = $privateKey;
}
/**
* Generate a new X25519 keypair
*/
public static function generate(): self
{
$keypair = sodium_crypto_box_keypair();
return new self(
sodium_crypto_box_publickey($keypair),
sodium_crypto_box_secretkey($keypair)
);
}
/**
* Load keypair from base64-encoded private key
*/
public static function fromPrivateKeyBase64(string $privateKeyBase64): self
{
$privateKey = base64_decode($privateKeyBase64, true);
if ($privateKey === false) {
throw new \InvalidArgumentException('Invalid base64 private key');
}
// Derive public key from private key
$publicKey = sodium_crypto_scalarmult_base($privateKey);
return new self($publicKey, $privateKey);
}
/**
* Get the raw public key bytes
*/
public function getPublicKey(): string
{
return $this->publicKey;
}
/**
* Get the raw private key bytes
*/
public function getPrivateKey(): string
{
return $this->privateKey;
}
/**
* Get the public key as base64
*/
public function getPublicKeyBase64(): string
{
return base64_encode($this->publicKey);
}
/**
* Get the private key as base64
*/
public function getPrivateKeyBase64(): string
{
return base64_encode($this->privateKey);
}
}

View file

@ -1,312 +0,0 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* STMF - Sovereign Form Encryption
*
* Decrypts STMF payloads that were encrypted client-side using the server's public key.
* Uses X25519 ECDH key exchange + ChaCha20-Poly1305 authenticated encryption.
*
* @example
* ```php
* $stmf = new STMF($privateKeyBase64);
* $formData = $stmf->decrypt($_POST['_stmf_payload']);
*
* $email = $formData->get('email');
* $password = $formData->get('password');
* ```
*/
class STMF
{
private const MAGIC = 'STMF';
private string $privateKey;
/**
* @param string $privateKeyBase64 Base64-encoded X25519 private key
*/
public function __construct(string $privateKeyBase64)
{
$privateKey = base64_decode($privateKeyBase64, true);
if ($privateKey === false || strlen($privateKey) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES) {
throw new \InvalidArgumentException('Invalid private key');
}
$this->privateKey = $privateKey;
}
/**
* Decrypt an STMF payload
*
* @param string $payloadBase64 Base64-encoded STMF payload
* @return FormData Decrypted form data
* @throws InvalidPayloadException If the payload format is invalid
* @throws DecryptionException If decryption fails
*/
public function decrypt(string $payloadBase64): FormData
{
// Decode base64
$payload = base64_decode($payloadBase64, true);
if ($payload === false) {
throw new InvalidPayloadException('Invalid base64 payload');
}
return $this->decryptRaw($payload);
}
/**
* Decrypt raw STMF bytes
*
* @param string $payload Raw STMF bytes
* @return FormData Decrypted form data
*/
public function decryptRaw(string $payload): FormData
{
// Verify magic
if (strlen($payload) < 4 || substr($payload, 0, 4) !== self::MAGIC) {
throw new InvalidPayloadException('Invalid STMF magic');
}
// Parse trix container
$trix = $this->parseTrixContainer($payload);
// Extract ephemeral public key from header
if (!isset($trix['header']['ephemeral_pk'])) {
throw new InvalidPayloadException('Missing ephemeral_pk in header');
}
$ephemeralPKBase64 = $trix['header']['ephemeral_pk'];
$ephemeralPK = base64_decode($ephemeralPKBase64, true);
if ($ephemeralPK === false || strlen($ephemeralPK) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) {
throw new InvalidPayloadException('Invalid ephemeral public key');
}
// Perform X25519 ECDH key exchange
$sharedSecret = sodium_crypto_scalarmult($this->privateKey, $ephemeralPK);
// Derive symmetric key using SHA-256 (same as Go implementation)
$symmetricKey = hash('sha256', $sharedSecret, true);
// Decrypt the payload with ChaCha20-Poly1305
$decrypted = $this->chachaDecrypt($trix['payload'], $symmetricKey);
if ($decrypted === null) {
throw new DecryptionException('Decryption failed (wrong key?)');
}
// Parse JSON
$data = json_decode($decrypted, true);
if ($data === null) {
throw new InvalidPayloadException('Invalid JSON in decrypted payload');
}
return FormData::fromArray($data);
}
/**
* Validate an STMF payload without decrypting
*
* @param string $payloadBase64 Base64-encoded STMF payload
* @return bool True if the payload appears valid
*/
public function validate(string $payloadBase64): bool
{
try {
$payload = base64_decode($payloadBase64, true);
if ($payload === false) {
return false;
}
if (strlen($payload) < 4 || substr($payload, 0, 4) !== self::MAGIC) {
return false;
}
$trix = $this->parseTrixContainer($payload);
return isset($trix['header']['ephemeral_pk']);
} catch (\Exception $e) {
return false;
}
}
/**
* Get payload info without decrypting
*
* @param string $payloadBase64 Base64-encoded STMF payload
* @return array{version: ?string, algorithm: ?string, ephemeral_pk: ?string}
*/
public function getInfo(string $payloadBase64): array
{
$payload = base64_decode($payloadBase64, true);
if ($payload === false) {
throw new InvalidPayloadException('Invalid base64 payload');
}
$trix = $this->parseTrixContainer($payload);
return [
'version' => $trix['header']['version'] ?? null,
'algorithm' => $trix['header']['algorithm'] ?? null,
'ephemeral_pk' => $trix['header']['ephemeral_pk'] ?? null,
];
}
/**
* Parse a Trix container
*
* Enchantrix Trix format:
* - Magic (4 bytes): "STMF"
* - Version (4 bytes, little-endian): 2
* - Header length (1 byte or varint)
* - Header (JSON)
* - Payload
*
* @return array{header: array, payload: string}
*/
private function parseTrixContainer(string $data): array
{
$offset = 4; // Skip magic
// Skip version (4 bytes)
if (strlen($data) < $offset + 4) {
throw new InvalidPayloadException('Payload too short for version');
}
$offset += 4;
// Read header length (varint - for now just handle 1-2 byte cases)
if (strlen($data) < $offset + 1) {
throw new InvalidPayloadException('Payload too short for header length');
}
$firstByte = ord($data[$offset]);
$headerLen = 0;
if ($firstByte < 128) {
// Single byte length
$headerLen = $firstByte;
$offset += 1;
} else {
// Two byte length (varint continuation)
if (strlen($data) < $offset + 2) {
throw new InvalidPayloadException('Payload too short for header length');
}
$secondByte = ord($data[$offset + 1]);
$headerLen = ($firstByte & 0x7F) | ($secondByte << 7);
$offset += 2;
}
// Read header
if (strlen($data) < $offset + $headerLen) {
throw new InvalidPayloadException('Payload too short for header');
}
$headerJson = substr($data, $offset, $headerLen);
$header = json_decode($headerJson, true);
if ($header === null) {
throw new InvalidPayloadException('Invalid header JSON: ' . json_last_error_msg());
}
$offset += $headerLen;
// Rest is payload
$payload = substr($data, $offset);
return [
'header' => $header,
'payload' => $payload,
];
}
/**
* Decrypt data encrypted by Go's Enchantrix ChaChaPolySigil
*
* Enchantrix format:
* - Nonce (24 bytes for XChaCha20-Poly1305)
* - Ciphertext + Auth tag (16 bytes)
*
* Enchantrix also applies XOR pre-obfuscation before encryption.
* After decryption, we must deobfuscate using the nonce as entropy.
*/
private function chachaDecrypt(string $ciphertext, string $key): ?string
{
$nonceLen = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES; // 24
if (strlen($ciphertext) < $nonceLen + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) {
return null;
}
$nonce = substr($ciphertext, 0, $nonceLen);
$encrypted = substr($ciphertext, $nonceLen);
try {
$obfuscated = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
$encrypted,
'', // Additional data
$nonce,
$key
);
if ($obfuscated === false) {
return null;
}
// Deobfuscate using XOR with nonce-derived key stream (Enchantrix pattern)
return $this->xorDeobfuscate($obfuscated, $nonce);
} catch (\SodiumException $e) {
return null;
}
}
/**
* Deobfuscate data using XOR with entropy-derived key stream.
* This matches Enchantrix's XORObfuscator.
*
* The key stream is derived by hashing: SHA256(entropy || blockNumber)
* for each 32-byte block needed.
*/
private function xorDeobfuscate(string $data, string $entropy): string
{
if (strlen($data) === 0) {
return $data;
}
$keyStream = $this->deriveKeyStream($entropy, strlen($data));
$result = '';
for ($i = 0; $i < strlen($data); $i++) {
$result .= chr(ord($data[$i]) ^ ord($keyStream[$i]));
}
return $result;
}
/**
* Derive a key stream from entropy using SHA-256.
* Matches Enchantrix's XORObfuscator.deriveKeyStream.
*/
private function deriveKeyStream(string $entropy, int $length): string
{
$stream = '';
$blockNum = 0;
while (strlen($stream) < $length) {
// SHA256(entropy || blockNumber as big-endian uint64)
$blockBytes = pack('J', $blockNum); // J = unsigned 64-bit big-endian
$block = hash('sha256', $entropy . $blockBytes, true);
$copyLen = min(32, $length - strlen($stream));
$stream .= substr($block, 0, $copyLen);
$blockNum++;
}
return $stream;
}
/**
* Create STMF instance from a KeyPair
*/
public static function fromKeyPair(KeyPair $keyPair): self
{
return new self($keyPair->getPrivateKeyBase64());
}
}

View file

@ -1,238 +0,0 @@
<?php
declare(strict_types=1);
namespace Borg\STMF\Tests;
require_once __DIR__ . '/../src/FormField.php';
require_once __DIR__ . '/../src/FormData.php';
require_once __DIR__ . '/../src/KeyPair.php';
require_once __DIR__ . '/../src/DecryptionException.php';
require_once __DIR__ . '/../src/InvalidPayloadException.php';
require_once __DIR__ . '/../src/STMF.php';
use Borg\STMF\STMF;
use Borg\STMF\KeyPair;
/**
* Interoperability test - decrypts payloads encrypted by Go
*/
class InteropTest
{
private array $vectors;
private int $passed = 0;
private int $failed = 0;
public function __construct(string $vectorsFile)
{
$json = file_get_contents($vectorsFile);
$this->vectors = json_decode($json, true);
if ($this->vectors === null) {
throw new \RuntimeException("Failed to parse test vectors: " . json_last_error_msg());
}
}
public function run(): bool
{
echo "Running STMF Interoperability Tests\n";
echo "===================================\n\n";
foreach ($this->vectors as $vector) {
$this->runVector($vector);
}
echo "\n===================================\n";
echo "Results: {$this->passed} passed, {$this->failed} failed\n";
return $this->failed === 0;
}
private function runVector(array $vector): void
{
$name = $vector['name'];
echo "Testing: {$name}... ";
try {
// Create STMF instance with private key
$stmf = new STMF($vector['private_key']);
// Decrypt the payload
$formData = $stmf->decrypt($vector['encrypted_b64']);
// Verify fields
$expectedFields = $vector['expected_fields'] ?? [];
foreach ($expectedFields as $key => $expectedValue) {
$actualValue = $formData->get($key);
if ($actualValue !== $expectedValue) {
throw new \RuntimeException(
"Field '{$key}': expected " . json_encode($expectedValue) .
", got " . json_encode($actualValue)
);
}
}
// Verify metadata if present
$expectedMeta = $vector['expected_meta'] ?? [];
if ($expectedMeta) {
$actualMeta = $formData->getMetadata();
foreach ($expectedMeta as $key => $expectedValue) {
$actualValue = $actualMeta[$key] ?? null;
if ($actualValue !== $expectedValue) {
throw new \RuntimeException(
"Metadata '{$key}': expected " . json_encode($expectedValue) .
", got " . json_encode($actualValue)
);
}
}
}
// Verify field count
$expectedCount = count($expectedFields);
$actualCount = count($formData->fields());
if ($actualCount !== $expectedCount) {
throw new \RuntimeException(
"Field count: expected {$expectedCount}, got {$actualCount}"
);
}
echo "PASS\n";
$this->passed++;
} catch (\Exception $e) {
echo "FAIL\n";
echo " Error: " . $e->getMessage() . "\n";
$this->failed++;
}
}
}
// Additional standalone tests
class StandaloneTests
{
public static function runAll(): bool
{
echo "\nRunning Standalone PHP Tests\n";
echo "============================\n\n";
$passed = 0;
$failed = 0;
// Test 1: KeyPair generation
echo "Testing: KeyPair generation... ";
try {
$kp = KeyPair::generate();
if (strlen($kp->getPublicKey()) !== 32) {
throw new \RuntimeException("Public key wrong length");
}
if (strlen($kp->getPrivateKey()) !== 32) {
throw new \RuntimeException("Private key wrong length");
}
echo "PASS\n";
$passed++;
} catch (\Exception $e) {
echo "FAIL: " . $e->getMessage() . "\n";
$failed++;
}
// Test 2: KeyPair from private key
echo "Testing: KeyPair from private key... ";
try {
$kp1 = KeyPair::generate();
$kp2 = KeyPair::fromPrivateKeyBase64($kp1->getPrivateKeyBase64());
if ($kp1->getPublicKeyBase64() !== $kp2->getPublicKeyBase64()) {
throw new \RuntimeException("Public keys don't match");
}
echo "PASS\n";
$passed++;
} catch (\Exception $e) {
echo "FAIL: " . $e->getMessage() . "\n";
$failed++;
}
// Test 3: Invalid payload validation
echo "Testing: Invalid payload detection... ";
try {
$kp = KeyPair::generate();
$stmf = STMF::fromKeyPair($kp);
$isValid = $stmf->validate("not-valid-base64!!!");
if ($isValid) {
throw new \RuntimeException("Should have rejected invalid payload");
}
$isValid2 = $stmf->validate(base64_encode("FAKE" . str_repeat("\x00", 100)));
if ($isValid2) {
throw new \RuntimeException("Should have rejected fake STMF");
}
echo "PASS\n";
$passed++;
} catch (\Exception $e) {
echo "FAIL: " . $e->getMessage() . "\n";
$failed++;
}
// Test 4: FormData methods
echo "Testing: FormData methods... ";
try {
$fields = [
\Borg\STMF\FormField::fromArray(['name' => 'email', 'value' => 'test@test.com']),
\Borg\STMF\FormField::fromArray(['name' => 'tag', 'value' => 'one']),
\Borg\STMF\FormField::fromArray(['name' => 'tag', 'value' => 'two']),
];
$fd = new \Borg\STMF\FormData($fields, ['origin' => 'https://example.com']);
if ($fd->get('email') !== 'test@test.com') {
throw new \RuntimeException("get() failed");
}
if (!$fd->has('email')) {
throw new \RuntimeException("has() failed");
}
if ($fd->has('nonexistent')) {
throw new \RuntimeException("has() false positive");
}
$tags = $fd->getAll('tag');
if (count($tags) !== 2 || $tags[0] !== 'one' || $tags[1] !== 'two') {
throw new \RuntimeException("getAll() failed");
}
if ($fd->getOrigin() !== 'https://example.com') {
throw new \RuntimeException("getOrigin() failed");
}
echo "PASS\n";
$passed++;
} catch (\Exception $e) {
echo "FAIL: " . $e->getMessage() . "\n";
$failed++;
}
echo "\n============================\n";
echo "Standalone: {$passed} passed, {$failed} failed\n";
return $failed === 0;
}
}
// Run tests
if (php_sapi_name() === 'cli') {
$vectorsFile = __DIR__ . '/test_vectors.json';
if (!file_exists($vectorsFile)) {
echo "Error: test_vectors.json not found.\n";
echo "Generate it with: go run tests/generate_test_vectors.go > tests/test_vectors.json\n";
exit(1);
}
// Check sodium extension
if (!extension_loaded('sodium')) {
echo "Error: sodium extension not loaded.\n";
echo "Enable it in php.ini or install php-sodium.\n";
exit(1);
}
$interop = new InteropTest($vectorsFile);
$interopPassed = $interop->run();
$standalonePassed = StandaloneTests::runAll();
exit(($interopPassed && $standalonePassed) ? 0 : 1);
}

View file

@ -1,159 +0,0 @@
// +build ignore
// This program generates test vectors for PHP interoperability testing.
// Run with: go run generate_test_vectors.go > test_vectors.json
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/Snider/Borg/pkg/stmf"
)
type TestVector struct {
Name string `json:"name"`
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"`
EncryptedB64 string `json:"encrypted_b64"`
ExpectedFields map[string]string `json:"expected_fields"`
ExpectedMeta map[string]string `json:"expected_meta"`
}
func main() {
var vectors []TestVector
// Test 1: Simple form with two fields
{
kp, _ := stmf.GenerateKeyPair()
formData := stmf.NewFormData().
AddField("email", "test@example.com").
AddFieldWithType("password", "secret123", "password")
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
vectors = append(vectors, TestVector{
Name: "simple_form",
PrivateKey: kp.PrivateKeyBase64(),
PublicKey: kp.PublicKeyBase64(),
EncryptedB64: encrypted,
ExpectedFields: map[string]string{
"email": "test@example.com",
"password": "secret123",
},
ExpectedMeta: nil,
})
}
// Test 2: Form with metadata
{
kp, _ := stmf.GenerateKeyPair()
formData := stmf.NewFormData().
AddField("username", "johndoe").
AddField("action", "login").
SetMetadata("origin", "https://example.com").
SetMetadata("timestamp", "1735265000")
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
vectors = append(vectors, TestVector{
Name: "form_with_metadata",
PrivateKey: kp.PrivateKeyBase64(),
PublicKey: kp.PublicKeyBase64(),
EncryptedB64: encrypted,
ExpectedFields: map[string]string{
"username": "johndoe",
"action": "login",
},
ExpectedMeta: map[string]string{
"origin": "https://example.com",
"timestamp": "1735265000",
},
})
}
// Test 3: Unicode content
{
kp, _ := stmf.GenerateKeyPair()
formData := stmf.NewFormData().
AddField("name", "日本語テスト").
AddField("emoji", "🔐🛡️✅").
AddField("mixed", "Hello 世界 مرحبا")
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
vectors = append(vectors, TestVector{
Name: "unicode_content",
PrivateKey: kp.PrivateKeyBase64(),
PublicKey: kp.PublicKeyBase64(),
EncryptedB64: encrypted,
ExpectedFields: map[string]string{
"name": "日本語テスト",
"emoji": "🔐🛡️✅",
"mixed": "Hello 世界 مرحبا",
},
ExpectedMeta: nil,
})
}
// Test 4: Large form with many fields
{
kp, _ := stmf.GenerateKeyPair()
formData := stmf.NewFormData()
expectedFields := make(map[string]string)
for i := 0; i < 20; i++ {
key := fmt.Sprintf("field_%d", i)
value := fmt.Sprintf("value_%d_with_some_content", i)
formData.AddField(key, value)
expectedFields[key] = value
}
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
vectors = append(vectors, TestVector{
Name: "large_form",
PrivateKey: kp.PrivateKeyBase64(),
PublicKey: kp.PublicKeyBase64(),
EncryptedB64: encrypted,
ExpectedFields: expectedFields,
ExpectedMeta: nil,
})
}
// Test 5: Special characters
{
kp, _ := stmf.GenerateKeyPair()
formData := stmf.NewFormData().
AddField("sql", "'; DROP TABLE users; --").
AddField("html", "<script>alert('xss')</script>").
AddField("json", `{"key": "value", "nested": {"a": 1}}`).
AddField("newlines", "line1\nline2\nline3")
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
vectors = append(vectors, TestVector{
Name: "special_characters",
PrivateKey: kp.PrivateKeyBase64(),
PublicKey: kp.PublicKeyBase64(),
EncryptedB64: encrypted,
ExpectedFields: map[string]string{
"sql": "'; DROP TABLE users; --",
"html": "<script>alert('xss')</script>",
"json": `{"key": "value", "nested": {"a": 1}}`,
"newlines": "line1\nline2\nline3",
},
ExpectedMeta: nil,
})
}
// Output as JSON
output, err := json.MarshalIndent(vectors, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println(string(output))
}

View file

@ -1,81 +0,0 @@
[
{
"name": "simple_form",
"private_key": "cHSFC/ZN/whRWfQSHMHvQcEQgNm8VLPqr3FGW9pIUWw=",
"public_key": "9N840/Td0GTeGrmCip+4o/iftrh11l5IsxeUr4M3vzU=",
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6IjdYeTlGdWZSUkdJYW1WdTBEbE5VM2dGQWEveFdFZVVjWU9TVWRVN1NLSHc9IiwidmVyc2lvbiI6IjEuMCJ9WfRt459u9b3sGFhx5JaxQ3Nr1sVVy7Mebr4NnqfzX6GhQzs8iLZuF7EbeyY0auSBrgHIH3WBrvPj2H0rr7gnmIMesIRRs6HWR76vkvAb1FfbC6MOArduGfBK6edKaejtdC7rD9NtgpHaEEruNTE1e7SRQFF41ufu97+OqwfuyIMVyICmlvgW7ln+T6/PwMnhHf8dZ+rksc7SFnhwt5akBBxXOUbVgEvz",
"expected_fields": {
"email": "test@example.com",
"password": "secret123"
},
"expected_meta": null
},
{
"name": "form_with_metadata",
"private_key": "NVYNU8Ruc0aG0Yh8YHZfLASlH0xeCZXRJ3rP4WiQ5t4=",
"public_key": "0nJS0TpZPk/oaEAwpbPKbRboTIBa7qkeYRIGmE1A61Q=",
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6ImM3WCtDVDBOeDRDWFVjOWJHVEhwWFNZQ0dWckswbWZMRmhLWFhWT1pSbTg9IiwidmVyc2lvbiI6IjEuMCJ9y+1OD5VnQmBqck8tM3vs1fBznR6ZhK/nGFXVoxlC5jZjpBgAbExrb4AKgBmkvM8t+oVWIH2lyRfKArEJFalqm8H+Gv/OoebUSR0qwoHlYUaIGU6JAbNb5gEmrsJBTz7E2/FCdILokVaWNicu6p9eCye2OH4lEWNCGI4WTZJZMO9N45Kqj3UUqNm9dAar3hpYKcezvbSWpM1OjUBO9F5ye0tnIiqinJvlFmkzfKpBz0mseWE1QL8BcnbUcaSgaTSGxs1jDq4JrUDMtPODeoxgTCFju0GTJfmo9g==",
"expected_fields": {
"action": "login",
"username": "johndoe"
},
"expected_meta": {
"origin": "https://example.com",
"timestamp": "1735265000"
}
},
{
"name": "unicode_content",
"private_key": "p9JUNyRQYhQef7tNse3rUAIxyntRjnv3DPCqiKobg0Y=",
"public_key": "V2V27btYoVYui/6O3U/v24xu6g0Rsz3x8NHyOre2sFc=",
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6IlhxZnZSQlBpbGt4dWc3S0k3ZWFGWGNMQkJQaHZOb2dZa0JOM2xra1E2REk9IiwidmVyc2lvbiI6IjEuMCJ97lEfIzlKEs3MxXPb+taqw1QFPoEp8U/+WjOY8PFNYUXBFstbfybSFzXakthOARFcRU1RzoiHG+mWlGdwcBdcVWhodZDZj0C6NSMqLVx3bZLIoGlzN7v5N+b+xs+ApVQpl3x4LqbML6Jj5zhisTlaoEmMld+FeH8zRmp7a+FbNfMBM9V+IIRY6p3nPeo8czmyrwyrGscDnUkFaThdv7D2v1kFOFc5EOlfzaFPsDLq+ewa5OkGpEYnEu8UM+B122fzhHEr7sOUjHOk0RSkC33hNOWzmcs5SYsc2GEp86f/aeYp6SyiKUNHFg==",
"expected_fields": {
"emoji": "🔐🛡️✅",
"mixed": "Hello 世界 مرحبا",
"name": "日本語テスト"
},
"expected_meta": null
},
{
"name": "large_form",
"private_key": "6ZEQmKTUiojvQumqRdJsFcm91tz21QYgfvetxXO/VGs=",
"public_key": "iMk1wvMZc3fr8WqAqiPPZj/9x0pZuGh8kTECGqAJKQI=",
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6ImJad2w1ZjI1SjBJdmFBb0lXWUZlWjhIVkFFU1lhYktWdXVSeDFXTW53RU09IiwidmVyc2lvbiI6IjEuMCJ9QbTXLryMbYSnNJV0TPUpFkgArf1PkA0vx/R0Ml5VIXxuZuMayx8mE2GVSvPlYDp5UgIndl5uQ/oA9Mx0rqOpIQozRdPUHFKA98c3XRYQqhiWguBRpLpFtV3HKgtPdfPtS+rq2/jPICH+DKyLOCYVVHK+/E2b/39wjBHWTP1XuSHbU0bdS7+YkXyjh2xzrsS3HvVL3YbPN11Q1thizT9C2WxiKfetmwig480a8lJV8ej5ew9303iEGqhtRArxgHAI19syFQHkOVJBYQh83/B3HcPoF5TKuVelZ6FvB+/HETpbfaL3rsB0zZLR+PZn2X+MM4wsvPkWf+VcRTQjEXJKlQQAuy5rrgLg0LAOQbko7zKjfCL4MPyrYx/Mc4Ht4OyIxssQUYh5FV6+kCRlh/abu5XVuyv4lswG0SCzA5eYPmDGlZHJuRP3gcpKkwS92/X0NYZjxPPhZStKcPf5NOBrXmAtQoWdwV0i7aZBvNipBvJ3EUhkSJoKhYV4g4TSOcHS69RNCL8z7qg/7MFfihJbklDuSyEZCV7DW+6SJggdyEfs5e0e9Hf8fXGBsGophfNorh8vypV4KA0spnpKHLwuCWxVaYUX5vpFupPWo546M8JunvTJf80bPiL8neEDqkWkxXDda1K+c7JHsIrz2iOdDN/6qYTYMr2F+vXF+GERBHc2qMfH7EdzgCB6DqgxDWEXAGCbrnUJTIaew3CPaeU49ECDy9UlRLh4cFuRNEXsDYm7ALjThwulONU8BUoYhIcKuXowV0OEr6bSogF5CmrdwwUCrHNxDBJA3lP7/oBSSoj1FOVf3e2GGSmLJml1OiunTbW5voiFmiQdwhWkYcrmBr0ZMj6BPOSirPhsjNCqxML65VnOpsengPr0MUHL/bXUPUp0sqGHgRTViS7OvN4WxH1otOjzCAV1EdyAKIE4pcPSP6x6SZV4AIEjcSiBy08UaryL5UpO8Q8aLbu0hQ7ftMGF4SK0hqZxncTV46s67yQI5EE09qqefLHtV4N5FwD3t28FFmF9Bkmm6H2lChTFBenLE4nBDzHjHME9z/9xe1ryZzbX9DpSpH8LngdeE+5pZi72KhMHGy6r8tFJJBNMsOPnoYAhYFiAK817yVWgYK/W4SDksL4MyN3pYmX9hmug0b18TV6QwoDIvApio3zcGICUPD804xYuE/7rUjJQLlp/bj9049lu8AZaaYNPF39jvJMQqU0+dZ1OfBhCkkbaUza0Vv+YIEnJ+ZicKdpf5X4Vbltpgw4CDhNXBVNo9GmQDHzHhgapnWmkQQHn9eOlTgjUB+M2AvCwzpx3aHO+6QfiRVj/Xf9U6UMWoSp2ui+C8u2YWvRgvs+BTJyeq5fNbxvLgvT7l24I6RKMl4JzPcpgo1ysaXtPsBbO1Lz6wa3vIazVsX4bax0b2GVRyfW1g/SL2PggP8R0ob5zj7K2PSakVwOIMHw9NiUcVgrY2o8LYml+VeKZJFA3SUkseFN1gH9UYeLsnj+rmAs/n5V8XVPbuklBX8eIIt0E1ZiDz6eQuWnKJzKiwvGhALtG9vgTyIpFNgdlg7dWBcw/GBZNvXDXgwKDe+ypPU2XObErVTF29NEV0vE7gH5j727ZfgHsiiQWq3Qt2GGuMMsPA+m3xJ6NtAvoe9td6hpP6CMdSv++5N3tXKjauf3fpaeJetxS+AfW+8PT5gBFN5U6cblUQ3igTZs2dRQt4gCK1toBKo8k51ikL3zAxNeGdhS9j+z/0eavkEs8df2cWZ2FZ5OF5VaMOH+XzL/JwpHep5yJfO9IuHp+tejGLs9H9e+x69rXHqB9TkZtcrYSm9ChO3L9olZGaZWWgRnYdyT1cwv76Rdvv11K0kiPmmuBQSRQe1ri/G2+mh+WxAny3gBc4ElWQOhUF+J4A9F31gSKhcUIOB1ZaApuXMswBpr8RYHr",
"expected_fields": {
"field_0": "value_0_with_some_content",
"field_1": "value_1_with_some_content",
"field_10": "value_10_with_some_content",
"field_11": "value_11_with_some_content",
"field_12": "value_12_with_some_content",
"field_13": "value_13_with_some_content",
"field_14": "value_14_with_some_content",
"field_15": "value_15_with_some_content",
"field_16": "value_16_with_some_content",
"field_17": "value_17_with_some_content",
"field_18": "value_18_with_some_content",
"field_19": "value_19_with_some_content",
"field_2": "value_2_with_some_content",
"field_3": "value_3_with_some_content",
"field_4": "value_4_with_some_content",
"field_5": "value_5_with_some_content",
"field_6": "value_6_with_some_content",
"field_7": "value_7_with_some_content",
"field_8": "value_8_with_some_content",
"field_9": "value_9_with_some_content"
},
"expected_meta": null
},
{
"name": "special_characters",
"private_key": "bET5cDIamjtYKBGJSSAekIrH2mw54YamHCtyrWOSMlw=",
"public_key": "umOQqu/3cBBs3PeWs4vQiG3DxNARWlOJfrtG65RQdkk=",
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6Ik9nVnF4ZERJcnhsd2t2REdlQ3BtYVNUS0RhNkJQZFBMTm9Bek4yV2RJMDQ9IiwidmVyc2lvbiI6IjEuMCJ9fMvVueLCedw67bt7iLXJvZE66NOaPfwu2TsmN3sseXGu5M2csgYN+ngT6/XcMzAl7MHc3sHsvUE7H2MHTlhLih46WiyTzpXvfmzZHv3/t6oBxDwcm8O++ksCuuOnhHAfh65340/EuEMl8Da71zF00cFyuIvAmJFaBtn3Tlj6wd+9jfORuxv1TeRKLtMIojbLl9hZv25UHJSgjrlGL3kYdpg6WLqVKwz81P5RP10LbCQzHmH+4/lIAaFZAzf9mHcIHGj3ytKuDXDe1GhEhkUCK6Pe7PGuojIglhXMj9mijvlh2TCyxGQDw0VhCBCjVyWkWSPnTTb0bQOs3WLpGHN+XLgewVdSRTLbGy1t0PEkhjNftca8mLd4qEcSHzlFphGpN0u4R+Am0VSV44cqYDjeDKZnYyuBGu+ti6h+33AHfJU8EWzSMJXJEhP9Uqb0OuFyzfc7tVgGVAeEOBxGo/nyzPzi",
"expected_fields": {
"html": "\u003cscript\u003ealert('xss')\u003c/script\u003e",
"json": "{\"key\": \"value\", \"nested\": {\"a\": 1}}",
"newlines": "line1\nline2\nline3",
"sql": "'; DROP TABLE users; --"
},
"expected_meta": null
}
]

View file

@ -8,7 +8,17 @@ import (
"github.com/ulikunitz/xz"
)
// Compress compresses data using the specified format.
// Compress compresses a byte slice using the specified format.
// Supported formats are "gz" and "xz". If an unsupported format is provided,
// the original data is returned unmodified.
//
// Example:
//
// compressedData, err := compress.Compress([]byte("hello world"), "gz")
// if err != nil {
// // handle error
// }
// // compressedData now holds the gzipped version of "hello world"
func Compress(data []byte, format string) ([]byte, error) {
var buf bytes.Buffer
var writer io.WriteCloser
@ -39,7 +49,17 @@ func Compress(data []byte, format string) ([]byte, error) {
return buf.Bytes(), nil
}
// Decompress decompresses data, detecting the format automatically.
// Decompress decompresses a byte slice, automatically detecting the compression
// format (gz or xz) by inspecting the header magic bytes. If the data is not
// compressed in a recognized format, it is returned unmodified.
//
// Example:
//
// decompressedData, err := compress.Decompress(compressedData)
// if err != nil {
// // handle error
// }
// // decompressedData now holds the original uncompressed data
func Decompress(data []byte) ([]byte, error) {
// Check for gzip header
if len(data) > 2 && data[0] == 0x1f && data[1] == 0x8b {

View file

@ -1,27 +0,0 @@
// Package console provides an encrypted PWA demo server with browser integration.
package console
import (
"fmt"
"os/exec"
"runtime"
)
// OpenBrowser opens the default browser to the specified URL.
// Supports macOS, Linux, and Windows.
func OpenBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
return cmd.Start()
}

View file

@ -1,139 +0,0 @@
package console
import (
_ "embed"
"fmt"
"net/http"
"net/url"
"os"
"sync"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/tim"
)
//go:embed unlock.html
var unlockHTML []byte
// Server serves encrypted STIM content with an optional unlock page.
type Server struct {
stimData []byte
password string
port string
mu sync.RWMutex
unlocked bool
rootFS *datanode.DataNode
}
// NewServer creates a new console server.
// If password is provided, the content is decrypted immediately.
// If password is empty, an unlock page is shown until the user provides the password.
func NewServer(stimPath, password, port string) (*Server, error) {
data, err := os.ReadFile(stimPath)
if err != nil {
return nil, fmt.Errorf("reading STIM file: %w", err)
}
s := &Server{
stimData: data,
password: password,
port: port,
}
// If password provided, unlock immediately
if password != "" {
if err := s.unlock(password); err != nil {
return nil, fmt.Errorf("decrypting STIM: %w", err)
}
}
return s, nil
}
// unlock decrypts the STIM data with the given password.
func (s *Server) unlock(password string) error {
s.mu.Lock()
defer s.mu.Unlock()
m, err := tim.FromSigil(s.stimData, password)
if err != nil {
return err
}
s.rootFS = m.RootFS
s.unlocked = true
return nil
}
// isUnlocked returns whether the content has been decrypted.
func (s *Server) isUnlocked() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.unlocked
}
// Start begins serving HTTP requests.
func (s *Server) Start() error {
http.HandleFunc("/", s.handleRoot)
http.HandleFunc("/unlock", s.handleUnlock)
return http.ListenAndServe(":"+s.port, nil)
}
// handleRoot serves the main content or unlock page.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if !s.isUnlocked() {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(unlockHTML)
return
}
s.mu.RLock()
fs := http.FS(s.rootFS)
s.mu.RUnlock()
http.FileServer(fs).ServeHTTP(w, r)
}
// handleUnlock processes the unlock form submission.
func (s *Server) handleUnlock(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
redirectWithError(w, r, "Invalid form submission")
return
}
password := r.FormValue("password")
if password == "" {
redirectWithError(w, r, "Password is required")
return
}
if err := s.unlock(password); err != nil {
redirectWithError(w, r, "Incorrect password")
return
}
// Success - redirect to content
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// redirectWithError redirects to the unlock page with an error message.
func redirectWithError(w http.ResponseWriter, r *http.Request, message string) {
http.Redirect(w, r, "/?error="+url.QueryEscape(message), http.StatusSeeOther)
}
// Port returns the server's port.
func (s *Server) Port() string {
return s.port
}
// URL returns the full server URL.
func (s *Server) URL() string {
return fmt.Sprintf("http://localhost:%s", s.port)
}

View file

@ -1,253 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Borg Console - Unlock</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;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: #e0e0e0;
}
.container {
width: 100%;
max-width: 420px;
}
.card {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 2.5rem;
border: 1px solid rgba(255,255,255,0.1);
backdrop-filter: blur(10px);
}
.logo {
text-align: center;
margin-bottom: 1.5rem;
}
.logo-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
h1 {
text-align: center;
font-size: 1.8rem;
margin-bottom: 0.5rem;
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;
}
.input-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #aaa;
font-size: 0.85rem;
}
input[type="password"] {
width: 100%;
padding: 1rem;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(0,0,0,0.3);
color: #fff;
font-size: 1rem;
transition: all 0.2s;
}
input[type="password"]:focus {
outline: none;
border-color: #00d9ff;
box-shadow: 0 0 0 3px rgba(0, 217, 255, 0.1);
}
input[type="password"]::placeholder {
color: #666;
}
button {
width: 100%;
padding: 1rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-size: 1rem;
background: linear-gradient(135deg, #00d9ff 0%, #00ff94 100%);
color: #000;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 217, 255, 0.4);
}
button:active {
transform: translateY(0);
}
.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: 1.5rem;
color: #ff5252;
display: none;
text-align: center;
font-size: 0.9rem;
}
.error-banner.visible {
display: block;
}
.footer {
text-align: center;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255,255,255,0.1);
}
.footer p {
font-size: 0.75rem;
color: #666;
}
.footer code {
background: rgba(0,0,0,0.3);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
}
.loading {
display: none;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.loading.visible {
display: flex;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(0, 217, 255, 0.3);
border-top-color: #00d9ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn-text {
display: block;
}
.btn-text.hidden {
display: none;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="logo">
<div class="logo-icon">🔐</div>
<h1>Borg Console</h1>
<p class="subtitle">Enter password to unlock encrypted content</p>
</div>
<div id="error-banner" class="error-banner"></div>
<form id="unlock-form" method="POST" action="/unlock">
<div class="input-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter your password"
autocomplete="current-password"
required
autofocus
>
</div>
<button type="submit" id="submit-btn">
<span class="btn-text" id="btn-text">Unlock Console</span>
<span class="loading" id="loading">
<span class="spinner"></span>
<span>Decrypting...</span>
</span>
</button>
</form>
<div class="footer">
<p>Encrypted with <code>ChaCha20-Poly1305</code></p>
</div>
</div>
</div>
<script>
const form = document.getElementById('unlock-form');
const errorBanner = document.getElementById('error-banner');
const btnText = document.getElementById('btn-text');
const loading = document.getElementById('loading');
const submitBtn = document.getElementById('submit-btn');
// Check for error in URL params
const params = new URLSearchParams(window.location.search);
const error = params.get('error');
if (error) {
errorBanner.textContent = error;
errorBanner.classList.add('visible');
// Clean URL
history.replaceState(null, '', window.location.pathname);
}
form.addEventListener('submit', function(e) {
// Show loading state
btnText.classList.add('hidden');
loading.classList.add('visible');
submitBtn.disabled = true;
errorBanner.classList.remove('visible');
});
</script>
</body>
</html>

View file

@ -3,7 +3,6 @@ package datanode
import (
"archive/tar"
"bytes"
"errors"
"io"
"io/fs"
"os"
@ -13,22 +12,37 @@ import (
"time"
)
var (
ErrInvalidPassword = errors.New("invalid password")
ErrPasswordRequired = errors.New("password required")
)
// DataNode is an in-memory filesystem that is compatible with fs.FS.
// DataNode represents an in-memory filesystem, compatible with the standard
// library's io/fs.FS interface. It stores files and their contents in memory,
// making it useful for manipulating collections of files, such as those from
// a tar archive or a Git repository, without writing them to disk.
type DataNode struct {
files map[string]*dataFile
}
// New creates a new, empty DataNode.
// New creates and returns a new, empty DataNode. This is the starting point
// for building an in-memory filesystem.
//
// Example:
//
// dn := datanode.New()
func New() *DataNode {
return &DataNode{files: make(map[string]*dataFile)}
}
// FromTar creates a new DataNode from a tarball.
// FromTar creates a new DataNode by reading a tar archive. The tarball's
// contents are unpacked into the in-memory filesystem.
//
// Example:
//
// tarData, err := os.ReadFile("my-archive.tar")
// if err != nil {
// // handle error
// }
// dn, err := datanode.FromTar(tarData)
// if err != nil {
// // handle error
// }
func FromTar(tarball []byte) (*DataNode, error) {
dn := New()
tarReader := tar.NewReader(bytes.NewReader(tarball))
@ -54,7 +68,20 @@ func FromTar(tarball []byte) (*DataNode, error) {
return dn, nil
}
// ToTar serializes the DataNode to a tarball.
// ToTar serializes the DataNode into a tar archive. This is useful for
// saving the in-memory filesystem to disk or for transmitting it over a
// network.
//
// Example:
//
// tarData, err := dn.ToTar()
// if err != nil {
// // handle error
// }
// err = os.WriteFile("my-archive.tar", tarData, 0644)
// if err != nil {
// // handle error
// }
func (d *DataNode) ToTar() ([]byte, error) {
buf := new(bytes.Buffer)
tw := tar.NewWriter(buf)
@ -81,7 +108,14 @@ func (d *DataNode) ToTar() ([]byte, error) {
return buf.Bytes(), nil
}
// AddData adds a file to the DataNode.
// AddData adds a file to the DataNode with the given name and content. If the
// file already exists, it will be overwritten. Directory paths are created
// implicitly and do not need to be added separately.
//
// Example:
//
// dn.AddData("my-file.txt", []byte("hello world"))
// dn.AddData("my-dir/my-other-file.txt", []byte("hello again"))
func (d *DataNode) AddData(name string, content []byte) {
name = strings.TrimPrefix(name, "/")
if name == "" {
@ -99,7 +133,21 @@ func (d *DataNode) AddData(name string, content []byte) {
}
}
// Open opens a file from the DataNode.
// Open opens a file from the DataNode for reading. It returns an fs.File,
// which can be used with standard library functions that operate on files.
// This method is part of the fs.FS interface implementation.
//
// Example:
//
// file, err := dn.Open("my-file.txt")
// if err != nil {
// // handle error
// }
// defer file.Close()
// content, err := io.ReadAll(file)
// if err != nil {
// // handle error
// }
func (d *DataNode) Open(name string) (fs.File, error) {
name = strings.TrimPrefix(name, "/")
if file, ok := d.files[name]; ok {
@ -118,7 +166,18 @@ func (d *DataNode) Open(name string) (fs.File, error) {
return nil, fs.ErrNotExist
}
// ReadDir reads and returns all directory entries for the named directory.
// ReadDir reads the named directory and returns a list of directory entries.
// This method is part of the fs.ReadDirFS interface implementation.
//
// Example:
//
// entries, err := dn.ReadDir("my-dir")
// if err != nil {
// // handle error
// }
// for _, entry := range entries {
// fmt.Println(entry.Name())
// }
func (d *DataNode) ReadDir(name string) ([]fs.DirEntry, error) {
name = strings.TrimPrefix(name, "/")
if name == "." {
@ -171,7 +230,16 @@ func (d *DataNode) ReadDir(name string) ([]fs.DirEntry, error) {
return entries, nil
}
// Stat returns the FileInfo structure describing file.
// Stat returns the fs.FileInfo structure describing the named file or directory.
// This method is part of the fs.StatFS interface implementation.
//
// Example:
//
// info, err := dn.Stat("my-file.txt")
// if err != nil {
// // handle error
// }
// fmt.Println(info.Size())
func (d *DataNode) Stat(name string) (fs.FileInfo, error) {
name = strings.TrimPrefix(name, "/")
if file, ok := d.files[name]; ok {
@ -191,12 +259,31 @@ func (d *DataNode) Stat(name string) (fs.FileInfo, error) {
return nil, fs.ErrNotExist
}
// ExistsOptions allows customizing the Exists check.
// ExistsOptions provides options for customizing the behavior of the Exists
// method.
type ExistsOptions struct {
// WantType specifies the desired file type (e.g., fs.ModeDir for a
// directory). If the file exists but is not of the desired type, Exists
// will return false.
WantType fs.FileMode
}
// Exists returns true if the file or directory exists.
// Exists checks if a file or directory at the given path exists in the DataNode.
// It can optionally check if the file is of a specific type (e.g., a directory).
//
// Example:
//
// // Check if a file exists
// exists, err := dn.Exists("my-file.txt")
// if err != nil {
// // handle error
// }
//
// // Check if a directory exists
// exists, err = dn.Exists("my-dir", datanode.ExistsOptions{WantType: fs.ModeDir})
// if err != nil {
// // handle error
// }
func (d *DataNode) Exists(name string, opts ...ExistsOptions) (bool, error) {
info, err := d.Stat(name)
if err != nil {
@ -216,14 +303,30 @@ func (d *DataNode) Exists(name string, opts ...ExistsOptions) (bool, error) {
return true, nil
}
// WalkOptions allows customizing the Walk behavior.
// WalkOptions provides options for customizing the behavior of the Walk method.
type WalkOptions struct {
MaxDepth int
Filter func(path string, d fs.DirEntry) bool
// MaxDepth limits the depth of the walk. A value of 0 means no limit.
MaxDepth int
// Filter is a function that can be used to skip files or directories. If
// the function returns false for an entry, that entry is skipped. If the
// entry is a directory, the entire subdirectory is skipped.
Filter func(path string, d fs.DirEntry) bool
// SkipErrors causes the walk to continue when an error is encountered.
SkipErrors bool
}
// Walk recursively descends the file tree rooted at root, calling fn for each file or directory.
// Walk walks the in-memory file tree rooted at root, calling fn for each file or
// directory in the tree, including root. The walk is depth-first.
//
// Example:
//
// err := dn.Walk(".", func(path string, d fs.DirEntry, err error) error {
// if err != nil {
// return err
// }
// fmt.Println(path)
// return nil
// })
func (d *DataNode) Walk(root string, fn fs.WalkDirFunc, opts ...WalkOptions) error {
var maxDepth int
var filter func(string, fs.DirEntry) bool
@ -276,7 +379,15 @@ func (d *DataNode) Walk(root string, fn fs.WalkDirFunc, opts ...WalkOptions) err
})
}
// CopyFile copies a file from the DataNode to the local filesystem.
// CopyFile copies a file from the DataNode to a specified path on the local
// filesystem.
//
// Example:
//
// err := dn.CopyFile("my-file.txt", "/tmp/my-file.txt", 0644)
// if err != nil {
// // handle error
// }
func (d *DataNode) CopyFile(sourcePath string, target string, perm os.FileMode) error {
sourceFile, err := d.Open(sourcePath)
if err != nil {

View file

@ -1,10 +1,7 @@
package datanode
import (
"archive/tar"
"bytes"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
@ -351,65 +348,6 @@ func TestWalk_Ugly(t *testing.T) {
}
}
func TestWalk_Options(t *testing.T) {
dn := New()
dn.AddData("root.txt", []byte("root"))
dn.AddData("a/a1.txt", []byte("a1"))
dn.AddData("a/b/b1.txt", []byte("b1"))
dn.AddData("c/c1.txt", []byte("c1"))
t.Run("MaxDepth", func(t *testing.T) {
var paths []string
err := dn.Walk(".", func(path string, d fs.DirEntry, err error) error {
paths = append(paths, path)
return nil
}, WalkOptions{MaxDepth: 1})
if err != nil {
t.Fatalf("Walk failed: %v", err)
}
expected := []string{".", "a", "c", "root.txt"}
sort.Strings(paths)
if !reflect.DeepEqual(paths, expected) {
t.Errorf("expected paths %v, got %v", expected, paths)
}
})
t.Run("Filter", func(t *testing.T) {
var paths []string
err := dn.Walk(".", func(path string, d fs.DirEntry, err error) error {
paths = append(paths, path)
return nil
}, WalkOptions{Filter: func(path string, d fs.DirEntry) bool {
return !strings.HasPrefix(path, "a")
}})
if err != nil {
t.Fatalf("Walk failed: %v", err)
}
expected := []string{".", "c", "c/c1.txt", "root.txt"}
sort.Strings(paths)
if !reflect.DeepEqual(paths, expected) {
t.Errorf("expected paths %v, got %v", expected, paths)
}
})
t.Run("SkipErrors", func(t *testing.T) {
// Mock a walk failure by passing a non-existent root with SkipErrors.
// Normally, WalkDir calls fn with an error for the root if it doesn't exist.
var called bool
err := dn.Walk("nonexistent", func(path string, d fs.DirEntry, err error) error {
called = true
return err
}, WalkOptions{SkipErrors: true})
if err != nil {
t.Errorf("expected no error with SkipErrors, got %v", err)
}
if called {
t.Error("callback should NOT be called if error is skipped internally")
}
})
}
func TestCopyFile_Good(t *testing.T) {
dn := New()
dn.AddData("foo.txt", []byte("foo"))
@ -459,127 +397,6 @@ func TestCopyFile_Ugly(t *testing.T) {
}
}
func TestToTar_Good(t *testing.T) {
dn := New()
dn.AddData("foo.txt", []byte("foo"))
dn.AddData("bar/baz.txt", []byte("baz"))
tarball, err := dn.ToTar()
if err != nil {
t.Fatalf("ToTar failed: %v", err)
}
if len(tarball) == 0 {
t.Fatal("expected non-empty tarball")
}
// Verify tar content
tr := tar.NewReader(bytes.NewReader(tarball))
files := make(map[string]string)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar.Next failed: %v", err)
}
content, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("read tar content failed: %v", err)
}
files[header.Name] = string(content)
}
if files["foo.txt"] != "foo" {
t.Errorf("expected foo.txt content 'foo', got %q", files["foo.txt"])
}
if files["bar/baz.txt"] != "baz" {
t.Errorf("expected bar/baz.txt content 'baz', got %q", files["bar/baz.txt"])
}
}
func TestFromTar_Good(t *testing.T) {
// Create a tarball
buf := new(bytes.Buffer)
tw := tar.NewWriter(buf)
files := []struct{ Name, Body string }{
{"foo.txt", "foo"},
{"bar/baz.txt", "baz"},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Mode: 0600,
Size: int64(len(file.Body)),
Typeflag: tar.TypeReg,
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("WriteHeader failed: %v", err)
}
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatalf("Write failed: %v", err)
}
}
if err := tw.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
dn, err := FromTar(buf.Bytes())
if err != nil {
t.Fatalf("FromTar failed: %v", err)
}
// Verify DataNode content
exists, _ := dn.Exists("foo.txt")
if !exists {
t.Error("foo.txt missing")
}
exists, _ = dn.Exists("bar/baz.txt")
if !exists {
t.Error("bar/baz.txt missing")
}
}
func TestTarRoundTrip_Good(t *testing.T) {
dn1 := New()
dn1.AddData("a.txt", []byte("a"))
dn1.AddData("b/c.txt", []byte("c"))
tarball, err := dn1.ToTar()
if err != nil {
t.Fatalf("ToTar failed: %v", err)
}
dn2, err := FromTar(tarball)
if err != nil {
t.Fatalf("FromTar failed: %v", err)
}
// Verify dn2 matches dn1
exists, _ := dn2.Exists("a.txt")
if !exists {
t.Error("a.txt missing in dn2")
}
exists, _ = dn2.Exists("b/c.txt")
if !exists {
t.Error("b/c.txt missing in dn2")
}
}
func TestFromTar_Bad(t *testing.T) {
// Pass invalid data (truncated header)
// A valid tar header is 512 bytes.
truncated := make([]byte, 100)
_, err := FromTar(truncated)
if err == nil {
t.Error("expected error for truncated tar header, got nil")
} else if err != io.EOF && err != io.ErrUnexpectedEOF {
// Verify it's some sort of read error or EOF related
// Depending on implementation details of archive/tar
}
}
func toSortedNames(entries []fs.DirEntry) []string {
var names []string
for _, e := range entries {

View file

@ -1,3 +1,4 @@
// Package github provides a client for interacting with the GitHub API.
package github
import (
@ -11,23 +12,40 @@ import (
"golang.org/x/oauth2"
)
// Repo represents a GitHub repository, containing the information needed to
// clone it.
type Repo struct {
// CloneURL is the URL used to clone the repository.
CloneURL string `json:"clone_url"`
}
// GithubClient is an interface for interacting with the Github API.
// GithubClient defines the interface for interacting with the GitHub API. This
// allows for mocking the client in tests.
type GithubClient interface {
// GetPublicRepos retrieves a list of all public repository clone URLs for a
// given user or organization.
GetPublicRepos(ctx context.Context, userOrOrg string) ([]string, error)
}
// NewGithubClient creates a new GithubClient.
// NewGithubClient creates and returns a new GithubClient.
//
// Example:
//
// client := github.NewGithubClient()
// repos, err := client.GetPublicRepos(context.Background(), "my-org")
// if err != nil {
// // handle error
// }
func NewGithubClient() GithubClient {
return &githubClient{}
}
type githubClient struct{}
// NewAuthenticatedClient creates a new authenticated http client.
// NewAuthenticatedClient creates a new http.Client that authenticates with the
// GitHub API using a token from the GITHUB_TOKEN environment variable. If the
// variable is not set, it returns the default http.Client. This variable can
// be overridden in tests to provide a mock client.
var NewAuthenticatedClient = func(ctx context.Context) *http.Client {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {

View file

@ -12,20 +12,31 @@ import (
)
var (
// NewClient is a variable that holds the function to create a new GitHub client.
// This allows for mocking in tests.
// NewClient is a function that creates a new GitHub client. It is a
// variable to allow for mocking in tests.
NewClient = func(httpClient *http.Client) *github.Client {
return github.NewClient(httpClient)
}
// NewRequest is a variable that holds the function to create a new HTTP request.
// NewRequest is a function that creates a new HTTP request. It is a
// variable to allow for mocking in tests.
NewRequest = func(method, url string, body io.Reader) (*http.Request, error) {
return http.NewRequest(method, url, body)
}
// DefaultClient is the default http client
// DefaultClient is the default http client used for making requests. It is
// a variable to allow for mocking in tests.
DefaultClient = &http.Client{}
)
// GetLatestRelease gets the latest release for a repository.
// GetLatestRelease fetches the latest release metadata for a given GitHub
// repository.
//
// Example:
//
// release, err := github.GetLatestRelease("my-org", "my-repo")
// if err != nil {
// // handle error
// }
// fmt.Println(release.GetTagName())
func GetLatestRelease(owner, repo string) (*github.RepositoryRelease, error) {
client := NewClient(nil)
release, _, err := client.Repositories.GetLatestRelease(context.Background(), owner, repo)
@ -35,7 +46,20 @@ func GetLatestRelease(owner, repo string) (*github.RepositoryRelease, error) {
return release, nil
}
// DownloadReleaseAsset downloads a release asset.
// DownloadReleaseAsset downloads the content of a release asset.
//
// Example:
//
// // Assuming 'release' is a *github.RepositoryRelease
// for _, asset := range release.Assets {
// if asset.GetName() == "my-asset.zip" {
// data, err := github.DownloadReleaseAsset(asset)
// if err != nil {
// // handle error
// }
// // do something with data
// }
// }
func DownloadReleaseAsset(asset *github.ReleaseAsset) ([]byte, error) {
req, err := NewRequest("GET", asset.GetBrowserDownloadURL(), nil)
if err != nil {
@ -61,7 +85,16 @@ func DownloadReleaseAsset(asset *github.ReleaseAsset) ([]byte, error) {
return buf.Bytes(), nil
}
// ParseRepoFromURL parses the owner and repository from a GitHub URL.
// ParseRepoFromURL extracts the owner and repository name from a variety of
// GitHub URL formats, including HTTPS, Git, and SCP-style URLs.
//
// Example:
//
// owner, repo, err := github.ParseRepoFromURL("https://github.com/my-org/my-repo.git")
// if err != nil {
// // handle error
// }
// fmt.Println(owner, repo) // "my-org", "my-repo"
func ParseRepoFromURL(u string) (owner, repo string, err error) {
u = strings.TrimSuffix(u, ".git")

View file

@ -1,3 +1,4 @@
// Package logger provides a simple configurable logger for the application.
package logger
import (
@ -5,6 +6,19 @@ import (
"os"
)
// New creates a new slog.Logger. If verbose is true, the logger will be
// configured to show debug messages. Otherwise, it will only show info
// level and above.
//
// Example:
//
// // Create a standard logger
// log := logger.New(false)
// log.Info("This is an info message")
//
// // Create a verbose logger
// verboseLog := logger.New(true)
// verboseLog.Debug("This is a debug message")
func New(verbose bool) *slog.Logger {
level := slog.LevelInfo
if verbose {

Some files were not shown because too many files have changed in this diff Show more