Compare commits

..

164 Commits
0.2 ... master

Author SHA1 Message Date
76dbbacd98 Bump version 2024-04-30 22:22:13 +09:30
ba31f52ee2 Use zip for windows releases 2022-12-05 23:44:06 +10:30
779f9e1992 Goodbye perl build script. 2022-12-05 23:36:16 +10:30
553337bfab Migrate to goreleaser for next version 2022-12-05 23:24:27 +10:30
73c74cd37e Start to rework tests 2022-12-05 22:04:40 +10:30
2bd62a95a4 Add arm binaries for mac and linux 2022-12-05 22:03:11 +10:30
3497bfd194 Add state reasons 2022-12-05 22:01:52 +10:30
ada43b176b Refactor image processing for more flexibility. Add support for resizing images if they exceed the discord 8Mb limit. 2022-11-01 13:06:16 +10:30
326807b395 Fix documentation on watch intervals. Fixes #26 2022-09-15 22:14:59 +09:30
4dd166e65e Update changelog for release 2022-05-09 09:57:26 +09:30
9f7090a2f8 Bump version 2022-05-08 11:39:54 +09:30
1bf557c3eb Update changelog 2022-05-08 11:39:14 +09:30
df0c6d090d Fix race condition causing multiple uploads 2022-05-08 11:38:07 +09:30
b851a4f773 🙏🏼 2022-05-02 21:21:00 +09:30
e612f8ae59 Update README 2022-05-01 17:47:47 +09:30
05ddf07fc4 Update (and fix old typos) in changelog 2022-05-01 17:42:23 +09:30
9423bc32e9 Automate the population of the windows exe metadata 2022-05-01 17:39:00 +09:30
243c349366 Tidy up, add discord link 2022-05-01 17:01:02 +09:30
d8b16674dd Only do systray stuff on windows 2022-05-01 16:51:52 +09:30
9294ca9e33 Windows exe resource stuff 2022-05-01 15:50:27 +09:30
2a99541f58 go mod tidy 2022-05-01 14:40:40 +09:30
b04ed6aa62 Add systray with icon 2022-05-01 12:37:05 +09:30
06ac259e0a Open browser on startup automatically, with configuration option to disable. 2022-05-01 11:55:20 +09:30
0c2fafdc7a Bump version 2022-05-01 11:32:25 +09:30
f9433ae0cd Update changelog 2022-04-04 19:45:21 +09:30
ba7ae21248 Refactor how logs are handled. 2022-04-04 19:10:07 +09:30
fbd267e687 Show if a new version is available in the web interface 2022-04-03 20:33:20 +09:30
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
23c0aa2a34 Add Exclude to the config as well. 2021-02-09 22:07:40 +10:30
2d1ac3c803 Clean up logging and improve web interface 2021-02-07 22:06:19 +10:30
942f81a378 Bump version 2021-02-07 22:05:49 +10:30
287efab257 We no longer create a .bat file. 2021-02-07 22:05:16 +10:30
2b159e5532 Update README for the new version 2021-02-07 22:05:01 +10:30
851f073e99 Read/Write config to $HOME 2021-02-07 11:42:13 +10:30
ec658520b7 go fmt 2021-02-07 08:10:27 +10:30
6b1867f35f Support noWatermark config 2021-02-07 08:10:06 +10:30
6e493522c8 Add username and watch period web configuration 2021-02-06 12:50:44 +10:30
4d09901fb3 Fix up the javascript for loading config data. 2021-02-03 21:43:23 +10:30
9c9d4e492a Wrapper for HTML pages and start of the config web interface 2021-02-02 22:09:18 +10:30
e1f5afa788 go fmt 2021-01-31 18:48:48 +10:30
46a0f5a187 Rework configuration to it's own package and make it available to web server. Start a template driven web interface. 2021-01-31 17:53:32 +10:30
55bb5a8bae Fix build instructions 2021-01-31 09:56:09 +10:30
c2b9bf410d Bring into 2021 2021-01-30 21:23:58 +10:30
a4f958f846 Continue web server integration 2020-03-26 11:40:35 +10:30
14f8fe1933 Fix go generate call 2020-03-20 05:51:25 +10:30
752ff42a19 Add asset generation. 2020-03-20 05:49:12 +10:30
1ef062d19c Generate assets when releasing 2017-07-27 22:11:15 +09:30
6f09841209 Ignore generated asset directory 2017-07-27 22:11:03 +09:30
4619bb5383 Automatically open web browser on startup 2017-07-27 22:04:47 +09:30
e240f5dbd0 Embedded assets 2017-07-27 22:04:32 +09:30
Justin Hawkins
f976777f40 Start fleshing out web server 2017-07-27 12:18:02 +09:30
80a905b7d6 Improve update check error and make it non-fatal 2017-07-26 22:44:56 +09:30
497d2e3e27 Basic web server startup 2017-07-26 22:40:21 +09:30
ab54ace0d2 Add empty web class for the upcoming build-in web server 2017-07-26 14:24:02 +09:30
450765145b Add note about existing file handling 2017-07-26 13:24:07 +09:30
3970c611a4 Add --exclude flag (to avoid uploading thumbnails) 2017-02-28 22:50:03 +10:30
d8dc3e4ea8 Fix stupid text 2017-02-28 22:21:22 +10:30
3693d94297 Fix for new version variable name 2017-02-28 22:14:41 +10:30
8ded2b2e2d Document the --no-watermark feature 2017-02-28 22:10:53 +10:30
e3e712d073 Add retries, with backoff 2017-02-28 22:07:57 +10:30
1ecac568f7 Mark watermarked images be uploaded, and make it an optional option. 2017-02-28 21:32:18 +10:30
82ba3be742 Hacky image watermarking (not yet complete) 2017-02-26 21:06:48 +10:30
Justin Hawkins
4825dc56e6 Refactor away some globals. This is probably still not very idiomatic. 2017-02-23 12:55:10 +10:30
65b9241492 Refactor according to lint 2017-02-22 21:13:07 +10:30
Justin Hawkins
73b33f5872 Improve doc 2017-02-22 16:48:29 +10:30
Justin Hawkins
cc0fee57c2 Sub directories are scanned 2017-02-22 16:47:50 +10:30
Justin Hawkins
05a3a0d09a Update README 2017-02-22 16:46:48 +10:30
Justin Hawkins
72588642b6 Fix .gitignore 2017-02-22 15:52:28 +10:30
Justin Hawkins
7ff4685a70 Simple release build script 2017-02-22 15:47:51 +10:30
Justin Hawkins
f6b92ee8bd Show github link in --version 2017-02-22 15:47:26 +10:30
Justin Hawkins
68d9ab7859 Check path before starting to prevent crash. Show id of upload. 2017-02-21 17:10:00 +10:30
Justin Hawkins
d2d7843b6f Show upload rate and speed 2017-02-21 16:22:34 +10:30
Justin Hawkins
13589535a8 Add timeouts for uploads and version check. 2017-02-21 14:57:10 +10:30
Justin Hawkins
cb1f1d1a05 Version and help commands. 2017-02-21 12:28:26 +10:30
Justin Hawkins
699ca9fcfc Add username support, clean up command line parsing, help and output 2017-02-21 12:24:14 +10:30
39 changed files with 3342 additions and 227 deletions

1
.github/FUNDING.yml vendored Normal file
View File

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

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 ./...

28
.gitignore vendored
View File

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

45
.goreleaser.yaml Normal file
View File

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

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

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

109
CHANGELOG.md Normal file
View File

@ -0,0 +1,109 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [v0.13.0] - 2022-11-01
- Resize images if needed to fit in discord 8Mb upload limits
## [v0.12.4] - 2022-09-15
- Document that watcher intervals are in seconds
## [v0.12.3] - 2022-05-09
- Fix a race condition occasionally causing multiple duplicate uploads
## [v0.12.2] - 2022-05-01
- Automatically open your web browser to the `dau` web interface
(can be disabled in configuration)
- Add system tray/menubar icon with menus to open web interface, quit and
other links
- Superfluous text console removed on windows
## [v0.12.1] - 2022-05-01
- Show if a new version is available in the web interface
- Rework logging and fix the log display in the web interface
## [v0.12.0] - 2022-04-03
- Break upload page into pending/current/complete sections
- Add preview thumbnails for each upload
- Add feature to hold an image for upload, so the user can
choose to upload it or not
- Add simple image editor to add text captions
- Discord server created: https://discord.gg/eErG9sntbZ
## [v0.11.2] - 2021-10-19
- Really fix the bug where too large attachments keep retrying
- Fix tests on Windows
## [v0.11.1] - 2021-10-11
- Improve logging and error handling
- Improve tests
- Fix problem where attachments too large for discord fail immediately and do not retry
- Fix problem with version checking
## [v0.11.0] - 2021-10-10
- Switched to semantic versioning
- Now supports multiple watchers - multiple directories can be monitored for new images
- Complete UI rework to support new features and decrease ugliness
- Add many tests
## [0.10.0] - 2021-06-08
This version adds a page showing recent uploads, with thumbnails.
This is not much use except as a log at this stage, but is the basis for future versions which will allow you to hold files before uploading, and edit them (crop, add text, etc) as well.
## [0.9.0] - 2021-06-04
Fix the version update check so that users are actually informed about new releases.
## [0.8.0] - 2021-06-03
This version makes the logs available in the web interface.
## [0.7.0] - 2021-02-09
The long awaited (!) web interface launches with this version. No more messing with command line arguments and .bat files.
Just run the exe and hit http://localhost:9090 to configure the app. See the updated README.md for more information on the configuration.
## [0.6.0] - 2017-02-28
Add --exclude option to avoid uploading files in thumbnail directories
## [0.5.0] - 2017-02-28
* Automatic watermarking of images to perform shameless self-promotion of this tool (disable with --no-watermark)
* Automatically retry failed uploads
* Internal cleanups
## [0.4.0] - 2017-02-28
* Fix crash if the specified directory did not exist
* Better output for showing new version info
* Show speed of upload
## [0.3.0] - 2017-02-21
* Support 'username' sending
* Timeout on all HTTP connections
* Default to current directory if --directory not specified
## [0.2.0] - 2017-02-21
* First golang version, improved output and parsing of responses.
* Built in update checks.
## [0.1.0] - 2017-02-16
Initial release

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 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,4 +1,6 @@
# Automatically upload screenshots from your computer into a discord channel
# Automatically upload screenshots into a discord channel
[![Go](https://github.com/tardisx/discord-auto-upload/actions/workflows/go.yml/badge.svg)](https://github.com/tardisx/discord-auto-upload/actions/workflows/go.yml)
This program automatically uploads new screenshots that appear in a folder on your computer to Discord and posts them in a channel:
@ -6,6 +8,8 @@ This program automatically uploads new screenshots that appear in a folder on yo
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
@ -16,44 +20,94 @@ Point it at your Steam screenshot folder, or similar, and shortly after you hit
### Binaries
TBD
Binaries are available for Mac, Linux and Windows [here](https://github.com/tardisx/discord-auto-upload/releases/latest).
#### From source
TBD
You'll need to [download Go](https://golang.org/dl/), check the code out somewhere, run 'go generate' and then 'go build'.
## Using it
`dau` is a command line driven program. When executed, it will continually scan a directory for new images, and each time it finds one it will upload it to discord, via the discord web hook.
`dau` configuration is managed via its internal web interface. When the executable is run, you can visit
`http://localhost:9090` in your web browser to configure it. On Windows, a tray icon is created to provide
access to the web interface.
The web browser will be loaded automatically when you start the program, if possible. This option can be
disabled in the settings.
Configuration persists across runs, it is saved in a file called '.dau.json' in your home directory.
The first time you run it, you will need to configure at least the discord web hook and the watch path for
`dau` to be useful.
While running, `dau` will continually scan a directory for new images, and each time it finds one it will upload it to discord, via the discord web hook.
`dau` will only upload "new" screenshots, where "new" means a file that appears in a directory that it is watching, if it appears *after* it has started executing.
Thus, you do not have to worry about pointing `dau` at a directory full of images, it will only upload new ones.
If `dau` is on your path, you can run it from your screenshot folder and there is then no need to specify the path to your images.
## Configuration options
Note that currently `dau` does not look in subdirectories. Please submit an issue if this is a use case for you.
See the web interface at http://localhost:9090 to configure `dau`. The configuration is a single page of options,
no changes will take effect until the "Save All Configuration" button has been pressed.
The only two mandatory command line parameters are the discord webhook URL:
### Global options
`--webhook URL` - the webhook URL (see [here](https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks) for details).
* Server port - the port number the web server listens on. Requires restart
* Watch interval - how often each watcher will check the directory for new files, in seconds
and the directory to watch:
### Watcher configuration
`--directory /some/path/here` - the directory that screenshots will appear in.
There can be one or more watchers configured. Each watcher looks in a particular directory,
and uploads new files to a different discord channel.
You will have to quote the path on windows, or anywhere where the directory path contains spaces.
Each watcher has the following configuration options:
Other parameters are:
* 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.
`--watch xx` - specify how many seconds to wait between scanning the directory. The default is 10 seconds.
## Holding uploads
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:
* It gives you a chance to vet your screenshot selection before uploading
* It allows you to edit the images before uploading.
In the list of uploads there are three actions you can take on each file:
* Press "upload" to upload the image
* Press "reject" to reject the image
* Click on the image thumbnail to edit the image
If you click on the image thumbnail, an image editor will open, and allow you to add text captions to your image.
More functionality is coming soon. When you are finished editing, choose "Apply" and you will return to the uploads
list. Click "upload" to upload your edited image.
## Limitations/bugs
* Only files ending jpg, gif or png are uploaded.
* If multiple screenshots occur quickly (<1 second apart) not all may be uploaded.
* Files to upload are determined by the file modification time. If you drag and drop existing files they will
not be detected and uploaded. Only newly created files will be detected.
## Troubleshooting
Please check the "log" page on the web interface for information when things are
not working as you expect.
## TODO
This is just a quick hack. Open to suggestions on new features and improvements.
Open an [issue](https://github.com/tardisx/discord-auto-upload/issues/new) and let me know.
Open an [issue](https://github.com/tardisx/discord-auto-upload/issues/new) and let me know what you'd like to see.
Please include any relevant logs from the console when reporting bugs.

192
config/config.go Normal file
View File

@ -0,0 +1,192 @@
package config
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"strings"
daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/mitchellh/go-homedir"
)
// Config for the application
type ConfigV1 struct {
WebHookURL string
Path string
Watch int
Username string
NoWatermark bool
Exclude string
}
type Watcher struct {
WebHookURL string
Path string
Username string
NoWatermark bool
HoldUploads bool
Exclude []string
}
type ConfigV2 struct {
WatchInterval int
Version int
Port int
Watchers []Watcher
}
type ConfigV3 struct {
WatchInterval int
Version int
Port int
OpenBrowserOnStart bool
Watchers []Watcher
}
type ConfigService struct {
Config *ConfigV3
Changed chan bool
ConfigFilename string
}
func DefaultConfigService() *ConfigService {
c := ConfigService{
ConfigFilename: defaultConfigPath(),
}
return &c
}
// LoadOrInit loads the current configuration from the config file, or creates
// a new config file if none exists.
func (c *ConfigService) LoadOrInit() error {
daulog.Debugf("Trying to load config from %s\n", c.ConfigFilename)
_, err := os.Stat(c.ConfigFilename)
if os.IsNotExist(err) {
daulog.Info("NOTE: No config file, writing out sample configuration")
daulog.Info("You need to set the configuration via the web interface")
c.Config = DefaultConfig()
return c.Save()
} else {
return c.Load()
}
}
func DefaultConfig() *ConfigV3 {
c := ConfigV3{}
c.Version = 3
c.WatchInterval = 10
c.Port = 9090
c.OpenBrowserOnStart = true
w := Watcher{
WebHookURL: "https://webhook.url.here",
Path: "/your/screenshot/dir/here",
Username: "",
NoWatermark: false,
Exclude: []string{},
}
c.Watchers = []Watcher{w}
return &c
}
// Load will load the configuration from a known-to-exist config file.
func (c *ConfigService) Load() error {
daulog.Debugf("Loading from %s", c.ConfigFilename)
data, err := ioutil.ReadFile(c.ConfigFilename)
if err != nil {
return fmt.Errorf("cannot read config file %s: %s", c.ConfigFilename, err.Error())
}
err = json.Unmarshal([]byte(data), &c.Config)
if err != nil {
return fmt.Errorf("cannot decode config file %s: %s", c.ConfigFilename, err.Error())
}
// Version 0 predates config migrations
if c.Config.Version == 0 {
// need to migrate this
daulog.Info("Migrating config to V2")
configV1 := ConfigV1{}
err = json.Unmarshal([]byte(data), &configV1)
if err != nil {
return fmt.Errorf("cannot decode legacy config file as v1 %s: %s", c.ConfigFilename, err.Error())
}
// copy stuff across
c.Config.Version = 2
c.Config.WatchInterval = configV1.Watch
c.Config.Port = 9090 // this never used to be configurable
onlyWatcher := Watcher{
WebHookURL: configV1.WebHookURL,
Path: configV1.Path,
Username: configV1.Username,
NoWatermark: configV1.NoWatermark,
Exclude: strings.Split(configV1.Exclude, " "),
}
c.Config.Watchers = []Watcher{onlyWatcher}
}
if c.Config.Version == 2 {
// need to migrate this
daulog.Info("Migrating config to V3")
c.Config.Version = 3
c.Config.OpenBrowserOnStart = true
}
return nil
}
func (c *ConfigService) Save() error {
daulog.Info("saving configuration")
// sanity checks
for _, watcher := range c.Config.Watchers {
// give the sample one a pass? this is kinda gross...
if watcher.Path == "/your/screenshot/dir/here" {
continue
}
info, err := os.Stat(watcher.Path)
if os.IsNotExist(err) {
return fmt.Errorf("path '%s' does not exist", watcher.Path)
}
if !info.IsDir() {
return fmt.Errorf("path '%s' is not a directory", watcher.Path)
}
}
for _, watcher := range c.Config.Watchers {
if strings.Index(watcher.WebHookURL, "https://") != 0 {
return fmt.Errorf("webhook URL '%s' does not look valid", watcher.WebHookURL)
}
}
if c.Config.WatchInterval < 1 {
return fmt.Errorf("watch interval should be greater than 0 - '%d' invalid", c.Config.WatchInterval)
}
jsonString, _ := json.Marshal(c.Config)
err := ioutil.WriteFile(c.ConfigFilename, jsonString, os.ModePerm)
if err != nil {
return fmt.Errorf("cannot save config %s: %s", c.ConfigFilename, err.Error())
}
return nil
}
func homeDir() string {
dir, err := homedir.Dir()
if err != nil {
panic(err)
}
return dir
}
func defaultConfigPath() string {
homeDir := homeDir()
return homeDir + string(os.PathSeparator) + ".dau.json"
}

106
config/config_test.go Normal file
View File

@ -0,0 +1,106 @@
package config
import (
"io/ioutil"
"os"
"testing"
)
func TestNoConfig(t *testing.T) {
c := ConfigService{}
c.ConfigFilename = emptyTempFile()
err := os.Remove(c.ConfigFilename)
if err != nil {
t.Fatalf("could not remove file: %v", err)
}
defer os.Remove(c.ConfigFilename) // because we are about to create it
err = c.LoadOrInit()
if err != nil {
t.Errorf("unexpected failure from load: %s", err)
}
if c.Config.Version != 3 {
t.Error("not version 3 starting config")
}
if fileSize(c.ConfigFilename) < 40 {
t.Errorf("File is too small %d bytes", fileSize(c.ConfigFilename))
}
}
func TestEmptyFileConfig(t *testing.T) {
c := ConfigService{}
c.ConfigFilename = emptyTempFile()
defer os.Remove(c.ConfigFilename)
err := c.LoadOrInit()
if err == nil {
t.Error("unexpected success from LoadOrInit()")
}
}
func TestMigrateFromV1toV3(t *testing.T) {
c := ConfigService{}
c.ConfigFilename = v1Config()
err := c.LoadOrInit()
if err != nil {
t.Error("unexpected error from LoadOrInit()")
}
if c.Config.Version != 3 {
t.Errorf("Version %d not 3", c.Config.Version)
}
if c.Config.OpenBrowserOnStart != true {
t.Errorf("Open browser on start not true")
}
if len(c.Config.Watchers) != 1 {
t.Error("wrong amount of watchers")
}
if c.Config.Watchers[0].Path != "/private/tmp" {
t.Error("Wrong path")
}
if c.Config.WatchInterval != 69 {
t.Error("Wrong watch interval")
}
if c.Config.Port != 9090 {
t.Error("Wrong port")
}
}
func v1Config() string {
f, err := ioutil.TempFile("", "dautest-*")
if err != nil {
panic(err)
}
config := `{"WebHookURL":"https://discord.com/api/webhooks/abc123","Path":"/private/tmp","Watch":69,"Username":"abcdedf","NoWatermark":true,"Exclude":"ab cd ef"}`
f.Write([]byte(config))
defer f.Close()
return f.Name()
}
func emptyTempFile() string {
f, err := ioutil.TempFile("", "dautest-*")
if err != nil {
panic(err)
}
f.Close()
return f.Name()
}
func fileSize(file string) int {
fi, err := os.Stat(file)
if err != nil {
panic(err)
}
return int(fi.Size())
}

372
dau.go
View File

@ -1,213 +1,203 @@
package main
import (
"fmt"
"strings"
"github.com/pborman/getopt"
"path/filepath"
"os"
"time"
"net/http"
"log"
"io"
"bytes"
"mime/multipart"
"encoding/json"
"io/ioutil"
"context"
"flag"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"time"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"github.com/tardisx/discord-auto-upload/config"
daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/tardisx/discord-auto-upload/upload"
"github.com/skratchdot/open-golang/open"
// "github.com/tardisx/discord-auto-upload/upload"
"github.com/tardisx/discord-auto-upload/version"
"github.com/tardisx/discord-auto-upload/web"
)
var current_version = "0.2"
var last_check = time.Now()
var new_last_check = time.Now()
var webhook_url string
type webhook_response struct {
Test string
}
func keepLines(s string, n int) string {
result := strings.Join(strings.Split(s, "\n")[:n], "\n")
return strings.Replace(result, "\r", "", -1)
type watch struct {
lastCheck time.Time
newLastCheck time.Time
config config.Watcher
uploader *upload.Uploader
}
func main() {
webhook, path, watch := parse_options()
webhook_url = webhook
check_updates()
parseOptions()
// wander the path, forever
for {
err := filepath.Walk(path, check_file)
if err != nil { log.Fatal("oh dear") }
//fmt.Printf("filepath.Walk() returned %v\n", err)
last_check = new_last_check
time.Sleep(time.Duration(watch)*time.Second)
}
}
// grab the conf, register to notice changes
conf := config.DefaultConfigService()
configChanged := make(chan bool)
conf.Changed = configChanged
conf.LoadOrInit()
func check_updates() {
// create the uploader
up := upload.NewUploader()
type GithubRelease struct {
Html_url string
Tag_name string
Name string
Body string
}
// log.Print("Opening web browser")
// open.Start("http://localhost:9090")
web := web.WebService{Config: conf, Uploader: up}
web.StartWebServer()
resp, err := http.Get("https://api.github.com/repos/tardisx/discord-auto-upload/releases/latest")
if (err != nil) {
log.Fatal("could not check for updates")
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if (err != nil) {
log.Fatal("could not check read update response")
}
if conf.Config.OpenBrowserOnStart {
openWebBrowser(conf.Config.Port)
}
var latest GithubRelease
err = json.Unmarshal(body, &latest)
go func() {
version.GetOnlineVersion()
if version.UpdateAvailable() {
daulog.Info("*** NEW VERSION AVAILABLE ***")
daulog.Infof("You are currently on version %s, but version %s is available\n", version.CurrentVersion, version.LatestVersionInfo.TagName)
daulog.Info("----------- Release Info -----------")
daulog.Info(version.LatestVersionInfo.Body)
daulog.Info("------------------------------------")
daulog.Info("Upgrade at https://github.com/tardisx/discord-auto-upload/releases/latest")
}
}()
if (err != nil) {
log.Fatal("could not parse JSON", err)
}
if (current_version != latest.Tag_name) {
fmt.Println("A new version is available:", latest.Tag_name)
fmt.Println("----------- Release Info -----------")
fmt.Println(latest.Body)
fmt.Println("------------------------------------")
fmt.Println("( You are currently on version:", current_version, ")")
}
// create the watchers, restart them if config changes
// blocks forever
go func() {
startWatchers(conf, up, configChanged)
}()
mainloop(conf)
}
func parse_options() (webhook_url string, path string, watch int) {
// Declare the flags to be used
// helpFlag := getopt.Bool('h', "display help")
webhookFlag := getopt.StringLong("webhook", 'w', "", "webhook URL")
pathFlag := getopt.StringLong("directory", 'd', "", "directory")
watchFlag := getopt.Int16Long("watch", 's', 10, "time between scans")
getopt.Parse()
return *webhookFlag, *pathFlag, int(*watchFlag)
}
func check_file(path string, f os.FileInfo, err error) error {
// fmt.Println("Comparing", f.ModTime(), "to", last_check, "for", path)
if f.ModTime().After(last_check) && f.Mode().IsRegular() {
if file_eligible(path) {
// process file
process_file(path)
}
if new_last_check.Before(f.ModTime()) {
new_last_check = f.ModTime()
}
}
return nil
}
func file_eligible(file string) (bool) {
extension := strings.ToLower(filepath.Ext(file))
if extension == ".png" || extension == ".jpg" || extension == ".gif" {
return true
}
return false
}
func process_file(file string) {
log.Print("Uploading ", file)
extraParams := map[string]string{
// "username": "Some username",
}
type DiscordAPIResponseAttachment struct {
Url string
Proxy_url string
Size int
Width int
Height int
Filename string
}
type DiscordAPIResponse struct {
Attachments []DiscordAPIResponseAttachment
id int64
}
request, err := newfileUploadRequest(webhook_url, extraParams, "file", file)
if err != nil {
log.Fatal(err)
}
client := &http.Client{}
resp, err := client.Do(request)
if err != nil {
log.Fatal("Error performing request:", err)
} else {
if (resp.StatusCode != 200) {
log.Print("Bad response from server:", resp.StatusCode)
return
}
res_body, err := ioutil.ReadAll(resp.Body)
if (err != nil) {
log.Fatal("could not deal with body", err)
}
resp.Body.Close()
var res DiscordAPIResponse
err = json.Unmarshal(res_body, &res)
if (err != nil) {
log.Fatal("could not parse JSON", err)
fmt.Println("Response was:", res_body)
return
}
if (len(res.Attachments) < 1) {
log.Print("bad response - no attachments?")
return
}
var a = res.Attachments[0]
log.Printf("Uploaded to %s %dx%d, %d bytes\n", a.Url, a.Width, a.Height, a.Size)
}
func startWatchers(config *config.ConfigService, up *upload.Uploader, configChange chan bool) {
for {
daulog.Debug("Creating watchers")
ctx, cancel := context.WithCancel(context.Background())
for _, c := range config.Config.Watchers {
daulog.Infof("Creating watcher for %s with interval %d", c.Path, config.Config.WatchInterval)
watcher := watch{uploader: up, lastCheck: time.Now(), newLastCheck: time.Now(), config: c}
go watcher.Watch(config.Config.WatchInterval, ctx)
}
// wait for single that the config changed
<-configChange
cancel()
daulog.Info("starting new watchers due to config change")
}
}
func 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)
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 (w *watch) Watch(interval int, ctx context.Context) {
for {
select {
case <-ctx.Done():
daulog.Info("Killing old watcher")
return
default:
newFiles := w.ProcessNewFiles()
for _, f := range newFiles {
w.uploader.AddFile(f, w.config)
}
// upload them
w.uploader.Upload()
daulog.Debugf("sleeping for %ds before next check of %s", interval, w.config.Path)
time.Sleep(time.Duration(interval) * time.Second)
}
}
}
// ProcessNewFiles returns an array of new files that have appeared since
// the last time ProcessNewFiles was run.
func (w *watch) ProcessNewFiles() []string {
var newFiles []string
// check the path each time around, in case it goes away or something
if w.checkPath() {
// walk the path
err := filepath.WalkDir(w.config.Path,
func(path string, d fs.DirEntry, err error) error {
return w.checkFile(path, &newFiles, w.config.Exclude)
})
if err != nil {
log.Fatal("could not watch path", err)
}
w.lastCheck = w.newLastCheck
}
return newFiles
}
// checkPath makes sure the path exists, and is a directory.
// It logs errors if there are problems, and returns false
func (w *watch) checkPath() bool {
src, err := os.Stat(w.config.Path)
if err != nil {
daulog.Errorf("Problem with path '%s': %s", w.config.Path, err)
return false
}
if !src.IsDir() {
daulog.Errorf("Problem with path '%s': is not a directory", w.config.Path)
return false
}
return true
}
// checkFile checks if a file is eligible, first looking at extension (to
// avoid statting files uselessly) then modification times.
// If the file is eligible, not excluded and new enough to care we add it
// to the passed in array of files
func (w *watch) checkFile(path string, found *[]string, exclusions []string) error {
extension := strings.ToLower(filepath.Ext(path))
if !(extension == ".png" || extension == ".jpg" || extension == ".gif") {
return nil
}
fi, err := os.Stat(path)
if err != nil {
return err
}
if fi.ModTime().After(w.lastCheck) && fi.Mode().IsRegular() {
excluded := false
for _, exclusion := range exclusions {
if strings.Contains(path, exclusion) {
excluded = true
}
}
if !excluded {
*found = append(*found, path)
}
}
if w.newLastCheck.Before(fi.ModTime()) {
w.newLastCheck = fi.ModTime()
}
return nil
}
func parseOptions() {
var versionFlag bool
flag.BoolVar(&versionFlag, "version", false, "show version")
flag.Parse()
if versionFlag {
fmt.Println("dau - https://github.com/tardisx/discord-auto-upload")
fmt.Printf("Version: %s\n", version.CurrentVersion)
os.Exit(0)
}
}
func openWebBrowser(port int) {
address := fmt.Sprintf("http://localhost:%d", port)
open.Start(address)
}

BIN
dau.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

12
dau_nonwindows.go Normal file
View File

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

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
}

60
dau_windows.go Normal file
View File

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

15
go.mod Normal file
View File

@ -0,0 +1,15 @@
module github.com/tardisx/discord-auto-upload
go 1.16
require (
github.com/fogleman/gg v1.3.0
github.com/getlantern/systray v1.2.1
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/gorilla/mux v1.8.0
github.com/mitchellh/go-homedir v1.1.0
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/stretchr/testify v1.6.1 // indirect
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e
golang.org/x/mod v0.7.0
)

68
go.sum Normal file
View File

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

188
image/image.go Normal file
View File

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

62
image/thumb.go Normal file
View File

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

64
image/watermark.go Normal file
View File

@ -0,0 +1,64 @@
package image
import (
"fmt"
i "image"
"image/jpeg"
"image/png"
"os"
daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/fogleman/gg"
"golang.org/x/image/font/inconsolata"
)
// applyWatermark applies the watermark to the image
func (s *Store) applyWatermark() error {
in, err := os.Open(s.uploadSourceFilename())
defer in.Close()
im, _, err := i.Decode(in)
if err != nil {
daulog.Errorf("Cannot decode image: %v - skipping watermarking", err)
return fmt.Errorf("cannot decode image: %w", err)
}
bounds := im.Bounds()
// var S float64 = float64(bounds.Max.X)
dc := gg.NewContext(bounds.Max.X, bounds.Max.Y)
dc.Clear()
dc.SetRGB(0, 0, 0)
dc.SetFontFace(inconsolata.Regular8x16)
dc.DrawImage(im, 0, 0)
dc.DrawRoundedRectangle(0, float64(bounds.Max.Y-18.0), 320, float64(bounds.Max.Y), 0)
dc.SetRGB(0, 0, 0)
dc.Fill()
dc.SetRGB(1, 1, 1)
dc.DrawString("github.com/tardisx/discord-auto-upload", 5.0, float64(bounds.Max.Y)-5.0)
waterMarkedFile, err := os.CreateTemp("", "dau_watermark_file_*")
if err != nil {
return err
}
defer waterMarkedFile.Close()
if s.OriginalFormat == "png" {
png.Encode(waterMarkedFile, dc.Image())
} else if s.OriginalFormat == "jpeg" {
jpeg.Encode(waterMarkedFile, dc.Image(), nil)
} else {
panic("Cannot handle " + s.OriginalFormat)
}
s.WatermarkedFilename = waterMarkedFile.Name()
return nil
}

95
log/log.go Normal file
View File

@ -0,0 +1,95 @@
package log
import (
"fmt"
"time"
)
type Logger interface {
WriteEntry(l LogEntry)
}
type LogEntryType string
type LogEntry struct {
Timestamp time.Time `json:"ts"`
Type LogEntryType `json:"type"`
Entry string `json:"log"`
}
const (
LogTypeInfo = "info"
LogTypeError = "error"
LogTypeDebug = "debug"
)
var loggers []Logger
var logInput chan LogEntry
var Memory *MemoryLogger
func init() {
// create some loggers
Memory = &MemoryLogger{maxsize: 100}
stdout := &StdoutLogger{}
loggers = []Logger{Memory, stdout}
// wait for log entries
logInput = make(chan LogEntry)
go func() {
for {
aLog := <-logInput
for _, l := range loggers {
l.WriteEntry(aLog)
}
}
}()
}
func Debug(entry string) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: entry,
Type: LogTypeDebug,
}
}
func Debugf(entry string, args ...interface{}) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: fmt.Sprintf(entry, args...),
Type: LogTypeDebug,
}
}
func Info(entry string) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: entry,
Type: LogTypeInfo,
}
}
func Infof(entry string, args ...interface{}) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: fmt.Sprintf(entry, args...),
Type: LogTypeInfo,
}
}
func Error(entry string) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: entry,
Type: LogTypeError,
}
}
func Errorf(entry string, args ...interface{}) {
logInput <- LogEntry{
Timestamp: time.Now(),
Entry: fmt.Sprintf(entry, args...),
Type: LogTypeError,
}
}

29
log/memory.go Normal file
View File

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

12
log/stdout.go Normal file
View File

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

View File

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

297
upload/upload.go Normal file
View File

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

97
upload/upload_test.go Normal file
View File

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

81
version/version.go Normal file
View File

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

21
version/version_test.go Normal file
View File

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

5
web/data/alpine.js Normal file

File diff suppressed because one or more lines are too long

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

@ -0,0 +1,221 @@
{{ define "content" }}
<main role="main" class="inner DAU" x-data="configuration()" x-init="get_config()">
<h1 class="DAU-heading">Config</h1>
<div x-cloak x-show="error" class="alert alert-danger" role="alert" x-text="error">
</div>
<div x-cloak x-show="success" class="alert alert-success" role="alert" x-text="success">
</div>
<form x-cloak class="">
<p>Configuration changes are not made until the Save button is pressed
at the bottom of this page.
</p>
<h3>global configuration</h3>
<p>The server port dictates which TCP port the web server listens on.
If you change this number you will need to restart.
</p>
<p>The Watch Interval is how often new files will be discovered by your
watchers in seconds (watchers are configured below).</p>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Server port</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only">Server port</label>
<input type="text" class="form-control" placeholder="" x-model.number="config.Port">
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Open browser on startup</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only">Open browser</label>
<button type="button" @click="config.OpenBrowserOnStart = ! config.OpenBrowserOnStart" class="btn btn-success" x-text="config.OpenBrowserOnStart ? 'Enabled' : 'Disabled'"></button>
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Watch interval</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only">Watch interval</label>
<input type="text" class="form-control" placeholder="" x-model.number="config.WatchInterval">
</div>
</div>
<h3>watcher configuration</h3>
<p>You may configure one or more watchers. Each watcher watches a
single directory (and all subdirectories) and when a new image file
is found it uploads it to the specified channel via the webhook URL.
</p>
<p><a href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks">
Click here</a> for information on how to find your discord webhook URL.</p>
<p>You may also specify a username for the bot to masquerade as. This is a cosmetic
change only, and does not hide the uploaders actual identity.
</p>
<p>A watcher can be configured to hold uploads. This causes the new images seen
by the watcher to be held for review on the <a href="/uploads.html">uploads page</a>.
This allows each image to be individually uploaded or skipped.
</p>
<p>Exclusions can be specified, zero or more arbitrary strings. If any
file matches one of those strings then it will not be uploaded. This is most
often used if you use software (like Steam) which automatically creates thumbnails
in the same directory as the screenshots.
</p>
<template x-for="(watcher, i) in config.Watchers">
<div class="my-5">
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Directory to watch</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only" for="">Directory</label>
<input type="text" class="form-control" placeholder="" x-model="watcher.Path">
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Webhook URL</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only" for="">WebHook URL</label>
<input type="text" class="form-control" placeholder="" x-model="watcher.WebHookURL">
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Username</span>
</div>
<div class="col-sm-6 my-1">
<label class="sr-only" for="">Username</label>
<input type="text" class="form-control" placeholder="" x-model="watcher.Username">
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Watermark</span>
</div>
<div class="col-sm-6 my-1">
<button type="button" @click="config.Watchers[i].NoWatermark = ! config.Watchers[i].NoWatermark" class="btn btn-success" x-text="watcher.NoWatermark ? 'Disabled 😢' : 'Enabled'"></button>
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Hold Uploads</span>
</div>
<div class="col-sm-6 my-1">
<button type="button" @click="config.Watchers[i].HoldUploads = ! config.Watchers[i].HoldUploads" class="btn btn-success" x-text="watcher.HoldUploads ? 'Enabled' : 'Disabled'"></button>
</div>
</div>
<div class="form-row align-items-center">
<div class="col-sm-6 my-1">
<span>Exclusions</span>
</div>
<div class="col-sm-6 my-1">
<template x-for="(exclude, j) in config.Watchers[i].Exclude">
<div class="form-row">
<div class="col">
<input type="text" class="form-control" x-model="config.Watchers[i].Exclude[j]">
</div>
<div class="col">
<button type="button" class="btn btn-danger" href="#" @click.prevent="config.Watchers[i].Exclude.splice(j, 1);">
-
</button>
</div>
</div>
</template>
<button type="button" class="btn btn-secondary" href="#"
@click.prevent="config.Watchers[i].Exclude.push('');">
+</button>
</div>
</div>
<button type="button" class="btn btn-primary" href="#" @click.prevent="config.Watchers.splice(i, 1);">Remove
this watcher</button>
</div>
</template>
<div class="my-5">
<button type="button" class="btn btn-secondary" href="#"
@click.prevent="config.Watchers.push({Username: '', WebHookURL: 'https://webhook.url.here/', Path: '/directory/path/here', NoWatermark: false, HoldUploads: false, Exclude: []});">
Add a new watcher</button>
</div>
<div class="my-5">
<button type="button" class="my-4 btn btn-danger" href="#" @click="save_config()">
Save all Configuration
</button>
</div>
</form>
</main>
{{ end }}
{{ define "js" }}
<script>
function configuration() {
return {
config: {}, error: '', success: '',
get_config() {
fetch('/rest/config')
.then(response => response.json()) // convert to json
.then(json => {
this.config = json;
console.log(json);
})
},
save_config() {
this.error = '';
this.success = '';
fetch('/rest/config', { method: 'POST', body: JSON.stringify(this.config) })
.then(response => response.json()) // convert to json
.then(json => {
if (json.error) {
this.error = json.error
} else {
this.success = 'Configuration saved';
this.config = json;
}
window.scrollTo(0,0);
})
}
}
}
</script>
{{ end }}

118
web/data/dau.css Normal file
View File

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

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

12
web/data/index.html Normal file
View File

@ -0,0 +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" target="_blank">Learn more</a>
</p>
</main>
{{ end }}
{{ define "js" }}
{{ end }}

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

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

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

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

69
web/data/wrapper.tmpl Normal file
View File

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

368
web/server.go Normal file
View File

@ -0,0 +1,368 @@
package web
import (
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"io"
"io/ioutil"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/tardisx/discord-auto-upload/config"
"github.com/tardisx/discord-auto-upload/image"
daulog "github.com/tardisx/discord-auto-upload/log"
"github.com/tardisx/discord-auto-upload/upload"
"github.com/tardisx/discord-auto-upload/version"
)
type WebService struct {
Config *config.ConfigService
Uploader *upload.Uploader
}
type ErrorResponse struct {
Error string `json:"error"`
}
type StartUploadRequest struct {
Id int32 `json:"id"`
}
type StartUploadResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
//go:embed data
var webFS embed.FS
// DAUWebServer - stuff for the web server
type DAUWebServer struct {
// ConfigChange chan int
}
func (ws *WebService) getStatic(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
path = strings.TrimLeft(path, "/")
if path == "" {
path = "index.html"
}
extension := filepath.Ext(string(path))
if extension == ".html" { // html file
t, err := template.ParseFS(webFS, "data/wrapper.tmpl", "data/"+path)
if err != nil {
daulog.Errorf("when fetching: %s got: %s", path, err)
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return
}
var b struct {
Body string
Path string
Version string
NewVersionAvailable bool
NewVersionInfo version.GithubRelease
}
b.Path = path
b.Version = version.CurrentVersion
b.NewVersionAvailable = version.UpdateAvailable()
b.NewVersionInfo = version.LatestVersionInfo
err = t.ExecuteTemplate(w, "layout", b)
if err != nil {
panic(err)
}
return
} else { // anything else
otherStatic, err := webFS.ReadFile("data/" + path)
if err != nil {
daulog.Errorf("when fetching: %s got: %s", path, err)
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return
}
w.Header().Set("Content-Type", mime.TypeByExtension(extension))
w.Write(otherStatic)
return
}
}
func (ws *WebService) getLogs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
showDebug := false
debug, present := r.URL.Query()["debug"]
if present && len(debug[0]) > 0 && debug[0] != "0" {
showDebug = true
}
text := ""
for _, log := range daulog.Memory.Entries() {
if !showDebug && log.Type == daulog.LogTypeDebug {
continue
}
text = text + fmt.Sprintf(
"%-6s %-19s %s\n", log.Type, log.Timestamp.Format("2006-01-02 15:04:05"), log.Entry,
)
}
w.Write([]byte(text))
}
func (ws *WebService) handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
newConfig := config.ConfigV3{}
defer r.Body.Close()
b, err := ioutil.ReadAll(r.Body)
if err != nil {
returnJSONError(w, "could not read body?")
return
}
err = json.Unmarshal(b, &newConfig)
if err != nil {
returnJSONError(w, "badly formed JSON")
return
}
ws.Config.Config = &newConfig
err = ws.Config.Save()
if err != nil {
returnJSONError(w, err.Error())
return
}
// config has changed, so tell the world
if ws.Config.Changed != nil {
ws.Config.Changed <- true
}
}
b, _ := json.Marshal(ws.Config.Config)
w.Write(b)
}
func (ws *WebService) getUploads(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ups := ws.Uploader.Uploads
text, err := json.Marshal(ups)
if err != nil {
// not sure how this would happen, so we probably want to find out the hard way
panic(err)
}
w.Write([]byte(text))
}
func (ws *WebService) imageThumb(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
vars := mux.Vars(r)
id, err := strconv.ParseInt(vars["id"], 10, 32)
if err != nil {
returnJSONError(w, "bad id")
return
}
ul := ws.Uploader.UploadById(int32(id))
if ul == nil {
returnJSONError(w, "bad id")
return
}
err = ul.Image.ThumbPNG(image.ThumbTypeOriginal, w)
if err != nil {
returnJSONError(w, "could not create thumb")
return
}
}
func (ws *WebService) imageMarkedupThumb(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
vars := mux.Vars(r)
id, err := strconv.ParseInt(vars["id"], 10, 32)
if err != nil {
returnJSONError(w, "bad id")
return
}
ul := ws.Uploader.UploadById(int32(id))
if ul == nil {
returnJSONError(w, "bad id")
return
}
err = ul.Image.ThumbPNG(image.ThumbTypeMarkedUp, w)
if err != nil {
returnJSONError(w, "could not create thumb")
return
}
}
func (ws *WebService) image(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.ParseInt(vars["id"], 10, 32)
if err != nil {
returnJSONError(w, "bad id")
return
}
ul := ws.Uploader.UploadById(int32(id))
if ul == nil {
returnJSONError(w, "bad id")
return
}
img, err := os.Open(ul.Image.OriginalFilename)
if err != nil {
returnJSONError(w, "could not open image file")
return
}
defer img.Close()
io.Copy(w, img)
}
func (ws *WebService) modifyUpload(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == "POST" {
vars := mux.Vars(r)
change := vars["change"]
id, err := strconv.ParseInt(vars["id"], 10, 32)
if err != nil {
returnJSONError(w, "bad id")
return
}
anUpload := ws.Uploader.UploadById(int32(id))
if anUpload == nil {
returnJSONError(w, "bad id")
return
}
if anUpload.State == upload.StatePending {
if change == "start" {
anUpload.State = upload.StateQueued
res := StartUploadResponse{Success: true, Message: "upload queued"}
resString, _ := json.Marshal(res)
w.Write(resString)
return
} else if change == "skip" {
anUpload.State = upload.StateSkipped
anUpload.Image.Cleanup()
res := StartUploadResponse{Success: true, Message: "upload skipped"}
resString, _ := json.Marshal(res)
w.Write(resString)
return
} else if change == "markup" {
newImageData := r.FormValue("image")
//data:image/png;base64,xxxx
// I know this is dumb, we should just send binary image data, but I can't
// see that Fabric makes that possible.
if strings.Index(newImageData, "data:image/png;base64,") != 0 {
returnJSONError(w, "bad image data")
return
}
imageDataBase64 := newImageData[22:]
b, err := base64.StdEncoding.DecodeString(imageDataBase64)
if err != nil {
returnJSONError(w, err.Error())
return
}
// write to a temporary file
tempfile, err := ioutil.TempFile("", "dau_markup-*")
if err != nil {
log.Fatal(err)
}
n, err := tempfile.Write(b)
if n != len(b) {
log.Fatalf("only wrote %d bytes??", n)
}
if err != nil {
log.Fatalf("Could not write temp file: %v", err)
}
tempfile.Close()
anUpload.Image.ModifiedFilename = tempfile.Name()
} else {
returnJSONError(w, "bad change type")
return
}
}
res := StartUploadResponse{Success: false, Message: "upload does not exist, or already queued"}
resString, _ := json.Marshal(res)
w.WriteHeader(400)
w.Write(resString)
return
}
returnJSONError(w, "bad request")
}
func (ws *WebService) StartWebServer() {
r := mux.NewRouter()
r.HandleFunc("/rest/logs", ws.getLogs)
r.HandleFunc("/rest/uploads", ws.getUploads)
r.HandleFunc("/rest/upload/{id:[0-9]+}/{change}", ws.modifyUpload)
r.HandleFunc("/rest/image/{id:[0-9]+}/thumb", ws.imageThumb)
r.HandleFunc("/rest/image/{id:[0-9]+}/markedup_thumb", ws.imageMarkedupThumb)
r.HandleFunc("/rest/image/{id:[0-9]+}", ws.image)
r.HandleFunc("/rest/config", ws.handleConfig)
r.PathPrefix("/").HandlerFunc(ws.getStatic)
go func() {
listen := fmt.Sprintf(":%d", ws.Config.Config.Port)
daulog.Infof("Starting web server on http://localhost%s", listen)
srv := &http.Server{
Handler: r,
Addr: listen,
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}()
}
func returnJSONError(w http.ResponseWriter, errMessage string) {
w.WriteHeader(400)
errJSON := ErrorResponse{
Error: errMessage,
}
errString, _ := json.Marshal(errJSON)
w.Write(errString)
}

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