Big refactor to allow for multiple watchers, v2 configuration file with migration and new UI for configuration

This commit is contained in:
Justin Hawkins 2021-10-06 23:12:43 +10:30
parent 7dddc92364
commit 8483fe7db9
8 changed files with 512 additions and 529 deletions

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
"strings"
daulog "github.com/tardisx/discord-auto-upload/log" daulog "github.com/tardisx/discord-auto-upload/log"
@ -21,62 +22,115 @@ type ConfigV1 struct {
Exclude string Exclude string
} }
type ConfigV2Watcher struct { type Watcher struct {
WebHookURL string WebHookURL string
Path string Path string
Username string Username string
NoWatermark bool NoWatermark bool
Exclude string Exclude []string
} }
type ConfigV2 struct { type ConfigV2 struct {
WatchInterval int WatchInterval int
Version int Version int
Watchers []ConfigV2Watcher Port int
Watchers []Watcher
} }
var Config ConfigV2 type ConfigService struct {
var configPath string Config ConfigV2
ConfigFilename string
}
func Init() { func DefaultConfigService() *ConfigService {
configPath = defaultConfigPath() c := ConfigService{
ConfigFilename: defaultConfigPath(),
}
return &c
} }
// LoadOrInit loads the current configuration from the config file, or creates // LoadOrInit loads the current configuration from the config file, or creates
// a new config file if none exists. // a new config file if none exists.
func LoadOrInit() error { func (c *ConfigService) LoadOrInit() error {
daulog.SendLog(fmt.Sprintf("Trying to load config from %s", configPath), daulog.LogTypeDebug) daulog.SendLog(fmt.Sprintf("Trying to load config from %s\n", c.ConfigFilename), daulog.LogTypeDebug)
_, err := os.Stat(configPath) _, err := os.Stat(c.ConfigFilename)
if os.IsNotExist(err) { if os.IsNotExist(err) {
daulog.SendLog("NOTE: No config file, writing out sample configuration", daulog.LogTypeInfo) daulog.SendLog("NOTE: No config file, writing out sample configuration", daulog.LogTypeInfo)
daulog.SendLog("You need to set the configuration via the web interface", daulog.LogTypeInfo) daulog.SendLog("You need to set the configuration via the web interface", daulog.LogTypeInfo)
Config.Version = 2 c.Config = *DefaultConfig()
Config.WatchInterval = 10 return c.Save()
return SaveConfig()
} else { } else {
return LoadConfig() return c.Load()
} }
} }
// LoadConfig will load the configuration from a known-to-exist config file. func DefaultConfig() *ConfigV2 {
func LoadConfig() error { c := ConfigV2{}
data, err := ioutil.ReadFile(configPath) c.Version = 2
if err != nil { c.WatchInterval = 10
return fmt.Errorf("cannot read config file %s: %s", configPath, err.Error()) c.Port = 9090
w := Watcher{
WebHookURL: "abcedf",
Path: "/Users/justin/tmp",
Username: "",
NoWatermark: false,
Exclude: []string{},
} }
err = json.Unmarshal([]byte(data), &Config) c.Watchers = []Watcher{w}
if err != nil { return &c
return fmt.Errorf("cannot decode config file %s: %s", configPath, err.Error())
} }
// Load will load the configuration from a known-to-exist config file.
func (c *ConfigService) Load() error {
fmt.Printf("Loading from %s\n\n", c.ConfigFilename)
data, err := ioutil.ReadFile(c.ConfigFilename)
if err != nil {
return fmt.Errorf("cannot read config file %s: %s", c.ConfigFilename, err.Error())
}
err = json.Unmarshal([]byte(data), &c.Config)
if err != nil {
return fmt.Errorf("cannot decode config file %s: %s", c.ConfigFilename, err.Error())
}
fmt.Printf("Got config: %#v", c.Config)
// Version 0 predates config migrations
if c.Config.Version == 0 {
// need to migrate this
daulog.SendLog("Migrating config to V2", daulog.LogTypeInfo)
configV1 := ConfigV1{}
err = json.Unmarshal([]byte(data), &configV1)
if err != nil {
return fmt.Errorf("cannot decode legacy config file as v1 %s: %s", c.ConfigFilename, err.Error())
}
// copy stuff across
c.Config.Version = 2
c.Config.WatchInterval = configV1.Watch
c.Config.Port = 9090 // this never used to be configurable
onlyWatcher := Watcher{
WebHookURL: configV1.WebHookURL,
Path: configV1.Path,
Username: configV1.Username,
NoWatermark: configV1.NoWatermark,
Exclude: strings.Split(configV1.Exclude, " "),
}
c.Config.Watchers = []Watcher{onlyWatcher}
}
return nil return nil
} }
func SaveConfig() error { func (c *ConfigService) Save() error {
daulog.SendLog("saving configuration", daulog.LogTypeInfo) daulog.SendLog("saving configuration", daulog.LogTypeInfo)
jsonString, _ := json.Marshal(Config) jsonString, _ := json.Marshal(c.Config)
err := ioutil.WriteFile(configPath, jsonString, os.ModePerm) err := ioutil.WriteFile(c.ConfigFilename, jsonString, os.ModePerm)
if err != nil { if err != nil {
return fmt.Errorf("cannot save config %s: %s", configPath, err.Error()) return fmt.Errorf("cannot save config %s: %s", c.ConfigFilename, err.Error())
} }
return nil return nil
} }

View File

@ -7,39 +7,76 @@ import (
) )
func TestNoConfig(t *testing.T) { func TestNoConfig(t *testing.T) {
if Config.Version != 0 { c := ConfigService{}
t.Error("not 0 empty config")
}
configPath = emptyTempFile() c.ConfigFilename = emptyTempFile()
os.Remove(configPath) os.Remove(c.ConfigFilename)
defer os.Remove(c.ConfigFilename) // because we are about to create it
err := LoadOrInit() err := c.LoadOrInit()
if err != nil { if err != nil {
t.Errorf("unexpected failure from load: %s", err) t.Errorf("unexpected failure from load: %s", err)
} }
if Config.Version != 2 { if c.Config.Version != 2 {
t.Error("not version 2 starting config") t.Error("not version 2 starting config")
} }
if fileSize(configPath) < 40 { if fileSize(c.ConfigFilename) < 40 {
t.Errorf("File is too small %d bytes", fileSize(configPath)) t.Errorf("File is too small %d bytes", fileSize(c.ConfigFilename))
} }
os.Remove(configPath)
} }
func TestEmptyFileConfig(t *testing.T) { func TestEmptyFileConfig(t *testing.T) {
c := ConfigService{}
configPath = emptyTempFile() c.ConfigFilename = emptyTempFile()
defer os.Remove(c.ConfigFilename)
err := LoadOrInit() err := c.LoadOrInit()
if err == nil { if err == nil {
t.Error("unexpected success from LoadOrInit()") t.Error("unexpected success from LoadOrInit()")
} }
os.Remove(configPath) }
func TestMigrateFromV1toV2(t *testing.T) {
c := ConfigService{}
c.ConfigFilename = v1Config()
err := c.LoadOrInit()
if err != nil {
t.Error("unexpected error from LoadOrInit()")
}
if c.Config.Version != 2 {
t.Errorf("Version %d not 2", c.Config.Version)
}
if len(c.Config.Watchers) != 1 {
t.Error("wrong amount of watchers")
}
if c.Config.Watchers[0].Path != "/private/tmp" {
t.Error("Wrong path")
}
if c.Config.WatchInterval != 69 {
t.Error("Wrong watch interval")
}
if c.Config.Port != 9090 {
t.Error("Wrong port")
}
}
func v1Config() string {
f, err := ioutil.TempFile("", "dautest-*")
if err != nil {
panic(err)
}
config := `{"WebHookURL":"https://discord.com/api/webhooks/abc123","Path":"/private/tmp","Watch":69,"Username":"abcdedf","NoWatermark":true,"Exclude":"ab cd ef"}`
f.Write([]byte(config))
defer f.Close()
return f.Name()
} }
func emptyTempFile() string { func emptyTempFile() string {

168
dau.go
View File

@ -2,7 +2,9 @@ package main
import ( import (
"encoding/json" "encoding/json"
"flag"
"fmt" "fmt"
"io/fs"
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net/http"
@ -15,59 +17,132 @@ import (
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
"github.com/pborman/getopt"
// "github.com/skratchdot/open-golang/open" // "github.com/skratchdot/open-golang/open"
"github.com/tardisx/discord-auto-upload/config" "github.com/tardisx/discord-auto-upload/config"
daulog "github.com/tardisx/discord-auto-upload/log" daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/tardisx/discord-auto-upload/uploads" "github.com/tardisx/discord-auto-upload/upload"
// "github.com/tardisx/discord-auto-upload/upload"
"github.com/tardisx/discord-auto-upload/version" "github.com/tardisx/discord-auto-upload/version"
"github.com/tardisx/discord-auto-upload/web" "github.com/tardisx/discord-auto-upload/web"
) )
var lastCheck = time.Now() type watch struct {
var newLastCheck = time.Now() lastCheck time.Time
newLastCheck time.Time
config config.Watcher
uploader upload.Uploader
}
func main() { func main() {
parseOptions() parseOptions()
// grab the config
config := config.DefaultConfigService()
config.LoadOrInit()
// create the uploader
up := upload.Uploader{}
// log.Print("Opening web browser") // log.Print("Opening web browser")
// open.Start("http://localhost:9090") // open.Start("http://localhost:9090")
web := web.WebService{Config: *config}
web.StartWebServer() web.StartWebServer()
checkUpdates() go func() { checkUpdates() }()
daulog.SendLog(fmt.Sprintf("Waiting for images to appear in %s", config.Config.Path), daulog.LogTypeInfo) // create the watchers
// wander the path, forever
log.Printf("Conf: %#v", config.Config)
for _, c := range config.Config.Watchers {
log.Printf("Creating watcher for %v", c)
watcher := watch{uploader: up, lastCheck: time.Now(), newLastCheck: time.Now(), config: c}
go watcher.Watch(config.Config.WatchInterval)
}
select {}
}
func (w *watch) Watch(interval int) {
for { for {
if checkPath(config.Config.Path) { newFiles := w.ProcessNewFiles()
err := filepath.Walk(config.Config.Path, for _, f := range newFiles {
func(path string, f os.FileInfo, err error) error { return checkFile(path, f, err) }) w.uploader.AddFile(f, w.config)
}
// upload them
w.uploader.Upload()
daulog.SendLog(fmt.Sprintf("sleeping for %ds before next check of %s", interval, w.config.Path), daulog.LogTypeDebug)
time.Sleep(time.Duration(interval) * time.Second)
}
}
// ProcessNewFiles returns an array of new files that have appeared since
// the last time ProcessNewFiles was run.
func (w *watch) ProcessNewFiles() []string {
var newFiles []string
// check the path each time around, in case it goes away or something
if w.checkPath() {
// walk the path
err := filepath.WalkDir(w.config.Path,
func(path string, d fs.DirEntry, err error) error {
return w.checkFile(path, &newFiles)
})
if err != nil { if err != nil {
log.Fatal("could not watch path", err) log.Fatal("could not watch path", err)
} }
lastCheck = newLastCheck w.lastCheck = w.newLastCheck
}
daulog.SendLog(fmt.Sprintf("sleeping for %ds before next check of %s", config.Config.Watch, config.Config.Path), daulog.LogTypeDebug)
time.Sleep(time.Duration(config.Config.Watch) * time.Second)
} }
return newFiles
} }
func checkPath(path string) bool { // checkPath makes sure the path exists, and is a directory.
src, err := os.Stat(path) // It logs errors if there are problems, and returns false
func (w *watch) checkPath() bool {
src, err := os.Stat(w.config.Path)
if err != nil { if err != nil {
log.Printf("Problem with path '%s': %s", path, err) log.Printf("Problem with path '%s': %s", w.config.Path, err)
return false return false
} }
if !src.IsDir() { if !src.IsDir() {
log.Printf("Problem with path '%s': is not a directory", path) log.Printf("Problem with path '%s': is not a directory", w.config.Path)
return false return false
} }
return true return true
} }
// checkFile checks if a file is eligible, first looking at extension (to
// avoid statting files uselessly) then modification times.
// If the file is eligble, and new enough to care we add it to the passed in
// array of files
func (w *watch) checkFile(path string, found *[]string) error {
log.Printf("Considering %s", path)
extension := strings.ToLower(filepath.Ext(path))
if !(extension == ".png" || extension == ".jpg" || extension == ".gif") {
return nil
}
fi, err := os.Stat(path)
if err != nil {
return err
}
if fi.ModTime().After(w.lastCheck) && fi.Mode().IsRegular() {
*found = append(*found, path)
}
if w.newLastCheck.Before(fi.ModTime()) {
w.newLastCheck = fi.ModTime()
}
return nil
}
func checkUpdates() { func checkUpdates() {
type GithubRelease struct { type GithubRelease struct {
@ -115,61 +190,14 @@ func checkUpdates() {
} }
func parseOptions() { func parseOptions() {
var versionFlag bool
flag.BoolVar(&versionFlag, "version", false, "show version")
flag.Parse()
// Declare the flags to be used if versionFlag {
helpFlag := getopt.BoolLong("help", 'h', "help")
versionFlag := getopt.BoolLong("version", 'v', "show version")
getopt.SetParameters("")
getopt.Parse()
if *helpFlag {
getopt.PrintUsage(os.Stderr)
os.Exit(0)
}
if *versionFlag {
fmt.Println("dau - https://github.com/tardisx/discord-auto-upload") fmt.Println("dau - https://github.com/tardisx/discord-auto-upload")
fmt.Printf("Version: %s\n", version.CurrentVersion) fmt.Printf("Version: %s\n", version.CurrentVersion)
os.Exit(0) os.Exit(0)
} }
// grab the config
config.LoadOrInit()
}
func checkFile(path string, f os.FileInfo, err error) error {
if f.ModTime().After(lastCheck) && f.Mode().IsRegular() {
if fileEligible(path) {
// process file
processFile(path)
}
if newLastCheck.Before(f.ModTime()) {
newLastCheck = f.ModTime()
}
}
return nil
}
func fileEligible(file string) bool {
if config.Config.Exclude != "" && strings.Contains(file, config.Config.Exclude) {
return false
}
extension := strings.ToLower(filepath.Ext(file))
if extension == ".png" || extension == ".jpg" || extension == ".gif" {
return true
}
return false
}
func processFile(file string) {
daulog.SendLog("Sending to uploader", daulog.LogTypeInfo)
uploads.AddFile(file)
} }

View File

@ -1,6 +1,7 @@
package log package log
import ( import (
"log"
"time" "time"
) )
@ -42,4 +43,5 @@ func SendLog(entry string, entryType LogEntryType) {
Entry: entry, Entry: entry,
Type: entryType, Type: entryType,
} }
log.Printf(entry)
} }

View File

@ -1,6 +1,6 @@
// The uploads pacakge encapsulates dealing with file uploads to // Package upload encapsulates prepping an image for sending to discord,
// discord // and actually uploading it there.
package uploads package upload
import ( import (
"bytes" "bytes"
@ -22,48 +22,70 @@ import (
"golang.org/x/image/font/inconsolata" "golang.org/x/image/font/inconsolata"
) )
type Uploader struct {
Uploads []*Upload
}
type Upload struct { type Upload struct {
Uploaded bool `json:"uploaded"` // has this file been uploaded to discord Uploaded bool `json:"uploaded"` // has this file been uploaded to discord
UploadedAt time.Time `json:"uploaded_at"` UploadedAt time.Time `json:"uploaded_at"`
originalFilename string // path on the local disk originalFilename string // path on the local disk
mungedFilename string // post-watermark filenameToUpload string // post-watermark, or just original if unwatermarked
webhookURL string
watermark bool // should watermark
usernameOverride string
Url string `json:"url"` // url on the discord CDN Url string `json:"url"` // url on the discord CDN
Width int `json:"width"` Width int `json:"width"`
Height int `json:"height"` Height int `json:"height"`
} }
var Uploads []*Upload func (u *Uploader) AddFile(file string, conf config.Watcher) {
func AddFile(file string) {
thisUpload := Upload{ thisUpload := Upload{
Uploaded: false, Uploaded: false,
originalFilename: file, originalFilename: file,
watermark: !conf.NoWatermark,
webhookURL: conf.WebHookURL,
usernameOverride: conf.Username,
} }
Uploads = append(Uploads, &thisUpload) u.Uploads = append(u.Uploads, &thisUpload)
ProcessUpload(&thisUpload)
} }
func ProcessUpload(up *Upload) { // Upload uploads any files that have not yet been uploaded
func (u *Uploader) Upload() {
file := up.originalFilename for _, upload := range u.Uploads {
if !upload.Uploaded {
if !config.Config.NoWatermark { upload.processUpload()
daulog.SendLog("Copying to temp location and watermarking ", daulog.LogTypeInfo) }
file = mungeFile(file) }
up.mungedFilename = file
} }
if config.Config.WebHookURL == "" { func (u *Upload) processUpload() {
// file := u.originalFilename
if u.webhookURL == "" {
daulog.SendLog("WebHookURL is not configured - cannot upload!", daulog.LogTypeError) daulog.SendLog("WebHookURL is not configured - cannot upload!", daulog.LogTypeError)
return return
} }
if u.watermark {
daulog.SendLog("Watermarking", daulog.LogTypeInfo)
u.applyWatermark()
} else {
u.filenameToUpload = u.originalFilename
}
extraParams := map[string]string{} extraParams := map[string]string{}
if config.Config.Username != "" { if u.usernameOverride != "" {
daulog.SendLog("Overriding username with "+config.Config.Username, daulog.LogTypeInfo) daulog.SendLog("Overriding username with "+u.usernameOverride, daulog.LogTypeInfo)
extraParams["username"] = config.Config.Username extraParams["username"] = u.usernameOverride
} }
type DiscordAPIResponseAttachment struct { type DiscordAPIResponseAttachment struct {
@ -83,7 +105,7 @@ func ProcessUpload(up *Upload) {
var retriesRemaining = 5 var retriesRemaining = 5
for retriesRemaining > 0 { for retriesRemaining > 0 {
request, err := newfileUploadRequest(config.Config.WebHookURL, extraParams, "file", file) request, err := newfileUploadRequest(u.webhookURL, extraParams, "file", u.filenameToUpload)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -145,19 +167,19 @@ func ProcessUpload(up *Upload) {
daulog.SendLog(fmt.Sprintf("Uploaded to %s %dx%d", a.URL, a.Width, a.Height), daulog.LogTypeInfo) daulog.SendLog(fmt.Sprintf("Uploaded to %s %dx%d", a.URL, a.Width, a.Height), daulog.LogTypeInfo)
daulog.SendLog(fmt.Sprintf("id: %d, %d bytes transferred in %.2f seconds (%.2f KiB/s)", res.ID, a.Size, elapsed.Seconds(), rate), daulog.LogTypeInfo) daulog.SendLog(fmt.Sprintf("id: %d, %d bytes transferred in %.2f seconds (%.2f KiB/s)", res.ID, a.Size, elapsed.Seconds(), rate), daulog.LogTypeInfo)
up.Url = a.URL u.Url = a.URL
up.Uploaded = true u.Uploaded = true
up.Width = a.Width u.Width = a.Width
up.Height = a.Height u.Height = a.Height
up.UploadedAt = time.Now() u.UploadedAt = time.Now()
break break
} }
} }
if !config.Config.NoWatermark { if u.watermark {
daulog.SendLog(fmt.Sprintf("Removing temporary file: %s", file), daulog.LogTypeDebug) daulog.SendLog(fmt.Sprintf("Removing temporary file: %s", u.filenameToUpload), daulog.LogTypeDebug)
os.Remove(file) os.Remove(u.filenameToUpload)
} }
if retriesRemaining == 0 { if retriesRemaining == 0 {
@ -196,9 +218,9 @@ func newfileUploadRequest(uri string, params map[string]string, paramName, path
return req, err return req, err
} }
func mungeFile(path string) string { func (u *Upload) applyWatermark() {
reader, err := os.Open(path) reader, err := os.Open(u.originalFilename)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -206,7 +228,10 @@ func mungeFile(path string) string {
im, _, err := image.Decode(reader) im, _, err := image.Decode(reader)
if err != nil { if err != nil {
log.Fatal(err) log.Printf("Cannot decode image: %v - skipping watermarking", err)
u.watermark = false
u.filenameToUpload = u.originalFilename
return
} }
bounds := im.Bounds() bounds := im.Bounds()
// var S float64 = float64(bounds.Max.X) // var S float64 = float64(bounds.Max.X)
@ -236,7 +261,7 @@ func mungeFile(path string) string {
actualName := tempfile.Name() + ".png" actualName := tempfile.Name() + ".png"
dc.SavePNG(actualName) dc.SavePNG(actualName)
return actualName u.filenameToUpload = actualName
} }
func sleepForRetries(retry int) { func sleepForRetries(retry int) {

View File

@ -1,159 +1,153 @@
{{ define "content" }} {{ define "content" }}
<main role="main" class="inner DAU"> <main role="main" class="inner DAU" x-data="configuration()" x-init="get_config()">
<h1 class="DAU-heading">Config</h1> <h1 class="DAU-heading">Config</h1>
<p class="lead">Discord-auto-upload configuration</p>
<a href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"><p class="lead">How to find your discord webhook</p></a>
</a>
<form class=""> <form class="">
<div class="form-row align-items-center config-item" data-key="webhook">
<div class="col-sm-5 my-1">
<span>Discord WebHook URL</span>
</div>
<div class="col-sm-4 my-1">
<label class="sr-only" for="inlineFormInputName">Name</label>
<input type="text" class="form-control rest-field" placeholder="https://....">
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
<form class=""> <p>Configuration changes are not made until the Save button is pressed
<div class="form-row align-items-center config-item" data-key="username"> at the bottom of this page.
<div class="col-sm-5 my-1"> </p>
<span>Bot username (optional)</span>
</div>
<div class="col-sm-4 my-1">
<label class="sr-only" for="inlineFormInputName">Name</label>
<input type="text" class="form-control rest-field" placeholder="">
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
<form class=""> <h3>global configuration</h3>
<div class="form-row align-items-center config-item" data-key="directory">
<div class="col-sm-5 my-1"> <p>The server port dictates which TCP port the web server listens on.
If you change this number you will need to restart.
</p>
<p>The Watch Interval is how often new files will be discovered by your
watchers (configured below).</p>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Server port</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only">Server port</label>
<input type="text" class="form-control" placeholder="" x-model.number="config.Port">
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Watch interval</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only">Watch interval</label>
<input type="text" class="form-control" placeholder="" x-model.number="config.WatchInterval">
</div>
</div>
<h3>watcher configuration</h3>
<p>You may configure one or more watchers. Each watcher watches a
single directory (and all subdirectories) and when a new image file
is found it uploads it to the specified channel via the webhook URL.
</p>
<p><a href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks">
Click here</a> for information on how to find your discord webhook URL.</p>
<p>You may also specify a username for the bot to masquerade as. This is a cosmetic
change only, and does not hide the uploaders actual identity.
</p>
<template x-for="(watcher, i) in config.Watchers">
<div class="my-5">
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Directory to watch</span> <span>Directory to watch</span>
</div> </div>
<div class="col-sm-4 my-1"> <div class="col-sm-6 my-1">
<label class="sr-only" for="inlineFormInputName">Name</label> <label class="sr-only" for="">Directory</label>
<input type="text" class="form-control rest-field" placeholder="/..."> <input type="text" class="form-control" placeholder="" x-model="watcher.Path">
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div> </div>
</div> </div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Webhook URL</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only" for="">WebHook URL</label>
<input type="text" class="form-control" placeholder="" x-model="watcher.WebHookURL">
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Username</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only" for="">Username</label>
<input type="text" class="form-control" placeholder="" x-model="watcher.Username">
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Watermark</span>
</div>
<div class="col-sm-6 my-1">
<button type="button" @click="config.Watchers[i].NoWatermark = ! config.Watchers[i].NoWatermark" class="btn btn-success" x-text="watcher.NoWatermark ? 'Disabled 😢' : 'Enabled'"></button>
</div>
</div>
<button type="button" class="btn btn-primary" href="#" @click.prevent="config.Watchers.splice(i, 1);">Remove
this watcher</button>
</div>
</template>
<div class="my-5">
<button type="button" class="btn btn-secondary" href="#"
@click.prevent="config.Watchers.push({Username: '', WebHookURL: '', Path: '', NoWatermark: false});">
Add a new watcher</button>
</div>
<div class="my-5">
<button type="button" class="my-4 btn btn-danger" href="#" @click="save_config()">
Save all Configuration
</button>
</div>
</form> </form>
<form class="">
<div class="form-row align-items-center config-item" data-key="watch">
<div class="col-sm-5 my-1">
<span>Period between filesystem checks (seconds)</span>
</div>
<div class="col-sm-4 my-1">
<label class="sr-only" for="inlineFormInputName">Seconds</label>
<input type="text" class="form-control rest-field " placeholder="/...">
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
<form class="">
<div class="form-row align-items-center config-item" data-key="nowatermark">
<div class="col-sm-5 my-1">
<span>Do not watermark images</span>
</div>
<div class="col-sm-4 my-1">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input rest-field rest-field-boolean" id="input-nowatermark">
<label class="custom-control-label" for="input-nowatermark">&nbsp;</label>
<span id="sadness" style="">😭</span>
</div>
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
<form class="">
<div class="form-row align-items-center config-item" data-key="exclude">
<div class="col-sm-5 my-1">
<span>Files to exclude</span>
</div>
<div class="col-sm-4 my-1">
<label class="sr-only" for="input-exclude">Name</label>
<input type="text" id="input-exclude" class="form-control rest-field" placeholder="">
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
</main> </main>
{{ end }} {{ end }}
{{ define "js" }} {{ define "js" }}
<script src="//unpkg.com/alpinejs" defer></script>
<script> <script>
function update_sadness () { function configuration() {
if ($('#input-nowatermark').prop('checked')) { return {
$('#sadness').css('visibility',''); config: {},
get_config() {
fetch('/rest/config')
.then(response => response.json()) // convert to json
.then(json => {
this.config = json;
console.log(json);
})
},
save_config() {
fetch('/rest/config', { method: 'POST', body: JSON.stringify(this.config) })
.then(response => response.json()) // convert to json
.then(json => {
this.config = json;
console.log(json);
})
} }
else {
$('#sadness').css('visibility','hidden');
} }
} }
$(document).ready(function() {
$('#input-nowatermark').on('click', function() { update_sadness(); });
// populate each field
$('.config-item').each(function() {
let el = $(this);
let key = el.data('key');
$.ajax({ method: 'get', url: '/rest/config/'+key})
.done(function(data) {
var this_el = $(".config-item[data-key='"+key+"']").find('.rest-field');
if (this_el.hasClass('rest-field-boolean')) {
this_el.prop('checked', data.value);
}
else {
this_el.val(data.value);
}
update_sadness();
});
});
// respond to button clicks to update
$('.config-item button').on('click', function(e,f) {
key = $(this).parents('.config-item').data('key');
val = $(this).parents('.config-item').find('.rest-field').val();
if ($(this).parents('.config-item').find('.rest-field-boolean').length) {
val = $(this).parents('.config-item').find('.rest-field').prop('checked') ? 1 : 0;
}
$.post('/rest/config/'+key, { value: val })
.done(function(d) {
if (d.success) {
alert('Updated config');
} else {
alert("Error: " + d.error);
}
});
return false;
});
});
</script> </script>
{{ end }} {{ end }}

View File

@ -5,44 +5,32 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"io/ioutil"
"log" "log"
"mime" "mime"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/tardisx/discord-auto-upload/config" "github.com/tardisx/discord-auto-upload/config"
daulog "github.com/tardisx/discord-auto-upload/log" daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/tardisx/discord-auto-upload/uploads"
"github.com/tardisx/discord-auto-upload/version" "github.com/tardisx/discord-auto-upload/version"
) )
type WebService struct {
Config config.ConfigService
}
//go:embed data //go:embed data
var webFS embed.FS var webFS embed.FS
// DAUWebServer - stuff for the web server // DAUWebServer - stuff for the web server
type DAUWebServer struct { type DAUWebServer struct {
ConfigChange chan int // ConfigChange chan int
} }
type valueStringResponse struct { func (ws *WebService) getStatic(w http.ResponseWriter, r *http.Request) {
Success bool `json:"success"`
Value string `json:"value"`
}
type valueBooleanResponse struct {
Success bool `json:"success"`
Value bool `json:"value"`
}
type errorResponse struct {
Success bool `json:"success"`
Error string `json:"error"`
}
func getStatic(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
path = strings.TrimLeft(path, "/") path = strings.TrimLeft(path, "/")
@ -52,8 +40,7 @@ func getStatic(w http.ResponseWriter, r *http.Request) {
extension := filepath.Ext(string(path)) extension := filepath.Ext(string(path))
if extension == ".html" { if extension == ".html" { // html file
t, err := template.ParseFS(webFS, "data/wrapper.tmpl", "data/"+path) t, err := template.ParseFS(webFS, "data/wrapper.tmpl", "data/"+path)
if err != nil { if err != nil {
log.Printf("when fetching: %s got: %s", path, err) log.Printf("when fetching: %s got: %s", path, err)
@ -78,7 +65,7 @@ func getStatic(w http.ResponseWriter, r *http.Request) {
panic(err) panic(err)
} }
return return
} else { } else { // anything else
otherStatic, err := webFS.ReadFile("data/" + path) otherStatic, err := webFS.ReadFile("data/" + path)
if err != nil { if err != nil {
@ -96,200 +83,7 @@ func getStatic(w http.ResponseWriter, r *http.Request) {
} }
// TODO there should be locks around all these config accesses func (ws *WebService) getLogs(w http.ResponseWriter, r *http.Request) {
func getSetWebhook(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.WebHookURL}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
config.Config.WebHookURL = r.PostForm.Get("value")
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.WebHookURL}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
// TODO there should be locks around all these config accesses
func getSetUsername(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.Username}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
config.Config.Username = r.PostForm.Get("value")
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.Username}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getSetWatch(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: strconv.Itoa(config.Config.Watch)}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
i, err := strconv.Atoi(r.PostForm.Get("value"))
if err != nil {
response := errorResponse{Success: false, Error: fmt.Sprintf("Bad value for watch: %v", err)}
js, _ := json.Marshal(response)
w.Write(js)
return
}
if i < 1 {
response := errorResponse{Success: false, Error: "must be > 0"}
js, _ := json.Marshal(response)
w.Write(js)
return
}
config.Config.Watch = i
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: strconv.Itoa(config.Config.Watch)}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getSetNoWatermark(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueBooleanResponse{Success: true, Value: config.Config.NoWatermark}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
v := r.PostForm.Get("value")
if v != "0" && v != "1" {
response := errorResponse{Success: false, Error: fmt.Sprintf("Bad value for nowatermark: %v", err)}
js, _ := json.Marshal(response)
w.Write(js)
return
}
if v == "0" {
config.Config.NoWatermark = false
} else {
config.Config.NoWatermark = true
}
config.SaveConfig()
postResponse := valueBooleanResponse{Success: true, Value: config.Config.NoWatermark}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getSetDirectory(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.Path}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
newPath := r.PostForm.Get("value")
// sanity check this path
stat, err := os.Stat(newPath)
if os.IsNotExist(err) {
// not exist
response := errorResponse{Success: false, Error: fmt.Sprintf("Path: %s - does not exist", newPath)}
js, _ := json.Marshal(response)
w.Write(js)
return
} else if !stat.IsDir() {
// not a directory
response := errorResponse{Success: false, Error: fmt.Sprintf("Path: %s - is not a directory", newPath)}
js, _ := json.Marshal(response)
w.Write(js)
return
}
config.Config.Path = newPath
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.Path}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getSetExclude(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.Exclude}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
config.Config.Exclude = r.PostForm.Get("value")
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.Exclude}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getLogs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
showDebug := false showDebug := false
@ -312,29 +106,50 @@ func getLogs(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(text)) w.Write([]byte(text))
} }
func getUploads(w http.ResponseWriter, r *http.Request) { func (ws *WebService) handleConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") if r.Method == "POST" {
ups := uploads.Uploads newConfig := config.ConfigV2{}
text, _ := json.Marshal(ups)
w.Write([]byte(text)) defer r.Body.Close()
b, err := ioutil.ReadAll(r.Body)
if err != nil {
w.Write([]byte("bad body"))
return
}
err = json.Unmarshal(b, &newConfig)
if err != nil {
w.Write([]byte("bad data"))
log.Printf("%s", err)
return
}
ws.Config.Config = newConfig
ws.Config.Save()
} }
func StartWebServer() { b, _ := json.Marshal(ws.Config.Config)
w.Write(b)
}
http.HandleFunc("/", getStatic) // func getUploads(w http.ResponseWriter, r *http.Request) {
http.HandleFunc("/rest/config/webhook", getSetWebhook) // w.Header().Set("Content-Type", "application/json")
http.HandleFunc("/rest/config/username", getSetUsername) // ups := uploads.Uploads
http.HandleFunc("/rest/config/watch", getSetWatch) // text, _ := json.Marshal(ups)
http.HandleFunc("/rest/config/nowatermark", getSetNoWatermark) // w.Write([]byte(text))
http.HandleFunc("/rest/config/directory", getSetDirectory) // }
http.HandleFunc("/rest/config/exclude", getSetExclude)
http.HandleFunc("/rest/logs", getLogs) func (ws *WebService) StartWebServer() {
http.HandleFunc("/rest/uploads", getUploads)
http.HandleFunc("/", ws.getStatic)
http.HandleFunc("/rest/logs", ws.getLogs)
// http.HandleFunc("/rest/uploads", getUploads)
http.HandleFunc("/rest/config", ws.handleConfig)
go func() { go func() {
log.Print("Starting web server on http://localhost:9090") listen := fmt.Sprintf(":%d", ws.Config.Config.Port)
err := http.ListenAndServe(":9090", nil) // set listen port log.Print("Starting web server on http://localhost%s", listen)
err := http.ListenAndServe(listen, nil) // set listen port
if err != nil { if err != nil {
log.Fatal("ListenAndServe: ", err) log.Fatal("ListenAndServe: ", err)
} }

View File

@ -6,15 +6,19 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/tardisx/discord-auto-upload/config"
) )
func TestHome(t *testing.T) { func TestHome(t *testing.T) {
s := WebService{}
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
getStatic(w, req) s.getStatic(w, req)
res := w.Result() res := w.Result()
defer res.Body.Close()
data, err := ioutil.ReadAll(res.Body) data, err := ioutil.ReadAll(res.Body)
if err != nil { if err != nil {
t.Errorf("expected error to be nil got %v", err) t.Errorf("expected error to be nil got %v", err)
} }
@ -28,6 +32,7 @@ func TestHome(t *testing.T) {
} }
func TestNotFound(t *testing.T) { func TestNotFound(t *testing.T) {
s := WebService{}
notFounds := []string{ notFounds := []string{
"/abc.html", "/foo.html", "/foo.html", "/../foo.html", "/abc.html", "/foo.html", "/foo.html", "/../foo.html",
@ -38,8 +43,9 @@ func TestNotFound(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, nf, nil) req := httptest.NewRequest(http.MethodGet, nf, nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
getStatic(w, req) s.getStatic(w, req)
res := w.Result() res := w.Result()
defer res.Body.Close() defer res.Body.Close()
b, err := ioutil.ReadAll(res.Body) b, err := ioutil.ReadAll(res.Body)
if err != nil { if err != nil {
@ -54,3 +60,25 @@ func TestNotFound(t *testing.T) {
} }
} }
} }
func TestGetConfig(t *testing.T) {
conf := config.DefaultConfigService()
conf.Config = *config.DefaultConfig()
s := WebService{Config: *conf}
req := httptest.NewRequest(http.MethodGet, "/rest/config", nil)
w := httptest.NewRecorder()
s.handleConfig(w, req)
res := w.Result()
defer res.Body.Close()
b, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("expected error to be nil got %v", err)
}
if string(b) != `{"WatchInterval":10,"Version":2,"Watchers":[{"WebHookURL":"abcedf","Path":"/Users/justin/tmp","Username":"","NoWatermark":false,"Exclude":[]}]}` {
t.Errorf("Got unexpected response %v", string(b))
}
}