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"
"io/ioutil"
"os"
"strings"
daulog "github.com/tardisx/discord-auto-upload/log"
@ -21,62 +22,115 @@ type ConfigV1 struct {
Exclude string
}
type ConfigV2Watcher struct {
type Watcher struct {
WebHookURL string
Path string
Username string
NoWatermark bool
Exclude string
Exclude []string
}
type ConfigV2 struct {
WatchInterval int
Version int
Watchers []ConfigV2Watcher
Port int
Watchers []Watcher
}
var Config ConfigV2
var configPath string
type ConfigService struct {
Config ConfigV2
ConfigFilename string
}
func Init() {
configPath = defaultConfigPath()
func DefaultConfigService() *ConfigService {
c := ConfigService{
ConfigFilename: defaultConfigPath(),
}
return &c
}
// LoadOrInit loads the current configuration from the config file, or creates
// a new config file if none exists.
func LoadOrInit() error {
daulog.SendLog(fmt.Sprintf("Trying to load config from %s", configPath), daulog.LogTypeDebug)
_, err := os.Stat(configPath)
func (c *ConfigService) LoadOrInit() error {
daulog.SendLog(fmt.Sprintf("Trying to load config from %s\n", c.ConfigFilename), daulog.LogTypeDebug)
_, err := os.Stat(c.ConfigFilename)
if os.IsNotExist(err) {
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)
Config.Version = 2
Config.WatchInterval = 10
return SaveConfig()
c.Config = *DefaultConfig()
return c.Save()
} else {
return LoadConfig()
return c.Load()
}
}
// LoadConfig will load the configuration from a known-to-exist config file.
func LoadConfig() error {
data, err := ioutil.ReadFile(configPath)
if err != nil {
return fmt.Errorf("cannot read config file %s: %s", configPath, err.Error())
func DefaultConfig() *ConfigV2 {
c := ConfigV2{}
c.Version = 2
c.WatchInterval = 10
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}
return &c
}
// 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 decode config file %s: %s", configPath, err.Error())
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
}
func SaveConfig() error {
func (c *ConfigService) Save() error {
daulog.SendLog("saving configuration", daulog.LogTypeInfo)
jsonString, _ := json.Marshal(Config)
err := ioutil.WriteFile(configPath, jsonString, os.ModePerm)
jsonString, _ := json.Marshal(c.Config)
err := ioutil.WriteFile(c.ConfigFilename, jsonString, os.ModePerm)
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
}

View File

@ -7,39 +7,76 @@ import (
)
func TestNoConfig(t *testing.T) {
if Config.Version != 0 {
t.Error("not 0 empty config")
}
c := ConfigService{}
configPath = emptyTempFile()
os.Remove(configPath)
c.ConfigFilename = emptyTempFile()
os.Remove(c.ConfigFilename)
defer os.Remove(c.ConfigFilename) // because we are about to create it
err := LoadOrInit()
err := c.LoadOrInit()
if err != nil {
t.Errorf("unexpected failure from load: %s", err)
}
if Config.Version != 2 {
if c.Config.Version != 2 {
t.Error("not version 2 starting config")
}
if fileSize(configPath) < 40 {
t.Errorf("File is too small %d bytes", fileSize(configPath))
if fileSize(c.ConfigFilename) < 40 {
t.Errorf("File is too small %d bytes", fileSize(c.ConfigFilename))
}
os.Remove(configPath)
}
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 {
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 {

172
dau.go
View File

@ -2,7 +2,9 @@ package main
import (
"encoding/json"
"flag"
"fmt"
"io/fs"
"io/ioutil"
"log"
"net/http"
@ -15,59 +17,132 @@ import (
_ "image/jpeg"
_ "image/png"
"github.com/pborman/getopt"
// "github.com/skratchdot/open-golang/open"
"github.com/tardisx/discord-auto-upload/config"
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/web"
)
var lastCheck = time.Now()
var newLastCheck = time.Now()
type watch struct {
lastCheck time.Time
newLastCheck time.Time
config config.Watcher
uploader upload.Uploader
}
func main() {
parseOptions()
// grab the config
config := config.DefaultConfigService()
config.LoadOrInit()
// create the uploader
up := upload.Uploader{}
// log.Print("Opening web browser")
// open.Start("http://localhost:9090")
web := web.WebService{Config: *config}
web.StartWebServer()
checkUpdates()
go func() { checkUpdates() }()
daulog.SendLog(fmt.Sprintf("Waiting for images to appear in %s", config.Config.Path), daulog.LogTypeInfo)
// wander the path, forever
// create the watchers
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 {
if checkPath(config.Config.Path) {
err := filepath.Walk(config.Config.Path,
func(path string, f os.FileInfo, err error) error { return checkFile(path, f, err) })
if err != nil {
log.Fatal("could not watch path", err)
}
lastCheck = newLastCheck
newFiles := w.ProcessNewFiles()
for _, f := range newFiles {
w.uploader.AddFile(f, w.config)
}
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)
// 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)
}
}
func checkPath(path string) bool {
src, err := os.Stat(path)
// 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 {
log.Fatal("could not watch path", err)
}
w.lastCheck = w.newLastCheck
}
return newFiles
}
// checkPath makes sure the path exists, and is a directory.
// 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 {
log.Printf("Problem with path '%s': %s", path, err)
log.Printf("Problem with path '%s': %s", w.config.Path, err)
return false
}
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 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() {
type GithubRelease struct {
@ -115,61 +190,14 @@ func checkUpdates() {
}
func parseOptions() {
var versionFlag bool
flag.BoolVar(&versionFlag, "version", false, "show version")
flag.Parse()
// Declare the flags to be used
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 {
if versionFlag {
fmt.Println("dau - https://github.com/tardisx/discord-auto-upload")
fmt.Printf("Version: %s\n", version.CurrentVersion)
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
import (
"log"
"time"
)
@ -42,4 +43,5 @@ func SendLog(entry string, entryType LogEntryType) {
Entry: entry,
Type: entryType,
}
log.Printf(entry)
}

View File

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

View File

@ -1,159 +1,153 @@
{{ define "content" }}
<main role="main" class="inner DAU">
<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>
<main role="main" class="inner DAU" x-data="configuration()" x-init="get_config()">
<h1 class="DAU-heading">Config</h1>
</a>
<form class="">
<p>Configuration changes are not made until the Save button is pressed
at the bottom of this page.
</p>
<h3>global configuration</h3>
<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>
<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>
<h3>watcher configuration</h3>
<form class="">
<div class="form-row align-items-center config-item" data-key="username">
<div class="col-sm-5 my-1">
<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>
<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>
<form class="">
<div class="form-row align-items-center config-item" data-key="directory">
<div class="col-sm-5 my-1">
<span>Directory to watch</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>
<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>
<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>
<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>
<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>
<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>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only" for="">Directory</label>
<input type="text" class="form-control" placeholder="" x-model="watcher.Path">
</div>
</div>
<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>
<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>
</main>
<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>
</main>
{{ end }}
{{ define "js" }}
<script src="//unpkg.com/alpinejs" defer></script>
<script>
function update_sadness () {
if ($('#input-nowatermark').prop('checked')) {
$('#sadness').css('visibility','');
}
else {
$('#sadness').css('visibility','hidden');
}
}
$(document).ready(function() {
function configuration() {
return {
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);
})
}
$('#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>
{{ end }}

View File

@ -5,44 +5,32 @@ import (
"encoding/json"
"fmt"
"html/template"
"io/ioutil"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/tardisx/discord-auto-upload/config"
daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/tardisx/discord-auto-upload/uploads"
"github.com/tardisx/discord-auto-upload/version"
)
type WebService struct {
Config config.ConfigService
}
//go:embed data
var webFS embed.FS
// DAUWebServer - stuff for the web server
type DAUWebServer struct {
ConfigChange chan int
// ConfigChange chan int
}
type valueStringResponse struct {
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) {
func (ws *WebService) getStatic(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
path = strings.TrimLeft(path, "/")
@ -52,8 +40,7 @@ func getStatic(w http.ResponseWriter, r *http.Request) {
extension := filepath.Ext(string(path))
if extension == ".html" {
if extension == ".html" { // html file
t, err := template.ParseFS(webFS, "data/wrapper.tmpl", "data/"+path)
if err != nil {
log.Printf("when fetching: %s got: %s", path, err)
@ -78,7 +65,7 @@ func getStatic(w http.ResponseWriter, r *http.Request) {
panic(err)
}
return
} else {
} else { // anything else
otherStatic, err := webFS.ReadFile("data/" + path)
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 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) {
func (ws *WebService) getLogs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
showDebug := false
@ -312,29 +106,50 @@ func getLogs(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(text))
}
func getUploads(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ups := uploads.Uploads
text, _ := json.Marshal(ups)
w.Write([]byte(text))
func (ws *WebService) handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
newConfig := config.ConfigV2{}
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()
}
b, _ := json.Marshal(ws.Config.Config)
w.Write(b)
}
func StartWebServer() {
// func getUploads(w http.ResponseWriter, r *http.Request) {
// w.Header().Set("Content-Type", "application/json")
// ups := uploads.Uploads
// text, _ := json.Marshal(ups)
// w.Write([]byte(text))
// }
http.HandleFunc("/", getStatic)
http.HandleFunc("/rest/config/webhook", getSetWebhook)
http.HandleFunc("/rest/config/username", getSetUsername)
http.HandleFunc("/rest/config/watch", getSetWatch)
http.HandleFunc("/rest/config/nowatermark", getSetNoWatermark)
http.HandleFunc("/rest/config/directory", getSetDirectory)
http.HandleFunc("/rest/config/exclude", getSetExclude)
func (ws *WebService) StartWebServer() {
http.HandleFunc("/rest/logs", getLogs)
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() {
log.Print("Starting web server on http://localhost:9090")
err := http.ListenAndServe(":9090", nil) // set listen port
listen := fmt.Sprintf(":%d", ws.Config.Config.Port)
log.Print("Starting web server on http://localhost%s", listen)
err := http.ListenAndServe(listen, nil) // set listen port
if err != nil {
log.Fatal("ListenAndServe: ", err)
}

View File

@ -6,15 +6,19 @@ import (
"net/http/httptest"
"strings"
"testing"
"github.com/tardisx/discord-auto-upload/config"
)
func TestHome(t *testing.T) {
s := WebService{}
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
getStatic(w, req)
s.getStatic(w, req)
res := w.Result()
defer res.Body.Close()
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("expected error to be nil got %v", err)
}
@ -28,6 +32,7 @@ func TestHome(t *testing.T) {
}
func TestNotFound(t *testing.T) {
s := WebService{}
notFounds := []string{
"/abc.html", "/foo.html", "/foo.html", "/../foo.html",
@ -38,8 +43,9 @@ func TestNotFound(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, nf, nil)
w := httptest.NewRecorder()
getStatic(w, req)
s.getStatic(w, req)
res := w.Result()
defer res.Body.Close()
b, err := ioutil.ReadAll(res.Body)
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))
}
}