1426 lines
35 KiB
Go
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()
|
|
}
|