39 Commits
0.4 ... 0.7

Author SHA1 Message Date
23c0aa2a34 Add Exclude to the config as well. 2021-02-09 22:07:40 +10:30
2d1ac3c803 Clean up logging and improve web interface 2021-02-07 22:06:19 +10:30
942f81a378 Bump version 2021-02-07 22:05:49 +10:30
287efab257 We no longer create a .bat file. 2021-02-07 22:05:16 +10:30
2b159e5532 Update README for the new version 2021-02-07 22:05:01 +10:30
851f073e99 Read/Write config to $HOME 2021-02-07 11:42:13 +10:30
ec658520b7 go fmt 2021-02-07 08:10:27 +10:30
6b1867f35f Support noWatermark config 2021-02-07 08:10:06 +10:30
6e493522c8 Add username and watch period web configuration 2021-02-06 12:50:44 +10:30
4d09901fb3 Fix up the javascript for loading config data. 2021-02-03 21:43:23 +10:30
9c9d4e492a Wrapper for HTML pages and start of the config web interface 2021-02-02 22:09:18 +10:30
e1f5afa788 go fmt 2021-01-31 18:48:48 +10:30
46a0f5a187 Rework configuration to it's own package and make it available to web server. Start a template driven web interface. 2021-01-31 17:53:32 +10:30
55bb5a8bae Fix build instructions 2021-01-31 09:56:09 +10:30
c2b9bf410d Bring into 2021 2021-01-30 21:23:58 +10:30
a4f958f846 Continue web server integration 2020-03-26 11:40:35 +10:30
14f8fe1933 Fix go generate call 2020-03-20 05:51:25 +10:30
752ff42a19 Add asset generation. 2020-03-20 05:49:12 +10:30
1ef062d19c Generate assets when releasing 2017-07-27 22:11:15 +09:30
6f09841209 Ignore generated asset directory 2017-07-27 22:11:03 +09:30
4619bb5383 Automatically open web browser on startup 2017-07-27 22:04:47 +09:30
e240f5dbd0 Embedded assets 2017-07-27 22:04:32 +09:30
Justin Hawkins
f976777f40 Start fleshing out web server 2017-07-27 12:18:02 +09:30
80a905b7d6 Improve update check error and make it non-fatal 2017-07-26 22:44:56 +09:30
497d2e3e27 Basic web server startup 2017-07-26 22:40:21 +09:30
ab54ace0d2 Add empty web class for the upcoming build-in web server 2017-07-26 14:24:02 +09:30
450765145b Add note about existing file handling 2017-07-26 13:24:07 +09:30
3970c611a4 Add --exclude flag (to avoid uploading thumbnails) 2017-02-28 22:50:03 +10:30
d8dc3e4ea8 Fix stupid text 2017-02-28 22:21:22 +10:30
3693d94297 Fix for new version variable name 2017-02-28 22:14:41 +10:30
8ded2b2e2d Document the --no-watermark feature 2017-02-28 22:10:53 +10:30
e3e712d073 Add retries, with backoff 2017-02-28 22:07:57 +10:30
1ecac568f7 Mark watermarked images be uploaded, and make it an optional option. 2017-02-28 21:32:18 +10:30
82ba3be742 Hacky image watermarking (not yet complete) 2017-02-26 21:06:48 +10:30
Justin Hawkins
4825dc56e6 Refactor away some globals. This is probably still not very idiomatic. 2017-02-23 12:55:10 +10:30
65b9241492 Refactor according to lint 2017-02-22 21:13:07 +10:30
Justin Hawkins
73b33f5872 Improve doc 2017-02-22 16:48:29 +10:30
Justin Hawkins
cc0fee57c2 Sub directories are scanned 2017-02-22 16:47:50 +10:30
Justin Hawkins
05a3a0d09a Update README 2017-02-22 16:46:48 +10:30
12 changed files with 1061 additions and 226 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,4 +1,4 @@
# Automatically upload screenshots from your computer into a discord channel # Automatically upload screenshots into a discord channel
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:
@@ -16,51 +16,72 @@ Point it at your Steam screenshot folder, or similar, and shortly after you hit
### Binaries ### Binaries
TBD Binaries are available for Mac, Linux and Windows [here](https://github.com/tardisx/discord-auto-upload/releases/latest).
#### From source #### From source
TBD 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
`dau` is a command line driven program. When executed, it will continually scan a directory for new images, and each time it finds one it will upload it to discord, via the discord web hook. `dau` configuration is managed via its internal web interface. When the executable is run, you can visit
`http://localhost:9090` in your web browser to configure the it. Configuration persists across runs, it is
saved in a file called '.dau.json' in your home directory.
The first time you run it, you will need to configure at least the discord web hook and the watch path for
`dau` to be useful.
While running, `dau` will continually scan a directory for new images, and each time it finds one it will upload it to discord, via the discord web hook.
`dau` will only upload "new" screenshots, where "new" means a file that appears in a directory that it is watching, if it appears *after* it has started executing. `dau` will only upload "new" screenshots, where "new" means a file that appears in a directory that it is watching, if it appears *after* it has started executing.
Thus, you do not have to worry about pointing `dau` at a directory full of images, it will only upload new ones. Thus, you do not have to worry about pointing `dau` at a directory full of images, it will only upload new ones.
If `dau` is on your path, you can run it from your screenshot folder and there is then no need to specify the path to your images. ## Configuration options
Note that currently `dau` does not look in subdirectories. Please submit an issue if this is a use case for you. See the web interface at http://localhost:9090 to configure `dau`.
The only two mandatory command line parameters are the discord webhook URL: ### 'Discord WebHook URL'
`--webhook URL` - the webhook URL (see [here](https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks) for details). The webhook URL from Discord. See https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks
for more information on setting one up.
and the directory to watch: ### 'Bot Username'
`--directory /some/path/here` - the directory that screenshots will appear in. This is completely optional and can be any arbitrary string. It makes the upload
appear to come from a different user (though this is visual only, and does not
actually hide the bot identity in any way). You might like to set it to your own
discord name.
You will have to quote the path on windows, or anywhere where the directory path contains spaces. Note that ### 'Directory to watch'
subdirectories will also be scanned.
Other parameters are: This is the path that `dau` will periodically inspect, looking for new images.
Note that subdirectories are also scanned. You need to enter the full filesystem
path here.
`--watch xx` - specify how many seconds to wait between scanning the directory. The default is 10 seconds. ### 'Period between filesystem checks'
`--username <username>` - an arbitrary string to show as the bot's username in the channel. This is the number of seconds between which `dau` will look for new images.
`--help` - show command line help. ### 'Do not watermark images'
`--version` - show the version. This will disable the watermarking of images. I like it when you don't set this :-)
### 'Files to exclude'
This is a string to match against the filename to check for exclusions. The common
use case is to use 'thumbnail' or similar if your image directory contains additional
thumbnail files.
## Limitations/bugs ## Limitations/bugs
* Only files ending jpg, gif or png are uploaded. * Only files ending jpg, gif or png are uploaded.
* If multiple screenshots occur quickly (<1 second apart) not all may be uploaded. * If multiple screenshots occur quickly (<1 second apart) not all may be uploaded.
* Files to upload are determined by the file modification time. If you drag and drop existing files they will
not be detected and uploaded. Only newly created files will be detected.
## TODO ## TODO
This is just a quick hack. Open to suggestions on new features and improvements. 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.
Please include any relevant logs from the console when reporting bugs.

View File

@@ -3,11 +3,11 @@
use strict; use strict;
use warnings; use warnings;
open my $fh, "<", "dau.go" || die $!; open my $fh, "<", "config/config.go" || die $!;
my $version; my $version;
while (<$fh>) { while (<$fh>) {
$version = $1 if /^const\s+current_version.*?"([\d\.]+)"/; $version = $1 if /^const\s+CurrentVersion.*?"([\d\.]+)"/;
} }
close $fh; close $fh;
@@ -19,9 +19,9 @@ system "mkdir", "release";
system "mkdir", "dist"; system "mkdir", "dist";
my %build = ( my %build = (
win => { env => { GOOS => 'windows', GOARCH => '386' }, filename => 'dau.exe' }, win => { env => { GOOS => 'windows', GOARCH => 'amd64' }, filename => 'dau.exe' },
linux => { env => { GOOS => 'linux', GOARCH => '386' }, filename => 'dau' }, linux => { env => { GOOS => 'linux', GOARCH => 'amd64' }, filename => 'dau' },
mac => { env => { GOOS => 'darwin', GOARCH => '386' }, filename => 'dau' }, mac => { env => { GOOS => 'darwin', GOARCH => 'amd64' }, filename => 'dau' },
); );
foreach my $type (keys %build) { foreach my $type (keys %build) {
@@ -30,6 +30,7 @@ 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};
@@ -38,15 +39,5 @@ foreach my $type (keys %build) {
} }
sub add_extras { sub add_extras {
# bat file for windows # we used to have a .bat file here, but no longer needed
open (my $fh, ">", "release/win/dau.bat") || die $!;
print $fh 'set WEBHOOK_URL=https://yourdiscordwebhookURLhere' . "\r\n";
print $fh 'set SCREENSHOTS="C:\your\screenshot\directory\here"' ."\r\n";
print $fh 'set USERNAME="Posted by Joe Bloggs"' . "\r\n";
print $fh 'set WATCH=10' . "\r\n";
print $fh 'dau.exe --webhook %WEBHOOK_URL% --directory %SCREENSHOTS% --username %USERNAME% --watch %WATCH%' . "\r\n";
print $fh 'pause' . "\r\n";
close $fh;
} }

72
config/config.go Normal file
View File

@@ -0,0 +1,72 @@
package config
import (
"github.com/mitchellh/go-homedir"
"log"
"os"
"encoding/json"
"io/ioutil"
)
// Config for the application
var Config struct {
WebHookURL string
Path string
Watch int
Username string
NoWatermark bool
Exclude string
}
const CurrentVersion string = "0.7"
// Load the current config or initialise with defaults
func LoadOrInit() {
configPath := configPath()
log.Printf("Trying to load from %s\n", configPath)
_, err := os.Stat(configPath)
if os.IsNotExist(err) {
log.Printf("NOTE: No config file, writing out sample configuration")
log.Printf("You need to set the configuration via the web interface")
Config.WebHookURL = ""
Config.Path = homeDir() + string(os.PathSeparator) + "screenshots"
Config.Watch = 10
SaveConfig()
} else {
LoadConfig()
}
}
func LoadConfig() {
path := configPath()
data, err := ioutil.ReadFile(path)
if err != nil {
log.Fatalf("cannot read config file %s: %s", path, err.Error())
}
err = json.Unmarshal([]byte(data), &Config)
if err != nil {
log.Fatalf("cannot decode config file %s: %s", path, err.Error())
}
}
func SaveConfig() {
log.Print("saving configuration")
path := configPath()
jsonString, _ := json.Marshal(Config)
err := ioutil.WriteFile(path, jsonString, os.ModePerm)
if (err != nil) {
log.Fatalf("Cannot save config %s: %s", path, err.Error())
}
}
func homeDir() string {
dir, err := homedir.Dir()
if (err != nil) { panic (err) }
return dir;
}
func configPath() string {
homeDir := homeDir()
return homeDir + string(os.PathSeparator) + ".dau.json"
}

153
data/config.html Normal file
View File

@@ -0,0 +1,153 @@
<main role="main" class="inner DAU">
<h1 class="DAU-heading">Config</h1>
<p class="lead">Discord-auto-upload configuration</p>
<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>

106
data/dau.css Normal file
View File

@@ -0,0 +1,106 @@
/*
* Globals
*/
/* Links */
a,
a:focus,
a:hover {
color: #fff;
}
/* Custom default button */
.btn-secondary,
.btn-secondary:hover,
.btn-secondary:focus {
color: #333;
text-shadow: none; /* Prevent inheritance from `body` */
background-color: #fff;
border: .05rem solid #fff;
}
/*
* Base structure
*/
html,
body {
height: 100%;
background-color: #333;
}
body {
display: -ms-flexbox;
display: flex;
color: #fff;
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
}
.DAU-container {
max-width: 42em;
}
/*
* Header
*/
.masthead {
margin-bottom: 2rem;
}
.masthead-brand {
margin-bottom: 0;
}
.nav-masthead .nav-link {
padding: .25rem 0;
font-weight: 700;
color: rgba(255, 255, 255, .5);
background-color: transparent;
border-bottom: .25rem solid transparent;
}
.nav-masthead .nav-link:hover,
.nav-masthead .nav-link:focus {
border-bottom-color: rgba(255, 255, 255, .25);
}
.nav-masthead .nav-link + .nav-link {
margin-left: 1rem;
}
.nav-masthead .active {
color: #fff;
border-bottom-color: #fff;
}
@media (min-width: 48em) {
.masthead-brand {
float: left;
}
.nav-masthead {
float: right;
}
}
/*
* Cover
*/
.cover {
padding: 0 1.5rem;
}
.cover .btn-lg {
padding: .75rem 1.25rem;
font-weight: 700;
}
/*
* Footer
*/
.mastfoot {
color: rgba(255, 255, 255, .5);
}

8
data/index.html Normal file
View File

@@ -0,0 +1,8 @@
<main role="main" class="inner DAU">
<h1 class="DAU-heading">Discord Auto Upload</h1>
<p class="lead">Hey look, it's DAU :-)</p>
<p class="lead">
<a href="https://github.com/tardisx/discord-auto-upload" class="btn btn-lg btn-secondary">Learn more</a>
</p>
</main>

58
data/wrapper.tmpl Normal file
View File

@@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" integrity="sha384-B0vP5xmATw1+K9KRQjQERJvTumQW0nPEzvF6L/Z6nronJ3oUOFUFpCjEUQouq2+l" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-Piv4xVNRyMGpqkS2by6br4gNJ7DXjqk09RmUpJ8jgGtD7zP9yug3goQfGII0yAns" crossorigin="anonymous"></script>
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</style>
<!-- Custom styles for this template -->
<link href="/dau.css" rel="stylesheet">
</head>
<body class="text-center">
<div class="DAU-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="masthead mb-auto">
<div class="inner">
<h3 class="masthead-brand">discord-auto-upload ({{.Version}})</h3>
<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 "config.html"}} active {{ end }}" href="/config.html">Config</a>
</nav>
</div>
</header>
{{.Body}}
<footer class="mastfoot mt-auto">
<div class="inner">
<!-- <p>DAU template for <a href="https://getbootstrap.com/">Bootstrap</a>, by <a href="https://twitter.com/mdo">@mdo</a>.</p> -->
</div>
</footer>
</div>
</body>
</html>

294
dau.go
View File

@@ -1,168 +1,195 @@
package main package main
//go:generate go-bindata -pkg assets -o assets/static.go -prefix data/ data
import ( import (
"fmt"
"strings"
"github.com/pborman/getopt"
"path/filepath"
"os"
"time"
"net/http"
"log"
"io"
"bytes" "bytes"
"mime/multipart"
"encoding/json" "encoding/json"
"fmt"
"io"
"io/ioutil" "io/ioutil"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"github.com/fogleman/gg"
"github.com/pborman/getopt"
// "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/web"
) )
const current_version = "0.4" var lastCheck = time.Now()
var newLastCheck = time.Now()
var last_check = time.Now()
var new_last_check = time.Now()
var webhook_url string
var username string
func main() { func main() {
webhook_opt, path, watch, username_opt := parse_options()
webhook_url = webhook_opt
username = username_opt
check_path(path) parseOptions()
check_updates()
log.Print("Waiting for images to appear in ", path) // log.Print("Opening web browser")
// open.Start("http://localhost:9090")
go web.StartWebServer()
checkUpdates()
log.Print("Waiting for images to appear in ", config.Config.Path)
// wander the path, forever // wander the path, forever
for { for {
err := filepath.Walk(path, check_file) if checkPath(config.Config.Path) {
if err != nil { log.Fatal("could not watch path", err) } err := filepath.Walk(config.Config.Path,
last_check = new_last_check func(path string, f os.FileInfo, err error) error { return checkFile(path, f, err) })
time.Sleep(time.Duration(watch)*time.Second) if err != nil {
log.Fatal("could not watch path", err)
}
lastCheck = newLastCheck
}
log.Printf("sleeping for %ds before next check of %s", config.Config.Watch, config.Config.Path)
time.Sleep(time.Duration(config.Config.Watch) * time.Second)
} }
} }
func check_path(path string) { func checkPath(path string) bool {
src, err := os.Stat(path) src, err := os.Stat(path)
if err != nil { if err != nil {
log.Fatal(err) log.Printf("Problem with path '%s': %s", path, err)
return false
} }
if !src.IsDir() { if !src.IsDir() {
log.Fatal(path, " is not a directory") log.Printf("Problem with path '%s': is not a directory", path)
os.Exit(1) return false
} }
return true
} }
func check_updates() { func checkUpdates() {
type GithubRelease struct { type GithubRelease struct {
Html_url string HTMLURL string
Tag_name string TagName string
Name string Name string
Body string Body string
} }
client := &http.Client{ Timeout: time.Second * 5 } client := &http.Client{Timeout: time.Second * 5}
resp, err := client.Get("https://api.github.com/repos/tardisx/discord-auto-upload/releases/latest") resp, err := client.Get("https://api.github.com/repos/tardisx/discord-auto-upload/releases/latest")
if (err != nil) { if err != nil {
log.Fatal("could not check for updates:", err) log.Print("WARNING: Update check failed: ", err)
return
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body) body, err := ioutil.ReadAll(resp.Body)
if (err != nil) { if err != nil {
log.Fatal("could not check read update response") log.Fatal("could not check read update response")
} }
var latest GithubRelease var latest GithubRelease
err = json.Unmarshal(body, &latest) err = json.Unmarshal(body, &latest)
if (err != nil) { if err != nil {
log.Fatal("could not parse JSON: ", err) log.Fatal("could not parse JSON: ", err)
} }
if (current_version < latest.Tag_name) { if config.CurrentVersion < latest.TagName {
fmt.Printf("You are currently on version %s, but version %s is available\n", current_version, latest.Tag_name) fmt.Printf("You are currently on version %s, but version %s is available\n", config.CurrentVersion, latest.TagName)
fmt.Println("----------- Release Info -----------") fmt.Println("----------- Release Info -----------")
fmt.Println(latest.Body) fmt.Println(latest.Body)
fmt.Println("------------------------------------") fmt.Println("------------------------------------")
fmt.Println("Upgrade at https://github.com/tardisx/discord-auto-upload/releases/latest")
} }
} }
func parse_options() (webhook_url string, path string, watch int, username string) { func parseOptions() {
// Declare the flags to be used // Declare the flags to be used
webhookFlag := getopt.StringLong("webhook", 'w', "", "discord webhook URL") helpFlag := getopt.BoolLong("help", 'h', "help")
pathFlag := getopt.StringLong("directory", 'd', "", "directory to scan, optional, defaults to current directory") versionFlag := getopt.BoolLong("version", 'v', "show version")
watchFlag := getopt.Int16Long ("watch", 's', 10, "time between scans")
usernameFlag := getopt.StringLong("username", 'u', "", "username for the bot upload")
helpFlag := getopt.BoolLong ("help", 'h', "help")
versionFlag := getopt.BoolLong ("version", 'v', "show version")
getopt.SetParameters("") getopt.SetParameters("")
getopt.Parse() getopt.Parse()
if (*helpFlag) { if *helpFlag {
getopt.PrintUsage(os.Stderr) getopt.PrintUsage(os.Stderr)
os.Exit(0) os.Exit(0)
} }
if (*versionFlag) { 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", current_version) fmt.Printf("Version: %s\n", config.CurrentVersion)
os.Exit(0) os.Exit(0)
} }
if ! getopt.IsSet("directory") { // grab the config
*pathFlag = "./" config.LoadOrInit()
log.Println("Defaulting to current directory")
}
if ! getopt.IsSet("webhook") {
log.Fatal("ERROR: You must specify a --webhook URL")
}
return *webhookFlag, *pathFlag, int(*watchFlag), *usernameFlag
} }
func check_file(path string, f os.FileInfo, err error) error { func checkFile(path string, f os.FileInfo, err error) error {
if f.ModTime().After(last_check) && f.Mode().IsRegular() { if f.ModTime().After(lastCheck) && f.Mode().IsRegular() {
if file_eligible(path) { if fileEligible(path) {
// process file // process file
process_file(path) processFile(path)
} }
if new_last_check.Before(f.ModTime()) { if newLastCheck.Before(f.ModTime()) {
new_last_check = f.ModTime() newLastCheck = f.ModTime()
} }
} }
return nil return nil
} }
func file_eligible(file string) (bool) { func fileEligible(file string) bool {
if config.Config.Exclude != "" && strings.Contains(file, config.Config.Exclude) {
return false
}
extension := strings.ToLower(filepath.Ext(file)) extension := strings.ToLower(filepath.Ext(file))
if extension == ".png" || extension == ".jpg" || extension == ".gif" { if extension == ".png" || extension == ".jpg" || extension == ".gif" {
return true return true
} }
return false return false
} }
func process_file(file string) { func processFile(file string) {
if !config.Config.NoWatermark {
log.Print("Copying to temp location and watermarking ", file)
file = mungeFile(file)
}
if config.Config.WebHookURL == "" {
log.Print("WebHookURL is not configured - cannot upload!")
return
}
log.Print("Uploading ", file) log.Print("Uploading ", file)
extraParams := map[string]string{ } extraParams := map[string]string{}
if (username != "") { if config.Config.Username != "" {
extraParams["username"] = username log.Print("Overriding username with " + config.Config.Username)
extraParams["username"] = config.Config.Username
} }
type DiscordAPIResponseAttachment struct { type DiscordAPIResponseAttachment struct {
Url string URL string
Proxy_url string ProxyURL string
Size int Size int
Width int Width int
Height int Height int
@@ -171,53 +198,88 @@ func process_file(file string) {
type DiscordAPIResponse struct { type DiscordAPIResponse struct {
Attachments []DiscordAPIResponseAttachment Attachments []DiscordAPIResponseAttachment
Id int64 `json:",string"` ID int64 `json:",string"`
} }
request, err := newfileUploadRequest(webhook_url, extraParams, "file", file) var retriesRemaining = 5
for retriesRemaining > 0 {
request, err := newfileUploadRequest(config.Config.WebHookURL, extraParams, "file", file)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
start := time.Now() start := time.Now()
client := &http.Client{ Timeout: time.Second * 30 } client := &http.Client{Timeout: time.Second * 30}
resp, err := client.Do(request) resp, err := client.Do(request)
if err != nil { if err != nil {
log.Print("Error performing request:", err)
log.Fatal("Error performing request:", err) retriesRemaining--
sleepForRetries(retriesRemaining)
continue
} else { } else {
if (resp.StatusCode != 200) { if resp.StatusCode != 200 {
log.Print("Bad response from server:", resp.StatusCode) log.Print("Bad response from server:", resp.StatusCode)
return if b, err := ioutil.ReadAll(resp.Body); err == nil {
log.Print("Body:", string(b))
}
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
} }
res_body, err := ioutil.ReadAll(resp.Body) resBody, err := ioutil.ReadAll(resp.Body)
if (err != nil) { if err != nil {
log.Fatal("could not deal with body", err) log.Print("could not deal with body: ", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
} }
resp.Body.Close() resp.Body.Close()
var res DiscordAPIResponse var res DiscordAPIResponse
err = json.Unmarshal(res_body, &res) err = json.Unmarshal(resBody, &res)
if (err != nil) { if err != nil {
log.Print("could not parse JSON: ", err) log.Print("could not parse JSON: ", err)
fmt.Println("Response was:", string(res_body[:])) fmt.Println("Response was:", string(resBody[:]))
return retriesRemaining--
sleepForRetries(retriesRemaining)
continue
} }
if (len(res.Attachments) < 1) { if len(res.Attachments) < 1 {
log.Print("bad response - no attachments?") log.Print("bad response - no attachments?")
return retriesRemaining--
sleepForRetries(retriesRemaining)
continue
} }
var a = res.Attachments[0] var a = res.Attachments[0]
elapsed := time.Since(start) elapsed := time.Since(start)
rate := float64(a.Size) / elapsed.Seconds() / 1024.0 rate := float64(a.Size) / elapsed.Seconds() / 1024.0
log.Printf("Uploaded to %s %dx%d", a.Url, a.Width, a.Height) 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) 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 {
log.Print("Removing temporary file ", file)
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
log.Printf("Will retry in %d seconds (%d remaining attempts)", retryTime, retry)
time.Sleep(time.Duration(retryTime) * time.Second)
} }
func newfileUploadRequest(uri string, params map[string]string, paramName, path string) (*http.Request, error) { func newfileUploadRequest(uri string, params map[string]string, paramName, path string) (*http.Request, error) {
@@ -234,6 +296,9 @@ func newfileUploadRequest(uri string, params map[string]string, paramName, path
return nil, err return nil, err
} }
_, err = io.Copy(part, file) _, err = io.Copy(part, file)
if err != nil {
log.Fatal("Could not copy: ", err)
}
for key, val := range params { for key, val := range params {
_ = writer.WriteField(key, val) _ = writer.WriteField(key, val)
@@ -247,3 +312,46 @@ func newfileUploadRequest(uri string, params map[string]string, paramName, path
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
return req, err 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
}

12
go.mod Normal file
View File

@@ -0,0 +1,12 @@
module github.com/tardisx/discord-auto-upload
go 1.15
require (
github.com/fogleman/gg v1.3.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/mitchellh/go-homedir 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-20201208152932-35266b937fa6
)

13
go.sum Normal file
View File

@@ -0,0 +1,13 @@
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
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/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

290
web/server.go Normal file
View File

@@ -0,0 +1,290 @@
package web
import (
"encoding/json"
"fmt"
"github.com/tardisx/discord-auto-upload/assets"
"github.com/tardisx/discord-auto-upload/config"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"text/template"
)
// DAUWebServer - stuff for the web server
type DAUWebServer struct {
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) {
// haha this is dumb and I should change it
// fmt.Println(r.URL)
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 {
// Asset was not found.
fmt.Fprintln(w, err)
}
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 {
Body string
Path string
Version string
}
b.Body = string(data)
b.Path = string(sanitized_path)
b.Version = config.CurrentVersion
t.Execute(w, b)
return
}
// otherwise we are a static thing
w.Header().Set("Content-Type", mime.TypeByExtension(extension))
w.Write(data)
//
}
// 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 StartWebServer() {
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)
log.Print("Starting web server on http://localhost:9090")
err := http.ListenAndServe(":9090", nil) // set listen port
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}