Files
pawtoolbox/main.go
2026-08-26 07:30:25 +02:00

1426 lines
35 KiB
Go

package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"math"
"math/big"
"mime"
"net/http"
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
//go:embed web/*
var webFS embed.FS
// -------------------- configuration --------------------
type Config struct {
Addr string
ServerMode string
TLSCertFile string
TLSKeyFile string
HTTPRedirectAddr string
HTTPRedirectEnabled bool
AppsJSON string
DataDir string
ClipboardData string
MaxPerRoom int
PersistSecrets bool
FileMaxBytes int64
AuthUser string
AuthPass string
PW PWOptions
}
type PWOptions struct {
Length int
MinLower int
MinUpper int
MinDigits int
MinSymbols int
Custom string
Exclude string
NoAmbig bool
NoSeq bool
NoRepeat bool
Unique bool
Template string
SymbolSet string
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func getenvInt(k string, def int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func getenvInt64(k string, def int64) int64 {
if v := os.Getenv(k); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n
}
}
return def
}
func getenvBool(k string, def bool) bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv(k)))
if v == "" {
return def
}
switch v {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
return def
}
func loadConfig() Config {
mode := strings.ToLower(getenv("SERVER_MODE", "http"))
addr := getenv("ADDR", ":8080")
if mode == "https" && os.Getenv("ADDR") == "" {
addr = ":8443"
}
return Config{
Addr: addr, ServerMode: mode,
TLSCertFile: getenv("TLS_CERT_FILE", ""), TLSKeyFile: getenv("TLS_KEY_FILE", ""),
HTTPRedirectAddr: getenv("HTTP_REDIRECT_ADDR", ":8080"), HTTPRedirectEnabled: getenvBool("HTTP_REDIRECT_ENABLED", true),
AppsJSON: getenv("APPS_JSON", "./data/apps.json"), DataDir: getenv("DATA_DIR", "./data"),
ClipboardData: getenv("CLIPBOARD_DATA", "./data/clipboard.json"), MaxPerRoom: getenvInt("MAX_PER_ROOM", 200),
PersistSecrets: getenvBool("CLIPBOARD_PERSIST_SECRETS", true), FileMaxBytes: getenvInt64("FILE_MAX_BYTES", 256<<20),
AuthUser: getenv("AUTH_USER", ""), AuthPass: getenv("AUTH_PASS", ""),
PW: PWOptions{
Length: getenvInt("PWGEN_LENGTH", 20), MinLower: getenvInt("PWGEN_MIN_LOWER", 2), MinUpper: getenvInt("PWGEN_MIN_UPPER", 2),
MinDigits: getenvInt("PWGEN_MIN_DIGITS", 2), MinSymbols: getenvInt("PWGEN_MIN_SYMBOLS", 2), Custom: getenv("PWGEN_CHARSET", ""),
Exclude: getenv("PWGEN_EXCLUDE", ""), NoAmbig: getenvBool("PWGEN_NO_AMBIGUOUS", true), NoSeq: getenvBool("PWGEN_NO_SEQ", true),
NoRepeat: getenvBool("PWGEN_NO_REPEAT", true), Unique: getenvBool("PWGEN_UNIQUE", false), Template: getenv("PWGEN_TEMPLATE", ""),
SymbolSet: getenv("PWGEN_SYMBOLS", "!@#$%^&*()-=+;:,.?|"),
},
}
}
// -------------------- common helpers --------------------
type apiError struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func makeID() string {
var b [12]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
if r.TLS != nil {
w.Header().Set("Strict-Transport-Security", "max-age=15552000")
}
next.ServeHTTP(w, r)
})
}
func basicAuth(user, pass string, next http.Handler) http.Handler {
if user == "" && pass == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1
if !ok || !userOK || !passOK {
w.Header().Set("WWW-Authenticate", `Basic realm="PAW Toolbox"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func atomicJSON(path string, v any, perm os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm)
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// -------------------- app launcher --------------------
type App struct {
Title string `json:"title"`
URL string `json:"url"`
Icon string `json:"icon"`
Category string `json:"category"`
Color string `json:"color"`
}
func loadApps(path string) ([]App, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var apps []App
if err := json.Unmarshal(b, &apps); err != nil {
return nil, err
}
sort.Slice(apps, func(i, j int) bool { return strings.ToLower(apps[i].Title) < strings.ToLower(apps[j].Title) })
return apps, nil
}
// -------------------- clipboard --------------------
type Clip struct {
ID string `json:"id"`
Room string `json:"room"`
Type string `json:"type"`
Content string `json:"content,omitempty"`
Author string `json:"author,omitempty"`
Secret bool `json:"secret,omitempty"`
OneTime bool `json:"one_time,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type clipboardSnapshot struct {
Version int `json:"version"`
Rooms map[string][]*Clip `json:"rooms"`
}
type Room struct {
name string
max int
mu sync.RWMutex
clips []*Clip
subs map[chan *Clip]struct{}
closed bool
}
func newRoom(name string, max int) *Room {
return &Room{name: name, max: max, subs: map[chan *Clip]struct{}{}}
}
func expired(c *Clip, now time.Time) bool { return c.ExpiresAt != nil && !c.ExpiresAt.After(now) }
func (r *Room) pruneLocked(now time.Time) {
out := r.clips[:0]
for _, c := range r.clips {
if !expired(c, now) {
out = append(out, c)
}
}
r.clips = out
}
func publicClip(c *Clip) *Clip {
cp := *c
if c.Secret {
cp.Content = ""
}
return &cp
}
func (r *Room) add(c *Clip) {
r.mu.Lock()
defer r.mu.Unlock()
r.pruneLocked(time.Now().UTC())
if len(r.clips) >= r.max {
copy(r.clips, r.clips[1:])
r.clips[len(r.clips)-1] = c
} else {
r.clips = append(r.clips, c)
}
event := c
if c.Secret {
event = publicClip(c)
}
for ch := range r.subs {
select {
case ch <- event:
default:
}
}
}
func (r *Room) history(limit int) []*Clip {
r.mu.Lock()
defer r.mu.Unlock()
r.pruneLocked(time.Now().UTC())
if limit <= 0 || limit > len(r.clips) {
limit = len(r.clips)
}
start := len(r.clips) - limit
out := make([]*Clip, 0, limit)
for _, c := range r.clips[start:] {
if c.Secret {
out = append(out, publicClip(c))
} else {
cp := *c
out = append(out, &cp)
}
}
return out
}
func (r *Room) reveal(id string) (*Clip, bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.pruneLocked(time.Now().UTC())
for i, c := range r.clips {
if c.ID == id {
cp := *c
if c.OneTime {
r.clips = append(r.clips[:i], r.clips[i+1:]...)
}
return &cp, true
}
}
return nil, false
}
func (r *Room) latestReveal() (*Clip, bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.pruneLocked(time.Now().UTC())
if len(r.clips) == 0 {
return nil, false
}
i := len(r.clips) - 1
cp := *r.clips[i]
if r.clips[i].OneTime {
r.clips = r.clips[:i]
}
return &cp, true
}
func (r *Room) clear() { r.mu.Lock(); r.clips = nil; r.mu.Unlock() }
func (r *Room) subscribe() (chan *Clip, func()) {
ch := make(chan *Clip, 8)
r.mu.Lock()
if r.closed {
r.mu.Unlock()
close(ch)
return ch, func() {}
}
r.subs[ch] = struct{}{}
r.mu.Unlock()
return ch, func() {
r.mu.Lock()
if _, ok := r.subs[ch]; ok {
delete(r.subs, ch)
close(ch)
}
r.mu.Unlock()
}
}
func (r *Room) closeAll() {
r.mu.Lock()
if !r.closed {
r.closed = true
for ch := range r.subs {
close(ch)
delete(r.subs, ch)
}
}
r.mu.Unlock()
}
type ClipboardStore struct {
mu sync.RWMutex
rooms map[string]*Room
max int
path string
persistSecrets bool
}
func newClipboardStore(max int, path string, persistSecrets bool) *ClipboardStore {
return &ClipboardStore{rooms: map[string]*Room{}, max: max, path: path, persistSecrets: persistSecrets}
}
func validRoom(name string) bool {
if name == "" || len(name) > 64 {
return false
}
for _, r := range name {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
return false
}
}
return true
}
func (s *ClipboardStore) room(name string) *Room {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.rooms[name]; ok {
return r
}
r := newRoom(name, s.max)
s.rooms[name] = r
return r
}
func (s *ClipboardStore) roomsList() []string {
s.mu.RLock()
out := make([]string, 0, len(s.rooms))
for n := range s.rooms {
out = append(out, n)
}
s.mu.RUnlock()
sort.Strings(out)
return out
}
type RoomInfo struct {
Name string `json:"name"`
Count int `json:"count"`
Secrets int `json:"secrets"`
LastActive *time.Time `json:"last_active,omitempty"`
}
func (r *Room) info() RoomInfo {
r.mu.Lock()
defer r.mu.Unlock()
r.pruneLocked(time.Now().UTC())
info := RoomInfo{Name: r.name, Count: len(r.clips)}
for _, c := range r.clips {
if c.Secret {
info.Secrets++
}
if info.LastActive == nil || c.CreatedAt.After(*info.LastActive) {
t := c.CreatedAt
info.LastActive = &t
}
}
return info
}
func (s *ClipboardStore) roomDetails() []RoomInfo {
s.mu.RLock()
rooms := make([]*Room, 0, len(s.rooms))
for _, r := range s.rooms {
rooms = append(rooms, r)
}
s.mu.RUnlock()
out := make([]RoomInfo, 0, len(rooms))
for _, r := range rooms {
out = append(out, r.info())
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
return out
}
func (s *ClipboardStore) getRoom(name string) (*Room, bool) {
s.mu.RLock()
r, ok := s.rooms[name]
s.mu.RUnlock()
return r, ok
}
func (s *ClipboardStore) createRoom(name string) error {
if !validRoom(name) {
return errors.New("invalid room")
}
s.mu.Lock()
if _, ok := s.rooms[name]; !ok {
s.rooms[name] = newRoom(name, s.max)
}
s.mu.Unlock()
return s.save()
}
func (s *ClipboardStore) clearRoom(name string) error {
r, ok := s.getRoom(name)
if !ok {
return os.ErrNotExist
}
r.clear()
return s.save()
}
func (s *ClipboardStore) load() error {
b, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var snap clipboardSnapshot
if err := json.Unmarshal(b, &snap); err != nil {
return err
}
if snap.Version != 1 {
return fmt.Errorf("unsupported clipboard snapshot version %d", snap.Version)
}
for name, list := range snap.Rooms {
if !validRoom(name) {
continue
}
r := s.room(name)
r.mu.Lock()
for _, c := range list {
if !expired(c, time.Now().UTC()) {
r.clips = append(r.clips, c)
}
}
if len(r.clips) > r.max {
r.clips = r.clips[len(r.clips)-r.max:]
}
r.mu.Unlock()
}
return nil
}
func (s *ClipboardStore) save() error {
if s.path == "" {
return nil
}
snap := clipboardSnapshot{Version: 1, Rooms: map[string][]*Clip{}}
now := time.Now().UTC()
s.mu.RLock()
for name, r := range s.rooms {
r.mu.Lock()
r.pruneLocked(now)
list := make([]*Clip, 0, len(r.clips))
for _, c := range r.clips {
if c.Secret && !s.persistSecrets {
continue
}
cp := *c
list = append(list, &cp)
}
r.mu.Unlock()
snap.Rooms[name] = list
}
s.mu.RUnlock()
return atomicJSON(s.path, snap, 0o600)
}
func (s *ClipboardStore) deleteRoom(name string) error {
s.mu.Lock()
r, ok := s.rooms[name]
if ok {
delete(s.rooms, name)
}
s.mu.Unlock()
if ok {
r.closeAll()
}
return s.save()
}
// -------------------- file exchange --------------------
type FileMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Uploader string `json:"uploader,omitempty"`
UploadedAt time.Time `json:"uploaded_at"`
}
type fileSnapshot struct {
Version int `json:"version"`
Files []*FileMeta `json:"files"`
}
type FileStore struct {
mu sync.RWMutex
dir string
metaPath string
maxBytes int64
files map[string]*FileMeta
}
func newFileStore(dataDir string, maxBytes int64) *FileStore {
return &FileStore{dir: filepath.Join(dataDir, "files"), metaPath: filepath.Join(dataDir, "files.json"), maxBytes: maxBytes, files: map[string]*FileMeta{}}
}
func (s *FileStore) filePath(id string) string { return filepath.Join(s.dir, id+".bin") }
func (s *FileStore) load() error {
if err := os.MkdirAll(s.dir, 0o700); err != nil {
return err
}
b, err := os.ReadFile(s.metaPath)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var snap fileSnapshot
if err := json.Unmarshal(b, &snap); err != nil {
return err
}
if snap.Version != 1 {
return fmt.Errorf("unsupported file snapshot version %d", snap.Version)
}
for _, m := range snap.Files {
if _, err := os.Stat(s.filePath(m.ID)); err == nil {
s.files[m.ID] = m
}
}
return nil
}
func (s *FileStore) saveLocked() error {
list := make([]*FileMeta, 0, len(s.files))
for _, m := range s.files {
cp := *m
list = append(list, &cp)
}
sort.Slice(list, func(i, j int) bool { return list[i].UploadedAt.After(list[j].UploadedAt) })
return atomicJSON(s.metaPath, fileSnapshot{Version: 1, Files: list}, 0o600)
}
func (s *FileStore) list() []*FileMeta {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*FileMeta, 0, len(s.files))
for _, m := range s.files {
cp := *m
out = append(out, &cp)
}
sort.Slice(out, func(i, j int) bool { return out[i].UploadedAt.After(out[j].UploadedAt) })
return out
}
func cleanOriginalName(name string) (string, error) {
name = filepath.Base(strings.TrimSpace(name))
if name == "" || name == "." || name == ".." || len(name) > 240 {
return "", errors.New("invalid filename")
}
for _, r := range name {
if r < 32 || r == 127 {
return "", errors.New("invalid filename")
}
}
return name, nil
}
func (s *FileStore) upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, s.maxBytes+(2<<20))
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, apiError{Error: "Upload ungültig oder zu groß"})
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
f, h, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, apiError{Error: "Form-Feld 'file' fehlt"})
return
}
defer f.Close()
name, err := cleanOriginalName(h.Filename)
if err != nil {
writeJSON(w, http.StatusBadRequest, apiError{Error: err.Error()})
return
}
id := makeID()
tmp := s.filePath(id) + ".tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
writeJSON(w, 500, apiError{Error: "Datei konnte nicht angelegt werden"})
return
}
hash := sha256.New()
n, copyErr := io.Copy(io.MultiWriter(out, hash), io.LimitReader(f, s.maxBytes+1))
closeErr := out.Close()
if copyErr != nil || closeErr != nil || n > s.maxBytes {
_ = os.Remove(tmp)
writeJSON(w, http.StatusBadRequest, apiError{Error: "Upload fehlgeschlagen oder Datei zu groß"})
return
}
final := s.filePath(id)
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
writeJSON(w, 500, apiError{Error: "Upload konnte nicht abgeschlossen werden"})
return
}
meta := &FileMeta{ID: id, Name: name, Size: n, SHA256: hex.EncodeToString(hash.Sum(nil)), Uploader: strings.TrimSpace(r.FormValue("uploader")), UploadedAt: time.Now().UTC()}
s.mu.Lock()
s.files[id] = meta
err = s.saveLocked()
if err != nil {
delete(s.files, id)
}
s.mu.Unlock()
if err != nil {
_ = os.Remove(final)
log.Printf("file metadata save: %v", err)
writeJSON(w, http.StatusInternalServerError, apiError{Error: "Dateimetadaten konnten nicht gespeichert werden"})
return
}
writeJSON(w, http.StatusCreated, meta)
}
func (s *FileStore) download(w http.ResponseWriter, r *http.Request, id string) {
s.mu.RLock()
m, ok := s.files[id]
if ok {
cp := *m
m = &cp
}
s.mu.RUnlock()
if !ok {
writeJSON(w, 404, apiError{Error: "Datei nicht gefunden"})
return
}
f, err := os.Open(s.filePath(id))
if err != nil {
writeJSON(w, 404, apiError{Error: "Datei nicht gefunden"})
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(m.Size, 10))
w.Header().Set("X-Content-SHA256", m.SHA256)
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": m.Name}))
w.Header().Set("Cache-Control", "no-store")
_, _ = io.Copy(w, f)
}
func (s *FileStore) delete(w http.ResponseWriter, id string) {
s.mu.Lock()
_, ok := s.files[id]
if ok {
delete(s.files, id)
}
var err error
if ok {
err = s.saveLocked()
}
s.mu.Unlock()
if !ok {
writeJSON(w, 404, apiError{Error: "Datei nicht gefunden"})
return
}
_ = os.Remove(s.filePath(id))
if err != nil {
log.Printf("file metadata save: %v", err)
}
writeJSON(w, 200, map[string]any{"status": "deleted", "id": id})
}
// -------------------- password generator --------------------
const lower = "abcdefghijklmnopqrstuvwxyz"
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
const digits = "0123456789"
func randInt(n int64) (int64, error) {
if n <= 0 {
return 0, errors.New("invalid n")
}
x, err := rand.Int(rand.Reader, big.NewInt(n))
if err != nil {
return 0, err
}
return x.Int64(), nil
}
func pickRandom(set string) (byte, error) {
if len(set) == 0 {
return 0, errors.New("empty character set")
}
i, err := randInt(int64(len(set)))
if err != nil {
return 0, err
}
return set[i], nil
}
func removeChars(set, exclude string) string {
m := map[rune]bool{}
for _, r := range exclude {
m[r] = true
}
var b strings.Builder
for _, r := range set {
if !m[r] {
b.WriteRune(r)
}
}
return b.String()
}
func uniqueConcat(s string) string {
m := map[rune]bool{}
var b strings.Builder
for _, r := range s {
if !m[r] {
m[r] = true
b.WriteRune(r)
}
}
return b.String()
}
func buildSets(o PWOptions) [5]string {
ls, us, ds, ss := lower, upper, digits, o.SymbolSet
if o.NoAmbig {
ls = removeChars(ls, "l")
us = removeChars(us, "OI")
ds = removeChars(ds, "01")
ss = removeChars(ss, "|")
}
if o.Exclude != "" {
ls = removeChars(ls, o.Exclude)
us = removeChars(us, o.Exclude)
ds = removeChars(ds, o.Exclude)
ss = removeChars(ss, o.Exclude)
}
return [5]string{ls, us, ds, ss, uniqueConcat(ls + us + ds + ss + o.Custom)}
}
func shuffleBytes(b []byte) error {
for i := len(b) - 1; i > 0; i-- {
j64, err := randInt(int64(i + 1))
if err != nil {
return err
}
j := int(j64)
b[i], b[j] = b[j], b[i]
}
return nil
}
func hasSeq(s string, window int) bool {
if window <= 1 || len(s) < window {
return false
}
for i := 0; i <= len(s)-window; i++ {
asc, desc := true, true
for j := 1; j < window; j++ {
if s[i+j] != s[i+j-1]+1 {
asc = false
}
if s[i+j] != s[i+j-1]-1 {
desc = false
}
}
if asc || desc {
return true
}
}
return false
}
func hasRepeat(s string) bool {
for i := 1; i < len(s); i++ {
if s[i] == s[i-1] {
return true
}
}
return false
}
func bytesContains(b []byte, c byte) bool {
for _, x := range b {
if x == c {
return true
}
}
return false
}
func generateTemplate(t string, sets [5]string) (string, error) {
var b strings.Builder
for i := 0; i < len(t); i++ {
var set string
switch t[i] {
case 'l':
set = sets[0]
case 'L':
set = sets[1]
case 'd':
set = sets[2]
case 's':
set = sets[3]
case '\\':
if i+1 < len(t) {
i++
b.WriteByte(t[i])
continue
}
default:
b.WriteByte(t[i])
continue
}
c, err := pickRandom(set)
if err != nil {
return "", err
}
b.WriteByte(c)
}
return b.String(), nil
}
func generateOne(o PWOptions) (string, error) {
sets := buildSets(o)
if o.Template != "" {
for i := 0; i < 500; i++ {
p, err := generateTemplate(o.Template, sets)
if err != nil {
return "", err
}
if o.NoRepeat && hasRepeat(p) {
continue
}
if o.NoSeq && hasSeq(p, 3) {
continue
}
return p, nil
}
return "", errors.New("constraints could not be satisfied")
}
if o.Length <= 0 {
return "", errors.New("length must be > 0")
}
if o.MinLower+o.MinUpper+o.MinDigits+o.MinSymbols > o.Length {
return "", errors.New("sum of minimums exceeds length")
}
if len(sets[4]) == 0 {
return "", errors.New("empty character pool")
}
if o.Unique && len(sets[4]) < o.Length {
return "", errors.New("unique requested but character pool is too small")
}
for attempt := 0; attempt < 1000; attempt++ {
buf := make([]byte, 0, o.Length)
add := func(set string, n int) error {
for i := 0; i < n; i++ {
for {
c, err := pickRandom(set)
if err != nil {
return err
}
if o.Unique && bytesContains(buf, c) {
continue
}
buf = append(buf, c)
break
}
}
return nil
}
if err := add(sets[0], o.MinLower); err != nil {
return "", err
}
if err := add(sets[1], o.MinUpper); err != nil {
return "", err
}
if err := add(sets[2], o.MinDigits); err != nil {
return "", err
}
if err := add(sets[3], o.MinSymbols); err != nil {
return "", err
}
for len(buf) < o.Length {
c, err := pickRandom(sets[4])
if err != nil {
return "", err
}
if o.Unique && bytesContains(buf, c) {
continue
}
buf = append(buf, c)
}
if err := shuffleBytes(buf); err != nil {
return "", err
}
p := string(buf)
if o.NoRepeat && hasRepeat(p) {
continue
}
if o.NoSeq && hasSeq(p, 3) {
continue
}
return p, nil
}
return "", errors.New("constraints could not be satisfied after 1000 attempts")
}
func entropyBits(o PWOptions, pwd string) float64 {
sets := buildSets(o)
pool := len(sets[4])
if pool <= 1 {
return 0
}
return float64(len(pwd)) * math.Log2(float64(pool))
}
// -------------------- HTTP application --------------------
type Application struct {
cfg Config
clipboard *ClipboardStore
files *FileStore
index *template.Template
}
func newApplication(cfg Config) (*Application, error) {
cb := newClipboardStore(cfg.MaxPerRoom, cfg.ClipboardData, cfg.PersistSecrets)
if err := cb.load(); err != nil {
return nil, fmt.Errorf("clipboard load: %w", err)
}
files := newFileStore(cfg.DataDir, cfg.FileMaxBytes)
if err := files.load(); err != nil {
return nil, fmt.Errorf("files load: %w", err)
}
b, err := fs.ReadFile(webFS, "web/index.html")
if err != nil {
return nil, err
}
t, err := template.New("index").Parse(string(b))
if err != nil {
return nil, err
}
return &Application{cfg: cfg, clipboard: cb, files: files, index: t}, nil
}
func (a *Application) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = a.index.Execute(w, map[string]any{"FileMaxBytes": a.cfg.FileMaxBytes, "PWLength": a.cfg.PW.Length})
}
func (a *Application) handleApps(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, 405, apiError{Error: "use GET"})
return
}
apps, err := loadApps(a.cfg.AppsJSON)
if err != nil {
writeJSON(w, 500, apiError{Error: "Apps konnten nicht geladen werden"})
return
}
writeJSON(w, 200, apps)
}
func (a *Application) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"rooms": a.clipboard.roomsList(), "files": len(a.files.list()), "max_file_bytes": a.cfg.FileMaxBytes, "persist_secrets": a.cfg.PersistSecrets})
}
func parseTTL(v string) time.Duration {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return 0
}
if n > 1440 {
n = 1440
}
return time.Duration(n) * time.Minute
}
type postClipReq struct {
Type string `json:"type"`
Content string `json:"content"`
Author string `json:"author"`
Secret bool `json:"secret"`
OneTime bool `json:"one_time"`
TTLMinutes int `json:"ttl_minutes"`
}
func (a *Application) createClip(room string, req postClipReq) (*Clip, error) {
if !validRoom(room) {
return nil, errors.New("invalid room")
}
req.Content = strings.TrimRight(req.Content, "\r\n")
if req.Content == "" {
return nil, errors.New("content empty")
}
if len(req.Content) > 1<<20 {
return nil, errors.New("content too large")
}
if req.Type == "" {
req.Type = "text"
}
c := &Clip{ID: makeID(), Room: room, Type: req.Type, Content: req.Content, Author: req.Author, Secret: req.Secret, OneTime: req.OneTime, CreatedAt: time.Now().UTC()}
if req.TTLMinutes > 0 {
if req.TTLMinutes > 1440 {
req.TTLMinutes = 1440
}
t := c.CreatedAt.Add(time.Duration(req.TTLMinutes) * time.Minute)
c.ExpiresAt = &t
}
a.clipboard.room(room).add(c)
if err := a.clipboard.save(); err != nil {
log.Printf("clipboard save: %v", err)
}
return c, nil
}
func (a *Application) handleClipboardAPI(w http.ResponseWriter, r *http.Request) {
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/"), "/")
parts := strings.Split(path, "/")
if len(parts) == 0 || !validRoom(parts[0]) {
writeJSON(w, 404, apiError{Error: "not found"})
return
}
room := parts[0]
if len(parts) == 1 {
if r.Method == http.MethodDelete {
if err := a.clipboard.deleteRoom(room); err != nil {
writeJSON(w, 500, apiError{Error: err.Error()})
return
}
writeJSON(w, 200, map[string]any{"status": "deleted", "room": room})
return
}
writeJSON(w, 404, apiError{Error: "not found"})
return
}
switch parts[1] {
case "clip":
if len(parts) == 2 {
if r.Method != http.MethodPost {
writeJSON(w, 405, apiError{Error: "use POST"})
return
}
var req postClipReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, 400, apiError{Error: "invalid JSON"})
return
}
c, err := a.createClip(room, req)
if err != nil {
writeJSON(w, 400, apiError{Error: err.Error()})
return
}
resp := c
if c.Secret {
resp = publicClip(c)
}
writeJSON(w, 201, resp)
return
}
if len(parts) == 3 && r.Method == http.MethodGet {
rm, exists := a.clipboard.getRoom(room)
if !exists {
writeJSON(w, 404, apiError{Error: "room not found"})
return
}
c, ok := rm.reveal(parts[2])
if !ok {
writeJSON(w, 404, apiError{Error: "clip not found or expired"})
return
}
if c.OneTime {
_ = a.clipboard.save()
}
writeJSON(w, 200, c)
return
}
case "latest":
if r.Method != http.MethodGet {
writeJSON(w, 405, apiError{Error: "use GET"})
return
}
rm, exists := a.clipboard.getRoom(room)
if !exists {
writeJSON(w, 404, apiError{Error: "room not found"})
return
}
c, ok := rm.latestReveal()
if !ok {
writeJSON(w, 404, apiError{Error: "no clips yet"})
return
}
if c.OneTime {
_ = a.clipboard.save()
}
writeJSON(w, 200, c)
return
case "history":
if r.Method == http.MethodDelete {
if err := a.clipboard.clearRoom(room); err != nil {
if errors.Is(err, os.ErrNotExist) {
writeJSON(w, 404, apiError{Error: "room not found"})
} else {
writeJSON(w, 500, apiError{Error: err.Error()})
}
return
}
writeJSON(w, 200, map[string]any{"status": "cleared", "room": room})
return
}
if r.Method != http.MethodGet {
writeJSON(w, 405, apiError{Error: "use GET or DELETE"})
return
}
limit := 50
if n, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && n > 0 && n <= 200 {
limit = n
}
rm, exists := a.clipboard.getRoom(room)
if !exists {
writeJSON(w, 200, []*Clip{})
return
}
writeJSON(w, 200, rm.history(limit))
return
case "stream":
if r.Method != http.MethodGet {
writeJSON(w, 405, apiError{Error: "use GET"})
return
}
a.handleStream(w, r, room)
return
}
writeJSON(w, 404, apiError{Error: "unknown endpoint"})
}
func (a *Application) handleStream(w http.ResponseWriter, r *http.Request, room string) {
rm, exists := a.clipboard.getRoom(room)
if !exists {
writeJSON(w, 404, apiError{Error: "room not found"})
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
fl, ok := w.(http.Flusher)
if !ok {
writeJSON(w, 500, apiError{Error: "streaming unsupported"})
return
}
ch, unsub := rm.subscribe()
defer unsub()
tick := time.NewTicker(25 * time.Second)
defer tick.Stop()
for {
select {
case <-r.Context().Done():
return
case c, ok := <-ch:
if !ok {
return
}
b, _ := json.Marshal(c)
fmt.Fprintf(w, "event: clip\ndata: %s\n\n", b)
fl.Flush()
case <-tick.C:
fmt.Fprintf(w, ": ping\n\n")
fl.Flush()
}
}
}
func (a *Application) handleRooms(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, 200, a.clipboard.roomsList())
case http.MethodPost:
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
writeJSON(w, 400, apiError{Error: "invalid JSON"})
return
}
req.Name = strings.TrimSpace(req.Name)
if err := a.clipboard.createRoom(req.Name); err != nil {
writeJSON(w, 400, apiError{Error: err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]any{"status": "created", "room": req.Name})
default:
writeJSON(w, 405, apiError{Error: "use GET or POST"})
}
}
func (a *Application) handleRoomDetails(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, 405, apiError{Error: "use GET"})
return
}
writeJSON(w, 200, a.clipboard.roomDetails())
}
func (a *Application) handleGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, 405, apiError{Error: "use POST"})
return
}
count := 1
if n, err := strconv.Atoi(r.URL.Query().Get("count")); err == nil && n > 0 && n <= 20 {
count = n
}
room := r.URL.Query().Get("room")
store := room != ""
if room == "" {
room = "default"
}
ttl := parseTTL(r.URL.Query().Get("ttl_minutes"))
oneTime := r.URL.Query().Get("one_time") != "false"
secret := r.URL.Query().Get("secret") != "false"
type result struct {
Password string `json:"password"`
Entropy float64 `json:"entropy_bits"`
Stored bool `json:"stored"`
ClipID string `json:"clip_id,omitempty"`
}
res := make([]result, 0, count)
for i := 0; i < count; i++ {
pwd, err := generateOne(a.cfg.PW)
if err != nil {
writeJSON(w, 400, apiError{Error: err.Error()})
return
}
rr := result{Password: pwd, Entropy: entropyBits(a.cfg.PW, pwd)}
if store {
mins := 0
if ttl > 0 {
mins = int(ttl / time.Minute)
}
c, err := a.createClip(room, postClipReq{Type: "password", Content: pwd, Author: "PWGEN", Secret: secret, OneTime: oneTime, TTLMinutes: mins})
if err != nil {
writeJSON(w, 400, apiError{Error: err.Error()})
return
}
rr.Stored = true
rr.ClipID = c.ID
}
res = append(res, rr)
}
writeJSON(w, 200, res)
}
func (a *Application) handleFiles(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, 200, a.files.list())
case http.MethodPost:
a.files.upload(w, r)
default:
writeJSON(w, 405, apiError{Error: "use GET or POST"})
}
}
func (a *Application) handleFileByID(w http.ResponseWriter, r *http.Request) {
id := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/files/"), "/")
if id == "" || strings.Contains(id, "/") {
writeJSON(w, 404, apiError{Error: "not found"})
return
}
switch r.Method {
case http.MethodGet:
a.files.download(w, r, id)
case http.MethodDelete:
a.files.delete(w, id)
default:
writeJSON(w, 405, apiError{Error: "use GET or DELETE"})
}
}
func (a *Application) routes() http.Handler {
mux := http.NewServeMux()
sub, _ := fs.Sub(webFS, "web")
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
mux.HandleFunc("/api/status", a.handleStatus)
mux.HandleFunc("/api/apps", a.handleApps)
mux.HandleFunc("/api/rooms/details", a.handleRoomDetails)
mux.HandleFunc("/api/rooms", a.handleRooms)
mux.HandleFunc("/api/generate", a.handleGenerate)
mux.HandleFunc("/api/files", a.handleFiles)
mux.HandleFunc("/api/files/", a.handleFileByID)
mux.HandleFunc("/api/tools/", a.handleTools)
mux.HandleFunc("/api/", a.handleClipboardAPI)
mux.HandleFunc("/", a.handleIndex)
return securityHeaders(basicAuth(a.cfg.AuthUser, a.cfg.AuthPass, mux))
}
func main() {
cfg := loadConfig()
if cfg.ServerMode != "http" && cfg.ServerMode != "https" {
log.Fatalf("invalid SERVER_MODE %q", cfg.ServerMode)
}
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
log.Fatal(err)
}
app, err := newApplication(cfg)
if err != nil {
log.Fatal(err)
}
srv := &http.Server{Addr: cfg.Addr, Handler: app.routes(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 10 * time.Minute, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if cfg.ServerMode == "https" {
if cfg.TLSCertFile == "" || cfg.TLSKeyFile == "" {
log.Fatal("TLS_CERT_FILE and TLS_KEY_FILE required for https")
}
if cfg.HTTPRedirectEnabled && cfg.HTTPRedirectAddr != "" {
go func() {
rs := &http.Server{Addr: cfg.HTTPRedirectAddr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := strings.Split(r.Host, ":")[0]
target := "https://" + host + cfg.Addr + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
})}
if err := rs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("redirect server: %v", err)
}
}()
}
go func() {
log.Printf("PAW Toolbox HTTPS listening on %s", cfg.Addr)
if err := srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}()
} else {
go func() {
log.Printf("PAW Toolbox HTTP listening on %s", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}()
}
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
_ = app.clipboard.save()
}