feat: Add Borg Console and release workflow
This commit is contained in:
parent
b3755da69d
commit
741bbe11e8
6 changed files with 687 additions and 0 deletions
105
.github/workflows/release.yml
vendored
Normal file
105
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: 'go.mod'
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build binaries
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
# Linux amd64
|
||||||
|
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-linux-amd64 main.go
|
||||||
|
|
||||||
|
# Linux arm64
|
||||||
|
GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o dist/borg-linux-arm64 main.go
|
||||||
|
|
||||||
|
# macOS amd64
|
||||||
|
GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-darwin-amd64 main.go
|
||||||
|
|
||||||
|
# macOS arm64
|
||||||
|
GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o dist/borg-darwin-arm64 main.go
|
||||||
|
|
||||||
|
# Windows amd64
|
||||||
|
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-windows-amd64.exe main.go
|
||||||
|
|
||||||
|
- name: Build WASM module
|
||||||
|
run: |
|
||||||
|
GOOS=js GOARCH=wasm go build -o dist/stmf.wasm ./pkg/wasm/stmf/
|
||||||
|
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" dist/ 2>/dev/null || \
|
||||||
|
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/
|
||||||
|
|
||||||
|
- name: Build Console STIM
|
||||||
|
run: |
|
||||||
|
# Build borg for current platform first
|
||||||
|
go build -o borg main.go
|
||||||
|
|
||||||
|
# Build the encrypted console demo
|
||||||
|
./borg console build -p "borg-demo" -o dist/console.stim -s js/borg-stmf
|
||||||
|
|
||||||
|
- name: Create checksums
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
sha256sum * > checksums.txt
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
name: Borg ${{ steps.version.outputs.VERSION }}
|
||||||
|
body: |
|
||||||
|
## Borg ${{ steps.version.outputs.VERSION }}
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
| Platform | Binary |
|
||||||
|
|----------|--------|
|
||||||
|
| Linux x64 | `borg-linux-amd64` |
|
||||||
|
| Linux ARM64 | `borg-linux-arm64` |
|
||||||
|
| macOS x64 | `borg-darwin-amd64` |
|
||||||
|
| macOS ARM64 | `borg-darwin-arm64` |
|
||||||
|
| Windows x64 | `borg-windows-amd64.exe` |
|
||||||
|
|
||||||
|
### Console Demo
|
||||||
|
|
||||||
|
The `console.stim` is an encrypted PWA demo. Run it with:
|
||||||
|
```bash
|
||||||
|
borg console serve console.stim --open
|
||||||
|
```
|
||||||
|
Password: `borg-demo`
|
||||||
|
|
||||||
|
### WASM Module
|
||||||
|
|
||||||
|
- `stmf.wasm` - Browser encryption module
|
||||||
|
- `wasm_exec.js` - Go WASM runtime
|
||||||
|
|
||||||
|
files: |
|
||||||
|
dist/borg-linux-amd64
|
||||||
|
dist/borg-linux-arm64
|
||||||
|
dist/borg-darwin-amd64
|
||||||
|
dist/borg-darwin-arm64
|
||||||
|
dist/borg-windows-amd64.exe
|
||||||
|
dist/stmf.wasm
|
||||||
|
dist/wasm_exec.js
|
||||||
|
dist/console.stim
|
||||||
|
dist/checksums.txt
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
163
cmd/console.go
Normal file
163
cmd/console.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
BIN
console.stim
Normal file
BIN
console.stim
Normal file
Binary file not shown.
27
pkg/console/browser.go
Normal file
27
pkg/console/browser.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
139
pkg/console/server.go
Normal file
139
pkg/console/server.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
253
pkg/console/unlock.html
Normal file
253
pkg/console/unlock.html
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<!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>
|
||||||
Loading…
Add table
Reference in a new issue