90 Commits
0.7 ... v0.12.0

Author SHA1 Message Date
896946c751 Changelog 2022-04-03 19:02:12 +09:30
bc2e88786c Make web interface even more marginally better. 2022-04-03 19:00:31 +09:30
7c12b04700 Update documentation 2022-04-03 19:00:16 +09:30
2af34fddc8 Make config look marginally better. 2022-04-03 18:42:52 +09:30
093327088f Clean up changelog and javascript 2022-04-03 18:37:31 +09:30
369abfbbd3 Add fabric.js 2022-03-27 14:52:38 +10:30
9765c6909b Fix the scaling. 2022-03-27 14:51:56 +10:30
536657e0e8 Calculate scalefactor automatically 2021-12-30 21:53:34 +10:30
e049160cfc Use scalefactor to resize output appropriately and add a cancel function 2021-12-30 21:40:01 +10:30
111a33bc8a I guess not every image is pikachu 2021-12-29 21:58:23 +10:30
2f5bb2ff36 Cleanup temp files and return to upload page after editing. 2021-12-29 21:55:29 +10:30
563de29fcf Allow for marked up file thumbnails to be shown and uploaded. 2021-12-29 21:47:57 +10:30
bebc161256 Add vscode spelling info 2021-12-28 11:28:07 +10:30
4118866f7b Be able to push marked up image back to the server. 2021-12-28 11:27:37 +10:30
35e5a00888 Fix failing test with new config option 2021-12-16 23:01:34 +10:30
f64240e135 Delete option and better display layout 2021-12-16 22:58:46 +10:30
a099d738fc Arrange the colour choices more neatly. 2021-12-16 21:32:03 +10:30
e110fc307f Editor with basic text colour picker. 2021-12-14 23:15:40 +10:30
0a72d6e2dd Start of online image editing support. 2021-11-08 09:11:46 +10:30
ced209a7db Add "Hold Uploads" option to configuration 2021-11-07 13:43:25 +10:30
1228920004 Update changelog and bump version for this next release. 2021-11-07 13:29:57 +10:30
2042c7520d Add image thumbnail previews to the uploads page. 2021-11-07 13:25:18 +10:30
42fb7a2003 Continue changes to allow uploads to be "pending" so a decision can be made to either upload or skip them (and ultimately, edit as well). 2021-11-06 22:56:27 +10:30
e1f18b104f Remove debugging 2021-11-02 22:14:01 +10:30
5adf81fcf6 Fix typo 2021-11-02 22:10:47 +10:30
726ae9a5aa Use states on Uploads 2021-11-02 22:10:19 +10:30
6fa6c34ccb Add new function to generate files that can be resized. Add states for upcoming queue changes. 2021-11-01 20:59:01 +10:30
71c70ce965 Update changelog 2021-10-19 22:45:41 +10:30
02b26e60a9 Update changelog 2021-10-19 22:44:22 +10:30
a9df878024 Bump version 2021-10-19 22:40:59 +10:30
460fcf5523 Add test for the too big upload fail 2021-10-19 22:40:29 +10:30
672fd83f27 Properly fail an upload when it is too large. 2021-10-19 22:39:27 +10:30
c3f1813f6e Close files so that tests pass on windows 2021-10-16 16:18:02 +10:30
79d14c00bc Bump version to point release and update changelog 2021-10-11 22:53:37 +10:30
f180900d79 Error handling for the /rest/uploads endpoint 2021-10-11 22:50:10 +10:30
2c4c9fdde6 Handle "entity too large" errors immediately instead of retrying. 2021-10-11 22:48:49 +10:30
b9cacf6d33 Fix problem with uploads rest endpoint 2021-10-11 22:47:43 +10:30
6ad242e063 Fix version check test too 2021-10-11 21:02:08 +10:30
87d8222bc8 Argh - I got the version check backwards :-( 2021-10-11 20:58:42 +10:30
7670a0c5b7 Mark completely failed uploads as failed so we don't keep retrying them 2021-10-11 20:55:35 +10:30
2a3f4ea21a Add the ability to mock the http client and a test. 2021-10-11 20:25:53 +10:30
244cd7b9da Clean up console log printing 2021-10-11 20:25:20 +10:30
c2bbe13ca7 Clean up modules 2021-10-11 20:25:06 +10:30
1ef5ed3ce4 Fix release script 2021-10-10 14:55:46 +10:30
ec8b2453cd Clean up logging 2021-10-10 14:54:08 +10:30
3776374747 Fix test 2021-10-10 14:45:16 +10:30
6ece881a52 Fix upload logs 2021-10-10 14:44:12 +10:30
4534394abc Lesson learned - read the test output carefully 2021-10-10 14:13:59 +10:30
4c963ba559 Just a stab in the dark 2021-10-10 14:04:25 +10:30
c9f8ad60c3 Add debugging to work out why this is failing on github 2021-10-10 13:59:18 +10:30
4665380d15 Add a changelog 2021-10-10 13:57:34 +10:30
a739e62824 Fix test 2021-10-10 13:54:26 +10:30
c87d6ba79d Merge branch 'master' of https://github.com/tardisx/discord-auto-upload 2021-10-10 13:52:08 +10:30
fd6f6884ee Notice configuration changes and restart the watchers. 2021-10-10 13:51:52 +10:30
14ce147ec6 Notice configuration changes and restart the watchers. 2021-10-10 13:43:36 +10:30
90f8c3588b Sanity check the watch interval 2021-10-10 12:51:15 +10:30
3e6cf49394 Add sanity checks for configuration and UI to display errors. 2021-10-10 12:47:37 +10:30
1809033049 Support exclusions again. 2021-10-10 12:00:25 +10:30
f9614ffc48 Remove some system-specific testdata 2021-10-10 11:52:19 +10:30
26d4272aa2 Make config look less crappy 2021-10-10 11:51:20 +10:30
2b06c37be8 Fix debug output for startup and test 2021-10-06 23:17:16 +10:30
8483fe7db9 Big refactor to allow for multiple watchers, v2 configuration file with migration and new UI for configuration 2021-10-06 23:12:43 +10:30
7dddc92364 I'm no longer going to call this a quick hack when I spend significant refactoring time :-) 2021-10-04 19:48:26 +10:30
7f3161143f Start to refactor config to support new version of configuration with multiple watchers. 2021-10-04 15:38:49 +10:30
e3a7fad7a9 Add the badge. Everyone loves badges. 2021-10-04 13:30:03 +10:30
c85d134f7b Create go.yml 2021-10-04 13:28:28 +10:30
dd79dbed1d Better handling for not found cases, and test 2021-10-04 13:27:46 +10:30
87acf0aefb Rework to use golang 1.16 embed package instead of go-bindata. Rework templates to be less insane 2021-10-04 13:03:26 +10:30
3a65a60fcb Move software version handling to a new package 2021-10-04 12:20:16 +10:30
1812486b19 Switch to semver, basic test. 2021-06-17 18:47:59 +09:30
c47660addf Add uploads page 2021-06-08 22:20:11 +09:30
2d0e294af6 Fix title 2021-06-08 22:19:54 +09:30
283e0f3584 Bump version 2021-06-08 22:19:37 +09:30
9ef6ab71c7 Add upload package and update dependencies 2021-06-07 21:13:57 +09:30
701583d3fd Update year 2021-06-07 21:13:18 +09:30
0c156a19f0 Fix punctuation 2021-06-06 22:22:44 +09:30
cc54bb6469 Enable filtering by debug and automatic scroll to bottom 2021-06-06 17:15:38 +09:30
71c097e578 Update gitignore 2021-06-06 17:15:19 +09:30
d23e31c0e0 Bump version 2021-06-04 10:42:26 +09:30
9cb79a846e Umm, how did this ever work? 😫 2021-06-04 10:41:23 +09:30
a5ce0c7f63 Documenation update 2021-06-03 19:42:12 +09:30
bcc4e145a2 Limit number of log entries stored 2021-06-03 19:40:53 +09:30
d8c0b7d0ea Fix error popup 2021-06-03 19:40:43 +09:30
fdf70daba7 Improve log display, use a <pre> so it can be easily cut and pasted. 2021-06-03 19:36:48 +09:30
b69cdebf3b Send logs to web server for display there 2021-06-02 23:42:29 +09:30
9e22490fe2 Merge pull request #10 from NoahBohme/master
Open discord link in a new tab
2021-03-15 23:30:56 +10:30
NoahBohme
ae24f16631 Delete .github/workflows directory 2021-03-15 11:51:07 +01:00
NoahBohme
b69acac0d0 Create go.yml 2021-02-23 09:46:47 +01:00
noah
c833f185cc add link for discord webhook 2021-02-23 08:23:10 +01:00
noah
b3ee0d9d1d Open discord link in a new tab 2021-02-23 08:16:59 +01:00
32 changed files with 2372 additions and 757 deletions

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

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

2
.gitignore vendored
View File

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

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"cSpell.words": [
"daulog",
"inconsolata"
]
}

83
CHANGELOG.md Normal file
View File

@@ -0,0 +1,83 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [v0.12.0] - 2020-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) 2017 Justin Hawkins
Copyright (c) 2021 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

View File

@@ -1,11 +1,15 @@
# Automatically upload screenshots into a discord channel
[![Go](https://github.com/tardisx/discord-auto-upload/actions/workflows/go.yml/badge.svg)](https://github.com/tardisx/discord-auto-upload/actions/workflows/go.yml)
This program automatically uploads new screenshots that appear in a folder on your computer to Discord and posts them in a channel:
![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
@@ -20,7 +24,7 @@ Binaries are available for Mac, Linux and Windows [here](https://github.com/tard
#### From source
You'll need to [download Go](https://golang.org/dl/) check the code out somewhere, run 'go generate' and then 'go build'.
You'll need to [download Go](https://golang.org/dl/), check the code out somewhere, run 'go generate' and then 'go build'.
## Using it
@@ -39,39 +43,51 @@ Thus, you do not have to worry about pointing `dau` at a directory full of image
## Configuration options
See the web interface at http://localhost:9090 to configure `dau`.
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.
### 'Discord WebHook URL'
### Global options
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.
* 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
### 'Bot Username'
### Watcher configuration
This is completely optional and can be any arbitrary string. It makes the upload
There can be one or more watchers configured. Each watcher looks in a particular directory,
and uploads new files to a different discord channel.
Each watcher has the following configuration options:
* Directory to watch - This is the path that `dau` will periodically inspect, looking for new images.
Note that subdirectories are also scanned. You need to enter the full filesystem path here.
* Discord WebHook URL - The webhook URL from Discord. See https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks for more information on setting one up.
* Username - This is completely optional and can be any arbitrary string. It makes the upload
appear to come from a different user (though this is visual only, and does not
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.
### 'Directory to watch'
## Holding uploads
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.
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:
### 'Period between filesystem checks'
* It gives you a chance to vet your screenshot selection before uploading
* It allows you to edit the images before uploading.
This is the number of seconds between which `dau` will look for new images.
In the list of uploads there are three actions you can take on each file:
### 'Do not watermark images'
* Press "upload" to upload the image
* Press "reject" to reject the image
* Click on the image thumbnail to edit the image
This will disable the watermarking of images. I like it when you don't set this :-)
### 'Files to exclude'
This is a string to match against the filename to check for exclusions. The common
use case is to use 'thumbnail' or similar if your image directory contains additional
thumbnail files.
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.
## Limitations/bugs
@@ -80,8 +96,13 @@ thumbnail files.
* Files to upload are determined by the file modification time. If you drag and drop existing files they will
not be detected and uploaded. Only newly created files will be detected.
## TODO
This is just a relatively quick hack. Open to suggestions on new features and improvements.
## Troubleshooting
Please check the "log" page on the web interface for information when things are
not working as you expect.
## TODO
Open an [issue](https://github.com/tardisx/discord-auto-upload/issues/new) and let me know what you'd like to see.
Open an [issue](https://github.com/tardisx/discord-auto-upload/issues/new) and let me know.
Please include any relevant logs from the console when reporting bugs.

View File

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

View File

@@ -1,15 +1,19 @@
package config
import (
"github.com/mitchellh/go-homedir"
"log"
"os"
"encoding/json"
"io/ioutil"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"strings"
daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/mitchellh/go-homedir"
)
// Config for the application
var Config struct {
type ConfigV1 struct {
WebHookURL string
Path string
Watch int
@@ -18,55 +22,155 @@ var Config struct {
Exclude string
}
const CurrentVersion string = "0.7"
// Load the current config or initialise with defaults
func LoadOrInit() {
configPath := configPath()
log.Printf("Trying to load from %s\n", configPath)
_, err := os.Stat(configPath)
if os.IsNotExist(err) {
log.Printf("NOTE: No config file, writing out sample configuration")
log.Printf("You need to set the configuration via the web interface")
Config.WebHookURL = ""
Config.Path = homeDir() + string(os.PathSeparator) + "screenshots"
Config.Watch = 10
SaveConfig()
} else {
LoadConfig()
}
type Watcher struct {
WebHookURL string
Path string
Username string
NoWatermark bool
HoldUploads bool
Exclude []string
}
func LoadConfig() {
path := configPath()
data, err := ioutil.ReadFile(path)
if err != nil {
log.Fatalf("cannot read config file %s: %s", path, err.Error())
}
err = json.Unmarshal([]byte(data), &Config)
if err != nil {
log.Fatalf("cannot decode config file %s: %s", path, err.Error())
}
type ConfigV2 struct {
WatchInterval int
Version int
Port int
Watchers []Watcher
}
func SaveConfig() {
log.Print("saving configuration")
path := configPath()
jsonString, _ := json.Marshal(Config)
err := ioutil.WriteFile(path, jsonString, os.ModePerm)
if (err != nil) {
log.Fatalf("Cannot save config %s: %s", path, err.Error())
}
type ConfigService struct {
Config *ConfigV2
Changed chan bool
ConfigFilename string
}
func DefaultConfigService() *ConfigService {
c := ConfigService{
ConfigFilename: defaultConfigPath(),
}
return &c
}
// LoadOrInit loads the current configuration from the config file, or creates
// a new config file if none exists.
func (c *ConfigService) LoadOrInit() error {
daulog.SendLog(fmt.Sprintf("Trying to load config from %s\n", c.ConfigFilename), daulog.LogTypeDebug)
_, err := os.Stat(c.ConfigFilename)
if os.IsNotExist(err) {
daulog.SendLog("NOTE: No config file, writing out sample configuration", daulog.LogTypeInfo)
daulog.SendLog("You need to set the configuration via the web interface", daulog.LogTypeInfo)
c.Config = DefaultConfig()
return c.Save()
} else {
return c.Load()
}
}
func DefaultConfig() *ConfigV2 {
c := ConfigV2{}
c.Version = 2
c.WatchInterval = 10
c.Port = 9090
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 {
fmt.Printf("Loading from %s\n\n", c.ConfigFilename)
data, err := ioutil.ReadFile(c.ConfigFilename)
if err != nil {
return fmt.Errorf("cannot read config file %s: %s", c.ConfigFilename, err.Error())
}
err = json.Unmarshal([]byte(data), &c.Config)
if err != nil {
return fmt.Errorf("cannot decode config file %s: %s", c.ConfigFilename, err.Error())
}
// Version 0 predates config migrations
if c.Config.Version == 0 {
// need to migrate this
daulog.SendLog("Migrating config to V2", daulog.LogTypeInfo)
configV1 := ConfigV1{}
err = json.Unmarshal([]byte(data), &configV1)
if err != nil {
return fmt.Errorf("cannot decode legacy config file as v1 %s: %s", c.ConfigFilename, err.Error())
}
// copy stuff across
c.Config.Version = 2
c.Config.WatchInterval = configV1.Watch
c.Config.Port = 9090 // this never used to be configurable
onlyWatcher := Watcher{
WebHookURL: configV1.WebHookURL,
Path: configV1.Path,
Username: configV1.Username,
NoWatermark: configV1.NoWatermark,
Exclude: strings.Split(configV1.Exclude, " "),
}
c.Config.Watchers = []Watcher{onlyWatcher}
}
return nil
}
func (c *ConfigService) Save() error {
daulog.SendLog("saving configuration", daulog.LogTypeInfo)
// 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;
dir, err := homedir.Dir()
if err != nil {
panic(err)
}
return dir
}
func configPath() string {
homeDir := homeDir()
return homeDir + string(os.PathSeparator) + ".dau.json"
func defaultConfigPath() string {
homeDir := homeDir()
return homeDir + string(os.PathSeparator) + ".dau.json"
}

102
config/config_test.go Normal file
View File

@@ -0,0 +1,102 @@
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 != 2 {
t.Error("not version 2 starting config")
}
if fileSize(c.ConfigFilename) < 40 {
t.Errorf("File is too small %d bytes", fileSize(c.ConfigFilename))
}
}
func TestEmptyFileConfig(t *testing.T) {
c := ConfigService{}
c.ConfigFilename = emptyTempFile()
defer os.Remove(c.ConfigFilename)
err := c.LoadOrInit()
if err == nil {
t.Error("unexpected success from LoadOrInit()")
}
}
func TestMigrateFromV1toV2(t *testing.T) {
c := ConfigService{}
c.ConfigFilename = v1Config()
err := c.LoadOrInit()
if err != nil {
t.Error("unexpected error from LoadOrInit()")
}
if c.Config.Version != 2 {
t.Errorf("Version %d not 2", c.Config.Version)
}
if len(c.Config.Watchers) != 1 {
t.Error("wrong amount of watchers")
}
if c.Config.Watchers[0].Path != "/private/tmp" {
t.Error("Wrong path")
}
if c.Config.WatchInterval != 69 {
t.Error("Wrong watch interval")
}
if c.Config.Port != 9090 {
t.Error("Wrong port")
}
}
func v1Config() string {
f, err := ioutil.TempFile("", "dautest-*")
if err != nil {
panic(err)
}
config := `{"WebHookURL":"https://discord.com/api/webhooks/abc123","Path":"/private/tmp","Watch":69,"Username":"abcdedf","NoWatermark":true,"Exclude":"ab cd ef"}`
f.Write([]byte(config))
defer f.Close()
return f.Name()
}
func emptyTempFile() string {
f, err := ioutil.TempFile("", "dautest-*")
if err != nil {
panic(err)
}
f.Close()
return f.Name()
}
func fileSize(file string) int {
fi, err := os.Stat(file)
if err != nil {
panic(err)
}
return int(fi.Size())
}

View File

@@ -1,153 +0,0 @@
<main role="main" class="inner DAU">
<h1 class="DAU-heading">Config</h1>
<p class="lead">Discord-auto-upload configuration</p>
<form class="">
<div class="form-row align-items-center config-item" data-key="webhook">
<div class="col-sm-5 my-1">
<span>Discord WebHook URL</span>
</div>
<div class="col-sm-4 my-1">
<label class="sr-only" for="inlineFormInputName">Name</label>
<input type="text" class="form-control rest-field" placeholder="https://....">
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
<form class="">
<div class="form-row align-items-center config-item" data-key="username">
<div class="col-sm-5 my-1">
<span>Bot username (optional)</span>
</div>
<div class="col-sm-4 my-1">
<label class="sr-only" for="inlineFormInputName">Name</label>
<input type="text" class="form-control rest-field" placeholder="">
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
<form class="">
<div class="form-row align-items-center config-item" data-key="directory">
<div class="col-sm-5 my-1">
<span>Directory to watch</span>
</div>
<div class="col-sm-4 my-1">
<label class="sr-only" for="inlineFormInputName">Name</label>
<input type="text" class="form-control rest-field" placeholder="/...">
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
<form class="">
<div class="form-row align-items-center config-item" data-key="watch">
<div class="col-sm-5 my-1">
<span>Period between filesystem checks (seconds)</span>
</div>
<div class="col-sm-4 my-1">
<label class="sr-only" for="inlineFormInputName">Seconds</label>
<input type="text" class="form-control rest-field " placeholder="/...">
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
<form class="">
<div class="form-row align-items-center config-item" data-key="nowatermark">
<div class="col-sm-5 my-1">
<span>Do not watermark images</span>
</div>
<div class="col-sm-4 my-1">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input rest-field rest-field-boolean" id="input-nowatermark">
<label class="custom-control-label" for="input-nowatermark">&nbsp;</label>
<span id="sadness" style="">😭</span>
</div>
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
<form class="">
<div class="form-row align-items-center config-item" data-key="exclude">
<div class="col-sm-5 my-1">
<span>Files to exclude</span>
</div>
<div class="col-sm-4 my-1">
<label class="sr-only" for="input-exclude">Name</label>
<input type="text" id="input-exclude" class="form-control rest-field" placeholder="">
</div>
<div class="col-auto my-1">
<button type="submit" class="btn btn-primary">update</button>
</div>
</div>
</form>
</main>
<script>
function update_sadness () {
if ($('#input-nowatermark').prop('checked')) {
$('#sadness').css('visibility','');
}
else {
$('#sadness').css('visibility','hidden');
}
}
$(document).ready(function() {
$('#input-nowatermark').on('click', function() { update_sadness(); });
// populate each field
$('.config-item').each(function() {
let el = $(this);
let key = el.data('key');
$.ajax({ method: 'get', url: '/rest/config/'+key})
.done(function(data) {
var this_el = $(".config-item[data-key='"+key+"']").find('.rest-field');
if (this_el.hasClass('rest-field-boolean')) {
this_el.prop('checked', data.Value);
}
else {
this_el.val(data.Value);
}
update_sadness();
});
});
// respond to button clicks to update
$('.config-item button').on('click', function(e,f) {
key = $(this).parents('.config-item').data('key');
val = $(this).parents('.config-item').find('.rest-field').val();
if ($(this).parents('.config-item').find('.rest-field-boolean').length) {
val = $(this).parents('.config-item').find('.rest-field').prop('checked') ? 1 : 0;
}
$.post('/rest/config/'+key, { value: val })
.done(function(d) {
if (d.Success) {
alert('Updated config');
} else {
alert("Error: " + d.Error);
}
});
return false;
});
});
</script>

417
dau.go
View File

@@ -1,91 +1,189 @@
package main
//go:generate go-bindata -pkg assets -o assets/static.go -prefix data/ data
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"io/fs"
"io/ioutil"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"github.com/fogleman/gg"
"github.com/pborman/getopt"
// "github.com/skratchdot/open-golang/open"
"golang.org/x/image/font/inconsolata"
"github.com/tardisx/discord-auto-upload/config"
daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/tardisx/discord-auto-upload/upload"
// "github.com/tardisx/discord-auto-upload/upload"
"github.com/tardisx/discord-auto-upload/version"
"github.com/tardisx/discord-auto-upload/web"
)
var lastCheck = time.Now()
var newLastCheck = time.Now()
type watch struct {
lastCheck time.Time
newLastCheck time.Time
config config.Watcher
uploader *upload.Uploader
}
func main() {
parseOptions()
// grab the config, register to notice changes
config := config.DefaultConfigService()
configChanged := make(chan bool)
config.Changed = configChanged
config.LoadOrInit()
// create the uploader
up := upload.NewUploader()
// log.Print("Opening web browser")
// open.Start("http://localhost:9090")
go web.StartWebServer()
web := web.WebService{Config: config, Uploader: up}
web.StartWebServer()
checkUpdates()
go func() { checkUpdates() }()
log.Print("Waiting for images to appear in ", config.Config.Path)
// wander the path, forever
// create the watchers, restart them if config changes
// blocks forever
startWatchers(config, up, configChanged)
}
func startWatchers(config *config.ConfigService, up *upload.Uploader, configChange chan bool) {
for {
if checkPath(config.Config.Path) {
err := filepath.Walk(config.Config.Path,
func(path string, f os.FileInfo, err error) error { return checkFile(path, f, err) })
if err != nil {
log.Fatal("could not watch path", err)
}
lastCheck = newLastCheck
daulog.SendLog("Creating watchers", daulog.LogTypeInfo)
ctx, cancel := context.WithCancel(context.Background())
for _, c := range config.Config.Watchers {
log.Printf("Creating watcher for %s interval %d", c.Path, config.Config.WatchInterval)
watcher := watch{uploader: up, lastCheck: time.Now(), newLastCheck: time.Now(), config: c}
go watcher.Watch(config.Config.WatchInterval, ctx)
}
// wait for single that the config changed
<-configChange
cancel()
daulog.SendLog("starting new watchers due to config change", daulog.LogTypeInfo)
}
}
func (w *watch) Watch(interval int, ctx context.Context) {
for {
select {
case <-ctx.Done():
daulog.SendLog("Killing old watcher", daulog.LogTypeInfo)
return
default:
newFiles := w.ProcessNewFiles()
for _, f := range newFiles {
w.uploader.AddFile(f, w.config)
}
// upload them
w.uploader.Upload()
daulog.SendLog(fmt.Sprintf("sleeping for %ds before next check of %s", interval, w.config.Path), daulog.LogTypeDebug)
time.Sleep(time.Duration(interval) * time.Second)
}
log.Printf("sleeping for %ds before next check of %s", config.Config.Watch, config.Config.Path)
time.Sleep(time.Duration(config.Config.Watch) * time.Second)
}
}
func checkPath(path string) bool {
src, err := os.Stat(path)
// ProcessNewFiles returns an array of new files that have appeared since
// the last time ProcessNewFiles was run.
func (w *watch) ProcessNewFiles() []string {
var newFiles []string
// check the path each time around, in case it goes away or something
if w.checkPath() {
// walk the path
err := filepath.WalkDir(w.config.Path,
func(path string, d fs.DirEntry, err error) error {
return w.checkFile(path, &newFiles, 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 {
log.Printf("Problem with path '%s': %s", path, err)
daulog.SendLog(fmt.Sprintf("Problem with path '%s': %s", w.config.Path, err), daulog.LogTypeError)
return false
}
if !src.IsDir() {
log.Printf("Problem with path '%s': is not a directory", path)
daulog.SendLog(fmt.Sprintf("Problem with path '%s': is not a directory", w.config.Path), daulog.LogTypeError)
return false
}
return true
}
// checkFile checks if a file is eligible, first looking at extension (to
// avoid statting files uselessly) then modification times.
// If the file is eligible, not excluded and new enough to care we add it
// to the passed in array of files
func (w *watch) checkFile(path string, found *[]string, exclusions []string) error {
extension := strings.ToLower(filepath.Ext(path))
if !(extension == ".png" || extension == ".jpg" || extension == ".gif") {
return nil
}
fi, err := os.Stat(path)
if err != nil {
return err
}
if fi.ModTime().After(w.lastCheck) && fi.Mode().IsRegular() {
excluded := false
for _, exclusion := range exclusions {
if strings.Contains(path, exclusion) {
excluded = true
}
}
if !excluded {
*found = append(*found, path)
}
}
if w.newLastCheck.Before(fi.ModTime()) {
w.newLastCheck = fi.ModTime()
}
return nil
}
func checkUpdates() {
type GithubRelease struct {
HTMLURL string
TagName string
Name string
Body string
HTMLURL string `json:"html_url"`
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
}
daulog.SendLog("checking for new version", daulog.LogTypeInfo)
client := &http.Client{Timeout: time.Second * 5}
resp, err := client.Get("https://api.github.com/repos/tardisx/discord-auto-upload/releases/latest")
if err != nil {
log.Print("WARNING: Update check failed: ", err)
daulog.SendLog(fmt.Sprintf("WARNING: Update check failed: %v", err), daulog.LogTypeError)
return
}
defer resp.Body.Close()
@@ -101,257 +199,30 @@ func checkUpdates() {
log.Fatal("could not parse JSON: ", err)
}
if config.CurrentVersion < latest.TagName {
fmt.Printf("You are currently on version %s, but version %s is available\n", config.CurrentVersion, latest.TagName)
// pre v0.11.0 version (ie before semver) did a simple string comparison,
// but since "0.10.0" < "v0.11.0" they should still get prompted to upgrade
// ok
if version.NewVersionAvailable(latest.TagName) {
fmt.Printf("You are currently on version %s, but version %s is available\n", version.CurrentVersion, latest.TagName)
fmt.Println("----------- Release Info -----------")
fmt.Println(latest.Body)
fmt.Println("------------------------------------")
fmt.Println("Upgrade at https://github.com/tardisx/discord-auto-upload/releases/latest")
daulog.SendLog(fmt.Sprintf("New version available: %s - download at https://github.com/tardisx/discord-auto-upload/releases/latest", latest.TagName), daulog.LogTypeInfo)
}
daulog.SendLog("already running latest version", daulog.LogTypeInfo)
}
func parseOptions() {
var versionFlag bool
flag.BoolVar(&versionFlag, "version", false, "show version")
flag.Parse()
// Declare the flags to be used
helpFlag := getopt.BoolLong("help", 'h', "help")
versionFlag := getopt.BoolLong("version", 'v', "show version")
getopt.SetParameters("")
getopt.Parse()
if *helpFlag {
getopt.PrintUsage(os.Stderr)
os.Exit(0)
}
if *versionFlag {
if versionFlag {
fmt.Println("dau - https://github.com/tardisx/discord-auto-upload")
fmt.Printf("Version: %s\n", config.CurrentVersion)
fmt.Printf("Version: %s\n", version.CurrentVersion)
os.Exit(0)
}
// grab the config
config.LoadOrInit()
}
func checkFile(path string, f os.FileInfo, err error) error {
if f.ModTime().After(lastCheck) && f.Mode().IsRegular() {
if fileEligible(path) {
// process file
processFile(path)
}
if newLastCheck.Before(f.ModTime()) {
newLastCheck = f.ModTime()
}
}
return nil
}
func fileEligible(file string) bool {
if config.Config.Exclude != "" && strings.Contains(file, config.Config.Exclude) {
return false
}
extension := strings.ToLower(filepath.Ext(file))
if extension == ".png" || extension == ".jpg" || extension == ".gif" {
return true
}
return false
}
func processFile(file string) {
if !config.Config.NoWatermark {
log.Print("Copying to temp location and watermarking ", file)
file = mungeFile(file)
}
if config.Config.WebHookURL == "" {
log.Print("WebHookURL is not configured - cannot upload!")
return
}
log.Print("Uploading ", file)
extraParams := map[string]string{}
if config.Config.Username != "" {
log.Print("Overriding username with " + config.Config.Username)
extraParams["username"] = config.Config.Username
}
type DiscordAPIResponseAttachment struct {
URL string
ProxyURL string
Size int
Width int
Height int
Filename string
}
type DiscordAPIResponse struct {
Attachments []DiscordAPIResponseAttachment
ID int64 `json:",string"`
}
var retriesRemaining = 5
for retriesRemaining > 0 {
request, err := newfileUploadRequest(config.Config.WebHookURL, extraParams, "file", file)
if err != nil {
log.Fatal(err)
}
start := time.Now()
client := &http.Client{Timeout: time.Second * 30}
resp, err := client.Do(request)
if err != nil {
log.Print("Error performing request:", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
} else {
if resp.StatusCode != 200 {
log.Print("Bad response from server:", resp.StatusCode)
if b, err := ioutil.ReadAll(resp.Body); err == nil {
log.Print("Body:", string(b))
}
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
resBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Print("could not deal with body: ", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
resp.Body.Close()
var res DiscordAPIResponse
err = json.Unmarshal(resBody, &res)
if err != nil {
log.Print("could not parse JSON: ", err)
fmt.Println("Response was:", string(resBody[:]))
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
if len(res.Attachments) < 1 {
log.Print("bad response - no attachments?")
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
var a = res.Attachments[0]
elapsed := time.Since(start)
rate := float64(a.Size) / elapsed.Seconds() / 1024.0
log.Printf("Uploaded to %s %dx%d", a.URL, a.Width, a.Height)
log.Printf("id: %d, %d bytes transferred in %.2f seconds (%.2f KiB/s)", res.ID, a.Size, elapsed.Seconds(), rate)
break
}
}
if !config.Config.NoWatermark {
log.Print("Removing temporary file ", file)
os.Remove(file)
}
if retriesRemaining == 0 {
log.Fatal("Failed to upload, even after retries")
}
}
func sleepForRetries(retry int) {
if retry == 0 {
return
}
retryTime := (6-retry)*(6-retry) + 6
log.Printf("Will retry in %d seconds (%d remaining attempts)", retryTime, retry)
time.Sleep(time.Duration(retryTime) * time.Second)
}
func newfileUploadRequest(uri string, params map[string]string, paramName, path string) (*http.Request, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(paramName, filepath.Base(path))
if err != nil {
return nil, err
}
_, err = io.Copy(part, file)
if err != nil {
log.Fatal("Could not copy: ", err)
}
for key, val := range params {
_ = writer.WriteField(key, val)
}
err = writer.Close()
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", uri, body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req, err
}
func mungeFile(path string) string {
reader, err := os.Open(path)
if err != nil {
log.Fatal(err)
}
defer reader.Close()
im, _, err := image.Decode(reader)
if err != nil {
log.Fatal(err)
}
bounds := im.Bounds()
// var S float64 = float64(bounds.Max.X)
dc := gg.NewContext(bounds.Max.X, bounds.Max.Y)
dc.Clear()
dc.SetRGB(0, 0, 0)
dc.SetFontFace(inconsolata.Regular8x16)
dc.DrawImage(im, 0, 0)
dc.DrawRoundedRectangle(0, float64(bounds.Max.Y-18.0), 320, float64(bounds.Max.Y), 0)
dc.SetRGB(0, 0, 0)
dc.Fill()
dc.SetRGB(1, 1, 1)
dc.DrawString("github.com/tardisx/discord-auto-upload", 5.0, float64(bounds.Max.Y)-5.0)
tempfile, err := ioutil.TempFile("", "dau")
if err != nil {
log.Fatal(err)
}
tempfile.Close()
os.Remove(tempfile.Name())
actualName := tempfile.Name() + ".png"
dc.SavePNG(actualName)
return actualName
}

105
dau_test.go Normal file
View File

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

8
go.mod
View File

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

22
go.sum
View File

@@ -2,12 +2,22 @@ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/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/pborman/getopt v1.1.0 h1:eJ3aFZroQqq0bWmraivjQNt6Dmm5M0h2JcDW38/Azb0=
github.com/pborman/getopt v1.1.0/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI=
golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -0,0 +1,4 @@
package imageprocess
type Processor struct {
}

57
imageprocess/thumb.go Normal file
View File

@@ -0,0 +1,57 @@
package imageprocess
import (
"fmt"
"image"
"image/png"
"io"
"log"
"os"
"github.com/tardisx/discord-auto-upload/upload"
"golang.org/x/image/draw"
)
const (
thumbnailMaxX = 128
thumbnailMaxY = 128
)
func (ip *Processor) ThumbPNG(ul *upload.Upload, which string, w io.Writer) error {
var filename string
if which == "orig" {
filename = ul.OriginalFilename
} else if which == "markedup" {
filename = ul.MarkedUpFilename
} else {
log.Fatal("was passed incorrect 'which' arg")
}
file, err := os.Open(filename)
if err != nil {
return fmt.Errorf("could not open file: %s", err)
}
defer file.Close()
im, _, err := image.Decode(file)
if err != nil {
return fmt.Errorf("could not decode file: %s", err)
}
newXY := image.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 := image.NewRGBA(image.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
}

46
log/log.go Normal file
View File

@@ -0,0 +1,46 @@
package log
import (
"log"
"time"
)
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 LogEntries []LogEntry
var logInput chan LogEntry
func init() {
// wait for log entries
logInput = make(chan LogEntry)
go func() {
for {
aLog := <-logInput
LogEntries = append(LogEntries, aLog)
for len(LogEntries) > 100 {
LogEntries = LogEntries[1:]
}
}
}()
}
func SendLog(entry string, entryType LogEntryType) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: entry,
Type: entryType,
}
log.Printf("%6s: %s", entryType, entry)
}

354
upload/upload.go Normal file
View File

@@ -0,0 +1,354 @@
// Package upload encapsulates prepping an image for sending to discord,
// and actually uploading it there.
package upload
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"image"
"io"
"io/ioutil"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/fogleman/gg"
"github.com/tardisx/discord-auto-upload/config"
daulog "github.com/tardisx/discord-auto-upload/log"
"golang.org/x/image/font/inconsolata"
)
type State string
const (
StatePending State = "Pending" // waiting for decision to upload (could be edited)
StateQueued State = "Queued" // ready for upload
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"`
}
type Upload struct {
Id int32 `json:"id"`
UploadedAt time.Time `json:"uploaded_at"`
OriginalFilename string `json:"original_file"` // path on the local disk
MarkedUpFilename string `json:"markedup_file"` // a temporary file, if the user did some markup
webhookURL string
watermark bool // should watermark
usernameOverride string
Url string `json:"url"` // url on the discord CDN
Width int `json:"width"`
Height int `json:"height"`
State State `json:"state"`
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) {
atomic.AddInt32(&currentId, 1)
thisUpload := Upload{
Id: currentId,
OriginalFilename: file,
watermark: !conf.NoWatermark,
webhookURL: conf.WebHookURL,
usernameOverride: conf.Username,
State: StateQueued,
}
// if the user wants uploads to be held for editing etc,
// set it to Pending instead
if conf.HoldUploads {
thisUpload.State = StatePending
}
u.Uploads = append(u.Uploads, &thisUpload)
}
// Upload uploads any files that have not yet been uploaded
func (u *Uploader) Upload() {
for _, upload := range u.Uploads {
if upload.State == StateQueued {
upload.processUpload()
}
}
}
func (u *Uploader) UploadById(id int32) *Upload {
for _, anUpload := range u.Uploads {
if anUpload.Id == int32(id) {
return anUpload
}
}
return nil
}
func (u *Upload) RemoveMarkupTempFile() {
if len(u.MarkedUpFilename) > 0 {
os.Remove(u.MarkedUpFilename)
}
}
func (u *Upload) processUpload() error {
daulog.SendLog(fmt.Sprintf("Uploading: %s", u.OriginalFilename), daulog.LogTypeInfo)
baseFilename := filepath.Base(u.OriginalFilename)
if u.webhookURL == "" {
daulog.SendLog("WebHookURL is not configured - cannot upload!", daulog.LogTypeError)
return errors.New("webhook url not configured")
}
extraParams := map[string]string{}
if u.usernameOverride != "" {
daulog.SendLog("Overriding username with "+u.usernameOverride, daulog.LogTypeInfo)
extraParams["username"] = u.usernameOverride
}
type DiscordAPIResponseAttachment struct {
URL string
ProxyURL string
Size int
Width int
Height int
Filename string
}
type DiscordAPIResponse struct {
Attachments []DiscordAPIResponseAttachment
ID int64 `json:",string"`
}
var retriesRemaining = 5
for retriesRemaining > 0 {
// open an io.ReadCloser for the file we intend to upload
var filedata *os.File
var err error
if len(u.MarkedUpFilename) > 0 {
filedata, err = os.Open(u.MarkedUpFilename)
if err != nil {
log.Print("Error opening marked up file:", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
} else {
filedata, err = os.Open(u.OriginalFilename)
if err != nil {
log.Print("Error opening original file:", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
}
var imageData io.Reader
if u.watermark {
daulog.SendLog("Watermarking image", daulog.LogTypeInfo)
imageData, err = u.applyWatermark(filedata)
if err != nil {
log.Print("Error watermarking:", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
} else {
imageData = filedata
}
request, err := newfileUploadRequest(u.webhookURL, extraParams, "file", baseFilename, imageData)
if err != nil {
log.Printf("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 {
log.Print("Error performing request:", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
} else {
if resp.StatusCode == 413 {
// just fail immediately, we know this means the file was too big
daulog.SendLog("413 received - file too large", daulog.LogTypeError)
u.State = StateFailed
return errors.New("received 413 - file too large")
}
if resp.StatusCode != 200 {
// {"message": "Request entity too large", "code": 40005}
log.Print("Bad response from server:", resp.StatusCode)
if b, err := ioutil.ReadAll(resp.Body); err == nil {
log.Print("Body:", string(b))
daulog.SendLog(fmt.Sprintf("Bad response: %s", string(b)), daulog.LogTypeError)
}
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
resBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Print("could not deal with body: ", err)
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
resp.Body.Close()
var res DiscordAPIResponse
err = json.Unmarshal(resBody, &res)
// {"id": "851092588608880670", "type": 0, "content": "", "channel_id": "849615269706203171", "author": {"bot": true, "id": "849615314274484224", "username": "abcdedf", "avatar": null, "discriminator": "0000"}, "attachments": [{"id": "851092588332449812", "filename": "dau480457962.png", "size": 859505, "url": "https://cdn.discordapp.com/attachments/849615269706203171/851092588332449812/dau480457962.png", "proxy_url": "https://media.discordapp.net/attachments/849615269706203171/851092588332449812/dau480457962.png", "width": 640, "height": 640, "content_type": "image/png"}], "embeds": [], "mentions": [], "mention_roles": [], "pinned": false, "mention_everyone": false, "tts": false, "timestamp": "2021-06-06T13:38:05.660000+00:00", "edited_timestamp": null, "flags": 0, "components": [], "webhook_id": "849615314274484224"}
daulog.SendLog(fmt.Sprintf("Response: %s", string(resBody[:])), daulog.LogTypeDebug)
if err != nil {
log.Print("could not parse JSON: ", err)
fmt.Println("Response was:", string(resBody[:]))
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
if len(res.Attachments) < 1 {
log.Print("bad response - no attachments?")
retriesRemaining--
sleepForRetries(retriesRemaining)
continue
}
var a = res.Attachments[0]
elapsed := time.Since(start)
rate := float64(a.Size) / elapsed.Seconds() / 1024.0
daulog.SendLog(fmt.Sprintf("Uploaded to %s %dx%d", a.URL, a.Width, a.Height), daulog.LogTypeInfo)
daulog.SendLog(fmt.Sprintf("id: %d, %d bytes transferred in %.2f seconds (%.2f KiB/s)", res.ID, a.Size, elapsed.Seconds(), rate), daulog.LogTypeInfo)
u.Url = a.URL
u.State = StateComplete
u.Width = a.Width
u.Height = a.Height
u.UploadedAt = time.Now()
break
}
}
// remove any marked up file
u.RemoveMarkupTempFile()
if retriesRemaining == 0 {
daulog.SendLog("Failed to upload, even after all retries", daulog.LogTypeError)
u.State = StateFailed
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
}
// applyWatermark applies the watermark to the image
func (u *Upload) applyWatermark(in *os.File) (io.Reader, error) {
defer in.Close()
im, _, err := image.Decode(in)
if err != nil {
daulog.SendLog(fmt.Sprintf("Cannot decode image: %v - skipping watermarking", err), daulog.LogTypeError)
return nil, errors.New("cannot decode image")
}
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)
b := bytes.Buffer{}
dc.EncodePNG(&b)
return &b, nil
}
func sleepForRetries(retry int) {
if retry == 0 {
return
}
retryTime := (6-retry)*(6-retry) + 6
daulog.SendLog(fmt.Sprintf("Will retry in %d seconds (%d remaining attempts)", retryTime, retry), daulog.LogTypeError)
time.Sleep(time.Duration(retryTime) * time.Second)
}

101
upload/upload_test.go Normal file
View File

@@ -0,0 +1,101 @@
package upload
import (
"bytes"
"image"
"image/color"
"image/png"
"io/ioutil"
"math/rand"
"net/http"
"os"
"testing"
// "github.com/tardisx/discord-auto-upload/config"
)
// 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/", OriginalFilename: f.Name()}
u.Client = &MockClient{DoFunc: DoGoodUpload}
err := u.processUpload()
if err != nil {
t.Errorf("error occured: %s", err)
}
if u.Width != 640 || u.Height != 640 {
t.Error("dimensions wrong")
}
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/", 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 := image.Point{0, 0}
lowRight := image.Point{width, height}
img := image.NewRGBA(image.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)
}

29
version/version.go Normal file
View File

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

11
version/version_test.go Normal file
View File

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

5
web/data/alpine.js Normal file

File diff suppressed because one or more lines are too long

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

@@ -0,0 +1,210 @@
{{ define "content" }}
<main role="main" class="inner DAU" x-data="configuration()" x-init="get_config()">
<h1 class="DAU-heading">Config</h1>
<div x-cloak x-show="error" class="alert alert-danger" role="alert" x-text="error">
</div>
<div x-cloak x-show="success" class="alert alert-success" role="alert" x-text="success">
</div>
<form x-cloak class="">
<p>Configuration changes are not made until the Save button is pressed
at the bottom of this page.
</p>
<h3>global configuration</h3>
<p>The server port dictates which TCP port the web server listens on.
If you change this number you will need to restart.
</p>
<p>The Watch Interval is how often new files will be discovered by your
watchers (configured below).</p>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Server port</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only">Server port</label>
<input type="text" class="form-control" placeholder="" x-model.number="config.Port">
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Watch interval</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only">Watch interval</label>
<input type="text" class="form-control" placeholder="" x-model.number="config.WatchInterval">
</div>
</div>
<h3>watcher configuration</h3>
<p>You may configure one or more watchers. Each watcher watches a
single directory (and all subdirectories) and when a new image file
is found it uploads it to the specified channel via the webhook URL.
</p>
<p><a href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks">
Click here</a> for information on how to find your discord webhook URL.</p>
<p>You may also specify a username for the bot to masquerade as. This is a cosmetic
change only, and does not hide the uploaders actual identity.
</p>
<p>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

@@ -6,7 +6,7 @@
a,
a:focus,
a:hover {
color: #fff;
color: #f44;
}
/* Custom default button */
@@ -28,20 +28,29 @@ html,
body {
height: 100%;
background-color: #333;
padding: 2em;
max-width: 80em;
}
body {
display: -ms-flexbox;
display: flex;
/* display: -ms-flexbox;
display: flex; */
color: #fff;
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
}
.DAU-container {
max-width: 42em;
max-width: 52em;
}
.DAU-container-editor {
}
pre {
background-color: black;
color: aliceblue;
}
/*
* Header
@@ -104,3 +113,6 @@ body {
.mastfoot {
color: rgba(255, 255, 255, .5);
}
/* for alpine.js */
[x-cloak] { display: none !important; }

233
web/data/editor.html Normal file
View File

@@ -0,0 +1,233 @@
{{ 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 }}

1
web/data/fabric.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,12 @@
{{ 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">Learn more</a>
<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 }}

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

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

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

@@ -0,0 +1,141 @@
{{ 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>actions</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<template x-for="ul in uploads">
<tr>
<td x-text="ul.original_file"></td>
<td>
<button @click="start_upload(ul.id)" type="button" class="btn btn-primary">upload</button>
</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 x-text="ul.state"></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,14 +1,14 @@
{{ 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="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-Piv4xVNRyMGpqkS2by6br4gNJ7DXjqk09RmUpJ8jgGtD7zP9yug3goQfGII0yAns" crossorigin="anonymous"></script>
<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 {
@@ -31,20 +31,23 @@
<link href="/dau.css" rel="stylesheet">
</head>
<body class="text-center">
<body class="">
<div class="DAU-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<div class="DAU-container-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>
</nav>
</div>
</header>
{{.Body}}
{{ template "content" . }}
<footer class="mastfoot mt-auto">
<div class="inner">
@@ -55,4 +58,8 @@
</body>
{{ template "js" . }}
</html>
{{ end }}

View File

@@ -1,290 +1,368 @@
package web
import (
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/tardisx/discord-auto-upload/assets"
"github.com/tardisx/discord-auto-upload/config"
"html/template"
"io"
"io/ioutil"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"text/template"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/tardisx/discord-auto-upload/config"
"github.com/tardisx/discord-auto-upload/imageprocess"
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
// ConfigChange chan int
}
type valueStringResponse struct {
Success bool `json: 'success'`
Value string `json: 'value'`
}
func (ws *WebService) getStatic(w http.ResponseWriter, r *http.Request) {
type valueBooleanResponse struct {
Success bool `json: 'success'`
Value bool `json: 'value'`
}
type errorResponse struct {
Success bool `json: 'success'`
Error string `json: 'error'`
}
func getStatic(w http.ResponseWriter, r *http.Request) {
// haha this is dumb and I should change it
// fmt.Println(r.URL)
re := regexp.MustCompile(`[^a-zA-Z0-9\.]`)
path := r.URL.Path[1:]
sanitized_path := re.ReplaceAll([]byte(path), []byte("_"))
if string(sanitized_path) == "" {
sanitized_path = []byte("index.html")
path := r.URL.Path
path = strings.TrimLeft(path, "/")
if path == "" {
path = "index.html"
}
data, err := assets.Asset(string(sanitized_path))
if err != nil {
// Asset was not found.
fmt.Fprintln(w, err)
}
extension := filepath.Ext(string(path))
extension := filepath.Ext(string(sanitized_path))
if extension == ".html" { // html file
t, err := template.ParseFS(webFS, "data/wrapper.tmpl", "data/"+path)
if err != nil {
daulog.SendLog(fmt.Sprintf("when fetching: %s got: %s", path, err), daulog.LogTypeError)
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return
}
// is this a HTML file? if so wrap it in the template
if extension == ".html" {
wrapper, _ := assets.Asset("wrapper.tmpl")
t := template.Must(template.New("wrapper").Parse(string(wrapper)))
var b struct {
Body string
Path string
Version string
}
b.Body = string(data)
b.Path = string(sanitized_path)
b.Version = config.CurrentVersion
t.Execute(w, b)
b.Path = path
b.Version = version.CurrentVersion
err = t.ExecuteTemplate(w, "layout", b)
if err != nil {
panic(err)
}
return
} else { // anything else
otherStatic, err := webFS.ReadFile("data/" + path)
if err != nil {
daulog.SendLog(fmt.Sprintf("when fetching: %s got: %s", path, err), daulog.LogTypeError)
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return
}
w.Header().Set("Content-Type", mime.TypeByExtension(extension))
w.Write(otherStatic)
return
}
// otherwise we are a static thing
w.Header().Set("Content-Type", mime.TypeByExtension(extension))
w.Write(data)
//
}
// TODO there should be locks around all these config accesses
func getSetWebhook(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
func (ws *WebService) getLogs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.WebHookURL}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
config.Config.WebHookURL = r.PostForm.Get("value")
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.WebHookURL}
js, _ := json.Marshal(postResponse)
w.Write(js)
showDebug := false
debug, present := r.URL.Query()["debug"]
if present && len(debug[0]) > 0 && debug[0] != "0" {
showDebug = true
}
}
// TODO there should be locks around all these config accesses
func getSetUsername(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.Username}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
text := ""
for _, log := range daulog.LogEntries {
if !showDebug && log.Type == daulog.LogTypeDebug {
continue
}
config.Config.Username = r.PostForm.Get("value")
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.Username}
js, _ := json.Marshal(postResponse)
w.Write(js)
text = text + fmt.Sprintf(
"%-6s %-19s %s\n", log.Type, log.Timestamp.Format("2006-01-02 15:04:05"), log.Entry,
)
}
// js, _ := json.Marshal(daulog.LogEntries)
w.Write([]byte(text))
}
func getSetWatch(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
func (ws *WebService) handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: strconv.Itoa(config.Config.Watch)}
newConfig := config.ConfigV2{}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
defer r.Body.Close()
b, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
}
i, err := strconv.Atoi(r.PostForm.Get("value"))
if err != nil {
response := errorResponse{Success: false, Error: fmt.Sprintf("Bad value for watch: %v", err)}
js, _ := json.Marshal(response)
w.Write(js)
returnJSONError(w, "could not read body?")
return
}
if i < 1 {
response := errorResponse{Success: false, Error: "must be > 0"}
js, _ := json.Marshal(response)
w.Write(js)
return
}
config.Config.Watch = i
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: strconv.Itoa(config.Config.Watch)}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getSetNoWatermark(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueBooleanResponse{Success: true, Value: config.Config.NoWatermark}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
err = json.Unmarshal(b, &newConfig)
if err != nil {
log.Fatal(err)
}
v := r.PostForm.Get("value")
if v != "0" && v != "1" {
response := errorResponse{Success: false, Error: fmt.Sprintf("Bad value for nowatermark: %v", err)}
js, _ := json.Marshal(response)
w.Write(js)
returnJSONError(w, "badly formed JSON")
return
}
if v == "0" {
config.Config.NoWatermark = false
} else {
config.Config.NoWatermark = true
}
config.SaveConfig()
postResponse := valueBooleanResponse{Success: true, Value: config.Config.NoWatermark}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func getSetDirectory(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.Path}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
ws.Config.Config = &newConfig
err = ws.Config.Save()
if err != nil {
log.Fatal(err)
}
newPath := r.PostForm.Get("value")
// sanity check this path
stat, err := os.Stat(newPath)
if os.IsNotExist(err) {
// not exist
response := errorResponse{Success: false, Error: fmt.Sprintf("Path: %s - does not exist", newPath)}
js, _ := json.Marshal(response)
w.Write(js)
return
} else if !stat.IsDir() {
// not a directory
response := errorResponse{Success: false, Error: fmt.Sprintf("Path: %s - is not a directory", newPath)}
js, _ := json.Marshal(response)
w.Write(js)
returnJSONError(w, err.Error())
return
}
// config has changed, so tell the world
if ws.Config.Changed != nil {
ws.Config.Changed <- true
}
config.Config.Path = newPath
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.Path}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
b, _ := json.Marshal(ws.Config.Config)
w.Write(b)
}
func getSetExclude(w http.ResponseWriter, r *http.Request) {
func (ws *WebService) getUploads(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ups := ws.Uploader.Uploads
if r.Method == "GET" {
getResponse := valueStringResponse{Success: true, Value: config.Config.Exclude}
// I can't see any way this will fail
js, _ := json.Marshal(getResponse)
w.Write(js)
} else if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
log.Fatal(err)
}
config.Config.Exclude = r.PostForm.Get("value")
config.SaveConfig()
postResponse := valueStringResponse{Success: true, Value: config.Config.Exclude}
js, _ := json.Marshal(postResponse)
w.Write(js)
}
}
func StartWebServer() {
http.HandleFunc("/", getStatic)
http.HandleFunc("/rest/config/webhook", getSetWebhook)
http.HandleFunc("/rest/config/username", getSetUsername)
http.HandleFunc("/rest/config/watch", getSetWatch)
http.HandleFunc("/rest/config/nowatermark", getSetNoWatermark)
http.HandleFunc("/rest/config/directory", getSetDirectory)
http.HandleFunc("/rest/config/exclude", getSetExclude)
log.Print("Starting web server on http://localhost:9090")
err := http.ListenAndServe(":9090", nil) // set listen port
text, err := json.Marshal(ups)
if err != nil {
log.Fatal("ListenAndServe: ", err)
// 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")
processor := imageprocess.Processor{}
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 = processor.ThumbPNG(ul, "orig", 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")
processor := imageprocess.Processor{}
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 = processor.ThumbPNG(ul, "markedup", 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.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.RemoveMarkupTempFile()
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.MarkedUpFilename = 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)
log.Printf("Starting web server on http://localhost%s", listen)
srv := &http.Server{
Handler: r,
Addr: listen,
// Good practice: enforce timeouts for servers you create!
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)
}

85
web/server_test.go Normal file
View File

@@ -0,0 +1,85 @@
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":2,"Port":9090,"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)
}
}