Compare commits

..

No commits in common. "master" and "0.9" have entirely different histories.
master ... 0.9

42 changed files with 858 additions and 2956 deletions

1
.github/FUNDING.yml vendored
View File

@ -1 +0,0 @@
github: tardisx

View File

@ -1,25 +0,0 @@
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 ./...

3
.gitignore vendored
View File

@ -2,7 +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
versioninfo.json

View File

@ -1,45 +0,0 @@
before:
hooks:
# clean up/install modules
- go mod tidy
- "go run tools/windows_metadata/release.go v{{- .Version }}"
builds:
- main:
hooks:
pre:
- "rm -f resource.syso"
- "go generate"
ignore:
- goos: windows
goarch: arm64
ldflags:
- '{{ if eq .Os "windows" }}-H=windowsgui{{ end }}'
env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
archives:
- replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: i386
amd64: x86_64
format_overrides:
- goos: windows
format: zip
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- '^Merge:'
- '^docs:'
- '^test:'
- '^[Bb]ump'
- '^[Cc]lean'

10
.vscode/settings.json vendored
View File

@ -1,10 +0,0 @@
{
"cSpell.words": [
"daulog",
"Debugf",
"inconsolata",
"Infof",
"Markedup",
"skratchdot"
]
}

View File

@ -1,109 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [v0.13.0] - 2022-11-01
- Resize images if needed to fit in discord 8Mb upload limits
## [v0.12.4] - 2022-09-15
- Document that watcher intervals are in seconds
## [v0.12.3] - 2022-05-09
- Fix a race condition occasionally causing multiple duplicate uploads
## [v0.12.2] - 2022-05-01
- Automatically open your web browser to the `dau` web interface
(can be disabled in configuration)
- Add system tray/menubar icon with menus to open web interface, quit and
other links
- Superfluous text console removed on windows
## [v0.12.1] - 2022-05-01
- Show if a new version is available in the web interface
- Rework logging and fix the log display in the web interface
## [v0.12.0] - 2022-04-03
- Break upload page into pending/current/complete sections
- Add preview thumbnails for each upload
- Add feature to hold an image for upload, so the user can
choose to upload it or not
- Add simple image editor to add text captions
- Discord server created: https://discord.gg/eErG9sntbZ
## [v0.11.2] - 2021-10-19
- Really fix the bug where too large attachments keep retrying
- Fix tests on Windows
## [v0.11.1] - 2021-10-11
- Improve logging and error handling
- Improve tests
- Fix problem where attachments too large for discord fail immediately and do not retry
- Fix problem with version checking
## [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) 2021 Justin Hawkins Copyright (c) 2017 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,15 +1,11 @@
# 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)
Point it at your Steam screenshot folder, or similar, and shortly after you hit your screenshot hotkey the screenshot will appear in your discord chat. Point it at your Steam screenshot folder, or similar, and shortly after you hit your screenshot hotkey the screenshot will appear in your discord chat.
Need help? Join our discord: https://discord.gg/eErG9sntbZ
## What you'll need ## What you'll need
* A folder where screenshots are stored * A folder where screenshots are stored
@ -24,18 +20,13 @@ 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
`dau` configuration is managed via its internal web interface. When the executable is run, you can visit `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 it. On Windows, a tray icon is created to provide `http://localhost:9090` in your web browser to configure the it. Configuration persists across runs, it is
access to the web interface. saved in a file called '.dau.json' in your home directory.
The web browser will be loaded automatically when you start the program, if possible. This option can be
disabled in the settings.
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 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. `dau` to be useful.
@ -48,51 +39,39 @@ Thus, you do not have to worry about pointing `dau` at a directory full of image
## Configuration options ## Configuration options
See the web interface at http://localhost:9090 to configure `dau`. The configuration is a single page of options, See the web interface at http://localhost:9090 to configure `dau`.
no changes will take effect until the "Save All Configuration" button has been pressed.
### Global options ### 'Discord WebHook URL'
* Server port - the port number the web server listens on. Requires restart The webhook URL from Discord. See https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks
* Watch interval - how often each watcher will check the directory for new files, in seconds for more information on setting one up.
### Watcher configuration ### 'Bot Username'
There can be one or more watchers configured. Each watcher looks in a particular directory, This is completely optional and can be any arbitrary string. It makes the upload
and uploads new files to a different discord channel.
Each watcher has the following configuration options:
* Directory to watch - 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.
* Discord WebHook URL - 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.
* Username - 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 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 actually hide the bot identity in any way). You might like to set it to your own
discord name. discord name.
* Watermark - Disabling the watermark will prevent `dau` from putting a link to the projects
on the bottom left hand corner of your uploaded images. I really appreciate it when you leave this enabled :-)
* Hold Uploads - See "Holding uploads" below
* Exclusions - You can set one or more arbitrary strings to exclude files from being matched by this watcher.
This is most commonly used to prevent thumbnail images from being uploads.
## Holding uploads ### 'Directory to watch'
If the "Hold Uploads" option is selected, newly found files will not immediately be uploaded. They will be available This is the path that `dau` will periodically inspect, looking for new images.
in the "uploads" tab of the web interface. This has two purposes: Note that subdirectories are also scanned. You need to enter the full filesystem
path here.
* It gives you a chance to vet your screenshot selection before uploading ### 'Period between filesystem checks'
* It allows you to edit the images before uploading.
In the list of uploads there are three actions you can take on each file: This is the number of seconds between which `dau` will look for new images.
* Press "upload" to upload the image ### 'Do not watermark images'
* Press "reject" to reject the image
* Click on the image thumbnail to edit the image
If you click on the image thumbnail, an image editor will open, and allow you to add text captions to your image. This will disable the watermarking of images. I like it when you don't set this :-)
More functionality is coming soon. When you are finished editing, choose "Apply" and you will return to the uploads
list. Click "upload" to upload your edited image. ### '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
@ -107,7 +86,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 what you'd like to see. 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. Please include any relevant logs from the console when reporting bugs.

43
build-release.pl Executable file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env perl
use strict;
use warnings;
open my $fh, "<", "config/config.go" || die $!;
my $version;
while (<$fh>) {
$version = $1 if /^const\s+CurrentVersion.*?"([\d\.]+)"/;
}
close $fh;
die "no version?" unless defined $version;
# so lazy
system "rm", "-rf", "release", "dist";
system "mkdir", "release";
system "mkdir", "dist";
my %build = (
win => { env => { GOOS => 'windows', GOARCH => 'amd64' }, filename => 'dau.exe' },
linux => { env => { GOOS => 'linux', GOARCH => 'amd64' }, filename => 'dau' },
mac => { env => { GOOS => 'darwin', GOARCH => 'amd64' }, filename => 'dau' },
);
foreach my $type (keys %build) {
mkdir "release/$type";
}
add_extras();
system(qw{go generate});
foreach my $type (keys %build) {
local $ENV{GOOS} = $build{$type}->{env}->{GOOS};
local $ENV{GOARCH} = $build{$type}->{env}->{GOARCH};
system "go", "build", "-o", "release/$type/" . $build{$type}->{filename};
system "zip", "-j", "dist/dau-$type-$version.zip", ( glob "release/$type/*" );
}
sub add_extras {
# we used to have a .bat file here, but no longer needed
}

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
type ConfigV1 struct { var Config struct {
WebHookURL string WebHookURL string
Path string Path string
Watch int Watch int
@ -22,160 +22,46 @@ type ConfigV1 struct {
Exclude string Exclude string
} }
type Watcher struct { const CurrentVersion string = "0.9"
WebHookURL string
Path string
Username string
NoWatermark bool
HoldUploads bool
Exclude []string
}
type ConfigV2 struct { // Load the current config or initialise with defaults
WatchInterval int func LoadOrInit() {
Version int configPath := configPath()
Port int daulog.SendLog(fmt.Sprintf("Trying to load config from %s", configPath), daulog.LogTypeDebug)
Watchers []Watcher _, err := os.Stat(configPath)
}
type ConfigV3 struct {
WatchInterval int
Version int
Port int
OpenBrowserOnStart bool
Watchers []Watcher
}
type ConfigService struct {
Config *ConfigV3
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.Debugf("Trying to load config from %s\n", c.ConfigFilename)
_, err := os.Stat(c.ConfigFilename)
if os.IsNotExist(err) { if os.IsNotExist(err) {
daulog.Info("NOTE: No config file, writing out sample configuration") daulog.SendLog("NOTE: No config file, writing out sample configuration", daulog.LogTypeInfo)
daulog.Info("You need to set the configuration via the web interface") daulog.SendLog("You need to set the configuration via the web interface", daulog.LogTypeInfo)
c.Config = DefaultConfig()
return c.Save() Config.WebHookURL = ""
Config.Path = homeDir() + string(os.PathSeparator) + "screenshots"
Config.Watch = 10
SaveConfig()
} else { } else {
return c.Load() LoadConfig()
} }
} }
func DefaultConfig() *ConfigV3 { func LoadConfig() {
c := ConfigV3{} path := configPath()
c.Version = 3 data, err := ioutil.ReadFile(path)
c.WatchInterval = 10 if err != nil {
c.Port = 9090 log.Fatalf("cannot read config file %s: %s", path, err.Error())
c.OpenBrowserOnStart = true }
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
} }
// Load will load the configuration from a known-to-exist config file. func SaveConfig() {
func (c *ConfigService) Load() error { daulog.SendLog("saving configuration", daulog.LogTypeInfo)
daulog.Debugf("Loading from %s", c.ConfigFilename) path := configPath()
jsonString, _ := json.Marshal(Config)
data, err := ioutil.ReadFile(c.ConfigFilename) err := ioutil.WriteFile(path, jsonString, os.ModePerm)
if err != nil { if err != nil {
return fmt.Errorf("cannot read config file %s: %s", c.ConfigFilename, err.Error()) log.Fatalf("Cannot save config %s: %s", path, 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())
}
// Version 0 predates config migrations
if c.Config.Version == 0 {
// need to migrate this
daulog.Info("Migrating config to V2")
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}
}
if c.Config.Version == 2 {
// need to migrate this
daulog.Info("Migrating config to V3")
c.Config.Version = 3
c.Config.OpenBrowserOnStart = true
}
return nil
}
func (c *ConfigService) Save() error {
daulog.Info("saving configuration")
// sanity checks
for _, watcher := range c.Config.Watchers {
// give the sample one a pass? this is kinda gross...
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 {
@ -186,7 +72,7 @@ func homeDir() string {
return dir return dir
} }
func defaultConfigPath() string { func configPath() string {
homeDir := homeDir() homeDir := homeDir()
return homeDir + string(os.PathSeparator) + ".dau.json" return homeDir + string(os.PathSeparator) + ".dau.json"
} }

View File

@ -1,106 +0,0 @@
package config
import (
"io/ioutil"
"os"
"testing"
)
func TestNoConfig(t *testing.T) {
c := ConfigService{}
c.ConfigFilename = emptyTempFile()
err := os.Remove(c.ConfigFilename)
if err != nil {
t.Fatalf("could not remove file: %v", err)
}
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 != 3 {
t.Error("not version 3 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 TestMigrateFromV1toV3(t *testing.T) {
c := ConfigService{}
c.ConfigFilename = v1Config()
err := c.LoadOrInit()
if err != nil {
t.Error("unexpected error from LoadOrInit()")
}
if c.Config.Version != 3 {
t.Errorf("Version %d not 3", c.Config.Version)
}
if c.Config.OpenBrowserOnStart != true {
t.Errorf("Open browser on start not true")
}
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)
}
f.Close()
return f.Name()
}
func fileSize(file string) int {
fi, err := os.Stat(file)
if err != nil {
panic(err)
}
return int(fi.Size())
}

156
data/config.html Normal file
View File

@ -0,0 +1,156 @@
<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

@ -6,7 +6,7 @@
a, a,
a:focus, a:focus,
a:hover { a:hover {
color: #f44; color: #fff;
} }
/* Custom default button */ /* Custom default button */
@ -28,23 +28,18 @@ html,
body { body {
height: 100%; height: 100%;
background-color: #333; background-color: #333;
padding: 2em;
max-width: 80em;
} }
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);
} }
.DAU-container { .DAU-container {
max-width: 52em; max-width: 42em;
}
.DAU-container-editor {
} }
pre { pre {
@ -113,6 +108,3 @@ 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,7 +6,3 @@
<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 }}

18
data/logs.html Normal file
View File

@ -0,0 +1,18 @@
<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>

View File

@ -1,14 +1,14 @@
{{ define "layout" }}
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <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"> <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="/alpine.js" defer></script> <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/fabric@4.6.0/dist/fabric.min.js"></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> <style>
.bd-placeholder-img { .bd-placeholder-img {
@ -31,27 +31,21 @@
<link href="/dau.css" rel="stylesheet"> <link href="/dau.css" rel="stylesheet">
</head> </head>
<body class=""> <body class="text-center">
<div class="DAU-container-editor 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">
<div class="inner"> <div class="inner">
<h3 class="masthead-brand">discord-auto-upload ({{.Version}})</h3> <h3 class="masthead-brand">discord-auto-upload ({{.Version}})</h3>
<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>
{{ if eq .NewVersionAvailable true }}
<a class="nav-link" href="{{ .NewVersionInfo.HTMLURL }}">Ver {{ .NewVersionInfo.TagName }} available!</a>
{{ end }}
</nav> </nav>
</div> </div>
</header> </header>
{{ template "content" . }} {{.Body}}
<footer class="mastfoot mt-auto"> <footer class="mastfoot mt-auto">
<div class="inner"> <div class="inner">
@ -62,8 +56,4 @@
</body> </body>
{{ template "js" . }}
</html> </html>
{{ end }}

438
dau.go
View File

@ -1,203 +1,361 @@
package main package main
//go:generate go-bindata -pkg assets -o assets/static.go -prefix data/ data
import ( import (
"context" "bytes"
"flag" "encoding/json"
"fmt" "fmt"
"io/fs" "io"
"io/ioutil"
"log" "log"
"mime/multipart"
"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"
"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/skratchdot/open-golang/open"
// "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"
) )
type watch struct { var lastCheck = time.Now()
lastCheck time.Time var newLastCheck = time.Now()
newLastCheck time.Time
config config.Watcher
uploader *upload.Uploader
}
func main() { func main() {
parseOptions() parseOptions()
// grab the conf, register to notice changes
conf := config.DefaultConfigService()
configChanged := make(chan bool)
conf.Changed = configChanged
conf.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: conf, Uploader: up}
web.StartWebServer() web.StartWebServer()
if conf.Config.OpenBrowserOnStart { checkUpdates()
openWebBrowser(conf.Config.Port)
}
go func() { daulog.SendLog(fmt.Sprintf("Waiting for images to appear in %s", config.Config.Path), daulog.LogTypeInfo)
version.GetOnlineVersion() // wander the path, forever
if version.UpdateAvailable() {
daulog.Info("*** NEW VERSION AVAILABLE ***")
daulog.Infof("You are currently on version %s, but version %s is available\n", version.CurrentVersion, version.LatestVersionInfo.TagName)
daulog.Info("----------- Release Info -----------")
daulog.Info(version.LatestVersionInfo.Body)
daulog.Info("------------------------------------")
daulog.Info("Upgrade at https://github.com/tardisx/discord-auto-upload/releases/latest")
}
}()
// create the watchers, restart them if config changes
// blocks forever
go func() {
startWatchers(conf, up, configChanged)
}()
mainloop(conf)
}
func startWatchers(config *config.ConfigService, up *upload.Uploader, configChange chan bool) {
for { for {
daulog.Debug("Creating watchers") if checkPath(config.Config.Path) {
ctx, cancel := context.WithCancel(context.Background()) err := filepath.Walk(config.Config.Path,
for _, c := range config.Config.Watchers { func(path string, f os.FileInfo, err error) error { return checkFile(path, f, err) })
daulog.Infof("Creating watcher for %s with interval %d", c.Path, config.Config.WatchInterval) if err != nil {
watcher := watch{uploader: up, lastCheck: time.Now(), newLastCheck: time.Now(), config: c} log.Fatal("could not watch path", err)
go watcher.Watch(config.Config.WatchInterval, ctx)
}
// wait for single that the config changed
<-configChange
cancel()
daulog.Info("starting new watchers due to config change")
}
}
func (w *watch) Watch(interval int, ctx context.Context) {
for {
select {
case <-ctx.Done():
daulog.Info("Killing old watcher")
return
default:
newFiles := w.ProcessNewFiles()
for _, f := range newFiles {
w.uploader.AddFile(f, w.config)
} }
// upload them lastCheck = newLastCheck
w.uploader.Upload()
daulog.Debugf("sleeping for %ds before next check of %s", interval, w.config.Path)
time.Sleep(time.Duration(interval) * time.Second)
} }
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)
} }
} }
// ProcessNewFiles returns an array of new files that have appeared since func checkPath(path string) bool {
// the last time ProcessNewFiles was run. src, err := os.Stat(path)
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 {
log.Fatal("could not watch path", err)
}
w.lastCheck = w.newLastCheck
}
return newFiles
}
// checkPath makes sure the path exists, and is a directory.
// It logs errors if there are problems, and returns false
func (w *watch) checkPath() bool {
src, err := os.Stat(w.config.Path)
if err != nil { if err != nil {
daulog.Errorf("Problem with path '%s': %s", w.config.Path, err) log.Printf("Problem with path '%s': %s", path, err)
return false return false
} }
if !src.IsDir() { if !src.IsDir() {
daulog.Errorf("Problem with path '%s': is not a directory", w.config.Path) log.Printf("Problem with path '%s': is not a directory", path)
return false return false
} }
return true return true
} }
// checkFile checks if a file is eligible, first looking at extension (to func checkUpdates() {
// 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)) type GithubRelease struct {
HTMLURL string `json:"html_url"`
if !(extension == ".png" || extension == ".jpg" || extension == ".gif") { TagName string `json:"tag_name"`
return nil Name string `json:"name"`
Body string `json:"body"`
} }
fi, err := os.Stat(path) daulog.SendLog("checking for new version", daulog.LogTypeInfo)
client := &http.Client{Timeout: time.Second * 5}
resp, err := client.Get("https://api.github.com/repos/tardisx/discord-auto-upload/releases/latest")
if err != nil { if err != nil {
return err daulog.SendLog(fmt.Sprintf("WARNING: Update check failed: %v", err), daulog.LogTypeError)
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal("could not check read update response")
} }
if fi.ModTime().After(w.lastCheck) && fi.Mode().IsRegular() { var latest GithubRelease
excluded := false err = json.Unmarshal(body, &latest)
for _, exclusion := range exclusions {
if strings.Contains(path, exclusion) { if err != nil {
excluded = true log.Fatal("could not parse JSON: ", err)
}
}
if !excluded {
*found = append(*found, path)
}
} }
if w.newLastCheck.Before(fi.ModTime()) { if config.CurrentVersion < latest.TagName {
w.newLastCheck = fi.ModTime() fmt.Printf("You are currently on version %s, but version %s is available\n", config.CurrentVersion, latest.TagName)
fmt.Println("----------- Release Info -----------")
fmt.Println(latest.Body)
fmt.Println("------------------------------------")
fmt.Println("Upgrade at https://github.com/tardisx/discord-auto-upload/releases/latest")
daulog.SendLog(fmt.Sprintf("New version available: %s - download at https://github.com/tardisx/discord-auto-upload/releases/latest", latest.TagName), daulog.LogTypeInfo)
}
}
func parseOptions() {
// Declare the flags to be used
helpFlag := getopt.BoolLong("help", 'h', "help")
versionFlag := getopt.BoolLong("version", 'v', "show version")
getopt.SetParameters("")
getopt.Parse()
if *helpFlag {
getopt.PrintUsage(os.Stderr)
os.Exit(0)
}
if *versionFlag {
fmt.Println("dau - https://github.com/tardisx/discord-auto-upload")
fmt.Printf("Version: %s\n", config.CurrentVersion)
os.Exit(0)
}
// grab the config
config.LoadOrInit()
}
func checkFile(path string, f os.FileInfo, err error) error {
if f.ModTime().After(lastCheck) && f.Mode().IsRegular() {
if fileEligible(path) {
// process file
processFile(path)
}
if newLastCheck.Before(f.ModTime()) {
newLastCheck = f.ModTime()
}
} }
return nil return nil
} }
func parseOptions() { func fileEligible(file string) bool {
var versionFlag bool
flag.BoolVar(&versionFlag, "version", false, "show version")
flag.Parse()
if versionFlag { if config.Config.Exclude != "" && strings.Contains(file, config.Config.Exclude) {
fmt.Println("dau - https://github.com/tardisx/discord-auto-upload") return false
fmt.Printf("Version: %s\n", version.CurrentVersion)
os.Exit(0)
} }
extension := strings.ToLower(filepath.Ext(file))
if extension == ".png" || extension == ".jpg" || extension == ".gif" {
return true
}
return false
} }
func openWebBrowser(port int) { func processFile(file string) {
address := fmt.Sprintf("http://localhost:%d", port)
open.Start(address) 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
} }

BIN
dau.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@ -1,12 +0,0 @@
//go:build darwin || linux
// +build darwin linux
package main
import "github.com/tardisx/discord-auto-upload/config"
func mainloop(c *config.ConfigService) {
ch := make(chan bool)
<-ch
}

View File

@ -1,105 +0,0 @@
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?")
}
err := os.RemoveAll(dir)
if err != nil {
t.Fatalf("could not remove test dir: %v", err)
}
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)
}
f1, _ := os.Create(fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "a.gif"))
f2, _ := os.Create(fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "a.jpg"))
f3, _ := os.Create(fmt.Sprintf("%s%c%s", dir, os.PathSeparator, "a.png"))
f1.Close()
f2.Close()
f3.Close()
return dir
}

View File

@ -1,60 +0,0 @@
package main
import (
_ "embed"
"fmt"
"github.com/getlantern/systray"
"github.com/skratchdot/open-golang/open"
"github.com/tardisx/discord-auto-upload/config"
daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/tardisx/discord-auto-upload/version"
)
//go:generate goversioninfo
//go:embed dau.ico
var appIcon []byte
func mainloop(c *config.ConfigService) {
systray.Run(func() { onReady(c) }, onExit)
}
func onReady(c *config.ConfigService) {
systray.SetIcon(appIcon)
//systray.SetTitle("DAU")
systray.SetTooltip(fmt.Sprintf("discord-auto-upload %s", version.CurrentVersion))
openApp := systray.AddMenuItem("Open", "Open in web browser")
gh := systray.AddMenuItem("Github", "Open project page")
discord := systray.AddMenuItem("Discord", "Join us on discord")
ghr := systray.AddMenuItem("Release Notes", "Open project release notes")
quit := systray.AddMenuItem("Quit", "Quit")
go func() {
<-quit.ClickedCh
systray.Quit()
}()
go func() {
for {
select {
case <-openApp.ClickedCh:
openWebBrowser(c.Config.Port)
case <-gh.ClickedCh:
open.Start("https://github.com/tardisx/discord-auto-upload")
case <-ghr.ClickedCh:
open.Start(fmt.Sprintf("https://github.com/tardisx/discord-auto-upload/releases/tag/%s", version.CurrentVersion))
case <-discord.ClickedCh:
open.Start("https://discord.gg/eErG9sntbZ")
}
}
}()
// Sets the icon of a menu item. Only available on Mac and Windows.
// mQuit.SetIcon(icon.Data)
}
func onExit() {
// clean up here
daulog.Info("quitting on user request")
}

9
go.mod
View File

@ -1,15 +1,12 @@
module github.com/tardisx/discord-auto-upload module github.com/tardisx/discord-auto-upload
go 1.16 go 1.15
require ( require (
github.com/fogleman/gg v1.3.0 github.com/fogleman/gg v1.3.0
github.com/getlantern/systray v1.2.1
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/gorilla/mux v1.8.0
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/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/stretchr/testify v1.6.1 // indirect golang.org/x/image v0.0.0-20201208152932-35266b937fa6
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e
golang.org/x/mod v0.7.0
) )

63
go.sum
View File

@ -1,68 +1,13 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= 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/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.1 h1:udsC2k98v2hN359VTFShuQW6GGprRprw6kD6539JikI=
github.com/getlantern/systray v1.2.1/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 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/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 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/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= github.com/pborman/getopt v1.1.0 h1:eJ3aFZroQqq0bWmraivjQNt6Dmm5M0h2JcDW38/Azb0=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/pborman/getopt v1.1.0/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= 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= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e h1:PzJMNfFQx+QO9hrC1GwZ4BoPGeNGhfeQEgcQFArEjPk=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/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-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,188 +0,0 @@
// Package image is responsible for thumbnailing, resizing and watermarking
// images.
package image
import (
"fmt"
i "image"
"image/jpeg"
"image/png"
"io"
"os"
daulog "github.com/tardisx/discord-auto-upload/log"
"golang.org/x/image/draw"
)
// the filenames below are ordered in a specific way
// In the simplest case we only need the original filename.
// In more complex cases, we might have other files, created
// temporarily. These all need to be cleaned up.
// We upload the "final" file, depending on what actions have
// been taken.
type Store struct {
OriginalFilename string
OriginalFormat string // jpeg, png
ModifiedFilename string // if the user applied modifications
ResizedFilename string // if the file had to be resized to be uploaded
WatermarkedFilename string
MaxBytes int
Watermark bool
}
// ReadCloser returns an io.ReadCloser providing the imagedata
// with the manglings that have been requested
func (s *Store) ReadCloser() (io.ReadCloser, error) {
// determine format
s.determineFormat()
// check if we will fit the number of bytes, resize if necessary
err := s.resizeToUnder(int64(s.MaxBytes))
if err != nil {
return nil, err
}
// the conundrum here is that the watermarking could modify the file size again, maybe going over
// the MaxBytes size. That would mostly be about jpeg compression levels I guess...
if s.Watermark {
s.applyWatermark()
}
// return the reader
f, err := os.Open(s.uploadSourceFilename())
if err != nil {
return nil, err
}
return f, nil
}
func (s *Store) determineFormat() error {
file, err := os.Open(s.OriginalFilename)
if err != nil {
panic(fmt.Errorf("could not open file: %s", err))
}
defer file.Close()
_, format, err := i.Decode(file)
if err != nil {
panic(fmt.Errorf("could not decode file: %s", err))
}
s.OriginalFormat = format
return nil
}
// resizeToUnder resizes the image, if necessary
func (s *Store) resizeToUnder(size int64) error {
fileToResize := s.uploadSourceFilename()
fi, err := os.Stat(s.uploadSourceFilename())
if err != nil {
return err
}
currentSize := fi.Size()
if currentSize <= size {
return nil // nothing needs to be done
}
daulog.Infof("%s is %d bytes, need to resize to fit in %d", fileToResize, currentSize, size)
file, err := os.Open(fileToResize)
if err != nil {
panic(fmt.Errorf("could not open file: %s", err))
}
defer file.Close()
im, _, err := i.Decode(file)
if err != nil {
panic(fmt.Errorf("could not decode file: %s", err))
}
// if the size is 10% too big, we reduce X and Y by 10% - this is overkill but should
// get us across the line in most cases
fraction := float64(currentSize) / float64(size) // say 1.1 for 10%
newXY := i.Point{
X: int(float64(im.Bounds().Max.X) / fraction),
Y: int(float64(im.Bounds().Max.Y) / fraction),
}
daulog.Infof("fraction is %f, will resize to %dx%d", fraction, newXY.X, newXY.Y)
dst := i.NewRGBA(i.Rect(0, 0, newXY.X, newXY.Y))
draw.BiLinear.Scale(dst, dst.Rect, im, im.Bounds(), draw.Over, nil)
resizedFile, err := os.CreateTemp("", "dau_resize_file_*")
if err != nil {
return err
}
if s.OriginalFormat == "png" {
err = png.Encode(resizedFile, dst)
if err != nil {
return err
}
} else if s.OriginalFormat == "jpeg" {
err = jpeg.Encode(resizedFile, dst, nil)
if err != nil {
return err
}
} else {
panic("unknown format " + s.OriginalFormat)
}
s.ResizedFilename = resizedFile.Name()
resizedFile.Close()
fi, err = os.Stat(s.uploadSourceFilename())
if err != nil {
return err
}
newSize := fi.Size()
if newSize <= size {
daulog.Infof("File resized, now %d", newSize)
return nil // nothing needs to be done
} else {
return fmt.Errorf("failed to resize: was %d, now %d, needed %d", currentSize, newSize, size)
}
}
// uploadSourceFilename gives us the filename, which might be a watermarked, resized
// or markedup version, depending on what has happened to this file.
func (s Store) uploadSourceFilename() string {
if s.WatermarkedFilename != "" {
return s.WatermarkedFilename
}
if s.ResizedFilename != "" {
return s.ResizedFilename
}
if s.ModifiedFilename != "" {
return s.ModifiedFilename
}
return s.OriginalFilename
}
// UploadFilename provides a name to be assigned to the upload on Discord
func (s Store) UploadFilename() string {
return "image." + s.OriginalFormat
}
// Cleanup removes all the temporary files that we might have created
func (s Store) Cleanup() {
daulog.Infof("cleaning temporary files %#v", s)
if s.ModifiedFilename != "" {
daulog.Infof("removing %s", s.ModifiedFilename)
os.Remove(s.ModifiedFilename)
}
if s.ResizedFilename != "" {
daulog.Infof("removing %s", s.ResizedFilename)
os.Remove(s.ResizedFilename)
}
if s.WatermarkedFilename != "" {
daulog.Infof("removing %s", s.WatermarkedFilename)
os.Remove(s.WatermarkedFilename)
}
}

View File

@ -1,62 +0,0 @@
package image
import (
"fmt"
i "image"
"image/png"
"io"
"log"
"os"
"golang.org/x/image/draw"
)
const (
thumbnailMaxX = 128
thumbnailMaxY = 128
)
type ThumbType = string
const ThumbTypeOriginal = "orig"
const ThumbTypeMarkedUp = "markedup"
// ThumbPNG writes a thumbnail out to an io.Writer
func (ip *Store) ThumbPNG(t ThumbType, w io.Writer) error {
var filename string
if t == ThumbTypeOriginal {
filename = ip.OriginalFilename
} else if t == ThumbTypeMarkedUp {
filename = ip.ModifiedFilename
} else {
log.Fatal("was passed incorrect 'type' arg")
}
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("could not open file: %s", err)
}
defer file.Close()
im, _, err := i.Decode(file)
if err != nil {
return fmt.Errorf("could not decode file: %s", err)
}
newXY := i.Point{}
if im.Bounds().Max.X/thumbnailMaxX > im.Bounds().Max.Y/thumbnailMaxY {
newXY.X = thumbnailMaxX
newXY.Y = im.Bounds().Max.Y / (im.Bounds().Max.X / thumbnailMaxX)
} else {
newXY.Y = thumbnailMaxY
newXY.X = im.Bounds().Max.X / (im.Bounds().Max.Y / thumbnailMaxY)
}
dst := i.NewRGBA(i.Rect(0, 0, newXY.X, newXY.Y))
draw.BiLinear.Scale(dst, dst.Rect, im, im.Bounds(), draw.Over, nil)
png.Encode(w, dst)
return nil
}

View File

@ -1,64 +0,0 @@
package image
import (
"fmt"
i "image"
"image/jpeg"
"image/png"
"os"
daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/fogleman/gg"
"golang.org/x/image/font/inconsolata"
)
// applyWatermark applies the watermark to the image
func (s *Store) applyWatermark() error {
in, err := os.Open(s.uploadSourceFilename())
defer in.Close()
im, _, err := i.Decode(in)
if err != nil {
daulog.Errorf("Cannot decode image: %v - skipping watermarking", err)
return fmt.Errorf("cannot decode image: %w", 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)
waterMarkedFile, err := os.CreateTemp("", "dau_watermark_file_*")
if err != nil {
return err
}
defer waterMarkedFile.Close()
if s.OriginalFormat == "png" {
png.Encode(waterMarkedFile, dc.Image())
} else if s.OriginalFormat == "jpeg" {
jpeg.Encode(waterMarkedFile, dc.Image(), nil)
} else {
panic("Cannot handle " + s.OriginalFormat)
}
s.WatermarkedFilename = waterMarkedFile.Name()
return nil
}

View File

@ -1,14 +1,9 @@
package log package log
import ( import (
"fmt"
"time" "time"
) )
type Logger interface {
WriteEntry(l LogEntry)
}
type LogEntryType string type LogEntryType string
type LogEntry struct { type LogEntry struct {
@ -23,73 +18,28 @@ const (
LogTypeDebug = "debug" LogTypeDebug = "debug"
) )
var loggers []Logger var LogEntries []LogEntry
var logInput chan LogEntry var logInput chan LogEntry
var Memory *MemoryLogger
func init() { func init() {
// create some loggers
Memory = &MemoryLogger{maxsize: 100}
stdout := &StdoutLogger{}
loggers = []Logger{Memory, stdout}
// wait for log entries // wait for log entries
logInput = make(chan LogEntry) logInput = make(chan LogEntry)
go func() { go func() {
for { for {
aLog := <-logInput aLog := <-logInput
for _, l := range loggers { LogEntries = append(LogEntries, aLog)
l.WriteEntry(aLog) for len(LogEntries) > 100 {
LogEntries = LogEntries[1:]
} }
} }
}() }()
} }
func Debug(entry string) { func SendLog(entry string, entryType LogEntryType) {
logInput <- LogEntry{ logInput <- LogEntry{
Timestamp: time.Now(), Timestamp: time.Now(),
Entry: entry, Entry: entry,
Type: LogTypeDebug, Type: entryType,
}
}
func Debugf(entry string, args ...interface{}) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: fmt.Sprintf(entry, args...),
Type: LogTypeDebug,
}
}
func Info(entry string) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: entry,
Type: LogTypeInfo,
}
}
func Infof(entry string, args ...interface{}) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: fmt.Sprintf(entry, args...),
Type: LogTypeInfo,
}
}
func Error(entry string) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: entry,
Type: LogTypeError,
}
}
func Errorf(entry string, args ...interface{}) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: fmt.Sprintf(entry, args...),
Type: LogTypeError,
} }
} }

View File

@ -1,29 +0,0 @@
package log
import (
"sync"
)
type MemoryLogger struct {
size int
entries []LogEntry
maxsize int
lock sync.Mutex
}
func (m *MemoryLogger) WriteEntry(l LogEntry) {
// xxx needs mutex
// if m.entries == nil {
// m.entries = make([]LogEntry, 0)
// }
m.lock.Lock()
m.entries = append(m.entries, l)
if len(m.entries) > m.maxsize {
m.entries = m.entries[1:]
}
m.lock.Unlock()
}
func (m *MemoryLogger) Entries() []LogEntry {
return m.entries
}

View File

@ -1,12 +0,0 @@
package log
import (
"log"
)
type StdoutLogger struct {
}
func (m StdoutLogger) WriteEntry(l LogEntry) {
log.Printf("%-6s %s", l.Type, l.Entry)
}

View File

@ -1,84 +0,0 @@
package main
import (
"log"
"os"
"regexp"
"strings"
"golang.org/x/mod/semver"
)
const versionInfoTemplate = `
{
"FixedFileInfo": {
"FileVersion": {
"Major": MAJOR,
"Minor": MINOR,
"Patch": PATCH,
"Build": 0
},
"ProductVersion": {
"Major": MAJOR,
"Minor": MINOR,
"Patch": PATCH,
"Build": 0
},
"FileFlagsMask": "3f",
"FileFlags ": "00",
"FileOS": "040004",
"FileType": "01",
"FileSubType": "00"
},
"StringFileInfo": {
"Comments": "",
"CompanyName": "tardisx@github",
"FileDescription": "https://github.com/tardisx/discord-auto-upload",
"FileVersion": "",
"InternalName": "",
"LegalCopyright": "https://github.com/tardisx/discord-auto-upload/blob/master/LICENSE",
"LegalTrademarks": "",
"OriginalFilename": "",
"PrivateBuild": "",
"ProductName": "discord-auto-upload",
"ProductVersion": "VERSION",
"SpecialBuild": ""
},
"VarFileInfo": {
"Translation": {
"LangID": "0409",
"CharsetID": "04B0"
}
},
"IconPath": "dau.ico",
"ManifestPath": ""
}
`
var nonAlphanumericRegex = regexp.MustCompile(`[^0-9]+`)
func main() {
version := os.Args[1]
if !semver.IsValid(version) {
panic("bad version" + version)
}
parts := strings.Split(version, ".")
if len(parts) < 3 {
log.Fatalf("bad version: %s", version)
}
parts[0] = nonAlphanumericRegex.ReplaceAllString(parts[0], "")
parts[1] = nonAlphanumericRegex.ReplaceAllString(parts[1], "")
parts[2] = nonAlphanumericRegex.ReplaceAllString(parts[2], "")
out := versionInfoTemplate
out = strings.Replace(out, "MAJOR", parts[0], -1)
out = strings.Replace(out, "MINOR", parts[1], -1)
out = strings.Replace(out, "PATCH", parts[2], -1)
out = strings.Replace(out, "VERSION", version, -1)
f, _ := os.Create("versioninfo.json")
f.Write([]byte(out))
f.Close()
}

View File

@ -1,297 +0,0 @@
// Package upload encapsulates prepping an image for sending to discord,
// and actually uploading it there.
package upload
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"mime/multipart"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/tardisx/discord-auto-upload/config"
"github.com/tardisx/discord-auto-upload/image"
daulog "github.com/tardisx/discord-auto-upload/log"
)
type State string
const (
StatePending State = "Pending" // waiting for decision to upload (could be edited)
StateQueued State = "Queued" // ready for upload
StateWatermarking State = "Adding Watermark" // thumbnail generation
StateUploading State = "Uploading" // uploading
StateComplete State = "Complete" // finished successfully
StateFailed State = "Failed" // failed
StateSkipped State = "Skipped" // user did not want to upload
)
var currentId int32
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type Uploader struct {
Uploads []*Upload `json:"uploads"`
Lock sync.Mutex
}
type Upload struct {
Id int32 `json:"id"`
UploadedAt time.Time `json:"uploaded_at"`
Image *image.Store
webhookURL string
usernameOverride string
Url string `json:"url"` // url on the discord CDN
Width int `json:"width"`
Height int `json:"height"`
State State `json:"state"`
StateReason string `json:"state_reason"`
Client HTTPClient `json:"-"`
}
func NewUploader() *Uploader {
u := Uploader{}
uploads := make([]*Upload, 0)
u.Uploads = uploads
return &u
}
func (u *Uploader) AddFile(file string, conf config.Watcher) {
u.Lock.Lock()
atomic.AddInt32(&currentId, 1)
thisUpload := Upload{
Id: currentId,
UploadedAt: time.Time{},
Image: &image.Store{OriginalFilename: file, Watermark: !conf.NoWatermark, MaxBytes: 8_000_000},
webhookURL: conf.WebHookURL,
usernameOverride: conf.Username,
Url: "",
State: StateQueued,
Client: nil,
}
// if the user wants uploads to be held for editing etc,
// set it to Pending instead
if conf.HoldUploads {
thisUpload.State = StatePending
thisUpload.StateReason = ""
}
u.Uploads = append(u.Uploads, &thisUpload)
u.Lock.Unlock()
}
// Upload uploads any files that have not yet been uploaded
func (u *Uploader) Upload() {
u.Lock.Lock()
for _, upload := range u.Uploads {
if upload.State == StateQueued {
upload.processUpload()
}
}
u.Lock.Unlock()
}
func (u *Uploader) UploadById(id int32) *Upload {
u.Lock.Lock()
defer u.Lock.Unlock()
for _, anUpload := range u.Uploads {
if anUpload.Id == int32(id) {
return anUpload
}
}
return nil
}
func (u *Upload) processUpload() error {
daulog.Infof("Uploading: %s", u.Image.OriginalFilename)
if u.webhookURL == "" {
daulog.Error("WebHookURL is not configured - cannot upload!")
return errors.New("webhook url not configured")
}
extraParams := map[string]string{}
if u.usernameOverride != "" {
daulog.Infof("Overriding username with '%s'", u.usernameOverride)
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 {
// open an io.ReadCloser for the file we intend to upload
var err error
imageData, err := u.Image.ReadCloser()
if err != nil {
panic(err)
}
request, err := newfileUploadRequest(u.webhookURL, extraParams, "file", u.Image.UploadFilename(), imageData)
if err != nil {
daulog.Errorf("error creating upload request: %s", err)
return fmt.Errorf("could not create upload request: %s", err)
}
start := time.Now()
if u.Client == nil {
// if no client was specified (a unit test) then create
// a default one
u.Client = &http.Client{Timeout: time.Second * 30}
}
resp, err := u.Client.Do(request)
if err != nil {
daulog.Errorf("Error performing request: %s", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
} else {
if resp.StatusCode == 413 {
// just fail immediately, we know this means the file was too big
daulog.Error("413 received - file too large")
u.State = StateFailed
u.StateReason = "discord API said file too large"
return errors.New("received 413 - file too large")
}
if resp.StatusCode != 200 {
// {"message": "Request entity too large", "code": 40005}
daulog.Errorf("Bad response code from server: %d", resp.StatusCode)
if b, err := ioutil.ReadAll(resp.Body); err == nil {
daulog.Errorf("Body:\n%s", string(b))
}
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
resBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
daulog.Errorf("could not deal with body: %s", 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.Debugf("Response: %s", string(resBody[:]))
if err != nil {
daulog.Errorf("could not parse JSON: %s", err)
daulog.Errorf("Response was: %s", string(resBody[:]))
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
if len(res.Attachments) < 1 {
daulog.Error("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.Infof("Uploaded to %s %dx%d", a.URL, a.Width, a.Height)
daulog.Infof("id: %d, %d bytes transferred in %.2f seconds (%.2f KiB/s)", res.ID, a.Size, elapsed.Seconds(), rate)
u.Url = a.URL
u.State = StateComplete
u.StateReason = ""
u.Width = a.Width
u.Height = a.Height
u.UploadedAt = time.Now()
break
}
}
// remove any temporary files
u.Image.Cleanup()
if retriesRemaining == 0 {
daulog.Error("Failed to upload, even after all retries")
u.State = StateFailed
u.StateReason = "could not upload after all retries"
return errors.New("could not upload after all retries")
}
return nil
}
func newfileUploadRequest(uri string, params map[string]string, paramName string, filename string, filedata io.Reader) (*http.Request, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(paramName, filename)
if err != nil {
return nil, err
}
_, err = io.Copy(part, filedata)
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 sleepForRetries(retry int) {
if retry == 0 {
return
}
retryTime := (6-retry)*(6-retry) + 6
daulog.Errorf("Will retry in %d seconds (%d remaining attempts)", retryTime, retry)
time.Sleep(time.Duration(retryTime) * time.Second)
}

View File

@ -1,97 +0,0 @@
package upload
import (
"bytes"
i "image"
"image/color"
"image/png"
"io/ioutil"
"math/rand"
"net/http"
"os"
)
// https://www.thegreatcodeadventure.com/mocking-http-requests-in-golang/
type MockClient struct {
DoFunc func(req *http.Request) (*http.Response, error)
}
func (m *MockClient) Do(req *http.Request) (*http.Response, error) {
return m.DoFunc(req)
}
func DoGoodUpload(req *http.Request) (*http.Response, error) {
r := ioutil.NopCloser(bytes.NewReader([]byte(`{"id": "123456789012345678", "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": "123456789012345678"}`)))
return &http.Response{
StatusCode: 200,
Body: r,
}, nil
}
func DoTooBigUpload(req *http.Request) (*http.Response, error) {
r := ioutil.NopCloser(bytes.NewReader([]byte(`{"message": "Request entity too large", "code": 40005}`)))
return &http.Response{
StatusCode: 413,
Body: r,
}, nil
}
// func TestSuccessfulUpload(t *testing.T) {
// // create temporary file, processUpload requires that it exists, even though
// // we will not really be uploading it here
// f, _ := os.CreateTemp("", "dautest-upload-*")
// defer os.Remove(f.Name())
// u := Upload{webhookURL: "https://127.0.0.1/", Image: &image.Store{OriginalFilename: f.Name()}}
// u.Client = &MockClient{DoFunc: DoGoodUpload}
// err := u.processUpload()
// if err != nil {
// t.Errorf("error occured: %s", err)
// }
// if u.Url != "https://cdn.discordapp.com/attachments/849615269706203171/851092588332449812/dau480457962.png" {
// t.Error("URL wrong")
// }
// }
// func TestTooBigUpload(t *testing.T) {
// // create temporary file, processUpload requires that it exists, even though
// // we will not really be uploading it here
// f, _ := os.CreateTemp("", "dautest-upload-*")
// defer os.Remove(f.Name())
// u := Upload{webhookURL: "https://127.0.0.1/", Image: &image.Store{OriginalFilename: f.Name()}}
// u.Client = &MockClient{DoFunc: DoTooBigUpload}
// err := u.processUpload()
// if err == nil {
// t.Error("error did not occur?")
// } else if err.Error() != "received 413 - file too large" {
// t.Errorf("wrong error occurred: %s", err.Error())
// }
// if u.State != StateFailed {
// t.Error("upload should have been marked failed")
// }
// }
func tempImageGt8Mb() {
// about 12Mb
width := 2000
height := 2000
upLeft := i.Point{0, 0}
lowRight := i.Point{width, height}
img := i.NewRGBA(i.Rectangle{upLeft, lowRight})
// Colors are defined by Red, Green, Blue, Alpha uint8 values.
// Set color for each pixel.
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
color := color.RGBA{uint8(rand.Int31n(256)), uint8(rand.Int31n(256)), uint8(rand.Int31n(256)), 0xff}
img.Set(x, y, color)
}
}
// Encode as PNG.
f, _ := os.Create("image.png")
png.Encode(f, img)
}

View File

@ -1,81 +0,0 @@
package version
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"time"
daulog "github.com/tardisx/discord-auto-upload/log"
"golang.org/x/mod/semver"
)
const CurrentVersion string = "v0.13.0"
type GithubRelease struct {
HTMLURL string `json:"html_url"`
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
}
var LatestVersion string
var LatestVersionInfo GithubRelease
// UpdateAvailable returns true or false, depending on whether not a new version is available.
// It always returns false if the OnlineVersion has not yet been fetched.
func UpdateAvailable() bool {
if !semver.IsValid(CurrentVersion) {
panic(fmt.Sprintf("my current version '%s' is not valid", CurrentVersion))
}
if LatestVersion == "" {
return false
}
if !semver.IsValid(LatestVersion) {
// maybe this should just be a warning
daulog.Errorf("online version '%s' is not valid - assuming no new version", LatestVersion)
return false
}
comp := semver.Compare(LatestVersion, CurrentVersion)
if comp == 0 {
return false
}
if comp == 1 {
return true
}
return false // they are using a newer one than exists?
}
func GetOnlineVersion() {
daulog.Info("checking for new version")
client := &http.Client{Timeout: time.Second * 5}
resp, err := client.Get("https://api.github.com/repos/tardisx/discord-auto-upload/releases/latest")
if err != nil {
daulog.Errorf("WARNING: Update check failed: %s", err)
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal("could not check read update response")
}
var latest GithubRelease
err = json.Unmarshal(body, &latest)
if err != nil {
log.Fatal("could not parse JSON: ", err)
}
LatestVersion = latest.TagName
LatestVersionInfo = latest
daulog.Debugf("Latest version: %s", LatestVersion)
}

View File

@ -1,21 +0,0 @@
package version
import (
"testing"
)
func TestVersioningUpdate(t *testing.T) {
// pretend there is a new version
LatestVersion = "v0.13.9"
if !UpdateAvailable() {
t.Error("should be a version newer than " + CurrentVersion)
}
}
func TestVersioningNoUpdate(t *testing.T) {
// pretend there is not a new version
LatestVersion = "v0.12.1"
if UpdateAvailable() {
t.Error("should NOT be a version newer than " + CurrentVersion)
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,221 +0,0 @@
{{ 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 in seconds (watchers are 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>Open browser on startup</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only">Open browser</label>
<button type="button" @click="config.OpenBrowserOnStart = ! config.OpenBrowserOnStart" class="btn btn-success" x-text="config.OpenBrowserOnStart ? 'Enabled' : 'Disabled'"></button>
</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>A watcher can be configured to hold uploads. This causes the new images seen
by the watcher to be held for review on the <a href="/uploads.html">uploads page</a>.
This allows each image to be individually uploaded or skipped.
</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>Hold Uploads</span>
</div>
<div class="col-sm-6 my-1">
<button type="button" @click="config.Watchers[i].HoldUploads = ! config.Watchers[i].HoldUploads" class="btn btn-success" x-text="watcher.HoldUploads ? 'Enabled' : 'Disabled'"></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, HoldUploads: 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>
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

@ -1,233 +0,0 @@
{{ define "content" }}
<main role="main" class="" x-data="editor()" x-init="setup_canvas();">
<div class="row">
<div class="col">
<canvas id="c" x-bind:style="canvas_style">
</canvas>
<img :src="img_data">
</div>
<div class="col">
<div id="tools-top" x-show="!toolbar">
<button type="button" @click="add_some_text()" class="btn btn-primary">Add text</button>
<!-- <button type="button" @click="crop()" class="btn btn-primary">Crop</button> -->
<button type="button" @click="apply()" class="btn btn-primary">Apply</button>
<button type="button" @click="cancel()" class="btn btn-primary">Cancel</button>
</div>
<div id="tools-delete" x-show="toolbar == 'text'">
<button type="button" @click="delete_selected();" class="btn btn-primary" style="">delete</button>
</div>
<div id="tools-crop" x-show="toolbar == 'crop'">
<button type="button" @click="apply_crop();" class="btn btn-primary" style="">crop</button>
<button type="button" @click="cancel_crop();" class="btn btn-primary" style="">cancel</button>
</div>
<div id="tools-colour" x-show="toolbar == 'text'">
<table>
<tr>
<th>foreground</th>
<template x-for="colour in colours">
<td>
<button type="button" @click="set_colour(colour, 'fg')" class="btn btn-primary" :style="'background-color: '+colour">&nbsp;</button>
</td>
</template>
<td>
<button type="button" @click="set_colour('#fff0', 'fg')" class="btn btn-primary" style="">-</button>
</td>
</tr>
<tr>
<th>background</th>
<template x-for="colour in colours">
<td>
<button type="button" @click="set_colour(colour, 'bg')" class="btn btn-primary" :style="'background-color: '+colour">&nbsp;</button>
</td>
</template>
<td>
<button type="button" @click="set_colour('#fff0', 'bg')" class="btn btn-primary" style="">-</button>
</td>
</tr>
<tr>
<th>outline</th>
<template x-for="colour in colours">
<td>
<button type="button" @click="set_colour(colour, 'stroke')" class="btn btn-primary" :style="'background-color: '+colour">&nbsp;</button>
</td>
</template>
<td>
<button type="button" @click="set_colour('#fff0', 'stroke')" class="btn btn-primary" style="">-</button>
</td>
</tr>
</table>
</div>
</div>
</div>
</main>
{{ end }}
{{ define "js" }}
<script>
// for some reason, canvas does not work correctly if the object
// is managed by alpine - see https://github.com/fabricjs/fabric.js/issues/7485
var canvas = null;
function editor() {
return {
img_data: "", scaleFactor: 0.5,
toolbar: null,
crop_state: {},
colours: [ 'red', 'blue', 'green', 'white', 'yellow', 'black', 'purple'],
canvas_style: "",
// "position: absolute; width: 100%; height: 100%; left: 0px; top: 0px; touch-action: none; -webkit-user-select: none;",
setup_canvas() {
// seriously javascript? just imagine, in 2021....
var url = new URL(window.location);
var id = url.searchParams.get("id");
var self = this;
canvas = new fabric.Canvas('c');
canvas.on('selection:cleared', function(options) {
self.toolbar = null;
});
fabric.Image.fromURL('/rest/image/'+id, function(oImg) {
self.scaleFactor = scalefactor(oImg.width, oImg.height);
canvas.setDimensions({width: oImg.width, height: oImg.height});
oImg.selectable = false;
canvas.add(oImg);
canvas.setHeight(canvas.getHeight() * (self.scaleFactor));
canvas.setWidth(canvas.getWidth() * (self.scaleFactor));
canvas.setZoom(self.scaleFactor);
});
},
export_image() {
this.img_data = canvas.toDataURL({multiplier: 1/this.scaleFactor});
},
add_some_text() {
var text = new fabric.Textbox('double click to change', { left: 20, top: 20, width: 300, fontSize: 40 });
canvas.add(text);
canvas.setActiveObject(text);
this.toolbar = 'text';
var self = this;
text.on('selected', function(options) {
self.toolbar = 'text';
});
},
delete_selected() {
selected = canvas.getActiveObjects();
selected.forEach(el => {
canvas.discardActiveObject(el);
canvas.remove(el);
});
},
set_colour(colour, type) {
selected = canvas.getActiveObjects();
console.log();
selected.forEach(el => {
if (type === 'fg') {
el.set('fill', colour);
}
if (type === 'bg') {
el.set('textBackgroundColor', colour);
}
if (type === 'stroke') {
el.set('stroke', colour);
}
});
canvas.renderAll();
},
// crop mode - XXX not yet implemented
crop() {
this.toolbar = 'crop';
this.crop_state = {};
canvas.selection = false; // disable drag drop selection so we can see the crop rect
let self = this;
this.crop_state.rectangle = new fabric.Rect({
fill: 'transparent',
stroke: '#ccc',
strokeDashArray: [2, 2],
visible: false
});
console.log(this.crop_state.rectangle);
var container = document.getElementById('c').getBoundingClientRect();
canvas.add(this.crop_state.rectangle);
canvas.on("mouse:down", function(event) {
if(1) {
console.log('wow mouse is down', event.e);
self.crop_state.rectangle.width = 2;
self.crop_state.rectangle.height = 2;
self.crop_state.rectangle.left = event.e.offsetX / self.scaleFactor;
self.crop_state.rectangle.top = event.e.offsetY / self.scaleFactor;
self.crop_state.rectangle.visible = true;
self.crop_state.mouseDown = event.e;
canvas.bringToFront(self.crop_state.rectangle);
}
});
// draw the rectangle as the mouse is moved after a down click
canvas.on("mouse:move", function(event) {
if(self.crop_state.mouseDown && 1) {
self.crop_state.rectangle.width = event.e.offsetX / self.scaleFactor - self.crop_state.rectangle.left;
self.crop_state.rectangle.height = event.e.offsetY / self.scaleFactor - self.crop_state.rectangle.top;
canvas.renderAll();
}
});
// when mouse click is released, end cropping mode
canvas.on("mouse:up", function() {
console.log('MOUSE UP');
self.crop_state.mouseDown = null;
});
},
apply_crop() {
console.log(this.crop_state.rectangle.width);
},
apply() {
image_data = canvas.toDataURL({
format: 'png',
multiplier: 1.0/this.scaleFactor});
let formData = new FormData();
formData.append('image', image_data);
var url = new URL(window.location);
var id = url.searchParams.get("id");
fetch('/rest/upload/'+id+'/markup', {method: 'POST', body: formData})
.then(response => response.json()) // convert to json
.then(json => {
console.log(json);
window.location = '/uploads.html';
})
},
cancel() {
window.location = '/uploads.html';
},
}
}
function scalefactor(width, height) {
max_width = window.innerWidth * 3/5;
max_height = window.innerHeight * 5/6;
if (width <= max_width && height <= max_height) {
return 1.0;
}
factor = max_width/width;
if (height*factor <= max_height) {
return factor;
}
return 1/ (height/max_height);
}
</script>
{{ end }}

File diff suppressed because one or more lines are too long

View File

@ -1,47 +0,0 @@
{{ define "content" }}
<main role="main" class="inner DAU" x-data="logs()" x-init="get_logs()">
<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" @click="debug = !debug" class="btn btn-primary" x-text="debug ? 'debug' : 'no debug'"></button>
</div>
<div class="col-sm">
<button type="button" @click="scroll = !scroll" class="btn btn-primary" x-text="scroll ? 'auto-scroll' : 'no scroll'"></button>
</div>
</div>
</div>
<pre id="logs" x-text="text" class="text-left pre-scrollable">
</pre>
</main>
{{ end }}
{{ define "js" }}
<script>
function logs() {
return {
text: '', scroll: true, debug: false,
get_logs() {
fetch('/rest/logs?' + new URLSearchParams({ debug: this.debug ? "1" : "0" }))
.then(response => response.text())
.then(text => {
console.log(text);
this.text = text;
if (this.scroll) {
document.getElementById('logs').scrollTop =10000;
}
let self = this;
setTimeout(function() { self.get_logs(); }, 1000)
})
},
}
}
</script>
{{ end }}

View File

@ -1,146 +0,0 @@
{{ define "content" }}
<main role="main" x-data="uploads()" x-init="get_uploads();" class="inner DAU">
<h1 class="DAU-heading">Uploads</h1>
<p class="lead">Discord-auto-upload uploads</p>
<h2>Pending uploads</h2>
<table class="table table-condensed table-dark">
<thead>
<tr>
<th>filename</th>
<th>actions</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<template x-for="ul in pending">
<tr>
<td x-text="ul.original_file"></td>
<td>
<button @click="start_upload(ul.id)" type="button" class="btn btn-primary">upload</button>
<button @click="skip_upload(ul.id)" type="button" class="btn btn-primary">reject</button>
</td>
<td>
<a x-bind:href="'/editor.html?id='+ul.id"><img x-bind:src="'/rest/image/'+ul.id+'/thumb'"></a>
<a x-show="ul.markedup_file" x-bind:href="'/editor.html?id='+ul.id"><img x-bind:src="'/rest/image/'+ul.id+'/markedup_thumb'"></a>
</td>
</tr>
</template>
</tbody>
</table>
<h2>Current uploads</h2>
<table class="table table-condensed table-dark">
<thead>
<tr>
<th>filename</th>
<th>state</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<template x-for="ul in uploads">
<tr>
<td x-text="ul.original_file"></td>
<td>
<span x-text="ul.state"></span>
<div x-if="ul.state_reason">(<span x-text="ul.state_reason"></span>)</div>
</td>
<td>
<img :src="'/rest/image/'+ul.id+'/thumb'">
</td>
</tr>
</template>
</tbody>
</table>
<h2>Completed uploads</h2>
<table class="table table-condensed table-dark">
<thead>
<tr>
<th>filename</th>
<th>state</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<template x-for="ul in finished">
<tr>
<td x-text="ul.original_file"></td>
<td>
<span x-text="ul.state"></span>
<div x-if="ul.state_reason">(<span x-text="ul.state_reason"></span>)</div>
</td>
<td>
<img :src="'/rest/image/'+ul.id+'/thumb'">
</td>
</tr>
</template>
</tbody>
</table>
</main>
{{ end }}
{{ define "js" }}
<script>
function uploads() {
return {
pending: [], uploads: [], finished: [],
start_upload(id) {
console.log(id);
fetch('/rest/upload/'+id+'/start', {method: 'POST'})
.then(response => response.json()) // convert to json
.then(json => {
console.log(json);
})
},
skip_upload(id) {
console.log(id);
fetch('/rest/upload/'+id+'/skip', {method: 'POST'})
.then(response => response.json()) // convert to json
.then(json => {
console.log(json);
})
},
get_uploads() {
fetch('/rest/uploads')
.then(response => response.json()) // convert to json
.then(json => {
this.pending = [];
this.uploads = [];
this.finished = [];
json.forEach(ul => {
if (ul.state == 'Pending') {
this.pending.push(ul);
}
else if (ul.state == 'Complete' || ul.state == 'Failed' || ul.state == 'Skipped') {
this.finished.push(ul)
}
else {
this.uploads.push(ul);
}
});
this.config = json;
console.log(json);
let self = this;
setTimeout(function() { self.get_uploads(); } , 1000);
})
},
}
}
</script>
{{ end }}

View File

@ -1,368 +1,307 @@
package web package web
import ( import (
"embed"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template"
"io"
"io/ioutil"
"log" "log"
"mime" "mime"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "text/template"
"time"
"github.com/gorilla/mux" "github.com/tardisx/discord-auto-upload/assets"
"github.com/tardisx/discord-auto-upload/config" "github.com/tardisx/discord-auto-upload/config"
"github.com/tardisx/discord-auto-upload/image"
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
}
type ErrorResponse struct {
Error string `json:"error"`
}
type StartUploadRequest struct {
Id int32 `json:"id"`
}
type StartUploadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
//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
} }
func (ws *WebService) getStatic(w http.ResponseWriter, r *http.Request) { type valueStringResponse struct {
Success bool `json:"success"`
Value string `json:"value"`
}
path := r.URL.Path type valueBooleanResponse struct {
path = strings.TrimLeft(path, "/") Success bool `json:"success"`
if path == "" { Value bool `json:"value"`
path = "index.html" }
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
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")
} }
extension := filepath.Ext(string(path)) data, err := assets.Asset(string(sanitized_path))
if err != nil {
// Asset was not found.
fmt.Fprintln(w, err)
}
if extension == ".html" { // html file extension := filepath.Ext(string(sanitized_path))
t, err := template.ParseFS(webFS, "data/wrapper.tmpl", "data/"+path)
if err != nil {
daulog.Errorf("when fetching: %s got: %s", path, err)
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return
}
// 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
NewVersionAvailable bool
NewVersionInfo version.GithubRelease
} }
b.Path = path b.Body = string(data)
b.Version = version.CurrentVersion b.Path = string(sanitized_path)
b.NewVersionAvailable = version.UpdateAvailable() b.Version = config.CurrentVersion
b.NewVersionInfo = version.LatestVersionInfo 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.Errorf("when fetching: %s got: %s", path, err)
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return
}
w.Header().Set("Content-Type", mime.TypeByExtension(extension))
w.Write(otherStatic)
return return
} }
// otherwise we are a static thing
w.Header().Set("Content-Type", mime.TypeByExtension(extension))
w.Write(data)
//
} }
func (ws *WebService) getLogs(w http.ResponseWriter, r *http.Request) { // TODO there should be locks around all these config accesses
func getSetWebhook(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.WebHookURL}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
config.Config.WebHookURL = r.PostForm.Get("value")
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.WebHookURL}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
// TODO there should be locks around all these config accesses
func getSetUsername(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.Username}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
config.Config.Username = r.PostForm.Get("value")
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.Username}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getSetWatch(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: strconv.Itoa(config.Config.Watch)}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
i, err := strconv.Atoi(r.PostForm.Get("value"))
if err != nil {
response := errorResponse{Success: false, Error: fmt.Sprintf("Bad value for watch: %v", err)}
js, _ := json.Marshal(response)
w.Write(js)
return
}
if i < 1 {
response := errorResponse{Success: false, Error: "must be > 0"}
js, _ := json.Marshal(response)
w.Write(js)
return
}
config.Config.Watch = i
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: strconv.Itoa(config.Config.Watch)}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getSetNoWatermark(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueBooleanResponse{Success: true, Value: config.Config.NoWatermark}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
v := r.PostForm.Get("value")
if v != "0" && v != "1" {
response := errorResponse{Success: false, Error: fmt.Sprintf("Bad value for nowatermark: %v", err)}
js, _ := json.Marshal(response)
w.Write(js)
return
}
if v == "0" {
config.Config.NoWatermark = false
} else {
config.Config.NoWatermark = true
}
config.SaveConfig()
postResponse := valueBooleanResponse{Success: true, Value: config.Config.NoWatermark}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getSetDirectory(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.Path}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
newPath := r.PostForm.Get("value")
// sanity check this path
stat, err := os.Stat(newPath)
if os.IsNotExist(err) {
// not exist
response := errorResponse{Success: false, Error: fmt.Sprintf("Path: %s - does not exist", newPath)}
js, _ := json.Marshal(response)
w.Write(js)
return
} else if !stat.IsDir() {
// not a directory
response := errorResponse{Success: false, Error: fmt.Sprintf("Path: %s - is not a directory", newPath)}
js, _ := json.Marshal(response)
w.Write(js)
return
}
config.Config.Path = newPath
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.Path}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getSetExclude(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.Exclude}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
config.Config.Exclude = r.PostForm.Get("value")
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.Exclude}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getLogs(w http.ResponseWriter, r *http.Request) {
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.Memory.Entries() { 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,
) )
} }
// 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" {
newConfig := config.ConfigV3{}
defer r.Body.Close()
b, err := ioutil.ReadAll(r.Body)
if err != nil {
returnJSONError(w, "could not read body?")
return
}
err = json.Unmarshal(b, &newConfig)
if err != nil {
returnJSONError(w, "badly formed JSON")
return
}
ws.Config.Config = &newConfig
err = ws.Config.Save()
if err != nil {
returnJSONError(w, err.Error())
return
}
// config has changed, so tell the world
if ws.Config.Changed != nil {
ws.Config.Changed <- true
}
}
b, _ := json.Marshal(ws.Config.Config)
w.Write(b)
}
func (ws *WebService) getUploads(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ups := ws.Uploader.Uploads
text, err := json.Marshal(ups)
if err != nil {
// not sure how this would happen, so we probably want to find out the hard way
panic(err)
}
w.Write([]byte(text))
}
func (ws *WebService) imageThumb(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
vars := mux.Vars(r)
id, err := strconv.ParseInt(vars["id"], 10, 32)
if err != nil {
returnJSONError(w, "bad id")
return
}
ul := ws.Uploader.UploadById(int32(id))
if ul == nil {
returnJSONError(w, "bad id")
return
}
err = ul.Image.ThumbPNG(image.ThumbTypeOriginal, w)
if err != nil {
returnJSONError(w, "could not create thumb")
return
}
}
func (ws *WebService) imageMarkedupThumb(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
vars := mux.Vars(r)
id, err := strconv.ParseInt(vars["id"], 10, 32)
if err != nil {
returnJSONError(w, "bad id")
return
}
ul := ws.Uploader.UploadById(int32(id))
if ul == nil {
returnJSONError(w, "bad id")
return
}
err = ul.Image.ThumbPNG(image.ThumbTypeMarkedUp, w)
if err != nil {
returnJSONError(w, "could not create thumb")
return
}
}
func (ws *WebService) image(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.ParseInt(vars["id"], 10, 32)
if err != nil {
returnJSONError(w, "bad id")
return
}
ul := ws.Uploader.UploadById(int32(id))
if ul == nil {
returnJSONError(w, "bad id")
return
}
img, err := os.Open(ul.Image.OriginalFilename)
if err != nil {
returnJSONError(w, "could not open image file")
return
}
defer img.Close()
io.Copy(w, img)
}
func (ws *WebService) modifyUpload(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "POST" {
vars := mux.Vars(r)
change := vars["change"]
id, err := strconv.ParseInt(vars["id"], 10, 32)
if err != nil {
returnJSONError(w, "bad id")
return
}
anUpload := ws.Uploader.UploadById(int32(id))
if anUpload == nil {
returnJSONError(w, "bad id")
return
}
if anUpload.State == upload.StatePending {
if change == "start" {
anUpload.State = upload.StateQueued
res := StartUploadResponse{Success: true, Message: "upload queued"}
resString, _ := json.Marshal(res)
w.Write(resString)
return
} else if change == "skip" {
anUpload.State = upload.StateSkipped
anUpload.Image.Cleanup()
res := StartUploadResponse{Success: true, Message: "upload skipped"}
resString, _ := json.Marshal(res)
w.Write(resString)
return
} else if change == "markup" {
newImageData := r.FormValue("image")
//data:image/png;base64,xxxx
// I know this is dumb, we should just send binary image data, but I can't
// see that Fabric makes that possible.
if strings.Index(newImageData, "data:image/png;base64,") != 0 {
returnJSONError(w, "bad image data")
return
}
imageDataBase64 := newImageData[22:]
b, err := base64.StdEncoding.DecodeString(imageDataBase64)
if err != nil {
returnJSONError(w, err.Error())
return
}
// write to a temporary file
tempfile, err := ioutil.TempFile("", "dau_markup-*")
if err != nil {
log.Fatal(err)
}
n, err := tempfile.Write(b)
if n != len(b) {
log.Fatalf("only wrote %d bytes??", n)
}
if err != nil {
log.Fatalf("Could not write temp file: %v", err)
}
tempfile.Close()
anUpload.Image.ModifiedFilename = tempfile.Name()
} else {
returnJSONError(w, "bad change type")
return
}
}
res := StartUploadResponse{Success: false, Message: "upload does not exist, or already queued"}
resString, _ := json.Marshal(res)
w.WriteHeader(400)
w.Write(resString)
return
}
returnJSONError(w, "bad request")
} }
func (ws *WebService) StartWebServer() { func StartWebServer() {
r := mux.NewRouter() http.HandleFunc("/", getStatic)
http.HandleFunc("/rest/config/webhook", getSetWebhook)
r.HandleFunc("/rest/logs", ws.getLogs) http.HandleFunc("/rest/config/username", getSetUsername)
r.HandleFunc("/rest/uploads", ws.getUploads) http.HandleFunc("/rest/config/watch", getSetWatch)
r.HandleFunc("/rest/upload/{id:[0-9]+}/{change}", ws.modifyUpload) http.HandleFunc("/rest/config/nowatermark", getSetNoWatermark)
http.HandleFunc("/rest/config/directory", getSetDirectory)
r.HandleFunc("/rest/image/{id:[0-9]+}/thumb", ws.imageThumb) http.HandleFunc("/rest/config/exclude", getSetExclude)
r.HandleFunc("/rest/image/{id:[0-9]+}/markedup_thumb", ws.imageMarkedupThumb)
r.HandleFunc("/rest/image/{id:[0-9]+}", ws.image)
r.HandleFunc("/rest/config", ws.handleConfig)
r.PathPrefix("/").HandlerFunc(ws.getStatic)
http.HandleFunc("/rest/logs", getLogs)
go func() { go func() {
listen := fmt.Sprintf(":%d", ws.Config.Config.Port) log.Print("Starting web server on http://localhost:9090")
daulog.Infof("Starting web server on http://localhost%s", listen) err := http.ListenAndServe(":9090", nil) // set listen port
if err != nil {
srv := &http.Server{ log.Fatal("ListenAndServe: ", err)
Handler: r,
Addr: listen,
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
} }
log.Fatal(srv.ListenAndServe())
}() }()
} }
func returnJSONError(w http.ResponseWriter, errMessage string) {
w.WriteHeader(400)
errJSON := ErrorResponse{
Error: errMessage,
}
errString, _ := json.Marshal(errJSON)
w.Write(errString)
}

View File

@ -1,85 +0,0 @@
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)
}
exp := `{"WatchInterval":10,"Version":3,"Port":9090,"OpenBrowserOnStart":true,"Watchers":[{"WebHookURL":"https://webhook.url.here","Path":"/your/screenshot/dir/here","Username":"","NoWatermark":false,"HoldUploads":false,"Exclude":[]}]}`
if string(b) != exp {
t.Errorf("Got unexpected response\n%v\n%v", string(b), exp)
}
}