35 Commits
0.9 ... v0.11.0

Author SHA1 Message Date
1ef5ed3ce4 Fix release script 2021-10-10 14:55:46 +10:30
ec8b2453cd Clean up logging 2021-10-10 14:54:08 +10:30
3776374747 Fix test 2021-10-10 14:45:16 +10:30
6ece881a52 Fix upload logs 2021-10-10 14:44:12 +10:30
4534394abc Lesson learned - read the test output carefully 2021-10-10 14:13:59 +10:30
4c963ba559 Just a stab in the dark 2021-10-10 14:04:25 +10:30
c9f8ad60c3 Add debugging to work out why this is failing on github 2021-10-10 13:59:18 +10:30
4665380d15 Add a changelog 2021-10-10 13:57:34 +10:30
a739e62824 Fix test 2021-10-10 13:54:26 +10:30
c87d6ba79d Merge branch 'master' of https://github.com/tardisx/discord-auto-upload 2021-10-10 13:52:08 +10:30
fd6f6884ee Notice configuration changes and restart the watchers. 2021-10-10 13:51:52 +10:30
14ce147ec6 Notice configuration changes and restart the watchers. 2021-10-10 13:43:36 +10:30
90f8c3588b Sanity check the watch interval 2021-10-10 12:51:15 +10:30
3e6cf49394 Add sanity checks for configuration and UI to display errors. 2021-10-10 12:47:37 +10:30
1809033049 Support exclusions again. 2021-10-10 12:00:25 +10:30
f9614ffc48 Remove some system-specific testdata 2021-10-10 11:52:19 +10:30
26d4272aa2 Make config look less crappy 2021-10-10 11:51:20 +10:30
2b06c37be8 Fix debug output for startup and test 2021-10-06 23:17:16 +10:30
8483fe7db9 Big refactor to allow for multiple watchers, v2 configuration file with migration and new UI for configuration 2021-10-06 23:12:43 +10:30
7dddc92364 I'm no longer going to call this a quick hack when I spend significant refactoring time :-) 2021-10-04 19:48:26 +10:30
7f3161143f Start to refactor config to support new version of configuration with multiple watchers. 2021-10-04 15:38:49 +10:30
e3a7fad7a9 Add the badge. Everyone loves badges. 2021-10-04 13:30:03 +10:30
c85d134f7b Create go.yml 2021-10-04 13:28:28 +10:30
dd79dbed1d Better handling for not found cases, and test 2021-10-04 13:27:46 +10:30
87acf0aefb Rework to use golang 1.16 embed package instead of go-bindata. Rework templates to be less insane 2021-10-04 13:03:26 +10:30
3a65a60fcb Move software version handling to a new package 2021-10-04 12:20:16 +10:30
1812486b19 Switch to semver, basic test. 2021-06-17 18:47:59 +09:30
c47660addf Add uploads page 2021-06-08 22:20:11 +09:30
2d0e294af6 Fix title 2021-06-08 22:19:54 +09:30
283e0f3584 Bump version 2021-06-08 22:19:37 +09:30
9ef6ab71c7 Add upload package and update dependencies 2021-06-07 21:13:57 +09:30
701583d3fd Update year 2021-06-07 21:13:18 +09:30
0c156a19f0 Fix punctuation 2021-06-06 22:22:44 +09:30
cc54bb6469 Enable filtering by debug and automatic scroll to bottom 2021-06-06 17:15:38 +09:30
71c097e578 Update gitignore 2021-06-06 17:15:19 +09:30
26 changed files with 1424 additions and 746 deletions

25
.github/workflows/go.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Go
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.17
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...

2
.gitignore vendored
View File

@@ -2,6 +2,6 @@ dist
release release
discord-auto-upload discord-auto-upload
discord-auto-upload.exe discord-auto-upload.exe
assets
*.png *.png
*.jpg *.jpg
.DS_Store

62
CHANGELOG.md Normal file
View File

@@ -0,0 +1,62 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [v0.11.0] - 2021-10-10
- Switched to semantic versioning
- Now supports multiple watchers - multiple directories can be monitored for new images
- Complete UI rework to support new features and decrease ugliness
- Add many tests
## [0.10.0] - 2021-06-08
This version adds a page showing recent uploads, with thumbnails.
This is not much use except as a log at this stage, but is the basis for future versions which will allow you to hold files before uploading, and edit them (crop, add text, etc) as well.
## [0.9.0] - 2021-06-04
Fix the version update check so that users are actually informed about new releases.
## [0.8.0] - 2021-06-03
This version makes the logs available in the web interface.
## [0.7.0] - 2021-02-09
The long awaited (!) web interface launches with this version. No more messing with command line arguments and .bat files.
Just run the exe and hit http://localhost:9090 to configure the app. See the updated README.md for more information on the configuration.
## [0.6.0] - 2017-02-28
Add --exclude option to avoid uploading files in thumbnail directories
## [0.5.0] - 2017-02-28
* Automatic watermarking of images to perform shameless self-promotion of this tool (disable with --no-watermark)
* Automatically retry failed uploads
* Internal cleanups
## [0.4.0] - 2017-02-28
* Fix crash if the specified directory did not exist
* Better output for showing new version info
* Show speed of upload
## [0.3.0] - 2017-02-21
* Support 'username' sending
* Timeout on all HTTP connections
* Default to current directory if --directory not specified
## [0.2.0] - 2017-02-21
* First golang version, improved output and parsing of responses.
* Built in update checks.
## [0.1.0] - 2017-02-16
Initial release

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2017 Justin Hawkins Copyright (c) 2021 Justin Hawkins
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,5 +1,7 @@
# Automatically upload screenshots into a discord channel # Automatically upload screenshots into a discord channel
[![Go](https://github.com/tardisx/discord-auto-upload/actions/workflows/go.yml/badge.svg)](https://github.com/tardisx/discord-auto-upload/actions/workflows/go.yml)
This program automatically uploads new screenshots that appear in a folder on your computer to Discord and posts them in a channel: This program automatically uploads new screenshots that appear in a folder on your computer to Discord and posts them in a channel:
![Screenshot](http://i.imgur.com/QPS9V6f.jpg) ![Screenshot](http://i.imgur.com/QPS9V6f.jpg)
@@ -20,7 +22,7 @@ Binaries are available for Mac, Linux and Windows [here](https://github.com/tard
#### From source #### From source
You'll need to [download Go](https://golang.org/dl/) check the code out somewhere, run 'go generate' and then 'go build'. You'll need to [download Go](https://golang.org/dl/), check the code out somewhere, run 'go generate' and then 'go build'.
## Using it ## Using it
@@ -86,7 +88,7 @@ Please check the "log" page on the web interface for information when things are
not working as you expect. not working as you expect.
## TODO ## TODO
This is just a relatively quick hack. Open to suggestions on new features and improvements.
Open an [issue](https://github.com/tardisx/discord-auto-upload/issues/new) and let me know. Open an [issue](https://github.com/tardisx/discord-auto-upload/issues/new) and let me know what you'd like to see.
Please include any relevant logs from the console when reporting bugs. Please include any relevant logs from the console when reporting bugs.

View File

@@ -3,16 +3,19 @@
use strict; use strict;
use warnings; use warnings;
open my $fh, "<", "config/config.go" || die $!; open my $fh, "<", "version/version.go" || die $!;
my $version; my $version;
while (<$fh>) { while (<$fh>) {
$version = $1 if /^const\s+CurrentVersion.*?"([\d\.]+)"/; $version = $1 if /^const\s+CurrentVersion.*?"(v[\d\.]+)"/;
} }
close $fh; close $fh;
die "no version?" unless defined $version; die "no version?" unless defined $version;
# quit if tests fail
system("go test ./...") && die "not building release with failing tests";
# so lazy # so lazy
system "rm", "-rf", "release", "dist"; system "rm", "-rf", "release", "dist";
system "mkdir", "release"; system "mkdir", "release";
@@ -30,7 +33,6 @@ foreach my $type (keys %build) {
add_extras(); add_extras();
system(qw{go generate});
foreach my $type (keys %build) { foreach my $type (keys %build) {
local $ENV{GOOS} = $build{$type}->{env}->{GOOS}; local $ENV{GOOS} = $build{$type}->{env}->{GOOS};
local $ENV{GOARCH} = $build{$type}->{env}->{GOARCH}; local $ENV{GOARCH} = $build{$type}->{env}->{GOARCH};

View File

@@ -4,8 +4,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log"
"os" "os"
"strings"
daulog "github.com/tardisx/discord-auto-upload/log" daulog "github.com/tardisx/discord-auto-upload/log"
@@ -13,7 +13,7 @@ import (
) )
// Config for the application // Config for the application
var Config struct { type ConfigV1 struct {
WebHookURL string WebHookURL string
Path string Path string
Watch int Watch int
@@ -22,46 +22,145 @@ var Config struct {
Exclude string Exclude string
} }
const CurrentVersion string = "0.9" type Watcher struct {
WebHookURL string
Path string
Username string
NoWatermark bool
Exclude []string
}
// Load the current config or initialise with defaults type ConfigV2 struct {
func LoadOrInit() { WatchInterval int
configPath := configPath() Version int
daulog.SendLog(fmt.Sprintf("Trying to load config from %s", configPath), daulog.LogTypeDebug) Port int
_, err := os.Stat(configPath) Watchers []Watcher
}
type ConfigService struct {
Config *ConfigV2
Changed chan bool
ConfigFilename string
}
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 (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) { 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)
c.Config = DefaultConfig()
Config.WebHookURL = "" return c.Save()
Config.Path = homeDir() + string(os.PathSeparator) + "screenshots"
Config.Watch = 10
SaveConfig()
} else { } else {
LoadConfig() return c.Load()
} }
} }
func LoadConfig() { func DefaultConfig() *ConfigV2 {
path := configPath() c := ConfigV2{}
data, err := ioutil.ReadFile(path) c.Version = 2
if err != nil { c.WatchInterval = 10
log.Fatalf("cannot read config file %s: %s", path, err.Error()) c.Port = 9090
} w := Watcher{
err = json.Unmarshal([]byte(data), &Config) WebHookURL: "https://webhook.url.here",
if err != nil { Path: "/your/screenshot/dir/here",
log.Fatalf("cannot decode config file %s: %s", path, err.Error()) Username: "",
NoWatermark: false,
Exclude: []string{},
} }
c.Watchers = []Watcher{w}
return &c
} }
func SaveConfig() { // 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
}
func (c *ConfigService) Save() error {
daulog.SendLog("saving configuration", daulog.LogTypeInfo) daulog.SendLog("saving configuration", daulog.LogTypeInfo)
path := configPath() // sanity checks
jsonString, _ := json.Marshal(Config) for _, watcher := range c.Config.Watchers {
err := ioutil.WriteFile(path, jsonString, os.ModePerm)
if err != nil { // give the sample one a pass? this is kinda gross...
log.Fatalf("Cannot save config %s: %s", path, err.Error()) if watcher.Path == "/your/screenshot/dir/here" {
continue
} }
info, err := os.Stat(watcher.Path)
if os.IsNotExist(err) {
return fmt.Errorf("path '%s' does not exist", watcher.Path)
}
if !info.IsDir() {
return fmt.Errorf("path '%s' is not a directory", watcher.Path)
}
}
for _, watcher := range c.Config.Watchers {
if strings.Index(watcher.WebHookURL, "https://") != 0 {
return fmt.Errorf("webhook URL '%s' does not look valid", watcher.WebHookURL)
}
}
if c.Config.WatchInterval < 1 {
return fmt.Errorf("watch interval should be greater than 0 - '%d' invalid", c.Config.WatchInterval)
}
jsonString, _ := json.Marshal(c.Config)
err := ioutil.WriteFile(c.ConfigFilename, jsonString, os.ModePerm)
if err != nil {
return fmt.Errorf("cannot save config %s: %s", c.ConfigFilename, err.Error())
}
return nil
} }
func homeDir() string { func homeDir() string {
@@ -72,7 +171,7 @@ func homeDir() string {
return dir return dir
} }
func configPath() string { func defaultConfigPath() string {
homeDir := homeDir() homeDir := homeDir()
return homeDir + string(os.PathSeparator) + ".dau.json" return homeDir + string(os.PathSeparator) + ".dau.json"
} }

97
config/config_test.go Normal file
View File

@@ -0,0 +1,97 @@
package config
import (
"io/ioutil"
"os"
"testing"
)
func TestNoConfig(t *testing.T) {
c := ConfigService{}
c.ConfigFilename = emptyTempFile()
os.Remove(c.ConfigFilename)
defer os.Remove(c.ConfigFilename) // because we are about to create it
err := c.LoadOrInit()
if err != nil {
t.Errorf("unexpected failure from load: %s", err)
}
if c.Config.Version != 2 {
t.Error("not version 2 starting config")
}
if fileSize(c.ConfigFilename) < 40 {
t.Errorf("File is too small %d bytes", fileSize(c.ConfigFilename))
}
}
func TestEmptyFileConfig(t *testing.T) {
c := ConfigService{}
c.ConfigFilename = emptyTempFile()
defer os.Remove(c.ConfigFilename)
err := c.LoadOrInit()
if err == nil {
t.Error("unexpected success from LoadOrInit()")
}
}
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 {
f, err := ioutil.TempFile("", "dautest-*")
if err != nil {
panic(err)
}
return f.Name()
}
func fileSize(file string) int {
fi, err := os.Stat(file)
if err != nil {
panic(err)
}
return int(fi.Size())
}

View File

@@ -1,156 +0,0 @@
<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>
<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="">
<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>
<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>
<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>
<script>
function update_sadness () {
if ($('#input-nowatermark').prop('checked')) {
$('#sadness').css('visibility','');
}
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>

View File

@@ -1,18 +0,0 @@
<main role="main" class="inner DAU">
<h1 class="DAU-heading">Config</h1>
<p class="lead">Discord-auto-upload logs</p>
<pre id="logs" class="text-left pre-scrollable">
</pre>
</main>
<script>
$(document).ready(function() {
$.ajax({ method: 'get', url: '/rest/logs'})
.done(function(data) {
console.log(data);
$('#logs').text(data);
});
});
</script>

397
dau.go
View File

@@ -1,80 +1,174 @@
package main package main
//go:generate go-bindata -pkg assets -o assets/static.go -prefix data/ data
import ( import (
"bytes" "context"
"encoding/json" "encoding/json"
"flag"
"fmt" "fmt"
"io" "io/fs"
"io/ioutil" "io/ioutil"
"log" "log"
"mime/multipart"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"image"
_ "image/gif" _ "image/gif"
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
"github.com/fogleman/gg"
"github.com/pborman/getopt"
// "github.com/skratchdot/open-golang/open" // "github.com/skratchdot/open-golang/open"
"golang.org/x/image/font/inconsolata"
"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/upload"
// "github.com/tardisx/discord-auto-upload/upload"
"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, register to notice changes
config := config.DefaultConfigService()
configChanged := make(chan bool)
config.Changed = configChanged
config.LoadOrInit()
// create the uploader
up := upload.NewUploader()
// 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, Uploader: up}
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, restart them if config changes
// wander the path, forever // blocks forever
startWatchers(config, up, configChanged)
}
func startWatchers(config *config.ConfigService, up *upload.Uploader, configChange chan bool) {
for { for {
if checkPath(config.Config.Path) { daulog.SendLog("Creating watchers", daulog.LogTypeInfo)
err := filepath.Walk(config.Config.Path, ctx, cancel := context.WithCancel(context.Background())
func(path string, f os.FileInfo, err error) error { return checkFile(path, f, err) }) for _, c := range config.Config.Watchers {
log.Printf("Creating watcher for %s interval %d", c.Path, config.Config.WatchInterval)
watcher := watch{uploader: up, lastCheck: time.Now(), newLastCheck: time.Now(), config: c}
go watcher.Watch(config.Config.WatchInterval, ctx)
}
// wait for single that the config changed
<-configChange
cancel()
daulog.SendLog("starting new watchers due to config change", daulog.LogTypeInfo)
}
}
func (w *watch) Watch(interval int, ctx context.Context) {
for {
select {
case <-ctx.Done():
daulog.SendLog("Killing old watcher", daulog.LogTypeInfo)
return
default:
newFiles := w.ProcessNewFiles()
for _, f := range newFiles {
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, w.config.Exclude)
})
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) daulog.SendLog(fmt.Sprintf("Problem with path '%s': %s", w.config.Path, err), daulog.LogTypeError)
return false return false
} }
if !src.IsDir() { if !src.IsDir() {
log.Printf("Problem with path '%s': is not a directory", path) daulog.SendLog(fmt.Sprintf("Problem with path '%s': is not a directory", w.config.Path), daulog.LogTypeError)
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 eligible, not excluded and new enough to care we add it
// to the passed in array of files
func (w *watch) checkFile(path string, found *[]string, exclusions []string) error {
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() {
excluded := false
for _, exclusion := range exclusions {
if strings.Contains(path, exclusion) {
excluded = true
}
}
if !excluded {
*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 {
@@ -105,8 +199,11 @@ func checkUpdates() {
log.Fatal("could not parse JSON: ", err) log.Fatal("could not parse JSON: ", err)
} }
if config.CurrentVersion < latest.TagName { // pre v0.11.0 version (ie before semver) did a simple string comparison,
fmt.Printf("You are currently on version %s, but version %s is available\n", config.CurrentVersion, latest.TagName) // but since "0.10.0" < "v0.11.0" they should still get prompted to upgrade
// ok
if version.NewVersionAvailable(latest.TagName) {
fmt.Printf("You are currently on version %s, but version %s is available\n", version.CurrentVersion, latest.TagName)
fmt.Println("----------- Release Info -----------") fmt.Println("----------- Release Info -----------")
fmt.Println(latest.Body) fmt.Println(latest.Body)
fmt.Println("------------------------------------") fmt.Println("------------------------------------")
@@ -114,248 +211,18 @@ func checkUpdates() {
daulog.SendLog(fmt.Sprintf("New version available: %s - download at https://github.com/tardisx/discord-auto-upload/releases/latest", latest.TagName), daulog.LogTypeInfo) daulog.SendLog(fmt.Sprintf("New version available: %s - download at https://github.com/tardisx/discord-auto-upload/releases/latest", latest.TagName), daulog.LogTypeInfo)
} }
daulog.SendLog("already running latest version", daulog.LogTypeInfo)
} }
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", config.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) {
if !config.Config.NoWatermark {
daulog.SendLog("Copying to temp location and watermarking ", daulog.LogTypeInfo)
file = mungeFile(file)
}
if config.Config.WebHookURL == "" {
daulog.SendLog("WebHookURL is not configured - cannot upload!", daulog.LogTypeError)
return
}
daulog.SendLog("Uploading", daulog.LogTypeInfo)
extraParams := map[string]string{}
if config.Config.Username != "" {
log.Print("Overriding username with " + config.Config.Username)
extraParams["username"] = config.Config.Username
}
type DiscordAPIResponseAttachment struct {
URL string
ProxyURL string
Size int
Width int
Height int
Filename string
}
type DiscordAPIResponse struct {
Attachments []DiscordAPIResponseAttachment
ID int64 `json:",string"`
}
var retriesRemaining = 5
for retriesRemaining > 0 {
request, err := newfileUploadRequest(config.Config.WebHookURL, extraParams, "file", file)
if err != nil {
log.Fatal(err)
}
start := time.Now()
client := &http.Client{Timeout: time.Second * 30}
resp, err := client.Do(request)
if err != nil {
log.Print("Error performing request:", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
} else {
if resp.StatusCode != 200 {
log.Print("Bad response from server:", resp.StatusCode)
if b, err := ioutil.ReadAll(resp.Body); err == nil {
log.Print("Body:", string(b))
}
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
resBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Print("could not deal with body: ", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
resp.Body.Close()
var res DiscordAPIResponse
err = json.Unmarshal(resBody, &res)
if err != nil {
log.Print("could not parse JSON: ", err)
fmt.Println("Response was:", string(resBody[:]))
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
if len(res.Attachments) < 1 {
log.Print("bad response - no attachments?")
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
var a = res.Attachments[0]
elapsed := time.Since(start)
rate := float64(a.Size) / elapsed.Seconds() / 1024.0
log.Printf("Uploaded to %s %dx%d", a.URL, a.Width, a.Height)
log.Printf("id: %d, %d bytes transferred in %.2f seconds (%.2f KiB/s)", res.ID, a.Size, elapsed.Seconds(), rate)
break
}
}
if !config.Config.NoWatermark {
daulog.SendLog(fmt.Sprintf("Removing temporary file: %s", file), daulog.LogTypeDebug)
os.Remove(file)
}
if retriesRemaining == 0 {
log.Fatal("Failed to upload, even after retries")
}
}
func sleepForRetries(retry int) {
if retry == 0 {
return
}
retryTime := (6-retry)*(6-retry) + 6
daulog.SendLog(fmt.Sprintf("Will retry in %d seconds (%d remaining attempts)", retryTime, retry), daulog.LogTypeError)
time.Sleep(time.Duration(retryTime) * time.Second)
}
func newfileUploadRequest(uri string, params map[string]string, paramName, path string) (*http.Request, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(paramName, filepath.Base(path))
if err != nil {
return nil, err
}
_, err = io.Copy(part, file)
if err != nil {
log.Fatal("Could not copy: ", err)
}
for key, val := range params {
_ = writer.WriteField(key, val)
}
err = writer.Close()
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", uri, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req, err
}
func mungeFile(path string) string {
reader, err := os.Open(path)
if err != nil {
log.Fatal(err)
}
defer reader.Close()
im, _, err := image.Decode(reader)
if err != nil {
log.Fatal(err)
}
bounds := im.Bounds()
// var S float64 = float64(bounds.Max.X)
dc := gg.NewContext(bounds.Max.X, bounds.Max.Y)
dc.Clear()
dc.SetRGB(0, 0, 0)
dc.SetFontFace(inconsolata.Regular8x16)
dc.DrawImage(im, 0, 0)
dc.DrawRoundedRectangle(0, float64(bounds.Max.Y-18.0), 320, float64(bounds.Max.Y), 0)
dc.SetRGB(0, 0, 0)
dc.Fill()
dc.SetRGB(1, 1, 1)
dc.DrawString("github.com/tardisx/discord-auto-upload", 5.0, float64(bounds.Max.Y)-5.0)
tempfile, err := ioutil.TempFile("", "dau")
if err != nil {
log.Fatal(err)
}
tempfile.Close()
os.Remove(tempfile.Name())
actualName := tempfile.Name() + ".png"
dc.SavePNG(actualName)
return actualName
} }

99
dau_test.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
"testing"
"time"
"github.com/tardisx/discord-auto-upload/config"
"github.com/tardisx/discord-auto-upload/upload"
)
func TestWatchNewFiles(t *testing.T) {
dir := createFileTree()
defer os.RemoveAll(dir)
time.Sleep(time.Second)
w := watch{
config: config.Watcher{Path: dir},
uploader: upload.NewUploader(),
lastCheck: time.Now(),
newLastCheck: time.Now(),
}
files := w.ProcessNewFiles()
if len(files) != 0 {
t.Errorf("was not zero files (%d): %v", len(files), files)
}
// create a new file
time.Sleep(time.Second)
os.Create(fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "b.gif"))
files = w.ProcessNewFiles()
if len(files) != 1 {
t.Errorf("was not one file - got: %v", files)
}
if files[0] != fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "b.gif") {
t.Error("wrong file")
}
}
func TestExclsion(t *testing.T) {
dir := createFileTree()
defer os.RemoveAll(dir)
time.Sleep(time.Second)
w := watch{
config: config.Watcher{Path: dir, Exclude: []string{"thumb", "tiny"}},
uploader: upload.NewUploader(),
lastCheck: time.Now(),
newLastCheck: time.Now(),
}
files := w.ProcessNewFiles()
if len(files) != 0 {
t.Errorf("was not zero files (%d): %v", len(files), files)
}
// create a new file that would not hit exclusion, and two that would
time.Sleep(time.Second)
os.Create(fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "b.gif"))
os.Create(fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "b_thumb.gif"))
os.Create(fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "tiny_b.jpg"))
files = w.ProcessNewFiles()
if len(files) != 1 {
t.Error("was not one new file")
}
}
func TestCheckPath(t *testing.T) {
dir := createFileTree()
defer os.RemoveAll(dir)
w := watch{
config: config.Watcher{Path: dir},
uploader: upload.NewUploader(),
lastCheck: time.Now(),
newLastCheck: time.Now(),
}
if !w.checkPath() {
t.Error("checkPath failed?")
}
os.RemoveAll(dir)
if w.checkPath() {
t.Error("checkPath succeeded when shouldn't?")
}
}
func createFileTree() string {
dir, err := ioutil.TempDir("", "dau-test")
if err != nil {
log.Fatal(err)
}
os.Create(fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "a.gif"))
os.Create(fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "a.jpg"))
os.Create(fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "a.png"))
return dir
}

6
go.mod
View File

@@ -1,12 +1,12 @@
module github.com/tardisx/discord-auto-upload module github.com/tardisx/discord-auto-upload
go 1.15 go 1.16
require ( require (
github.com/fogleman/gg v1.3.0 github.com/fogleman/gg v1.3.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-homedir v1.1.0
github.com/pborman/getopt v1.1.0 github.com/pborman/getopt v1.1.0
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 golang.org/x/image v0.0.0-20210504121937-7319ad40d33e
golang.org/x/image v0.0.0-20201208152932-35266b937fa6 golang.org/x/mod v0.4.2
) )

18
go.sum
View File

@@ -6,8 +6,18 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/pborman/getopt v1.1.0 h1:eJ3aFZroQqq0bWmraivjQNt6Dmm5M0h2JcDW38/Azb0= github.com/pborman/getopt v1.1.0 h1:eJ3aFZroQqq0bWmraivjQNt6Dmm5M0h2JcDW38/Azb0=
github.com/pborman/getopt v1.1.0/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk= github.com/pborman/getopt v1.1.0/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI= golang.org/x/image v0.0.0-20210504121937-7319ad40d33e h1:PzJMNfFQx+QO9hrC1GwZ4BoPGeNGhfeQEgcQFArEjPk=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

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

279
upload/upload.go Normal file
View File

@@ -0,0 +1,279 @@
// Package upload encapsulates prepping an image for sending to discord,
// and actually uploading it there.
package upload
import (
"bytes"
"encoding/json"
"fmt"
"image"
"io"
"io/ioutil"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"github.com/fogleman/gg"
"github.com/tardisx/discord-auto-upload/config"
daulog "github.com/tardisx/discord-auto-upload/log"
"golang.org/x/image/font/inconsolata"
)
type Uploader struct {
Uploads []*Upload `json:"uploads"`
}
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
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 NewUploader() *Uploader {
u := Uploader{}
uploads := make([]*Upload, 0)
u.Uploads = uploads
return &u
}
func (u *Uploader) AddFile(file string, conf config.Watcher) {
thisUpload := Upload{
Uploaded: false,
originalFilename: file,
watermark: !conf.NoWatermark,
webhookURL: conf.WebHookURL,
usernameOverride: conf.Username,
}
u.Uploads = append(u.Uploads, &thisUpload)
}
// Upload uploads any files that have not yet been uploaded
func (u *Uploader) Upload() {
for _, upload := range u.Uploads {
if !upload.Uploaded {
upload.processUpload()
}
}
}
func (u *Upload) processUpload() {
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 u.usernameOverride != "" {
daulog.SendLog("Overriding username with "+u.usernameOverride, daulog.LogTypeInfo)
extraParams["username"] = u.usernameOverride
}
type DiscordAPIResponseAttachment struct {
URL string
ProxyURL string
Size int
Width int
Height int
Filename string
}
type DiscordAPIResponse struct {
Attachments []DiscordAPIResponseAttachment
ID int64 `json:",string"`
}
var retriesRemaining = 5
for retriesRemaining > 0 {
request, err := newfileUploadRequest(u.webhookURL, extraParams, "file", u.filenameToUpload)
if err != nil {
log.Fatal(err)
}
start := time.Now()
client := &http.Client{Timeout: time.Second * 30}
resp, err := client.Do(request)
if err != nil {
log.Print("Error performing request:", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
} else {
if resp.StatusCode != 200 {
// {"message": "Request entity too large", "code": 40005}
log.Print("Bad response from server:", resp.StatusCode)
if b, err := ioutil.ReadAll(resp.Body); err == nil {
log.Print("Body:", string(b))
daulog.SendLog(fmt.Sprintf("Bad response: %s", string(b)), daulog.LogTypeError)
}
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
resBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Print("could not deal with body: ", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
resp.Body.Close()
var res DiscordAPIResponse
err = json.Unmarshal(resBody, &res)
// {"id": "851092588608880670", "type": 0, "content": "", "channel_id": "849615269706203171", "author": {"bot": true, "id": "849615314274484224", "username": "abcdedf", "avatar": null, "discriminator": "0000"}, "attachments": [{"id": "851092588332449812", "filename": "dau480457962.png", "size": 859505, "url": "https://cdn.discordapp.com/attachments/849615269706203171/851092588332449812/dau480457962.png", "proxy_url": "https://media.discordapp.net/attachments/849615269706203171/851092588332449812/dau480457962.png", "width": 640, "height": 640, "content_type": "image/png"}], "embeds": [], "mentions": [], "mention_roles": [], "pinned": false, "mention_everyone": false, "tts": false, "timestamp": "2021-06-06T13:38:05.660000+00:00", "edited_timestamp": null, "flags": 0, "components": [], "webhook_id": "849615314274484224"}
daulog.SendLog(fmt.Sprintf("Response: %s", string(resBody[:])), daulog.LogTypeDebug)
if err != nil {
log.Print("could not parse JSON: ", err)
fmt.Println("Response was:", string(resBody[:]))
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
if len(res.Attachments) < 1 {
log.Print("bad response - no attachments?")
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
var a = res.Attachments[0]
elapsed := time.Since(start)
rate := float64(a.Size) / elapsed.Seconds() / 1024.0
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)
u.Url = a.URL
u.Uploaded = true
u.Width = a.Width
u.Height = a.Height
u.UploadedAt = time.Now()
break
}
}
if u.watermark {
daulog.SendLog(fmt.Sprintf("Removing temporary file: %s", u.filenameToUpload), daulog.LogTypeDebug)
os.Remove(u.filenameToUpload)
}
if retriesRemaining == 0 {
daulog.SendLog("Failed to upload, even after all retries", daulog.LogTypeError)
}
}
func newfileUploadRequest(uri string, params map[string]string, paramName, path string) (*http.Request, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(paramName, filepath.Base(path))
if err != nil {
return nil, err
}
_, err = io.Copy(part, file)
if err != nil {
log.Fatal("Could not copy: ", err)
}
for key, val := range params {
_ = writer.WriteField(key, val)
}
err = writer.Close()
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", uri, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req, err
}
func (u *Upload) applyWatermark() {
reader, err := os.Open(u.originalFilename)
if err != nil {
log.Fatal(err)
}
defer reader.Close()
im, _, err := image.Decode(reader)
if err != nil {
daulog.SendLog(fmt.Sprintf("Cannot decode image: %v - skipping watermarking", err), daulog.LogTypeError)
u.watermark = false
u.filenameToUpload = u.originalFilename
return
}
bounds := im.Bounds()
// var S float64 = float64(bounds.Max.X)
dc := gg.NewContext(bounds.Max.X, bounds.Max.Y)
dc.Clear()
dc.SetRGB(0, 0, 0)
dc.SetFontFace(inconsolata.Regular8x16)
dc.DrawImage(im, 0, 0)
dc.DrawRoundedRectangle(0, float64(bounds.Max.Y-18.0), 320, float64(bounds.Max.Y), 0)
dc.SetRGB(0, 0, 0)
dc.Fill()
dc.SetRGB(1, 1, 1)
dc.DrawString("github.com/tardisx/discord-auto-upload", 5.0, float64(bounds.Max.Y)-5.0)
tempfile, err := ioutil.TempFile("", "dau")
if err != nil {
log.Fatal(err)
}
tempfile.Close()
os.Remove(tempfile.Name())
actualName := tempfile.Name() + ".png"
dc.SavePNG(actualName)
u.filenameToUpload = actualName
}
func sleepForRetries(retry int) {
if retry == 0 {
return
}
retryTime := (6-retry)*(6-retry) + 6
daulog.SendLog(fmt.Sprintf("Will retry in %d seconds (%d remaining attempts)", retryTime, retry), daulog.LogTypeError)
time.Sleep(time.Duration(retryTime) * time.Second)
}

29
version/version.go Normal file
View File

@@ -0,0 +1,29 @@
package version
import (
"fmt"
"log"
"golang.org/x/mod/semver"
)
const CurrentVersion string = "v0.11.0"
func NewVersionAvailable(v string) bool {
if !semver.IsValid(CurrentVersion) {
panic(fmt.Sprintf("my current version '%s' is not valid", CurrentVersion))
}
if !semver.IsValid(v) {
// maybe this should just be a warning
log.Printf("passed in version '%s' is not valid - assuming no new version", v)
return false
}
comp := semver.Compare(v, CurrentVersion)
if comp == 0 {
return false
}
if comp == -1 {
return true
}
return false // they are using a newer one than exists?
}

11
version/version_test.go Normal file
View File

@@ -0,0 +1,11 @@
package version
import (
"testing"
)
func TestVersioning(t *testing.T) {
if !NewVersionAvailable("v0.1.0") {
t.Error("should be a version newer than v0.1.0")
}
}

196
web/data/config.html Normal file
View File

@@ -0,0 +1,196 @@
{{ define "content" }}
<main role="main" class="inner DAU" x-data="configuration()" x-init="get_config()">
<h1 class="DAU-heading">Config</h1>
<div x-cloak x-show="error" class="alert alert-danger" role="alert" x-text="error">
</div>
<div x-cloak x-show="success" class="alert alert-success" role="alert" x-text="success">
</div>
<form x-cloak 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>
<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>
<p>Exclusions can be specified, zero or more arbitrary strings. If any
file matches one of those strings then it will not be uploaded. This is most
often used if you use software (like Steam) which automatically creates thumbnails
in the same directory as the screenshots.
</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>
</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>
<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>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Exclusions</span>
</div>
<div class="col-sm-6 my-1">
<template x-for="(exclude, j) in config.Watchers[i].Exclude">
<div class="form-row">
<div class="col">
<input type="text" class="form-control" x-model="config.Watchers[i].Exclude[j]">
</div>
<div class="col">
<button type="button" class="btn btn-danger" href="#" @click.prevent="config.Watchers[i].Exclude.splice(j, 1);">
-
</button>
</div>
</div>
</template>
<button type="button" class="btn btn-secondary" href="#"
@click.prevent="config.Watchers[i].Exclude.push('');">
+</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: 'https://webhook.url.here/', Path: '/directory/path/here', NoWatermark: false, Exclude: []});">
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 configuration() {
return {
config: {}, error: '', success: '',
get_config() {
fetch('/rest/config')
.then(response => response.json()) // convert to json
.then(json => {
this.config = json;
console.log(json);
})
},
save_config() {
this.error = '';
this.success = '';
fetch('/rest/config', { method: 'POST', body: JSON.stringify(this.config) })
.then(response => response.json()) // convert to json
.then(json => {
if (json.error) {
this.error = json.error
} else {
this.success = 'Configuration saved';
this.config = json;
}
window.scrollTo(0,0);
})
}
}
}
</script>
{{ end }}

View File

@@ -6,7 +6,7 @@
a, a,
a:focus, a:focus,
a:hover { a:hover {
color: #fff; color: #f44;
} }
/* Custom default button */ /* Custom default button */
@@ -31,15 +31,15 @@ body {
} }
body { body {
display: -ms-flexbox; /* display: -ms-flexbox;
display: flex; display: flex; */
color: #fff; color: #fff;
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5); text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5); box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
} }
.DAU-container { .DAU-container {
max-width: 42em; max-width: 52em;
} }
pre { pre {
@@ -108,3 +108,6 @@ pre {
.mastfoot { .mastfoot {
color: rgba(255, 255, 255, .5); color: rgba(255, 255, 255, .5);
} }
/* for alpine.js */
[x-cloak] { display: none !important; }

View File

@@ -1,4 +1,4 @@
{{ define "content" }}
<main role="main" class="inner DAU"> <main role="main" class="inner DAU">
<h1 class="DAU-heading">Discord Auto Upload</h1> <h1 class="DAU-heading">Discord Auto Upload</h1>
<p class="lead">Hey look, it's DAU :-)</p> <p class="lead">Hey look, it's DAU :-)</p>
@@ -6,3 +6,7 @@
<a href="https://github.com/tardisx/discord-auto-upload" class="btn btn-lg btn-secondary" target="_blank">Learn more</a> <a href="https://github.com/tardisx/discord-auto-upload" class="btn btn-lg btn-secondary" target="_blank">Learn more</a>
</p> </p>
</main> </main>
{{ end }}
{{ define "js" }}
{{ end }}

61
web/data/logs.html Normal file
View File

@@ -0,0 +1,61 @@
{{ define "content" }}
<main role="main" class="inner DAU">
<h1 class="DAU-heading">Logs</h1>
<p class="lead">Discord-auto-upload logs</p>
<div class="container">
<div class="row">
<div class="col-sm">
<button type="button" onClick="debug=1; get_logs();" class="btn btn-primary">all logs</button>
</div>
<div class="col-sm">
<button type="button" onClick="debug=0; get_logs();" class="btn btn-primary">no debug</button>
</div>
<div class="col-sm">
<button type="button" id="scroll-button" onClick="toggle_scroll();" class="btn btn-primary">disable auto-scroll</button>
</div>
</div>
</div>
<pre id="logs" class="text-left pre-scrollable">
</pre>
</main>
{{ end }}
{{ define "js" }}
<script>
var debug = 0;
var scrl = true;
$(document).ready(function() {
get_logs();
setInterval(function() { get_logs(); }, 1000);
});
function toggle_scroll() {
scrl = !scrl;
if (scrl) {
$('#scroll-button').text('disable auto-scroll');
}
else {
$('#scroll-button').text('auto-scroll');
}
}
function get_logs() {
$.ajax({ method: 'get', url: '/rest/logs', data: { debug : debug }})
.done(function(data) {
$('#logs').text(data);
console.log('scrl is ', scrl);
if (scrl) {
$('#logs').scrollTop(10000);
}
});
}
</script>
{{ end }}

45
web/data/uploads.html Normal file
View File

@@ -0,0 +1,45 @@
{{ define "content" }}
<main role="main" class="inner DAU">
<h1 class="DAU-heading">Uploads</h1>
<p class="lead">Discord-auto-upload uploads</p>
<table class="table table-condensed table-dark">
<thead>
<tr><th>uploaded</th><th>dt</th><th>thumb</th></tr>
</thead>
<tbody id="uploads">
</tbody>
</table>
</main>
{{ end }}
{{ define "js" }}
<script>
$(document).ready(function() {
get_uploads();
});
function get_uploads() {
$.ajax({ method: 'get', url: '/rest/uploads'})
.done(function(data) {
console.log(data);
$('#uploads').empty();
if (! data) { return }
data.forEach(i => {
// {uploaded: true, uploaded_at: "2021-06-08T21:59:52.855936+09:30", url: "https://cdn.discordapp.com/attachments/849615269706203171/851800197046468628/dau736004285.png", width: 640, height: 640}
console.log(i);
row = $('<tr>');
row.append($('<td>').text(i.uploaded ? 'yes' : 'no'));
row.append($('<td>').text(i.uploaded_at));
row.append($('<td>').html($('<img>', { width : i.width/10, height : i.height/10, src : i.url })));
$('#uploads').prepend(row);
});
});
}
</script>
{{ end }}

View File

@@ -1,3 +1,5 @@
{{ define "layout" }}
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -31,7 +33,7 @@
<link href="/dau.css" rel="stylesheet"> <link href="/dau.css" rel="stylesheet">
</head> </head>
<body class="text-center"> <body class="">
<div class="DAU-container d-flex w-100 h-100 p-3 mx-auto flex-column"> <div class="DAU-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto"> <header class="masthead mb-auto">
@@ -40,12 +42,14 @@
<nav class="nav nav-masthead justify-content-center"> <nav class="nav nav-masthead justify-content-center">
<a class="nav-link {{ if eq .Path "index.html"}} active {{ end }}" href="/">Home</a> <a class="nav-link {{ if eq .Path "index.html"}} active {{ end }}" href="/">Home</a>
<a class="nav-link {{ if eq .Path "config.html"}} active {{ end }}" href="/config.html">Config</a> <a class="nav-link {{ if eq .Path "config.html"}} active {{ end }}" href="/config.html">Config</a>
<a class="nav-link {{ if eq .Path "uploads.html"}} active {{ end }}" href="/uploads.html">Uploads</a>
<a class="nav-link {{ if eq .Path "logs.html"}} active {{ end }}" href="/logs.html">Logs</a> <a class="nav-link {{ if eq .Path "logs.html"}} active {{ end }}" href="/logs.html">Logs</a>
</nav> </nav>
</div> </div>
</header> </header>
{{.Body}} {{ template "content" . }}
<footer class="mastfoot mt-auto"> <footer class="mastfoot mt-auto">
<div class="inner"> <div class="inner">
@@ -56,4 +60,7 @@
</body> </body>
{{ template "js" . }}
</html> </html>
{{ end }}

View File

@@ -1,281 +1,102 @@
package web package web
import ( import (
"embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template"
"io/ioutil"
"log" "log"
"mime" "mime"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"regexp" "strings"
"strconv"
"text/template"
"github.com/tardisx/discord-auto-upload/assets"
"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/upload"
"github.com/tardisx/discord-auto-upload/version"
) )
type WebService struct {
Config *config.ConfigService
Uploader *upload.Uploader
}
//go:embed data
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"` path := r.URL.Path
path = strings.TrimLeft(path, "/")
if path == "" {
path = "index.html"
} }
type valueBooleanResponse struct { extension := filepath.Ext(string(path))
Success bool `json:"success"`
Value bool `json:"value"`
}
type errorResponse struct { if extension == ".html" { // html file
Success bool `json:"success"` t, err := template.ParseFS(webFS, "data/wrapper.tmpl", "data/"+path)
Error string `json:"error"`
}
func getStatic(w http.ResponseWriter, r *http.Request) {
// haha this is dumb and I should change it
re := regexp.MustCompile(`[^a-zA-Z0-9\.]`)
path := r.URL.Path[1:]
sanitized_path := re.ReplaceAll([]byte(path), []byte("_"))
if string(sanitized_path) == "" {
sanitized_path = []byte("index.html")
}
data, err := assets.Asset(string(sanitized_path))
if err != nil { if err != nil {
// Asset was not found. daulog.SendLog(fmt.Sprintf("when fetching: %s got: %s", path, err), daulog.LogTypeError)
fmt.Fprintln(w, err) w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return
} }
extension := filepath.Ext(string(sanitized_path))
// is this a HTML file? if so wrap it in the template
if extension == ".html" {
wrapper, _ := assets.Asset("wrapper.tmpl")
t := template.Must(template.New("wrapper").Parse(string(wrapper)))
var b struct { var b struct {
Body string Body string
Path string Path string
Version string Version string
} }
b.Body = string(data) b.Path = path
b.Path = string(sanitized_path) b.Version = version.CurrentVersion
b.Version = config.CurrentVersion
t.Execute(w, b) err = t.ExecuteTemplate(w, "layout", b)
if err != nil {
panic(err)
}
return
} else { // anything else
otherStatic, err := webFS.ReadFile("data/" + path)
if err != nil {
daulog.SendLog(fmt.Sprintf("when fetching: %s got: %s", path, err), daulog.LogTypeError)
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return return
} }
// otherwise we are a static thing
w.Header().Set("Content-Type", mime.TypeByExtension(extension)) w.Header().Set("Content-Type", mime.TypeByExtension(extension))
w.Write(data) w.Write(otherStatic)
//
}
// 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 return
} }
if i < 1 {
response := errorResponse{Success: false, Error: "must be > 0"}
js, _ := json.Marshal(response)
w.Write(js)
return
} }
config.Config.Watch = i func (ws *WebService) getLogs(w http.ResponseWriter, r *http.Request) {
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
debug, present := r.URL.Query()["debug"]
if present && len(debug[0]) > 0 && debug[0] != "0" {
showDebug = true
}
text := "" text := ""
for _, log := range daulog.LogEntries { for _, log := range daulog.LogEntries {
if !showDebug && log.Type == daulog.LogTypeDebug {
continue
}
text = text + fmt.Sprintf( text = text + fmt.Sprintf(
"%-6s %-19s %s\n", log.Type, log.Timestamp.Format("2006-01-02 15:04:05"), log.Entry, "%-6s %-19s %s\n", log.Type, log.Timestamp.Format("2006-01-02 15:04:05"), log.Entry,
) )
@@ -283,23 +104,70 @@ func getLogs(w http.ResponseWriter, r *http.Request) {
// js, _ := json.Marshal(daulog.LogEntries) // js, _ := json.Marshal(daulog.LogEntries)
w.Write([]byte(text)) w.Write([]byte(text))
}
func (ws *WebService) handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
type ErrorResponse struct {
Error string `json:"error"`
}
newConfig := config.ConfigV2{}
defer r.Body.Close()
b, err := ioutil.ReadAll(r.Body)
if err != nil {
w.WriteHeader(400)
w.Write([]byte("bad body"))
return
}
err = json.Unmarshal(b, &newConfig)
if err != nil {
w.WriteHeader(400)
j, _ := json.Marshal(ErrorResponse{Error: "badly formed JSON"})
w.Write(j)
return
}
ws.Config.Config = &newConfig
err = ws.Config.Save()
if err != nil {
w.WriteHeader(400)
j, _ := json.Marshal(ErrorResponse{Error: err.Error()})
w.Write(j)
return
}
// config has changed, so tell the world
if ws.Config.Changed != nil {
ws.Config.Changed <- true
}
} }
func StartWebServer() { b, _ := json.Marshal(ws.Config.Config)
w.Write(b)
}
http.HandleFunc("/", getStatic) func (ws *WebService) 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 := ws.Uploader.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)
func (ws *WebService) StartWebServer() {
http.HandleFunc("/", ws.getStatic)
http.HandleFunc("/rest/logs", ws.getLogs)
http.HandleFunc("/rest/uploads", ws.getUploads)
http.HandleFunc("/rest/config", ws.handleConfig)
http.HandleFunc("/rest/logs", getLogs)
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.Printf("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)
} }

84
web/server_test.go Normal file
View File

@@ -0,0 +1,84 @@
package web
import (
"io/ioutil"
"net/http"
"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()
s.getStatic(w, req)
res := w.Result()
data, err := ioutil.ReadAll(res.Body)
if err != nil {
t.Errorf("expected error to be nil got %v", err)
}
if !strings.Contains(string(data), "DAU") {
t.Errorf("does not look like correct homepage at /")
}
if res.Header.Get("Content-Type") != "text/html; charset=utf-8" {
t.Errorf("wrong content type for / - %s", res.Header.Get("Content-Type"))
}
}
func TestNotFound(t *testing.T) {
s := WebService{}
notFounds := []string{
"/abc.html", "/foo.html", "/foo.html", "/../foo.html",
"/foo.gif",
}
for _, nf := range notFounds {
req := httptest.NewRequest(http.MethodGet, nf, nil)
w := httptest.NewRecorder()
s.getStatic(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) != "not found" {
t.Errorf("expected body to be not found, not '%s'", string(b))
}
if res.Header.Get("Content-Type") != "text/plain" {
t.Error("Wrong content type for not found")
}
}
}
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,"Port":9090,"Watchers":[{"WebHookURL":"https://webhook.url.here","Path":"/your/screenshot/dir/here","Username":"","NoWatermark":false,"Exclude":[]}]}` {
t.Errorf("Got unexpected response %v", string(b))
}
}