Compare commits

..

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

41 changed files with 221 additions and 3366 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 ./...

28
.gitignore vendored
View File

@ -1,8 +1,20 @@
dist
release
discord-auto-upload
discord-auto-upload.exe
*.png
*.jpg
.DS_Store
versioninfo.json
/blib/
/.build/
_build/
cover_db/
inc/
Build
!Build/
Build.bat
.last_cover_stats
/Makefile
/Makefile.old
/MANIFEST.bak
/META.yml
/META.json
/MYMETA.*
nytprof.out
/pm_to_blib
*.o
*.bs
/_eumm/

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"
]
}

17
BINARIES.md Normal file
View File

@ -0,0 +1,17 @@
# Building "binaries"
For perl toolchain-free distribution.
Install PAR::Packer first, then:
## Mac
pp -M IO::Socket::SSL -o dau-mac dau
## Linux
pp -M IO::Socket::SSL -o dau-linux dau
## Windows
pp -M IO::Socket::SSL -o dau.exe dau

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
Copyright (c) 2021 Justin Hawkins
Copyright (c) 2017 Justin Hawkins
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

126
README.md
View File

@ -1,113 +1,97 @@
# Automatically upload screenshots into a discord channel
# Automatically upload screenshots from your computer 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 script automaticall 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)
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
* A folder where screenshots are stored
* A [discord webhook](https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks)
* This program
* This script
* perl installed (or the windows binary)
## Getting started
### Binaries
### Linux
Binaries are available for Mac, Linux and Windows [here](https://github.com/tardisx/discord-auto-upload/releases/latest).
* Download this script:
#### From source
`curl -O https://raw.githubusercontent.com/tardisx/discord-auto-upload/master/dau`
You'll need to [download Go](https://golang.org/dl/), check the code out somewhere, run 'go generate' and then 'go build'.
* Put it somewhere on your path (if you want to be able to run it from anywhere)
* chmod +x it
* Install the dependencies:
CPAN: `cpan install Mojolicious IO::Socket::SSL`
CPANM: `cpanm Mojolicious IO::Socket::SSL`
Ubuntu/Debian: `sudo apt-get install libmojolicious-perl libio-socket-ssl-perl`
* test it:
`dau --help`
### Mac
Basically the same as Linux above. [Perlbrew](https://perlbrew.pl) is highly recommended so as not to disturb the system perl. No need for superuser access then either.
### Windows
* Grab the windows exe file:
`http://tba`
* Optional, put it somewhere on your path
* Open a command prompt
* Test it
`\some\path\dau --help`
If you want to hack it, audit it, or don't trust my exe, you can install
[Strawberry Perl](http://strawberryperl.com) and run it using that directly.
You'll need the same dependencies mentioned above in the Linux setup.
## Using it
`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
access to the web interface.
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
`dau` to be useful.
While running, `dau` will continually scan a directory for new images, and each time it finds one it will upload it to discord, via the discord web hook.
`dau` is a command line driven program. When executed, it will continually scan a directory for new images, and each time it finds one it will upload it to discord, via the discord web hook.
`dau` will only upload "new" screenshots, where "new" means a file that appears in a directory that it is watching, if it appears *after* it has started executing.
Thus, you do not have to worry about pointing `dau` at a directory full of images, it will only upload new ones.
## Configuration options
If `dau` is on your path, you can run it from your screenshot folder and there is then no need to specify the path to your images.
See the web interface at http://localhost:9090 to configure `dau`. The configuration is a single page of options,
no changes will take effect until the "Save All Configuration" button has been pressed.
Note that currently `dau` does not look in subdirectories. Please submit an issue if this is a use case for you.
### Global options
The only mandatory command line parameter is the discord webhook URL:
* Server port - the port number the web server listens on. Requires restart
* Watch interval - how often each watcher will check the directory for new files, in seconds
`--webhook URL` - the webhook URL (see [here](https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks) for details).
### Watcher configuration
Other parameters are:
There can be one or more watchers configured. Each watcher looks in a particular directory,
and uploads new files to a different discord channel.
`--watch xx` - specify how many seconds to wait between scanning the directory. The default is 10 seconds.
Each watcher has the following configuration options:
`--directory <somedir>` - the directory to watch for images to appear in. If this option is not supplied, will look in the current directory.
* 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
actually hide the bot identity in any way). You might like to set it to your own
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.
You will have to quote the path on windows, or anywhere where the directory path contains spaces.
## Holding uploads
`--username` - supply a 'username' with the webhook submission. Slightly misleading, it basically provides some extra text next to the "Bot" display on the upload to the channel.
If the "Hold Uploads" option is selected, newly found files will not immediately be uploaded. They will be available
in the "uploads" tab of the web interface. This has two purposes:
In the example screenshot, this was set to "tardisx uploaded from EDD".
* It gives you a chance to vet your screenshot selection before uploading
* It allows you to edit the images before uploading.
In the list of uploads there are three actions you can take on each file:
* Press "upload" to upload the image
* 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.
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.
`--debug` - provide extra debugging.
## Limitations/bugs
* Only files ending jpg, gif or png are uploaded.
* Subdirectories are not scanned.
* If multiple screenshots occur quickly (<1 second apart) not all may be uploaded.
* Files to upload are determined by the file modification time. If you drag and drop existing files they will
not be detected and uploaded. Only newly created files will be detected.
## Troubleshooting
Please check the "log" page on the web interface for information when things are
not working as you expect.
## TODO
This is just a 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.
Please include any relevant logs from the console when reporting bugs.
Open an [issue](https://github.com/tardisx/discord-auto-upload/issues/new) and let me know.

View File

@ -1,192 +0,0 @@
package config
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"strings"
daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/mitchellh/go-homedir"
)
// Config for the application
type ConfigV1 struct {
WebHookURL string
Path string
Watch int
Username string
NoWatermark bool
Exclude string
}
type Watcher struct {
WebHookURL string
Path string
Username string
NoWatermark bool
HoldUploads bool
Exclude []string
}
type ConfigV2 struct {
WatchInterval int
Version int
Port int
Watchers []Watcher
}
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) {
daulog.Info("NOTE: No config file, writing out sample configuration")
daulog.Info("You need to set the configuration via the web interface")
c.Config = DefaultConfig()
return c.Save()
} else {
return c.Load()
}
}
func DefaultConfig() *ConfigV3 {
c := ConfigV3{}
c.Version = 3
c.WatchInterval = 10
c.Port = 9090
c.OpenBrowserOnStart = true
w := Watcher{
WebHookURL: "https://webhook.url.here",
Path: "/your/screenshot/dir/here",
Username: "",
NoWatermark: false,
Exclude: []string{},
}
c.Watchers = []Watcher{w}
return &c
}
// Load will load the configuration from a known-to-exist config file.
func (c *ConfigService) Load() error {
daulog.Debugf("Loading from %s", c.ConfigFilename)
data, err := ioutil.ReadFile(c.ConfigFilename)
if err != nil {
return fmt.Errorf("cannot read config file %s: %s", c.ConfigFilename, err.Error())
}
err = json.Unmarshal([]byte(data), &c.Config)
if err != nil {
return fmt.Errorf("cannot decode config file %s: %s", c.ConfigFilename, err.Error())
}
// 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 {
dir, err := homedir.Dir()
if err != nil {
panic(err)
}
return dir
}
func defaultConfigPath() string {
homeDir := homeDir()
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())
}

128
dau Executable file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env perl
use strict;
use warnings;
use feature 'say';
use Mojo::UserAgent;
use Getopt::Long qw/GetOptions/;
use Data::Dumper qw/Dumper/;
my $webhook_url;
my $directory = "./";
my $username;
my $watch = 10;
my $help;
my $debug;
my $now = time();
my $error_count = 0;
my $error_max = 10;
my $version = '0.1';
GetOptions(
"webhook=s" => \$webhook_url,
"directory=s" => \$directory,
"username=s" => \$username,
"watch=i" => \$watch,
"help" => \$help,
"debug" => \$debug,
) || die usage();
usage() if $help;
if (! $webhook_url) {
usage("--webhook must be supplied");
}
sub usage {
my $error = shift || "";
my $indent = " " x length($0);
say "dau $version - https://github.com/tardisx/discord-auto-upload\n";
say "$0 --webhook <url> [--directory </some/path>]";
say "$indent [--username <\"custom username\">] [--watch <n>]\n";
say "The current directory will be used if no directory is specified.\n";
say "error: $error" if $error;
exit defined $error ? 1 : 0;
}
chdir $directory || die "cannot chdir to $directory: $!\n";
watch_dir();
sub watch_dir {
while (1) {
my @files = glob("*");
@files = grep { qualifies($_) } @files;
foreach my $file (sort { mtime($a) <=> mtime($b) } @files) {
debug("examining $file");
if (mtime($file) > $now) {
$now = mtime($file);
upload($file);
}
}
sleep $watch;
}
}
sub mtime {
my $f = shift;
return (stat($f))[9];
}
sub qualifies {
my $filename = shift;
return 1 if ($filename =~ /\.jpg$|\.gif$|\.png$/i);
return;
}
sub info {
say "- " . shift;
}
sub debug {
return unless $debug;
say "! " . shift;
}
sub error {
say "* " . shift;
}
sub upload {
my $file = shift;
info("uploading $file");
my $ua = Mojo::UserAgent->new;
my $data = {
upload => { file => $file },
$username ? ( username => $username ) : ()
};
my $tx = $ua->post($webhook_url, form => $data);
if (my $res = $tx->success) {
debug(Dumper($res->json));
my $url = $res->json->{attachments}->[0]->{url};
my $size = $res->json->{attachments}->[0]->{size};
my $width = $res->json->{attachments}->[0]->{width};
my $height = $res->json->{attachments}->[0]->{height};
info("uploaded ${width}x${height} $size bytes images to $url, submitted to webhook successfully");
}
else {
debug(Dumper($tx));
my $err = $tx->error;
if ($err->{code}) {
error("$err->{code} response: $err->{message}");
}
else {
error("Connection error: $err->{message}");
}
$error_count++;
if ($error_count >= $error_max) {
error("Sorry - too many errors - quitting");
}
}
}

203
dau.go
View File

@ -1,203 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"time"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"github.com/tardisx/discord-auto-upload/config"
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"
)
type watch struct {
lastCheck time.Time
newLastCheck time.Time
config config.Watcher
uploader *upload.Uploader
}
func main() {
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")
// open.Start("http://localhost:9090")
web := web.WebService{Config: conf, Uploader: up}
web.StartWebServer()
if conf.Config.OpenBrowserOnStart {
openWebBrowser(conf.Config.Port)
}
go func() {
version.GetOnlineVersion()
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 {
daulog.Debug("Creating watchers")
ctx, cancel := context.WithCancel(context.Background())
for _, c := range config.Config.Watchers {
daulog.Infof("Creating watcher for %s with interval %d", c.Path, config.Config.WatchInterval)
watcher := watch{uploader: up, lastCheck: time.Now(), newLastCheck: time.Now(), config: c}
go watcher.Watch(config.Config.WatchInterval, ctx)
}
// wait for single that the config changed
<-configChange
cancel()
daulog.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
w.uploader.Upload()
daulog.Debugf("sleeping for %ds before next check of %s", interval, w.config.Path)
time.Sleep(time.Duration(interval) * time.Second)
}
}
}
// ProcessNewFiles returns an array of new files that have appeared since
// the last time ProcessNewFiles was run.
func (w *watch) ProcessNewFiles() []string {
var newFiles []string
// check the path each time around, in case it goes away or something
if w.checkPath() {
// walk the path
err := filepath.WalkDir(w.config.Path,
func(path string, d fs.DirEntry, err error) error {
return w.checkFile(path, &newFiles, w.config.Exclude)
})
if err != nil {
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 {
daulog.Errorf("Problem with path '%s': %s", w.config.Path, err)
return false
}
if !src.IsDir() {
daulog.Errorf("Problem with path '%s': is not a directory", w.config.Path)
return false
}
return true
}
// checkFile checks if a file is eligible, first looking at extension (to
// avoid statting files uselessly) then modification times.
// If the file is eligible, not excluded and new enough to care we add it
// to the passed in array of files
func (w *watch) checkFile(path string, found *[]string, exclusions []string) error {
extension := strings.ToLower(filepath.Ext(path))
if !(extension == ".png" || extension == ".jpg" || extension == ".gif") {
return nil
}
fi, err := os.Stat(path)
if err != nil {
return err
}
if fi.ModTime().After(w.lastCheck) && fi.Mode().IsRegular() {
excluded := false
for _, exclusion := range exclusions {
if strings.Contains(path, exclusion) {
excluded = true
}
}
if !excluded {
*found = append(*found, path)
}
}
if w.newLastCheck.Before(fi.ModTime()) {
w.newLastCheck = fi.ModTime()
}
return nil
}
func parseOptions() {
var versionFlag bool
flag.BoolVar(&versionFlag, "version", false, "show version")
flag.Parse()
if versionFlag {
fmt.Println("dau - https://github.com/tardisx/discord-auto-upload")
fmt.Printf("Version: %s\n", version.CurrentVersion)
os.Exit(0)
}
}
func openWebBrowser(port int) {
address := fmt.Sprintf("http://localhost:%d", port)
open.Start(address)
}

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

15
go.mod
View File

@ -1,15 +0,0 @@
module github.com/tardisx/discord-auto-upload
go 1.16
require (
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/gorilla/mux v1.8.0
github.com/mitchellh/go-homedir v1.1.0
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-20210504121937-7319ad40d33e
golang.org/x/mod v0.7.0
)

68
go.sum
View File

@ -1,68 +0,0 @@
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/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/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/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
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/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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.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,95 +0,0 @@
package log
import (
"fmt"
"time"
)
type Logger interface {
WriteEntry(l LogEntry)
}
type LogEntryType string
type LogEntry struct {
Timestamp time.Time `json:"ts"`
Type LogEntryType `json:"type"`
Entry string `json:"log"`
}
const (
LogTypeInfo = "info"
LogTypeError = "error"
LogTypeDebug = "debug"
)
var loggers []Logger
var logInput chan LogEntry
var Memory *MemoryLogger
func init() {
// create some loggers
Memory = &MemoryLogger{maxsize: 100}
stdout := &StdoutLogger{}
loggers = []Logger{Memory, stdout}
// wait for log entries
logInput = make(chan LogEntry)
go func() {
for {
aLog := <-logInput
for _, l := range loggers {
l.WriteEntry(aLog)
}
}
}()
}
func Debug(entry string) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: entry,
Type: LogTypeDebug,
}
}
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,118 +0,0 @@
/*
* Globals
*/
/* Links */
a,
a:focus,
a:hover {
color: #f44;
}
/* Custom default button */
.btn-secondary,
.btn-secondary:hover,
.btn-secondary:focus {
color: #333;
text-shadow: none; /* Prevent inheritance from `body` */
background-color: #fff;
border: .05rem solid #fff;
}
/*
* Base structure
*/
html,
body {
height: 100%;
background-color: #333;
padding: 2em;
max-width: 80em;
}
body {
/* display: -ms-flexbox;
display: flex; */
color: #fff;
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
}
.DAU-container {
max-width: 52em;
}
.DAU-container-editor {
}
pre {
background-color: black;
color: aliceblue;
}
/*
* Header
*/
.masthead {
margin-bottom: 2rem;
}
.masthead-brand {
margin-bottom: 0;
}
.nav-masthead .nav-link {
padding: .25rem 0;
font-weight: 700;
color: rgba(255, 255, 255, .5);
background-color: transparent;
border-bottom: .25rem solid transparent;
}
.nav-masthead .nav-link:hover,
.nav-masthead .nav-link:focus {
border-bottom-color: rgba(255, 255, 255, .25);
}
.nav-masthead .nav-link + .nav-link {
margin-left: 1rem;
}
.nav-masthead .active {
color: #fff;
border-bottom-color: #fff;
}
@media (min-width: 48em) {
.masthead-brand {
float: left;
}
.nav-masthead {
float: right;
}
}
/*
* Cover
*/
.cover {
padding: 0 1.5rem;
}
.cover .btn-lg {
padding: .75rem 1.25rem;
font-weight: 700;
}
/*
* Footer
*/
.mastfoot {
color: rgba(255, 255, 255, .5);
}
/* for alpine.js */
[x-cloak] { display: none !important; }

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

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

View File

@ -1,368 +0,0 @@
package web
import (
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"io"
"io/ioutil"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/tardisx/discord-auto-upload/config"
"github.com/tardisx/discord-auto-upload/image"
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
type DAUWebServer struct {
// ConfigChange chan int
}
func (ws *WebService) getStatic(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
path = strings.TrimLeft(path, "/")
if path == "" {
path = "index.html"
}
extension := filepath.Ext(string(path))
if extension == ".html" { // html file
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
}
var b struct {
Body string
Path string
Version string
NewVersionAvailable bool
NewVersionInfo version.GithubRelease
}
b.Path = path
b.Version = version.CurrentVersion
b.NewVersionAvailable = version.UpdateAvailable()
b.NewVersionInfo = version.LatestVersionInfo
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
}
}
func (ws *WebService) getLogs(w http.ResponseWriter, r *http.Request) {
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 := ""
for _, log := range daulog.Memory.Entries() {
if !showDebug && log.Type == daulog.LogTypeDebug {
continue
}
text = text + fmt.Sprintf(
"%-6s %-19s %s\n", log.Type, log.Timestamp.Format("2006-01-02 15:04:05"), log.Entry,
)
}
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() {
r := mux.NewRouter()
r.HandleFunc("/rest/logs", ws.getLogs)
r.HandleFunc("/rest/uploads", ws.getUploads)
r.HandleFunc("/rest/upload/{id:[0-9]+}/{change}", ws.modifyUpload)
r.HandleFunc("/rest/image/{id:[0-9]+}/thumb", ws.imageThumb)
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)
go func() {
listen := fmt.Sprintf(":%d", ws.Config.Config.Port)
daulog.Infof("Starting web server on http://localhost%s", listen)
srv := &http.Server{
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)
}
}