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() }