From d8023759c034fc104886cb7f4b40d2b8a4c5afbb Mon Sep 17 00:00:00 2001 From: Webklex Date: Sat, 12 Sep 2020 20:16:45 +0200 Subject: [PATCH] Initial commit --- .gitignore | 5 + CHANGELOG.md | 15 + LICENSE | 21 ++ README.md | 282 ++++++++++++++++ bindata.go | 381 ++++++++++++++++++++++ build.sh | 88 +++++ htdocs/assets/css/theme.css | 0 htdocs/assets/images/apple-touch-icon.png | Bin 0 -> 6127 bytes htdocs/assets/images/favicon-16x16.png | Bin 0 -> 341 bytes htdocs/assets/images/favicon-32x32.png | Bin 0 -> 755 bytes htdocs/assets/images/favicon.ico | Bin 0 -> 15406 bytes htdocs/assets/js/app.js | 87 +++++ htdocs/template/index.tmpl | 67 ++++ htdocs/template/layout/footer.tmpl | 30 ++ htdocs/template/layout/header.tmpl | 24 ++ main.go | 42 +++ server/api.go | 112 +++++++ server/backend.go | 243 ++++++++++++++ server/client.go | 160 +++++++++ server/main.go | 196 +++++++++++ server/message.go | 46 +++ server/rate_limiter.go | 79 +++++ utils/config/config.go | 202 ++++++++++++ utils/config/struct.go | 67 ++++ utils/counter/main.go | 103 ++++++ utils/counter/section.go | 106 ++++++ utils/counter/struct.go | 30 ++ utils/filesystem/filesystem.go | 45 +++ utils/log/log.go | 50 +++ 29 files changed, 2481 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bindata.go create mode 100644 build.sh create mode 100644 htdocs/assets/css/theme.css create mode 100644 htdocs/assets/images/apple-touch-icon.png create mode 100644 htdocs/assets/images/favicon-16x16.png create mode 100644 htdocs/assets/images/favicon-32x32.png create mode 100644 htdocs/assets/images/favicon.ico create mode 100644 htdocs/assets/js/app.js create mode 100644 htdocs/template/index.tmpl create mode 100644 htdocs/template/layout/footer.tmpl create mode 100644 htdocs/template/layout/header.tmpl create mode 100644 main.go create mode 100644 server/api.go create mode 100644 server/backend.go create mode 100644 server/client.go create mode 100644 server/main.go create mode 100644 server/message.go create mode 100644 server/rate_limiter.go create mode 100644 utils/config/config.go create mode 100644 utils/config/struct.go create mode 100644 utils/counter/main.go create mode 100644 utils/counter/section.go create mode 100644 utils/counter/struct.go create mode 100644 utils/filesystem/filesystem.go create mode 100644 utils/log/log.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..342c0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/conf +/data +/.idea +/cache +/build \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4aaefa3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to `webklex/gohits` will be documented in this file. + +Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. + +## [UNRELEASED] +### Fixed +- NaN + +### Added +- NaN + +## [1.0.0] - 2020-09-12 +- Initial release \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a742ab --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Webklex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..318d041 --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ +# GoHits visitor counter + +[![Hits][link-hits]][link-website] +[![Total Downloads][link-downloads]][link-releases] +[![Latest Stable Version][link-version]][link-releases] +[![License][link-version]](LICENSE.md) +[![Website status][link-status]][link-website] + +## Description +An easy way to track your page or project views ("hits") of any GitHub or online project. + +Generate your own: [hits.webklex.com](https://hits.webklex.com) + +## Table of Contents +- [Features](#features) +- [Installation](#installation) +- [Configuration](#server-options) + - [HTTP & HTTPS](#http--https) + - [Letsencrypt](#letsencrypt) + - [Middlewares & Extensions](#middlewares--extensions) + - [Rate limiting & Quota management](#rate-limiting--quota-management) + - [Logging](#logging) + - [Additional](#additional) +- [Api](#api) + - [Output](#output) + - [CSV](#csv) + - [XML](#xml) + - [JSON](#json) + - [Websocket](#websocket) +- [Build](#build) +- [Support](#support) +- [Security](#security) +- [Credits](#credits) +- [License](#license) + +### Features +* Serving over HTTPS (TLS) using your own certificates, or provisioned automatically using [LetsEncrypt.org](https://letsencrypt.org) +* [HSTS ready](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security) to restrict your browser clients to always use HTTPS +* Configurable read and write timeouts to avoid stale clients consuming server resources +* Reverse proxy ready +* Configurable [CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) to restrict access to specific domains +* Configurable api prefix to serve the API alongside other APIs on the same host +* Optional round trip optimization by enabling [TCP Fast Open](https://en.wikipedia.org/wiki/TCP_Fast_Open) +* Integrated rate limit (quota) for your clients (per client IP) based on requests per time interval; several backends such as in-memory map (for single instance), or redis or memcache for distributed deployments are supported + +### Installation +Download and unpack a fitting [pre-compiled binary](https://github.com/webklex/gohits/releases) or build a binary +yourself by by following the [build](#build) instructions. + +Continue by configuring your application: +```bash +gohits -http=:8080 -gui gui -save +``` +Open a browser and navigate to `http://localhost:8080/` to verify everything is working. + +Please take a look at the available [options](#server-options) for further details. + +### Server Options +To see all the available options, use the `-help` option: +```bash +gohits -help +``` + | + +#### HTTP & HTTPS +| CLI | Config | Type | Default | Description | +| :--------------------- | :------------------- | :----- | :------------------- | :---------------------------------------------------------- | +| -http | HTTP | string | localhost:8080 | Address in form of ip:port to listen | +| -https | HTTPS | string | | Address in form of ip:port to listen | +| -write-timeout | WRITE_TIMEOUT | int | 15000000000 | Write timeout in nanoseconds for HTTP and HTTPS client connections | +| -read-timeout | READ_TIMEOUT | int | 30000000000 | Read timeout in nanoseconds for HTTP and HTTPS client connections | +| -tcp-fast-open | TCP_FAST_OPEN | bool | false | Enable TCP fast open | +| -tcp-naggle | TCP_NAGGLE | bool | false | Enable TCP Nagle's algorithm | +| -http2 | HTTP2 | bool | true | Enable HTTP/2 when TLS is enabled | +| -hsts | HSTS | string | | | +| -key | KEY | string | key.pem | X.509 key file for HTTPS server | +| -cert | CERT | string | cert.pem | X.509 certificate file for HTTPS server | + +#### Letsencrypt +| CLI | Config | Type | Default | Description | +| :--------------------- | :------------------- | :----- | :------------------- | :---------------------------------------------------------- | +| -letsencrypt | LETSENCRYPT | bool | false | Enable automatic TLS using letsencrypt.org | +| -letsencrypt-email | LETSENCRYPT_EMAIL | string | | Optional email to register with letsencrypt | +| -letsencrypt-hosts | LETSENCRYPT_HOSTS | string | | Comma separated list of hosts for the certificate | +| -letsencrypt-cert-dir | LETSENCRYPT_CERT_DIR | string | | Letsencrypt cert dir | + +#### Middlewares & Extensions +| CLI | Config | Type | Default | Description | +| :--------------------- | :------------------- | :----- | :------------------- | :---------------------------------------------------------- | +| -use-x-forwarded-for | USE_X_FORWARDED_FOR | bool | false | Use the X-Forwarded-For header when available (e.g. behind proxy) | +| -cors-origin | CORS_ORIGIN | string | * | Comma separated list of CORS origins endpoints | +| -api-prefix | API_PREFIX | string | / | API endpoint prefix | +| -gui | GUI | string | | Web gui directory | +| -session-lifetime | SESSION_LIFETIME | int | 1200000000000 | Session lifetime of an counted visitor (default 20min) | +| -pong-wait | PONG_WAIT | int | 24000000000 | Time allowed to read the next pong message from the peer. (default 24s) | +| -ping-period | PING_PERIOD | int | 12000000000 | Send pings to peer with this period. Must be less than pong-wait. (default 12s) | + +##### Rate limiting & Quota management +| CLI | Config | Type | Default | Description | +| :--------------------- | :------------------- | :----- | :------------------- | :---------------------------------------------------------- | +| -quota-burst | QUOTA_BURST | int | 3 | Max requests per source IP per request burst | +| -quota-interval | QUOTA_INTERVAL | int | 3600000000000 | Quota expiration interval, per source IP querying the API in nanoseconds | +| -quota-max | QUOTA_MAX | int | 1 | "Max requests per source IP per interval; set 0 to turn quotas off | + +#### Logging +| CLI | Config | Type | Default | Description | +| :--------------------- | :------------------- | :----- | :------------------- | :---------------------------------------------------------- | +| -logtostdout | LOGTOSTDOUT | bool | false | Log to stdout instead of stderr | +| -log-file | LOG_FILE | string | | Log file location | +| -logtimestamp | LOGTIMESTAMP | bool | true | Prefix non-access logs with timestamp | + +#### Additional +| CLI | Config | Type | Default | Description | +| :--------------------- | :------------------- | :----- | :------------------- | :---------------------------------------------------------- | +| -silent | SILENT | bool | false | Disable HTTP and HTTPS log request details | +| -config | | string | conf/settings.config | Config file path | +| -save | | bool | false | Save config | +| -version | | bool | false | Show version and exit | +| -help | | bool | false | Show help and exit | + +If you're using LetsEncrypt.org to provision your TLS certificates, you have to listen for HTTPS on port 443. Following +is an example of the server listening on 2 different ports: http (80) and https (443): +```bash +gohits \ + -http=:8080 \ + -https=:8443 \ + -hsts=max-age=31536000 \ + -letsencrypt \ + -letsencrypt-hosts=example.com \ + -gui gui \ + -save +``` + +```bash +$ cat conf/settings.config +{ + "HTTP": ":8080", + "HTTPS": ":8443", + "HSTS": "max-age=31536000", + "LETSENCRYPT": true, + "LETSENCRYPT_HOSTS": "example.com", + ... +``` + +By default, HTTP/2 is enabled over HTTPS. You can disable by passing the `-http2=false` flag. + +If the web server is running behind a reverse proxy or load balancer, you have to run it passing the `-use-x-forwarded-for` +parameter and provide the `X-Forwarded-For` HTTP header in all requests. This is for the gohits web server be able to log the +client IP, and to perform correctly identify new hits. + +## API +The API is served by endpoints that encode the response in different formats. + +```bash +curl :8080/json/{username}/{repository} +``` +Same semantics are available for the `/xml/{username}/{repository}` and `/csv/{username}/{repository}` endpoints. + +### Output +#### Section +| Name | Value type | JSON | XML | CSV | +| :-------------------- | :------------ | :------------------------ | :-------------------- | :---- | +| Username | string | username | Username | 0 | +| Repository | string | repository | Repository | 1 | +| Total | int | total | Total | 2 | +| Created at | datetime | created_at | CreatedAt | 3 | +| Updated at | datetime | updated_at | UpdatedAt | 4 | + +#### CSV +```bash +curl :8080/csv/webklex/gohits +``` +``` +webklex,gohits,55,2020-09-11 07:01:23,2020-09-12 00:10:07 +``` + +#### XML +```bash +curl :8080/xml/webklex/gohits +``` +```xml +
+ webklex + gohits + 55 + 2020-09-11T07:01:23.252745204+02:00 + 2020-09-12T00:10:07.7275806+02:00 +
+``` + +#### JSON +```bash +curl :8080/json/webklex/gohits +``` +```json +{ + "username": "webklex", + "repository": "gohits", + "total": 55, + "created_at": "2020-09-11T07:01:23.252745204+02:00", + "updated_at": "2020-09-12T00:10:07.7275806+02:00" +} +``` + +### Websocket +Url: `:8080/ws` + +You can subscribe to specific channels or to `all` in order to receive all recent hits. + +#### Payloads: +**Subscribe to channel `all`:** +```json +{ + "name": "subscribe", + "payload": "all" +} +``` +**Subscribe to channel `webklex/gohits`:** +```json +{ + "name": "subscribe", + "payload": "webklex/gohits" +} +``` +**Delete a the subscription of `all`:** +```json +{ + "name": "unsubscribe", + "payload": "all" +} +``` +#### Output +``` +00:26:07 webklex/gohits +``` + + +### Build +You can build your own binaries by calling `build.sh` +```bash +build.sh build_dir +``` + +### Features & pull requests +Everyone can contribute to this project. Every pull request will be considered but it can also happen to be declined. +To prevent unnecessary work, please consider to create a [feature issue](https://github.com/webklex/gohits/issues/new?template=feature_request.md) +first, if you're planning to do bigger changes. Of course you can also create a new [feature issue](https://github.com/webklex/gohits/issues/new?template=feature_request.md) +if you're just wishing a feature ;) + +>Off topic, rude or abusive issues will be deleted without any notice. + + +## Support +If you encounter any problems or if you find a bug, please don't hesitate to create a new [issue](https://github.com/webklex/gohits/issues). +However please be aware that it might take some time to get an answer. + +If you need **immediate** or **commercial** support, feel free to send me a mail at github@webklex.com. + +## Change log + +Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. + +## Security + +If you discover any security related issues, please email github@webklex.com instead of using the issue tracker. + +## Credits +- [Webklex][link-author] +- [All Contributors][link-contributors] + +## License +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. + +[link-downloads]: https://img.shields.io/github/downloads/webklex/gohits/total?style=flat-square +[link-version]: https://img.shields.io/github/license/webklex/gohits?style=flat-square +[link-license]: https://hits.webklex.com +[link-website]: https://hits.webklex.com +[link-releases]: https://github.com/webklex/gohits/releases +[link-hits]: https://hits.webklex.com/svg/webklex/gohits +[link-author]: https://github.com/webklex +[link-contributors]: https://github.com/webklex/gohits/graphs/contributors +[link-status]: https://img.shields.io/website?down_message=Offline&label=Website&style=flat-square&up_message=Online&url=https%3A%2F%2Fhits.webklex.com%2F \ No newline at end of file diff --git a/bindata.go b/bindata.go new file mode 100644 index 0000000..3e31cee --- /dev/null +++ b/bindata.go @@ -0,0 +1,381 @@ +// Code generated by go-bindata. (@generated) DO NOT EDIT. + + //Package main generated by go-bindata.// sources: +// htdocs/assets/css/theme.css +// htdocs/assets/images/apple-touch-icon.png +// htdocs/assets/images/favicon-16x16.png +// htdocs/assets/images/favicon-32x32.png +// htdocs/assets/images/favicon.ico +// htdocs/assets/js/app.js +package main + +import ( + "github.com/elazarl/go-bindata-assetfs" + "bytes" + "compress/gzip" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" +) + +func bindataRead(data []byte, name string) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewBuffer(data)) + if err != nil { + return nil, fmt.Errorf("read %q: %v", name, err) + } + + var buf bytes.Buffer + _, err = io.Copy(&buf, gz) + clErr := gz.Close() + + if err != nil { + return nil, fmt.Errorf("read %q: %v", name, err) + } + if clErr != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +type asset struct { + bytes []byte + info os.FileInfo +} + +type bindataFileInfo struct { + name string + size int64 + mode os.FileMode + modTime time.Time +} + +// Name return file name +func (fi bindataFileInfo) Name() string { + return fi.name +} + +// Size return file size +func (fi bindataFileInfo) Size() int64 { + return fi.size +} + +// Mode return file mode +func (fi bindataFileInfo) Mode() os.FileMode { + return fi.mode +} + +// ModTime return file modify time +func (fi bindataFileInfo) ModTime() time.Time { + return fi.modTime +} + +// IsDir return file whether a directory +func (fi bindataFileInfo) IsDir() bool { + return fi.mode&os.ModeDir != 0 +} + +// Sys return file is sys mode +func (fi bindataFileInfo) Sys() interface{} { + return nil +} + +var _htdocsAssetsCssThemeCss = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x01\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00") + +func htdocsAssetsCssThemeCssBytes() ([]byte, error) { + return bindataRead( + _htdocsAssetsCssThemeCss, + "htdocs/assets/css/theme.css", + ) +} + +func htdocsAssetsCssThemeCss() (*asset, error) { + bytes, err := htdocsAssetsCssThemeCssBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "htdocs/assets/css/theme.css", size: 0, mode: os.FileMode(511), modTime: time.Unix(1587836524, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _htdocsAssetsImagesAppleTouchIconPng = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xd4\xd6\xd5\x5b\x13\x8e\xf7\xc0\xf1\x11\x03\x19\x8d\x34\xd2\x12\xa3\x91\x12\xa9\xd1\x02\xd2\xdd\x0d\x43\xe9\x56\x46\x8c\x18\x1d\x2a\x92\x02\xa3\x43\xba\x43\xea\x43\x29\x12\x03\x94\xee\x11\xa3\xbb\x41\x7e\x8f\x7f\xc4\xef\xe2\x7b\x71\x2e\xce\xc5\x79\x9e\xf7\x73\xae\x5e\xb1\x3a\x5a\xaa\xc4\x20\x7a\x10\x00\x00\x20\x56\x7b\xad\xa4\x07\x00\x00\x1a\xff\xcd\x13\x3c\x00\x00\x20\x33\x8a\xf7\x02\x00\xa0\x69\x56\x53\x92\x37\x08\xb4\x3a\xc8\x21\xc8\xa0\x1a\x68\x1a\x7a\xfc\x13\xd0\xed\xe3\x6c\x4b\x8b\x70\xb3\x21\xfb\xcd\x0d\x8a\x5b\x0d\x8f\xb8\xd5\x61\x91\x27\x1e\x26\xcc\x50\xd2\x95\x97\xa7\xd0\xbd\xb1\x82\x65\x5d\xc8\xa4\xd3\x3c\x3d\x3b\x55\x60\xb3\x4b\x08\xeb\x87\xbe\xfe\x4d\xab\xf9\x9c\x3c\xa3\x16\x4d\xff\x5f\x01\x9b\x46\x8a\xa1\x92\x93\xcd\x65\xd0\xf6\xa1\xe5\x91\x94\x4c\x02\x9d\x97\xd3\xe1\x41\x54\xee\x96\xf7\xed\xd4\xf8\xb8\x47\x45\x85\xf6\xc3\x78\x8d\xb6\xa1\xf1\x9f\x03\x76\x24\x7a\xf7\x66\x7f\x4d\xda\x7a\x78\x45\xbf\x64\x93\x2c\x7f\x55\xfd\xb5\xe4\x1b\x80\x33\xa4\x80\x0c\x3b\x04\xeb\x09\xe3\xa8\x97\xae\x11\x44\x1f\xf2\xf6\x91\x85\x04\xcc\x80\xb5\xaf\xc7\xda\xcc\x4d\xf6\xcd\xc0\xf0\x3b\x83\x06\x47\x45\xd2\x08\x0f\xb1\x33\xaf\xa1\x3f\x60\x6c\x69\x93\xf7\x8a\x66\x05\x97\x95\x13\x04\x3f\x2b\x52\xb2\x66\x45\x3a\x04\x3d\x15\x18\x02\x61\x0c\x21\xd8\x3c\xcf\x65\xe1\x29\x47\x6a\xb1\x72\x03\x7b\x9f\xe3\xbf\x1d\xee\x51\xfd\xfc\xad\xa5\x82\xbc\x93\x09\x76\x88\x64\xb3\xd4\xc0\x45\xf1\xea\xdb\xdf\x07\x18\xad\x2a\x81\x59\x8a\x2b\xd5\xc1\x19\xd7\x27\x07\x4b\x2b\xbb\xfb\x4c\x8c\xd8\x4b\x67\x3f\xfb\x8a\xad\x92\xb7\xdd\x81\xeb\x02\x58\x94\x39\xdc\x8a\x6d\x96\x46\x90\x68\xec\xc4\x8a\x38\xeb\x81\xc8\x00\xad\x8b\x04\x1d\xc3\xa0\x8b\x79\x60\xfb\xd1\x62\xd6\x0b\x63\x40\x7c\xa4\x4e\x35\x65\x8a\x00\xa3\xc4\x46\x41\x65\x0f\x81\x21\x44\xbd\x8c\x22\x90\x9d\x64\x73\xba\x82\x4e\x39\x28\xb9\x7b\x6e\x3b\xec\xb0\x03\x59\x32\x42\x7c\xf2\x0c\xb7\x7a\x35\xfd\xfb\xd3\x5d\x06\xf7\x45\x24\xf6\xbc\x46\x51\x7f\xb6\x1a\x95\x04\x53\x96\x29\x0c\xe9\x26\x92\x87\xd5\x3e\x53\x50\xc2\xb4\xec\x6b\x57\x47\x66\x47\xd4\xe7\xaa\xe3\x56\xed\x14\x8f\x8d\x2b\xa6\xbc\x0b\x95\x16\xbb\xf9\x51\x39\xd9\x57\x25\xf1\x40\x84\x7a\xc8\xad\x0e\x32\xf9\x78\x1a\x84\xbd\xea\x82\xe0\x77\x5f\xcb\xce\xd0\x85\x98\x13\xe8\x6e\xda\xdf\x91\x52\x3d\x72\x31\x51\xf2\x34\x5d\x80\x51\x0f\x34\xbf\x49\x69\x14\x2c\x7b\x05\xc3\x9c\x9e\x6f\xb5\x5b\x22\x21\x8a\xaf\xbe\x64\x1d\x4f\x48\xa8\xcf\x3c\x10\x62\xf0\x5b\x3e\xfc\xfa\x75\xbb\xff\x14\x72\x24\x20\x14\xe3\xf1\x13\x94\xa1\x07\x51\x60\x5a\xb6\xd6\xb1\x48\x62\x50\x52\x88\x47\x3d\x6c\xcd\x68\xfa\x81\x4f\x67\x4a\xcc\x36\x87\x2b\x21\x34\x3c\x29\x5a\xe8\xb1\x64\x27\x6a\x39\x30\x54\x6a\x81\xf9\x4a\x56\x10\xb7\xfa\x40\x21\xe8\x7a\xba\x32\x0c\x0c\x7c\xde\xbf\x3b\xd8\xf8\x95\x7b\x38\xb7\x4c\x76\x69\xd3\xaf\x86\x1c\x3b\x58\xb3\xe9\xc7\x17\x53\x78\xb5\x01\x64\x78\xd7\xc1\x8c\x8e\x6b\x78\x6b\x46\xd3\x17\x7c\xca\x81\x25\x9b\x3d\xf1\xe5\xef\x6a\x50\xb6\x7e\x98\x91\xf4\xf3\x7e\x83\x09\x8f\x90\x03\xb4\x00\x30\x20\xf3\x5d\xd2\xbb\x06\x6c\xcb\x9a\x30\x9e\x4d\x05\xdd\xae\x5a\xff\x4e\x80\x8a\x5f\xa7\xa4\x2b\x16\xaf\x8d\x16\x16\x78\x73\xa1\x00\x26\x3f\xca\xca\x8a\x2b\x38\x93\x39\xf5\xe9\x9d\x14\x04\xcb\xac\x8b\x1d\x99\x1d\x45\x86\x85\x3f\xdb\xc7\x59\x8b\x5d\x42\x99\x42\xa1\x9b\xe5\x5d\x06\x73\xef\x4b\x4d\xb2\x98\x63\x05\xa1\x6e\x48\x84\x6d\x6c\xf9\x1f\xee\xa3\x04\x8d\x58\xb8\x28\x6d\x90\x3e\xff\x25\x2e\xc6\x1a\x01\x69\xa1\x4e\xac\xa5\xff\xd6\xe1\x9c\x45\x3a\xbc\x2c\xba\x1f\x60\x20\x2d\xe6\xab\x37\x57\x00\x02\xee\x84\xe7\xad\x8e\x85\xf7\xaa\x74\xf1\x67\x8e\x2d\x32\x13\xfe\x55\x2f\x0c\x3e\x06\x0e\xf0\x76\xa6\x85\x3a\xb1\xfc\xaf\x9c\xf0\xe1\xbc\xc2\xe0\x0d\x5e\x5e\xd9\x18\x58\x93\xe2\x51\xf3\xe8\xe0\xea\xfc\xe1\x3b\x19\x5b\x7b\x6e\xb5\xf0\x59\xc9\xf0\x77\x66\x5b\xac\xc0\xfe\xce\x60\xab\x37\xe9\xd8\xce\xa1\x02\x8a\x1d\x95\x9a\x12\xf8\x08\x05\x69\x5c\x71\x9e\x84\x68\xce\x7f\x1c\x13\x20\x24\xb0\x90\x42\xc5\x93\x17\xbb\xa7\xe2\x52\x3e\x37\x88\x5d\xba\x70\xa9\x94\xcc\xf8\xaf\x02\x47\xed\x39\x23\xb7\x5f\x2e\xc3\xf5\x3a\x80\x54\x40\xf0\x0c\x69\x1d\x73\x4d\xf6\x92\x52\x0d\x9d\xa3\xad\x67\xd2\x2e\x18\xcb\x4e\x7d\x62\x4b\x8f\x49\xc1\x6f\x0d\x49\xa1\x69\x18\xbb\x2a\x34\xce\xfd\x66\x09\xb0\x03\xc7\x3a\xb7\x0c\x1c\x2f\x5e\xbe\x24\x41\x74\x58\xb9\x01\x21\xca\xd6\x12\x4e\xa4\x67\xf6\x81\xda\xda\xd1\xa8\x6a\x99\x8c\x9e\xef\xfc\xc2\xf7\x85\xc3\x74\x36\xa4\x3c\x06\x9e\x77\x6d\xb2\x8b\x9e\x25\xa4\x02\x71\xb4\x07\x16\xf8\xf4\x53\x81\xae\x3e\x94\xa1\x4b\xd9\x4a\x8b\x58\x4e\x01\x67\x04\x6f\x68\x37\x7a\x97\xfa\xb0\x31\x87\x5c\x9b\x72\xa1\xca\x14\x3c\x21\x0d\x32\x49\xce\x2d\x03\x4d\x2c\xd0\xad\x17\x40\xdf\x1a\x64\x4f\x32\x4f\xb8\x99\xc1\x65\xeb\x3c\x4d\xeb\x9f\x97\x86\xea\xfb\x65\x9a\xe8\xdc\x3f\xe4\xfb\xee\x3a\xa3\xb3\xae\x15\xd5\xe6\x70\xbc\xd3\xd4\x06\x18\xf7\x2e\x64\x75\x9a\xe1\x35\xd8\x4b\x94\xe7\xa4\x3f\x8d\x3b\xa4\x41\xc6\x76\xb7\xcb\x89\xd7\x7a\x72\x21\x4a\x95\x27\x9c\x56\x67\x75\xb2\xc0\xdc\x99\x06\x98\xf2\x05\x9f\xe2\xfa\x2f\xc3\x28\x14\xa3\xd0\x40\x28\xdd\x23\xa8\xb2\x9d\x5f\x90\x8c\xbf\xcc\x77\x3d\x6b\x9a\x3d\x18\x60\xe5\x31\x18\xa4\x9a\x28\x17\x8b\x80\x5f\x72\xf7\x68\xab\x00\xbd\x1e\x76\xac\x62\x88\xb0\x5b\xa8\xba\xc1\xf9\xeb\xbb\xc6\xf4\x1d\x7c\x7a\x2b\xb0\x34\x9a\x47\x99\x17\x4f\x02\xf0\xc9\xab\xd9\x75\x9c\x6a\x48\xd0\x7f\x64\xd4\x1a\x2c\xe8\x2b\xe4\x5a\x4d\x7a\xe3\x67\xb6\xd8\x93\x95\x66\xbc\x64\x5a\xf8\x11\xd1\x2b\xb1\x6c\x7f\x80\x27\x11\x25\xc8\xf8\xa1\x68\xcf\x10\x97\x21\x76\x9b\x20\xcb\x39\xef\xe7\x51\x0b\x69\xd1\xba\x34\x12\xd4\xe5\xc4\x38\x32\x7e\xb5\x34\x2b\x1d\x2f\x9f\x24\x6b\x09\x95\x3d\x0d\x9d\x7b\x3a\x14\xbc\x97\x45\x2e\xf2\x97\x65\xd7\x73\xaf\x3b\x6e\x32\xe5\x61\xe1\x24\x9f\xa4\x54\x25\x47\xac\xbd\x54\x36\x31\x3b\x5d\x6c\x4d\xa7\x3f\x03\x85\x20\xc2\x5b\x35\x0c\x03\xda\x2b\xb6\x56\xd6\x98\x1b\xaf\x22\xef\x94\x3f\x51\x5c\xd5\xc4\xaf\x36\x05\x5f\xca\xc6\x44\xf6\x2f\x6d\xdf\x35\x85\x4a\x65\xd1\xfb\x15\xe1\x27\xc6\xd5\xc0\x32\x7a\x1e\xf6\xb2\x3f\x8a\xb3\x34\xb1\x22\xef\xb6\x09\x2b\xaf\xd5\xb1\xa4\x05\x82\xc9\x0b\x9b\x9e\xaa\x19\xda\x9d\x7e\x52\xf9\x1e\xf4\x7b\x19\x55\x17\x08\x63\x52\xb9\xbc\xc4\xe0\x45\xec\x73\xe6\xef\x30\xae\xc2\x5b\x0a\x54\xe6\x44\x0d\x3c\xee\x01\xa4\x5b\x96\x99\xe9\x5e\x59\x13\xba\x68\x3f\x61\x2a\x2a\x41\x51\x96\xac\x17\xbe\x41\x8a\x5f\x14\xdc\x6d\x36\x37\x14\x52\xf1\x12\x4f\xe4\xa7\xa6\x4c\x79\xfb\x4c\x9a\xad\xbb\x27\x10\x61\x5d\x01\xad\xcc\x01\x39\x1e\x02\x2b\xc1\x9b\x5d\xb8\x4e\xd5\x09\xb4\x14\x23\xaf\x8b\xf3\x39\x04\xee\x6f\xda\xb1\xd9\x0d\xe5\xaa\x9e\x6a\xab\x59\x5c\xda\x45\x38\x7a\x6c\x64\x26\xe4\xbb\x6c\x4f\xc6\xde\xbc\x2c\xcc\x96\xd2\x9f\xd0\x7a\xfc\x1b\x21\xc1\x66\xf2\xbd\xaf\xeb\xa7\x68\xe2\x32\x69\xc2\x88\x88\xe3\xd9\x6e\x32\x45\x46\x1b\x5b\x78\xd1\x58\x46\x29\x03\x34\x8f\x30\xf0\xed\x9b\x27\xc6\xa9\x3b\xd9\x0c\x8c\x2b\x1d\x50\xbd\x81\xe3\x01\xe2\x98\x79\xd1\x83\x0c\xc9\xfe\xe8\x23\x0b\x4a\x51\x8e\x17\x86\xb9\x9d\x3c\x09\xc6\x57\xf3\x22\x91\xa4\x7c\x3a\xb5\x76\x7a\xf0\x7c\xf2\x62\x37\xd5\xf4\x23\x8d\x55\x06\x71\xbc\xe4\xf4\x1a\x62\x5b\x13\xae\x6f\xaa\x73\x2f\xe1\x69\xac\x2e\x71\x57\xb1\x63\xe0\x5c\x77\x64\xc7\x1d\x90\x3a\x91\xd0\x16\x4a\xea\xa3\x1b\xf1\xea\xa2\x38\x9d\xa1\xb6\x41\x6b\x9b\x3d\x23\x91\xfe\x20\xf5\xbd\xaf\x01\x99\xf5\x43\xf8\xb8\xcd\x41\xa8\x61\x10\xcc\x87\xd2\x47\x7c\xe0\x4d\xcb\x67\x4d\x1c\xe3\x0b\x1f\xfb\xb5\xd6\x7b\xd7\x38\x7f\x33\x27\x0d\x3f\x90\x84\x80\x5b\xbd\xe3\xbc\xfc\x9e\xa0\x54\x09\xb5\xa3\xc5\x9f\x1f\xbf\xcb\x2b\x62\xe2\x5c\xbe\x68\xce\x5a\x7d\x4b\x26\x5d\x82\x5c\x11\x81\xee\x3f\x58\x3c\x71\x0e\x90\x20\x3d\x2f\xe2\x48\x9c\x35\x71\xd3\x8e\xc9\x95\x78\x45\x3a\x43\x74\xee\x34\x29\x8c\x5e\xc9\xbb\x61\x41\x44\x2f\x37\x3b\x33\xc9\xd8\x22\xf1\x93\x13\x49\x1c\x1b\xc1\xfa\xf0\x2a\x55\xfd\x8c\xfa\xc6\x9f\x69\x1c\xdf\xc3\x08\x67\xc5\x9b\xcc\x39\x3f\xbf\xf8\xdb\xc9\x59\xb1\xde\x3c\x5c\x7b\xc8\x21\xbd\x04\x49\xc7\x35\x63\x3b\x4f\xa1\x38\x83\x58\x9f\xa9\xe3\x34\xef\xc0\xb0\x45\x78\x26\xe4\xe1\x79\x56\x4c\xfd\x45\x22\x23\xaa\x18\x6b\xf7\xd8\xf5\x52\x86\x83\x58\x54\x63\x84\xda\x94\x54\x03\x0d\x7f\x94\xe6\xfb\x2a\x02\xf2\x87\x23\x93\x76\x0f\xcd\x1a\xb8\x54\x29\xd5\x8e\x15\xb5\xcc\x5f\x95\x3f\xa3\x3c\x25\xfc\xa4\xf6\xe6\x55\x5d\x61\xa5\x0c\xd0\x6e\xc2\x0c\x39\x4e\xd2\xf4\x2f\x99\xf1\xfe\x8d\xd7\x72\x76\x5e\x9d\xfc\x6c\x9f\x4d\x56\x9e\xed\xf6\x3d\xd0\x64\xe5\x29\x86\xbe\xd3\xa4\xad\xd9\x6f\x76\x26\x09\xfe\x90\x1f\xde\xad\xc9\xf3\x9b\xf8\xcf\x09\x15\xcf\x15\xd7\x71\x22\x15\x39\xad\x09\x50\xc4\xf7\xc8\x98\x6b\xc3\x3e\xe6\x27\x18\xcc\xff\x79\xb9\xbe\x92\x80\x39\x0f\x23\x29\x7a\x71\x2c\x45\x73\x2b\xec\x53\xd6\x66\x98\x24\xc6\xcc\x21\x54\x35\xd9\x65\x98\x44\xa8\x6e\x79\x8d\x4e\x1f\xad\xf2\x46\x79\x4f\x16\x8f\x4b\x64\x2c\xe7\x8e\xa6\x51\x98\x8d\x5b\x4a\x95\x03\x1c\x81\x07\xcc\xe0\x1f\xcd\x39\xd1\xcb\xee\x83\x22\xcc\xfd\x0d\xfc\xee\xed\x5f\xa9\x04\x57\x3d\x60\x5f\xfb\x7f\x0f\xd7\x1e\x6a\xb2\x2e\x91\x67\x7d\x08\x32\x92\x50\x8e\x88\x20\x39\xa3\x6d\xe3\x7e\x35\x6e\x42\x3e\xb8\xc7\xbc\x20\x84\xc7\x24\x92\x82\xf8\x6f\xbb\x86\x60\xed\x3f\xab\x41\x23\x67\x28\xe3\xed\x40\x28\x67\x63\x95\xa9\x54\x8d\x9a\x73\xe0\x31\xa6\x4c\x7a\x89\x81\xb2\x4a\x9c\xc9\x45\xcb\xfe\x9e\x57\xb3\x2c\x76\x8e\x2a\x0f\x4e\xd1\xdd\xf3\xee\x29\x21\xaa\xaf\xff\xd5\x79\xcb\xb4\xd2\xb1\x33\x8e\x54\x03\x22\x8c\xb3\xee\xf0\x65\x7a\x5a\x2f\x36\x22\x2d\xd5\x68\x5f\x7b\x35\x57\x8c\xdc\x7c\xb9\x7d\xe1\x3f\x55\xc5\x97\x6e\xac\x01\xbf\x3c\x11\x85\x32\x36\x72\x6f\xd5\xed\x87\x27\x85\x48\x44\x77\x82\xf6\xc4\x09\x43\x7b\x88\x0f\x38\xee\xd7\x97\x6f\x99\xfb\x53\x79\xe4\x6d\xf8\xea\x5c\x32\xeb\x9a\x6a\x28\xa5\x0f\x19\xd6\x86\x18\x15\x1f\x10\xe2\x91\x69\x47\x89\xe8\x5c\xb1\xd1\xd7\xb4\x29\xf3\x42\xa8\x46\x8e\xbb\x6b\x8c\x74\x02\xff\x4c\xcb\x99\x8a\x47\x4d\x8b\x80\xc7\x7f\xf8\x62\x78\x95\x62\x6a\xf8\x89\x5c\x01\x44\x27\x6b\xcb\x6b\xf0\x84\xe8\xf2\xf9\x65\x9e\x8f\x69\x48\xdb\xbb\x2b\x29\x8a\xe6\x51\xc5\x85\xe5\x50\x07\x8f\x96\x66\x73\x93\x4a\xb4\x7c\x11\xf9\x66\xb1\xa5\xd7\xfd\x4c\xf6\x84\x80\xa4\x9f\xea\xaf\x8e\xf1\x04\xc2\xb5\xaf\x14\xe9\xd5\xc8\x96\x42\xf1\xa8\xec\x74\x84\xd7\xef\xd5\x18\x3b\x5a\x28\x05\x0c\x02\x0f\x7f\x6c\x13\x0d\x8b\x93\xf5\x85\x94\xfb\xcc\xbf\x5e\x25\xdd\x9b\x9a\x27\xa8\x25\xb6\x0c\x7c\xf6\x39\xa9\x63\x59\xb8\x3f\x99\x4e\xf1\xfe\xb8\x43\x44\x3b\x93\x1e\xcd\xb3\xe5\x1e\xbb\xde\xe6\x46\xa0\x48\xde\xb7\x21\x52\x66\xc8\xc5\x3b\x74\x1e\x80\xee\x16\x24\x28\xe0\x93\x3b\x83\x65\x6d\xdd\xd6\x1c\xa8\xad\x7e\xec\x56\xf5\x9a\xdd\xf0\x7a\x23\x72\x39\xd5\xb6\xad\xaa\x42\xce\x9c\x5b\xc9\x76\xf1\x17\x21\x7a\x68\xce\x2c\x3c\x10\x0d\x46\x9a\x4b\x69\xf4\xfd\x3a\xd8\xbe\x5d\xdb\xdb\x88\x59\x6d\x8f\x20\x80\x05\x05\xfb\xa4\x83\x16\xa7\x25\x8d\x2f\x02\xd0\x43\x54\xf2\xe7\x4f\xfc\x89\x7e\xe4\x7b\xd2\x5f\x55\x27\xae\x1e\xf4\xdd\xa6\x25\x8d\x40\x50\xa6\x4b\xa7\x30\xa0\x4f\xf5\x93\xf5\x70\x2d\xff\xb5\x02\x70\x6c\x9e\x47\x82\x5d\xaf\xd1\x9d\x15\xb6\x54\x43\xa6\xd6\xd2\x52\x10\x9c\x79\x0a\x81\xdb\x88\xdf\xd0\xc9\xcb\xcf\x01\xa5\x7f\xf5\xee\x4b\x1d\x8e\x9a\xdb\x42\x23\x2e\xf4\xc5\x79\x02\x9b\xf2\x02\x89\x5c\x43\x36\xa1\xb2\x6c\x7e\xd4\x17\xa8\x9e\x9d\x75\x61\x9c\x04\x09\xc7\x49\x92\x22\x12\xba\xf0\xfe\x15\x2c\xc4\x50\x8e\x14\x71\xe9\x99\xef\x6c\xf5\xab\xc6\x15\x53\xb6\xc9\x45\x4e\x95\xfd\x4d\xdf\xe4\x3d\x4b\xac\xd3\xdd\xb2\x79\xc3\x07\x12\x6e\xa7\x84\x5d\xf4\xe0\x40\xe7\x12\x26\xaf\xa6\x67\x9b\x3a\x85\xa2\x2c\xb1\x7c\xd6\x2a\x7b\x70\x37\x59\xaf\x79\xfc\xac\x41\xf8\x7e\x84\x0d\x58\x78\xf7\xde\xd3\xa9\x8e\xf0\x86\x29\x3f\x11\x33\xaa\x16\x2b\x43\xcc\x0f\x73\x2d\xd7\x62\x77\xec\x0d\xfa\xc6\x01\x69\x39\x20\x4b\x5c\xf7\x8d\xc9\x15\x78\xf5\xb7\xf4\xca\x53\x4d\x7c\xdd\xc4\x12\x01\x29\x09\xc0\x6e\x7f\x3d\xa5\xd4\xbe\xd9\xad\xd5\xc5\x1d\x83\x3a\xc1\x4c\x4f\x75\x14\x35\x7c\x44\xa7\xf9\x5f\x7d\x9c\x85\xe6\xec\xe4\x3c\x91\xe5\xc3\x9f\x77\x87\x5f\x87\x54\x86\x6d\xea\x93\xa4\xa5\x52\x79\xc6\x61\x3f\xcb\xcf\x59\x27\x68\xee\xa3\x00\xd2\x42\x35\x1c\x36\x54\xbe\x16\x00\x70\x0e\x28\xb5\x6b\x19\xd3\xb8\x7b\x20\xe6\x6c\xec\x12\xeb\x94\x40\x5a\x68\x69\x60\xf8\x41\xad\x12\x16\xde\x3c\x05\x5d\x1b\x79\x29\x77\x0f\x44\x6a\xe0\xa9\x6b\x34\x07\x48\x08\x95\xf6\xe3\x47\xe2\x4b\x65\x2c\xbc\x79\x70\xae\x8d\x14\x9f\x6e\x18\xbc\x59\x49\xc5\xbd\x1f\x0c\x12\x42\x69\x9d\xe0\x2e\xd8\xc6\x62\x13\x35\x72\xbc\xf0\x66\xd7\xd2\x0d\x83\xfb\xc7\x71\xcd\x92\x68\x50\xda\x20\xf9\x3f\x7e\x64\xf0\x8a\xc3\x26\x6a\xd4\x97\xee\xf5\x37\x29\x00\xe0\xb8\xc5\x26\x2c\xc6\xe8\x53\xda\x20\xff\x7f\x53\xd2\xb0\x34\x57\xb0\x98\xf4\xd6\x27\x65\x4b\xcd\x29\x74\x1a\x3d\xb0\x24\x38\x9f\x5f\x8a\x17\xd5\x6d\xbe\x9e\x9f\x7e\xc6\x3c\xc7\xa3\x28\x1e\x92\x9f\x1d\xee\xd3\xde\x31\x21\x55\x14\x56\xbf\x31\xa8\xd7\xd3\xeb\x49\x6f\x9e\x5c\x52\xd7\xd1\xfb\x97\x0f\x67\x6e\x56\xab\x8d\x6d\xfc\x68\x6d\x5a\xf6\x25\xf3\xe9\x93\x83\x6f\x85\xa9\x78\x6e\xaa\x96\xf1\x4b\x2a\x7a\x92\x0e\x3e\xd6\x22\x8e\x31\xc9\x67\x1f\x5a\xc5\x64\xe3\x1e\x14\xd0\x85\xac\x5a\x79\x53\x9a\xe8\xa5\x42\x97\xdf\xf4\xf7\x76\x8f\x27\xe2\xa6\xd8\xa2\xc1\x35\xd7\xbd\x1f\xb1\x52\xa3\x2c\xe6\x4b\x7d\x2d\x41\xc1\x0d\x26\x6f\xf3\x8a\x8a\x8f\xf8\x55\x3f\x51\xf8\x48\xa8\x9e\x2c\x30\x12\x2c\x99\x26\x8e\xc4\xcc\x9a\x2e\xd5\xa7\x84\x47\xab\xe0\x09\xd5\x27\xab\xde\xf1\xf1\x07\x07\x98\xc6\x50\x77\x10\x90\xdd\x16\x1d\xae\x5c\xb6\xc0\x80\x8f\xaf\x7f\xd3\x31\xcf\x14\xc3\x78\x53\x83\x57\x2f\x7d\x82\xe7\x87\x36\xd5\xde\x6d\x66\x8c\xee\xd6\x69\xd3\xe8\x03\x6e\x7a\xb3\x47\x67\x1d\xf4\x15\xa6\x5c\xef\x45\xda\x47\x12\xa3\x05\x8b\x93\x7f\xf1\xf2\x8b\xeb\x1b\x61\xb3\x42\x32\xf1\x62\x92\x6f\x5b\xfb\x17\x35\x75\xf9\x96\x5d\x64\x99\xdb\xc0\x48\xc2\x4e\xec\xa9\xe8\x6b\xa1\x87\xc5\x91\x1d\x46\x6e\xf8\x68\x1c\x8e\x9a\xf4\xb5\x51\x83\x9b\xcb\xc4\xfc\xd3\xec\x9e\xe2\xc8\xb4\x95\x0e\xee\x14\xe1\x4e\x9c\xde\xce\x6f\xb2\x89\xd1\xcb\x71\xeb\xa3\xda\xed\x97\xbf\xb6\x23\x40\x54\xd2\x9e\x6a\x00\xaa\x60\x91\xd2\x00\xe8\x9b\xb2\x8c\x16\x49\xc4\x80\xd4\xba\xe5\x6d\xaf\x7f\xdc\xf0\x4a\x7b\x4f\x32\xc3\xf8\x29\x7b\x0a\xab\x7d\x56\x9a\x8a\xbd\x07\xb6\xdc\x1c\xd9\x40\x6f\xed\x09\xc0\x8e\x16\x8a\x8d\x5e\x5e\x0b\xe1\xb7\xd3\x51\x1a\xdb\x62\x83\x2c\x4b\x65\x0d\x9e\xab\xa3\xd5\x6c\xeb\xdb\xb4\x9d\x1d\xbb\x0c\x0e\x7b\x0b\xd3\x81\xba\x18\xa9\xcb\xa8\x33\x3e\xfa\xa2\x1a\xf4\xb8\x15\x63\xef\x5e\x74\x3b\xde\x2e\xd6\x0d\x28\x44\x8c\x53\x9b\xa1\x1a\x5c\xef\xe8\xa3\x0b\xc8\x6c\x10\x8e\xbb\x39\x1a\xea\xd8\x50\xc4\x9c\x63\xdd\x30\x27\x83\xd6\x7f\x45\x7c\x3f\x76\xf8\x89\x7f\xf1\xee\xa6\x2b\x32\x0d\xaf\x1f\x3a\x6d\xde\x07\xb1\xd6\xdd\x1a\xbc\x17\x66\x55\x54\x1b\xce\x5b\xa0\xee\xeb\x3b\x52\x72\xcb\x0b\xd1\x71\x2a\xa1\xa9\xfe\x39\x84\x2c\x18\x89\x0d\x48\x8a\x46\x50\xf4\xac\x58\xd1\xa9\xb8\x2d\x06\x9e\xff\xc0\xeb\x47\xff\x42\xcd\x1c\x82\xab\xce\xfc\x22\xb8\x85\xf5\xfd\x85\x5a\x9a\xe5\x09\x92\xdd\x4d\xa9\xa7\xb7\xca\xfc\xad\xf5\xc7\x2e\xd7\xe1\x1a\xe1\xed\xfa\xcf\xf5\x51\x41\xae\xfa\x44\x3e\x2d\xfd\x44\x8e\xba\x42\xb8\xa2\x9d\xa9\xf7\x37\xed\xb0\xf3\xfd\xe7\x6b\x5a\xce\xd8\xd3\x43\x86\xb0\x87\x1a\xcd\x59\x5d\xe7\x23\x8d\x99\x47\x2b\x95\x9b\xae\x8b\x08\xf9\xe0\x7c\xd6\x13\xe7\xc7\x98\xf5\xf4\xa3\xb9\xec\x04\x67\x07\x0f\x9a\x5c\xa6\xf4\x64\x08\x35\x5d\xc7\xfc\x14\x2c\x48\x13\xf4\x34\x5d\x74\x2e\x6b\x9e\x7f\x9c\xf8\xde\x45\x41\x56\xeb\xc8\x89\x8f\x19\x26\xcd\xd1\xd4\xf8\x23\x54\x22\x85\x7f\x94\x0c\x95\xba\x77\xd4\x41\x93\xf9\x2b\xfb\xe1\x7e\xec\x65\xd7\x3b\x7b\xa3\xe9\x29\x10\x65\x65\x96\xab\x80\x87\x38\x47\x1b\x5d\x57\x2b\x93\x30\x03\xce\x32\x73\x35\xb1\xad\x12\x97\x69\x4d\x7d\xec\xf7\xab\x4a\xd0\x42\x16\xec\xac\x1d\xce\x74\x6f\xe7\xcb\xcd\x30\xf5\x8a\xaf\xc1\x57\xd0\x71\x60\x32\x2f\xcf\x42\x02\x25\x2a\xf1\xe1\xb4\x42\xda\x62\x6e\xc5\xe2\xf4\x16\xd1\x1a\x17\x1f\xba\x62\x63\xbe\x91\xf1\x6a\x36\x1e\x5f\x65\x2b\x93\x22\x93\x67\xdc\x82\x5c\x65\x33\x24\xe8\xd3\x4c\x7a\x14\xee\xf4\x92\x4e\x59\x50\x85\x17\x8c\xcb\x7a\x89\x71\x68\x5f\xec\xb2\xd8\xd7\x4a\x5a\x36\x52\xe3\x0e\x33\x0e\x5b\x98\x3e\x0c\x9d\x2e\xf6\x3a\xab\xe7\x34\xd8\x1b\x69\x28\x63\x80\x9a\x29\x5a\x88\x0a\x14\x55\x81\xb3\xda\xa0\xd5\xa2\x97\xc9\x89\xcd\x55\x17\x7f\xcb\xd8\x62\x6a\xf2\x36\x45\xec\xeb\x8d\xae\x68\xb2\x0b\x02\x5d\x7d\x46\x7c\xa5\xe6\x44\x8f\x3d\x1c\xdd\xef\x6f\xe3\x6f\xe6\x28\x0f\x1a\x47\x4a\x2a\x28\xdd\xcd\xe6\x1d\x7e\xfd\x50\x68\xb8\x27\x75\x07\x2b\x85\x4d\x2b\x68\x5a\x44\xb3\xf3\x94\x08\x90\xda\x3b\xe7\x74\xad\xe9\x48\xa0\x44\x43\x4b\x16\xa7\x9c\x03\x24\xf0\x45\x2f\xfc\x26\x3f\xa9\xb8\x36\xba\x74\xfa\x2e\x63\xa4\x2d\x44\x50\x8b\x65\x0a\xe2\x72\x67\xf2\x44\xa4\x9d\x8c\x62\x6b\x15\xfb\xdf\xb4\x68\x31\xbd\xf6\x9c\xd1\xee\xed\x0b\xd3\x85\xfc\x10\x89\x2c\xeb\xad\x51\x28\xac\x07\xe8\x9a\x3d\xcf\x6f\xdf\xf6\x06\xc8\x1d\x75\xe9\xf6\xc5\xd6\xcd\xf0\xcd\x78\x31\x03\x86\x09\x4e\x8d\x1c\x07\x0b\x93\x9e\x47\xca\x89\xad\x3f\xd0\x07\xe3\xed\xed\x58\x2c\xd4\xcb\x75\x4b\xc7\xec\x8d\x0b\x31\xe7\xe7\x7d\x10\x44\xa8\x4a\xcc\xe2\x9c\x5f\x63\xb9\xdf\x9f\xf2\x9c\x91\xdf\x76\xb2\xf3\x43\xa2\xdd\x32\xc0\x13\x1d\x7b\x63\x2e\xca\xa6\xc5\x0a\x87\x23\xe9\xaa\x59\x86\x6f\xbe\x11\xa1\x71\x72\x60\x18\xbc\xac\x65\x1b\x9a\x04\x62\x17\x29\x57\x27\x01\x33\xae\x4e\x4c\x00\x71\x59\x83\x0b\x5d\x02\xf5\xf5\x11\x13\xb5\xd0\xbf\x0f\xdc\xd5\xa8\x1e\x4b\x11\xfa\x37\xd6\x0b\x6f\x2c\x1e\x0a\xf0\xb5\x75\xb1\x64\x17\xe4\xa4\x99\x86\x0a\xc1\xcb\xbf\x90\xa3\xdc\x0b\x18\x67\x86\xd5\x6e\x68\x4d\xa0\x02\xab\x07\x1e\x6f\xde\x69\x24\xe1\x67\x9d\x57\x45\xee\xe0\x65\x6d\xbd\x6e\x74\x84\x9d\xd9\x74\x83\xdd\xc4\x8d\x39\x4e\x8e\x42\x09\xb2\xdf\x2a\x6a\x6f\xfe\xba\x6b\x3f\xc9\x16\xfc\x87\x23\x5f\x89\xb1\xc1\xf7\xdd\x2b\xc0\xca\xbb\xb7\xac\x1f\x70\x47\x46\x55\xb7\x5d\x5d\x07\xd1\xe6\xfb\xe6\xee\xdf\x43\x23\x99\x57\xa4\xc7\x2d\x8e\xaf\xef\xd6\x8a\xc4\xa3\x93\x76\x17\x7f\x4d\x12\x8b\xca\x8b\x16\x94\x12\x86\xb6\x4e\x5b\x6c\x64\x34\xbd\x48\x98\xb9\x9c\x13\xc2\x63\x2a\xcc\x4d\x31\x79\xfc\x83\x7d\x4e\xb0\xb3\x62\xe0\x12\xec\xc9\x3d\xb9\x54\x2f\x5d\xb0\x2d\xf9\xc2\xf2\x72\x2c\x5f\x76\x31\x20\xff\x84\x43\xef\xc3\x13\xd5\x4e\x54\x98\x36\x9b\x78\x94\x33\x40\x26\xa2\x67\xfe\xa7\xb2\x19\x93\x77\xe1\x0c\xe9\x4a\xec\xea\x4e\xd5\x2d\xd7\x03\x22\x8c\x53\x84\xb9\xfc\xc9\xea\x3b\xeb\x63\x3a\x33\xbd\x79\xcf\x97\xe5\x07\xed\x62\x73\x43\x0d\x25\xef\x94\x99\x25\x72\xe9\x3f\x47\x4b\xe3\x78\xac\x9e\x7f\xf2\xde\xf3\x7e\xf0\x79\x55\x48\xe2\x56\x65\xbc\x1a\xd5\xdc\xf1\xde\xa8\xee\x2f\xab\x74\x95\xfa\xbd\xab\x3d\xa5\xf6\xfc\x77\x3a\xa5\x67\x8f\xb5\x6c\x31\x0c\x63\xa2\xcd\xb1\xca\x1d\xf5\xfa\x69\x62\xac\x76\x61\xa4\x8a\x90\xcf\xb3\xcf\x45\xf9\xc1\x96\x3a\x15\xda\x50\xb3\xea\xec\x7a\xd1\x45\x8e\xe8\x60\xcc\xeb\xde\x2b\x41\x90\xcd\xae\xa4\xec\x92\x20\xbf\xaf\x59\x4a\xda\xde\x7e\x56\x74\x73\x30\x59\xb5\xe4\x68\x84\xa4\xf1\xc6\x8e\xac\x53\xed\xa5\x0d\x76\x40\x55\xdc\xec\xe9\x8b\xc0\xfb\x88\x05\xc1\xe7\xc7\xa7\x91\xd8\x07\xdc\x75\xb9\xdf\x78\x0a\xd6\x07\x8b\x35\x67\xed\x8f\x31\xd5\xb6\xc8\x9f\x3f\x6c\x8b\x05\xee\x57\x1c\xdf\xbf\xac\xd2\xf9\x49\x5b\x4e\xe0\x10\xe7\x2d\x37\x4f\x36\xc0\xcb\x4c\x1f\x78\x7e\x5e\x36\xa9\x36\xe7\x2e\x0b\xcf\xb3\x64\xea\x2f\xeb\x62\x0a\xc4\x44\x1a\x22\x78\xd3\x3e\x95\x2d\xe6\xf5\xb4\xcc\x48\x2c\xd4\x27\x2d\xe4\x4a\x3a\x6c\xe6\x6c\xdf\x08\xd9\xc7\x30\x03\xad\x26\xdb\x7d\x0a\x5d\xb2\xea\xbe\x09\x24\x76\x18\xab\xd6\x7e\xb3\x5a\xd1\x30\xe4\x94\x39\xbf\x0f\xf6\xac\x0b\x7f\x6f\x8c\xd0\xb1\x42\x3a\x50\x41\x30\x64\xb3\x7d\x1e\x12\xe8\x3f\x4b\x6a\x8a\xdb\x2f\x7f\x88\xeb\x43\xf4\x71\x7c\x7f\xdc\xe7\xda\x86\x58\x04\x67\x19\x4b\x15\x2e\x4c\x34\x95\xf3\xc4\x4a\x32\xd8\x07\x16\xc6\x27\xdb\x7e\x0a\x6e\xd4\xf3\x39\x9b\x93\x1d\xa1\xdd\x55\xad\xeb\x46\x3f\x51\x88\xde\xda\x76\x8b\x5d\xbf\xc9\x21\x78\xde\x91\x14\xaf\xcd\xc1\xb8\xb0\x12\xf1\xd8\xda\xb3\xb2\xc3\x04\x90\x09\x13\x7c\x10\x49\x7a\x7f\x34\xd8\x7e\x9e\xe1\xfe\x6d\xe2\x1c\x27\xae\x63\x8c\x70\x75\x17\xce\xc8\x1c\x54\x5c\x3e\x21\x57\xa1\x37\xef\x8d\xe9\x50\x9d\x44\xd8\x36\xe3\x62\x8c\xfa\xa2\xd3\x5a\x85\x0e\x9e\x31\x23\xae\x7c\xfb\x8e\x28\xb2\x74\x45\xa5\x8f\xaa\xb5\xd1\x6d\x07\x5e\x7c\x2d\x2e\x1d\x26\xda\xb8\xf9\x53\x01\x2d\x24\x97\xeb\xf1\x9b\x56\x94\x37\xe6\x21\x8e\x8a\x8b\x93\xaf\xeb\x1d\x02\x14\x1a\x16\x30\xc2\x2a\x32\xfe\x18\xb0\xc6\x54\xc9\x1e\xfe\xfb\xc6\xab\xf4\xbf\x37\x02\xa8\xc7\xcf\xd3\x13\xa9\xfd\xd9\xd1\x9f\x4c\x1a\xc5\x92\x48\x89\x49\x66\x3e\xec\xa3\xcc\xfd\xa1\xb1\x05\x89\x42\x7d\x1a\x32\x9e\x60\x54\x63\x49\xb1\xf9\xfc\x3a\x67\xce\x0c\xd3\x1b\xd6\x7f\x62\xd9\x79\x12\x0c\x1b\xc2\xe8\x04\xc0\x5b\x80\x50\x8a\xb9\xa4\x25\x70\xe7\x3a\x93\x75\x6e\x88\xb2\xee\xfe\x53\xfa\x67\xe7\x84\x20\x33\x86\x52\xeb\xaa\x97\x37\x6c\x2c\x71\x30\x94\x8a\xf2\x8b\x2f\x21\x63\x0e\xdb\xf9\xe0\xc2\x79\x32\xe6\x9b\xe5\xb4\x3c\x1b\xb1\x3c\xc0\x3f\xba\x40\x55\xdb\x98\x78\x6d\xa2\xb0\x89\x1a\xa9\x78\xbc\x19\x92\xfe\x99\x22\x81\x77\x96\x8c\x97\xd2\x06\x59\xa5\xf3\x71\xc1\x33\x01\x9b\xa8\xd1\xde\xa2\x17\x61\x92\x0f\xc0\x71\x8b\x8a\x5b\x44\xd8\x53\xda\x20\x19\xd1\x20\xf1\x7e\x10\x90\x16\xca\x6b\xb0\x21\xfe\xac\x10\x80\x73\x40\x6b\x5c\xcb\x6a\xf4\xcf\x14\xf9\x6c\x2d\x6b\xb4\x40\x5a\xe8\x50\x6b\x38\x71\x9d\x02\x16\xde\x3c\x88\xaa\x8d\x78\xe8\x9f\x29\x06\xa9\x5c\x63\xa8\x40\x42\x28\xa3\xb2\x1f\x6e\x2f\x55\xb0\xf0\xe6\x85\xab\x6d\x14\x2b\x75\xc2\xe0\xcd\x0a\xca\xee\x7d\xc2\x20\x21\x54\x92\x17\x90\xc1\xee\x7f\x24\xa5\x00\x66\x5b\xc5\x63\xf6\x89\xe2\x64\x00\xf4\xf5\x37\xe8\x35\x24\xee\x34\x4a\x4c\x17\xcb\x56\xe0\xe9\x83\x1a\x3f\x0e\x43\x1e\x2f\xe8\x35\x0b\xe7\x30\xcd\x6f\x03\x02\x56\x75\x9f\x62\xfd\xc5\x64\x7d\x33\x38\xbb\xe8\x66\xa0\x3e\x80\x5d\xdb\x85\xae\xf5\xac\x2d\x3f\x2c\x95\x41\xa9\x3b\xaa\x49\xbb\x35\x4c\xca\xa7\x50\x75\x31\x5e\xdf\x14\xab\x7c\x58\xb9\x00\x16\x32\x6a\x8c\x8c\xa1\x01\xa2\xbc\x26\xf7\xf4\x4d\x39\x0e\xef\xd4\xf2\xc3\x22\x91\x6f\x16\x5b\x62\xeb\xa3\x88\x4b\x3b\x1c\xde\xe6\x90\x15\x5c\xa9\xd9\x4a\x32\xfc\x04\x35\x96\x86\x91\xfd\x51\xb3\xf9\x8a\x39\x51\xf2\xe3\xc6\xf8\x75\xb3\xe0\x56\x3b\x15\x9b\xc0\x3f\x1b\x61\xc1\x9b\x2e\x12\xd7\x3f\xb8\x3d\xb4\xc1\x6c\x1f\x7c\xb1\xfc\x7a\xd5\x16\xe8\xc4\xef\x53\x8d\x21\x5f\x76\xf7\x71\x44\xd4\xb7\xde\x42\xae\xde\xef\xea\xa0\xd0\xa7\x47\xd4\x90\x1a\x7d\x2b\x3a\xea\xd5\x22\x7d\x48\xc5\x5b\x3f\x3d\x7f\xb9\x03\xd4\x9b\x89\xfb\x1d\x12\xdb\x95\xd2\x77\x2a\x1f\x8a\x4c\xf0\x90\xab\x45\x7b\xe4\x0c\x30\x1e\x5e\x54\x91\x42\x4e\xa0\xcd\x87\x9f\xea\x14\x6b\x22\xe6\xcb\x0f\xdb\x33\x7f\x95\xb8\xd7\xad\xb0\x76\x5e\x7d\xa1\x5a\xef\xca\x7b\x06\x42\x0d\xe3\x15\xc1\xbc\x43\x6d\x5b\xe6\x5a\xe5\x8b\xe4\xf0\x7d\x93\xb9\x2a\x82\x80\xdf\x0f\xd3\x23\x7b\x8a\x61\x5d\xe8\x0d\xcf\xdd\xf2\xb7\x1c\x39\x0b\x61\xfd\x5b\xbe\xea\x39\x9e\xc7\xd9\x1c\xcc\x2d\xbb\xc7\x6d\x8a\x6a\xc5\xf1\xbb\xf8\x78\x06\x17\xa6\x61\xf9\xb2\x86\xb6\x5e\x1f\xba\x09\x9c\x08\xea\xe4\xa3\xbc\xa7\x7a\x77\xa2\x98\x9b\x9b\x5a\x96\xf4\xfd\x83\x73\x2b\x0e\x8e\xfc\x6d\xeb\xc2\x5a\x0d\x20\xa7\x88\xf8\x29\x19\x18\x68\x6e\x86\x22\x73\xb3\xc4\x97\xed\xa1\x29\xa2\x41\x5d\x0e\xe7\x6c\xbb\xe4\x99\x9b\x1f\x38\x94\x67\xd2\x9c\xa7\xee\x8e\x8a\xb5\x04\x8b\x5b\x3a\x4a\x14\x5d\xfd\xca\x18\xce\x09\x3e\x71\x7d\x55\xfc\xb7\xf5\x40\x94\xdd\x3e\xf2\x6f\x63\xcf\x06\x8d\xe5\x43\x35\xbd\x79\x7e\xeb\x1f\x1a\x5c\x5c\xc1\x1d\xc9\x99\x09\x49\x8f\xcc\xd4\x7a\x75\xd0\x8c\x26\x3e\x08\x34\xd3\xd8\x05\x68\x9e\xd9\xd9\x27\xb9\x1c\x5f\xda\xdc\xa3\xc1\xc8\x5a\x62\x71\x12\xc2\x7b\x29\xb8\xd7\xd2\x07\xbf\x3a\x97\x69\x48\x95\x69\xf2\xb3\xbc\xc5\xdd\xf0\x37\x1c\x4e\x47\x5f\xfc\x04\x1a\x5e\x5d\xc4\x39\xbc\xe0\x91\x42\x26\x37\xe9\x9d\x9f\xb5\x17\x14\x48\x13\xbd\x26\xf4\xcb\x20\xb3\x2b\x4a\xf1\x7e\x12\x06\x27\x0b\x9f\xae\x0e\x26\x7a\xcf\xd9\x76\x73\x74\x00\xf6\x74\x1d\x60\x63\x8d\x84\xa6\xa6\x04\xb1\xf4\x1b\x95\xad\xf3\x97\xcd\x5a\xe4\x0d\x2e\x23\xa4\xe2\x77\xeb\x0d\x0f\x1f\xb1\xd0\x64\xc8\xeb\xff\xfa\x77\xcb\x01\x00\x00\x40\x4d\x59\x4b\xa9\x5a\xc1\x26\xfc\xff\x02\x00\x00\xff\xff\xb0\x3c\x72\xec\xef\x17\x00\x00") + +func htdocsAssetsImagesAppleTouchIconPngBytes() ([]byte, error) { + return bindataRead( + _htdocsAssetsImagesAppleTouchIconPng, + "htdocs/assets/images/apple-touch-icon.png", + ) +} + +func htdocsAssetsImagesAppleTouchIconPng() (*asset, error) { + bytes, err := htdocsAssetsImagesAppleTouchIconPngBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "htdocs/assets/images/apple-touch-icon.png", size: 6127, mode: os.FileMode(511), modTime: time.Unix(1579224162, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _htdocsAssetsImagesFavicon16x16Png = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x00\x55\x01\xaa\xfe\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x10\x00\x00\x00\x10\x08\x06\x00\x00\x00\x1f\xf3\xff\x61\x00\x00\x01\x1c\x49\x44\x41\x54\x38\x4f\x63\x94\x9f\xfd\xbe\x96\x81\xe1\x7f\x35\x23\x23\x03\x3b\x03\x09\xe0\xff\x7f\x86\x9f\x0c\x0c\x8c\xad\x8c\xf2\x73\xde\xff\x60\x64\xf8\x4f\x92\x66\x98\x3d\xff\x19\x18\x7f\x32\x2a\xcc\x79\xf7\x9f\x04\x8b\x31\x94\x0e\x12\x03\x66\xbb\x70\x33\x7c\xf8\xf9\x9f\xa1\xf4\xf0\x37\xb0\x13\xa7\x38\x72\x31\xfc\xfb\xcf\xc0\x90\x77\x00\xc2\xef\xb3\xe7\x62\xe0\x62\x61\x64\xc8\xd8\xfb\x15\xcc\xef\xb0\xe1\x64\x10\xe3\x62\x62\x48\xda\xf5\x95\x01\xec\x85\x48\x75\x36\x86\x6f\x7f\xfe\x33\x6c\xbc\xfb\x1b\xac\x20\x54\x95\x15\x6c\xc0\xda\x3b\x10\x7e\xa0\x0a\x2b\x03\x1b\x13\x23\xc3\xca\x5b\xbf\xc0\x7c\x5f\x25\x56\x06\x3e\x36\x46\x86\xa5\x37\x7e\x41\x0c\x18\xf8\x40\x0c\x53\x65\x65\xf8\xf6\x87\x81\x61\xcb\x7d\x88\x93\x83\x54\x20\x5e\xd8\x00\xf5\x92\x9f\x12\x2b\x03\x1b\x33\x03\xc3\x9a\xdb\x10\x79\x2f\x05\x88\x17\x56\xdc\x82\x7a\x61\x9e\x2b\x37\xc3\x87\x5f\xff\x19\x8a\x0e\x42\x02\x6d\xba\x33\x37\xc3\xdf\x7f\x0c\x0c\x39\xfb\x21\x81\x36\xd1\x01\x12\x88\xa9\x7b\x20\xfc\x6e\x5b\x2e\x06\x31\x2e\x46\x86\xf8\x9d\xd0\x40\x1c\xf8\x30\xa0\xc8\x05\x14\x65\xa6\xff\x8c\x3f\x19\xc1\xd9\x99\x91\xa1\x9a\xd4\x1c\x09\xca\x89\x0c\xff\x19\x5a\x01\x44\xd8\x7d\x8c\x93\x94\xa9\xfb\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\x01\x00\x00\xff\xff\x39\xba\xe1\xe0\x55\x01\x00\x00") + +func htdocsAssetsImagesFavicon16x16PngBytes() ([]byte, error) { + return bindataRead( + _htdocsAssetsImagesFavicon16x16Png, + "htdocs/assets/images/favicon-16x16.png", + ) +} + +func htdocsAssetsImagesFavicon16x16Png() (*asset, error) { + bytes, err := htdocsAssetsImagesFavicon16x16PngBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "htdocs/assets/images/favicon-16x16.png", size: 341, mode: os.FileMode(511), modTime: time.Unix(1579224162, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _htdocsAssetsImagesFavicon32x32Png = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x00\xf3\x02\x0c\xfd\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x20\x00\x00\x00\x20\x08\x06\x00\x00\x00\x73\x7a\x7a\xf4\x00\x00\x02\xba\x49\x44\x41\x54\x58\x47\xed\x97\x4b\x48\x94\x51\x14\xc7\x7f\xf7\x9b\x97\x3a\x33\xa5\x21\x62\x58\x8d\x18\x49\x05\x25\x94\x10\x08\x85\x88\x69\x16\xd8\xaa\x4d\x46\x0f\x30\x32\x5d\x05\x59\xd0\x63\x63\x12\xb4\xca\xa2\x16\xa5\x65\x14\xd6\x22\x37\xad\x1a\x4b\x83\x30\x12\x17\x15\x85\x88\x69\x0f\x15\x52\xe9\x31\xf6\x98\x19\xc7\xd7\xdc\xf8\xbe\x69\x26\xef\xa0\xd2\xc6\xc6\xc5\xdc\xdd\x3d\xff\x99\x7b\xcf\xf9\x9f\x73\xfe\xdf\x3d\x02\x29\x85\xab\x61\xf4\x14\xc8\x2a\x84\x48\x17\x20\x58\xc0\x25\x41\x22\xe5\x08\x88\xab\x03\xe5\x29\xe7\x85\xab\xde\x73\x5a\x08\x6a\x17\xf0\xce\x39\x8f\x96\x92\x33\xc2\xd5\xe0\x19\x12\xb0\x3c\x26\x0e\xc0\xb0\xee\x40\x70\xa1\x69\x9f\x2b\x38\x3d\x1d\x22\xb3\xc1\x23\x63\x11\x7d\xf8\xce\xb8\x03\x71\x06\x16\x0f\x03\x9b\xd2\x4c\x5c\x2b\x74\xe0\x9b\x94\x1c\x70\x7b\x19\xf8\x15\x54\x9a\x63\xfd\x32\x8d\x1b\x45\x4e\x82\x52\x72\xa8\xc5\x4b\xef\x77\x15\xcf\x5a\xaa\x71\xab\xd8\x81\xcd\x24\x38\xfc\xd8\xcb\x9b\xaf\xd3\xca\xff\x33\xec\x82\xdb\x3b\x9c\x24\xdb\x04\x95\x4f\x7c\x74\x8e\x4c\x19\x78\x84\x81\xba\xfc\x24\x76\xaf\xb6\x19\xc6\xcb\xaf\xc6\xb8\xf8\x32\xa0\x1c\x70\x2e\x2f\x91\x7d\xeb\x12\x0c\x5b\x63\x57\x80\x9a\xce\x31\x05\x3f\x99\x9b\x40\x45\x4e\xa2\x61\xbb\xdf\x3b\xce\x89\x76\xbf\x82\x57\xe6\xd8\xa8\xce\x4d\x32\x6c\xee\xfe\x09\x8e\xb6\xf9\x54\x07\xf6\xae\xb5\x52\x9b\x67\xd7\x85\x9a\x83\x2d\x5e\xda\x3f\x85\x3c\x0c\xaf\xd2\x2c\x0b\x75\xf9\x76\x63\xab\x47\xe0\xee\x9f\x54\xf0\x82\x95\x66\xea\xb7\x3b\x8c\x0f\x89\x7e\x79\x73\xdf\x84\x82\x6f\x49\x37\xd3\x54\xe2\xc0\xa4\x09\x6a\x3a\xfc\x34\x76\x8f\xab\x0e\xe8\xbb\x0d\xa9\x26\x02\x53\x92\xbe\x28\x7a\xc3\x27\xe9\x69\x08\x4a\xe8\x19\x55\xe9\x0f\xe3\xd9\x29\x1a\x56\x4d\xd0\xf5\x4d\xa5\x3f\x8c\xeb\x69\x72\x5a\x05\xaf\xbf\xfc\xc5\x17\x4f\x11\x2a\x7c\xfd\xc7\x4d\x9c\x01\x85\x81\xec\x64\x8d\xc0\x34\x0c\x46\x69\xc0\xcc\x22\x92\x12\x3e\xfe\x9c\xbd\x08\x5d\x4e\x0d\x8b\x09\xde\xcd\x51\xc4\x2b\x1c\x1a\x76\x0b\xbc\x9d\x51\xc4\x11\x07\xf6\xac\xb1\x70\x61\xab\x9d\x69\x09\xfb\xdd\x5e\x3a\x86\xd5\x36\x2c\xc9\xb4\x70\xa5\x20\xd4\x86\x47\x5a\xbd\xb4\x0e\xaa\xf8\xb6\x0c\x33\x37\x8b\x1c\x68\x02\x8e\x3d\xf5\xf1\xe0\xbd\xda\xa6\x9b\xd3\x4c\xdc\xdb\xe5\xc4\x2c\xe0\xec\x73\x3f\x4d\x3d\xa1\x36\x8d\x38\x70\x29\x3f\x89\xd2\x79\x84\xa8\x36\x2f\x91\xb2\x7f\x14\xa2\xe6\xde\x71\xaa\xa3\x84\xa8\x2a\xc7\xc6\xf1\x3f\x42\xd4\xd2\x3f\x41\x45\xb4\x10\xe9\x42\x71\xbd\xd0\x8e\x7f\x4a\x52\xf6\xd0\xcb\x87\x1f\x2a\xcd\x1b\x53\x4d\x34\x16\x3b\x0c\x1d\xd0\xa5\xba\xdb\xa3\xf6\xba\x9e\xbe\x3b\x25\x4e\xac\x26\x28\x7f\xe4\xe5\xc5\x67\x15\x5f\xe5\xd4\xb8\xbb\xd3\xc1\x12\xab\xa0\xb2\xcd\xc7\xb3\xa1\x28\x29\xd6\xe9\xd0\xe9\xd3\x73\x3c\xd7\x13\x29\xfc\x5c\x9e\x0f\xd7\x7f\x33\x7b\x85\x84\x9e\xdb\x42\x60\x04\x11\x5e\xf1\x36\x8c\x33\x10\x7b\x06\x62\x3e\x98\xc4\x74\x34\x93\xfa\x68\x16\xeb\xe1\x34\x32\x9e\x0b\xaa\x80\xff\x33\x9e\xc3\x08\x12\x63\x3c\xff\x0d\xce\xf1\x3c\xb7\x55\xd7\x45\x6d\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\x01\x00\x00\xff\xff\x58\x02\xd4\xc6\xf3\x02\x00\x00") + +func htdocsAssetsImagesFavicon32x32PngBytes() ([]byte, error) { + return bindataRead( + _htdocsAssetsImagesFavicon32x32Png, + "htdocs/assets/images/favicon-32x32.png", + ) +} + +func htdocsAssetsImagesFavicon32x32Png() (*asset, error) { + bytes, err := htdocsAssetsImagesFavicon32x32PngBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "htdocs/assets/images/favicon-32x32.png", size: 755, mode: os.FileMode(511), modTime: time.Unix(1579224162, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _htdocsAssetsImagesFaviconIco = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xec\x5a\x5d\x6c\x14\xd5\x17\xbf\xe4\xcf\xd3\xff\xa9\x3e\xf8\x62\x62\xa7\x5f\x40\x15\x4d\x50\x50\x13\x01\x05\x14\x30\x28\xa2\x10\x21\x1a\x30\x62\x50\x3e\xa2\x82\x7c\xa8\x40\x94\x68\x34\x26\xfa\x80\x26\x46\x23\xb3\xdb\x42\xa8\xd2\x96\xb6\x94\x52\xac\xb4\xa5\x1f\x6c\x5b\x68\x88\x84\x68\x42\x34\xb4\x50\x0a\x14\x05\x76\xbe\x67\x77\x67\x77\x66\x8e\xb9\x77\x77\x67\x47\x66\x67\x76\x5b\xb0\xb3\x6b\xe6\x26\xbf\x34\xb7\xe7\x37\x73\xcf\x3d\x77\xcf\x99\x73\xcf\xbd\x08\x4d\x40\xff\x43\x05\x05\xf8\x6f\x11\x5a\x37\x11\xa1\x47\x11\x42\x45\x45\xf1\x7e\xf9\x5d\x08\x55\x4c\x44\x68\xda\xb4\x84\x7c\x32\x42\x7d\x77\x23\x54\x8e\x10\x2a\xc0\x3c\x14\xff\xbf\x5d\x63\x69\xea\x43\xd6\x47\x45\x18\x5f\x11\x8c\x06\xf8\x99\xb1\x3e\x6b\xbc\x83\x1e\xfb\xb3\xb9\x04\xe5\x52\x07\x44\xce\x37\xa4\xfa\x17\x5b\x40\x19\x6c\x36\xfa\x91\x81\xc3\xa0\x0c\xb5\xa5\xfa\x7f\x1c\x04\xe5\x72\xb7\xd1\x17\xbb\xdf\x03\xf1\xf8\x5b\xa9\x7e\xd7\x66\x10\x3b\x37\xa5\xfa\x1d\xef\x80\xd8\xbd\x35\xd5\x6f\xdf\x00\xd2\x89\x0f\x5c\x9f\x77\x12\x52\x60\x3b\x88\x9d\x1b\x4d\xf3\xd9\x06\x62\xd7\x16\xd3\x7c\xde\x05\xe9\xc4\xfb\xa6\xf9\xbc\x0d\x52\xcf\xce\x94\xbd\x86\xda\x88\x4d\x0c\xfb\x0c\x36\x43\x64\xa0\x29\xd5\x3f\xdf\x00\xca\xc5\x9f\x8d\x7e\xf8\xf7\x6a\x50\x86\x3b\x5c\x9f\xf7\x9d\xc0\x6d\xf9\x4f\xc2\x07\x99\xb1\xf8\x11\x1d\x7f\xd6\x36\x30\x64\xd9\xca\x13\x31\x66\x4e\x86\x38\x03\x80\x26\x30\x34\xb5\x93\xf1\x51\x23\x8c\x8f\xd2\xff\x7d\xdb\xe2\x31\xa8\x11\x3c\x66\x6a\x6c\x97\xd6\xd9\x98\xb7\x6b\xe3\x8f\x93\xcd\x9d\xd6\xc2\xad\xb1\x3d\x78\xb8\x05\xfe\x12\x60\x7c\xc5\x0e\x9c\xe2\x2c\xe4\x25\xce\x72\x7f\x7a\xb9\xd0\xbc\x02\xb4\x08\x0f\xaa\x74\x0d\xb8\x83\xf3\x2c\x72\xbe\xf1\x79\xd0\xc2\x41\xd0\x42\x37\x80\x6f\x58\x64\x91\x73\x75\x0b\x40\x93\xff\x02\x2d\xc2\x02\xdf\xb4\xcc\x2a\xaf\x79\x02\x54\xf1\x0a\x68\x8a\x00\xc2\x4f\x2b\x2d\x72\x9c\x8b\x24\x5b\xe8\xcc\xd7\x16\x79\xf8\x5c\x95\x21\x0f\xff\x56\x61\x91\x87\xce\x7e\x67\xc8\xcd\xdf\xec\x24\xe4\xd3\x5f\x1a\x72\xf3\x37\x3b\x09\x9c\x1b\xe8\xba\x0e\xba\x16\x03\xe1\xe8\xcb\x56\x79\xfb\x7a\xd0\x75\x8d\x40\x68\x5d\x63\xb5\x5f\xcb\xab\xa0\x6b\x2a\x79\x87\x39\xe7\x30\xec\xd7\xb4\x0c\x74\x2d\x4a\xe4\xe6\x1c\xe3\x56\x1b\x62\x3b\xd9\xad\x1f\x5e\x17\xae\x76\xae\xbd\xbc\xe6\x49\xe0\xea\x9e\xb6\x97\x57\xcf\x02\xae\x7e\xa1\xfb\xbf\x73\x0f\x39\x09\xbe\x71\x31\x70\x75\xf3\x6d\xe5\x5c\xc3\x22\xe0\xea\x9f\xb1\x97\xd7\x2f\x00\xfe\xd0\x73\xf6\xf2\x83\xf3\x80\x3f\xbc\x24\xad\x4c\xea\xd9\x01\x80\xfd\x0f\xfb\x57\xcb\x2a\x8b\x1c\xef\x35\x88\x7f\x62\xff\x6a\x5f\x67\x91\x0b\xc7\x5e\x4f\xf8\xa7\x0e\x92\x69\x4f\x62\xc8\x9b\x57\x10\xff\xc4\x4d\xee\xfb\xd8\x22\x8f\x0c\x34\x66\x88\x3f\xfb\x47\x11\x7f\x6a\x2d\x72\xf9\xf4\x17\xa6\xf8\xd3\x62\xb5\x7d\xd3\x52\x12\x5b\x55\x7e\x88\xf8\x71\x3a\xdb\x6b\xd2\x35\x50\xc5\xab\x24\x4e\xa4\xb3\xad\x2a\x0c\x93\x18\x8c\x63\xf5\xad\x72\xf6\xc0\x4c\x50\xd9\x41\xd0\x42\x41\x62\x0b\xb7\x7f\x6b\x1e\x3c\xe4\x06\xdc\xde\x7f\xb8\xb8\xff\x4a\xec\x43\x5d\x1b\xdf\xb4\x07\x0e\x8e\xdb\x5e\x90\xd2\xc9\x58\x89\xb1\x6f\xb7\xce\x90\xef\xad\x1c\x21\x34\x0d\x21\xf4\x5a\x86\x3a\x09\x6e\xec\xee\x82\x02\xd6\x57\xb8\x2a\xe8\xa3\xfa\x82\x34\x15\xca\xcd\xfd\x33\x59\xdf\x50\x90\x2e\xec\x67\xe9\xa2\xd5\x58\x67\xac\x3b\x7c\x7f\xcf\xff\x19\x9a\xfa\x86\x21\x7a\xbb\xad\x63\xd6\x50\xb0\xce\x78\x0e\xc1\x3d\x85\xcb\x49\xdf\x7d\x9d\x46\x3d\x07\xbc\x0e\x0c\x4d\xf5\xe4\x80\x2e\x63\x42\xd0\x47\x9d\x62\x7c\x94\xec\xb6\x1e\x63\xd6\x3f\x67\x7d\x35\x5b\xe4\xb3\xee\x1e\x3c\x78\xf0\xe0\xe1\x8e\xa3\x62\x32\x30\xfe\xd2\x51\xf0\xcb\xe2\xc8\x96\xef\x2f\x8b\x8f\x91\xb5\x4e\x25\xc0\x54\x4e\xc9\xcc\xab\x2c\x87\xd0\xd9\x6f\x41\x8b\xca\xa4\xe6\x2e\x05\x76\x00\x53\x31\xc9\x41\xef\x49\xa4\x7e\xa4\x29\x02\x68\x51\x09\xe4\xfe\xcf\x9c\xc7\xf1\x97\x81\xd4\xb7\x8b\xd4\xe2\xb5\x58\x18\xe4\x33\x5f\x01\xb3\xf7\x7e\x07\x7e\x29\x48\x5d\x5b\x40\x93\xaf\x83\xa6\x2a\x10\xfe\xd5\x0f\xcc\xbe\x07\x6d\xf9\xfc\x91\xe5\xa0\xc7\x42\x46\x3d\x4b\x95\x46\xd2\x9e\x19\x24\xc1\xd5\x2f\x24\xf3\x4c\x36\x2d\xcc\x00\x77\x68\xb1\x2d\x9f\xad\x9e\x49\x74\x49\x36\x3d\x2a\x81\x70\xf4\x15\x7b\xfe\x0f\x33\x48\xdd\xcc\xe0\xab\x11\x10\xda\xd7\xdb\xf2\xa5\x9e\x9d\x60\x6e\xba\xae\x93\xfa\x9b\x1d\x5f\x68\x7d\xc3\xc2\x17\x5a\xd7\xda\xcf\xb7\x71\x09\xe1\x98\x9b\x5d\xcd\x3f\x3e\xdf\x59\x46\x6d\x32\xd9\xe4\x5f\x76\xdb\xbf\xbf\x61\x11\xb1\x67\xb2\x1e\x1a\x63\x07\x80\xab\x99\x6d\xff\xfe\x9a\xd9\xa0\x86\xae\x1b\x7c\x72\xc6\x53\xf7\x94\x83\x3d\x1f\x01\x95\x1f\x36\xf8\x78\xbd\xf8\xc6\xf4\x75\x5b\xc2\xdf\xff\x30\xc4\x6e\x9e\x4b\xf1\x15\x31\x6d\x0d\x97\x31\xf9\xad\xd4\xb3\x03\x94\xe1\x4e\x50\x86\x5a\x89\x7d\x89\xaf\x39\xf8\xad\xd8\xb5\x19\x94\x4b\xed\xa0\x5c\x3a\x4e\x6a\xc1\x8e\x7e\xe9\x2f\x25\xe7\x35\xca\xc5\x63\x64\x0c\x72\xc7\xa3\xb2\xdc\x81\x5f\x02\xc2\xb1\xd5\xa0\x5c\x38\x0a\xd1\xcb\xdd\xc4\x77\x1c\xfd\x25\xf1\x0c\xb3\x6f\x6a\x82\xe7\x74\x26\x67\x8a\x0d\x7b\xa7\xc6\x9f\xc9\x8a\x5f\x1c\x7f\xf7\xbe\x07\x6c\xcf\xec\xac\xfc\xfb\x12\xfc\x51\xc4\x44\x0f\x1e\x3c\x78\x18\x6f\x54\x4c\x22\xdf\x6d\xb6\x6a\x7a\x76\xf1\xca\x5f\x06\x6c\xd5\x0c\x02\xc7\x6f\x85\xc1\x2f\x25\xef\xc6\xdf\x32\xc7\xdc\xca\xe0\x97\x00\xbb\xff\xa1\x04\x3f\x43\xce\x57\x39\x05\xe4\x93\x9f\x40\xf4\xea\x49\x88\x5e\x09\xc4\xef\x48\x66\xc8\xdf\xa4\xc0\x76\x50\xae\xf4\x40\xf4\x6a\x2f\x48\xdd\xdb\x32\xe6\x6f\x62\xe7\x26\x72\x17\x33\x3a\x72\x0a\xa4\xde\x8f\xe2\xb1\xdd\x41\x77\xb1\x6d\x2d\xb9\x7b\x18\xbd\xd6\x0f\x72\xff\xe7\x89\xef\x4c\x7a\x3e\x7f\xf8\x05\xd0\x63\xe1\xc4\x79\xab\x1e\x3f\x53\xac\x9d\x63\xcb\x67\x6b\xe7\x82\x16\x11\xfe\x91\x0f\x38\x9d\x03\xb3\x3f\x3e\x46\xce\x21\x0d\x7e\x54\x06\xfe\xc8\x4b\xf6\xfc\xaa\xe9\xa0\x72\x17\x8c\x9c\x49\x8f\x45\xe2\x39\x81\x0d\x5f\x0a\xa4\xcb\xdf\xac\x77\x4e\x92\x10\x5a\xd7\x58\xf3\xb7\xb6\x37\x6d\xf9\x69\xf3\x37\x9c\xa3\xdb\xe9\x5f\x3d\x73\x54\xf9\x1b\xb9\x0b\x12\x95\x52\xf9\xb3\x70\xd9\xd1\xfe\x5c\xdd\x7c\x62\x73\x23\x7f\x0e\xdd\x00\xae\xe1\x59\x7b\x7d\x0e\x3c\x4e\x72\x3c\x83\xaf\xf0\xc0\x3b\x9c\xf9\x12\xfb\xb3\x83\x29\xfb\xc4\x42\x8e\xf6\x27\xfb\x91\x53\x9f\x92\xb3\x64\xfc\xdb\x11\xdb\x37\x38\xfb\x30\xde\x5f\x04\xb6\x83\x26\xfd\x09\xaa\x7c\x03\xc4\xae\xad\xce\x3e\x8c\x7f\xcf\x1d\x1b\x89\x5d\xf0\xbc\xa5\xde\x5d\x19\x7c\xb2\x98\xac\x71\x8c\x1d\x24\xbf\x53\xbc\x57\x72\xf4\x17\x0f\x1e\x3c\x78\xf0\xe0\x21\x6f\x90\xcf\x67\x60\x94\x1e\xcc\xf7\xf3\x53\xba\xa8\xdb\x6d\x3d\xc6\x0c\x9a\xea\x65\xf7\x50\x2f\xe6\xd9\xdd\x87\xa4\xee\x21\x86\x2e\x5c\x99\xef\xf7\x37\x92\x77\x50\x6e\xd2\xd4\x52\x86\x2e\xea\x62\x68\xec\x0f\xb9\xe8\xd3\x94\x9e\xd0\x2d\x10\xa4\xef\x5d\x81\x75\x1e\xa7\xab\x48\x5e\xfb\x0f\xb7\xbf\x03\x00\x00\xff\xff\x5e\x11\x3e\x2a\x2e\x3c\x00\x00") + +func htdocsAssetsImagesFaviconIcoBytes() ([]byte, error) { + return bindataRead( + _htdocsAssetsImagesFaviconIco, + "htdocs/assets/images/favicon.ico", + ) +} + +func htdocsAssetsImagesFaviconIco() (*asset, error) { + bytes, err := htdocsAssetsImagesFaviconIcoBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "htdocs/assets/images/favicon.ico", size: 15406, mode: os.FileMode(511), modTime: time.Unix(1579224162, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _htdocsAssetsJsAppJs = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xa4\x56\x4d\x6f\xe3\x36\x10\xbd\xe7\x57\xcc\xf2\x50\x49\x50\x6c\x27\x28\x7a\xa8\x1d\xb9\xe8\xb6\x05\xd2\xa2\xbb\x3d\xa4\x40\x0f\x86\x61\xd0\xd2\x58\x22\x22\x91\x06\x49\xc5\x6b\x14\xfe\xef\xc5\xe8\xc3\xa4\x6c\x25\x9b\x62\x75\xc8\xc7\xf0\xcd\x7b\x8f\xc3\xe1\x48\x37\xe1\xae\x96\xa9\x15\x4a\x86\xd1\xbf\x37\x00\x00\x25\x5a\x30\x69\x81\x15\x87\x04\xd8\xc1\xb0\x45\x13\x16\x3b\x08\x4b\x95\x72\x82\x4e\xf7\x5a\x59\x95\xaa\x12\x92\x24\x01\x56\x58\xbb\x37\x73\x16\x0d\xd2\xfa\x3c\xa2\x3b\x98\x4d\xad\x4b\x48\x3a\x40\x1c\xcc\x67\xb3\x20\x3e\xb3\x15\xca\x58\xc9\x2b\x8c\x3d\x01\xa5\x2d\xfc\x04\xc1\xdc\x83\x51\x6c\x0e\x41\x10\xc5\x6c\x76\xf0\xe9\x33\x55\x71\x21\x21\x81\x2b\x7f\x71\xf0\x4d\x42\x8b\x1b\x5f\xc3\x40\x02\x99\x4a\xeb\x0a\xa5\x9d\xe6\x68\x7f\x2b\x91\xfe\x34\x1f\x8f\xbf\x94\xdc\x98\xcf\xbc\xc2\x90\xb5\x5e\x26\xa4\xc2\xa2\xd6\xe2\x4e\xe9\x90\x28\x04\x24\x70\xb7\x00\x01\x0f\x0d\xdb\xb4\x44\x99\xdb\x62\x01\x22\x8e\xbb\xd2\xd3\x43\x4b\x2b\xb1\x9e\x0a\x29\x51\x3f\xfe\xfd\xe9\xcf\x46\x95\x48\x5b\xb6\x93\xf3\x64\x54\xfa\x8c\x16\x12\x90\x75\x59\xba\x72\x54\x26\x87\x04\xfe\x78\xfa\xeb\xf3\xd4\x58\x2d\x64\x2e\x76\xc7\xd0\x09\x90\xb5\x39\x30\x53\x6f\x4d\xaa\xc5\x16\xd9\xed\x79\x69\xcf\x8f\xa5\xe2\xd9\x1c\x18\x2f\xcb\x2e\x7e\x8a\x1c\x73\x61\x2b\x3a\x45\xe6\xd5\xbe\x14\x86\x1c\xac\xd6\x2e\x74\x66\xce\x20\x81\x1d\x2f\x0d\x76\x75\xec\x3b\x0d\x52\x25\x25\xa6\x76\xd3\x6e\x20\x8c\xc0\xb9\x73\x7b\xc2\x03\xfc\x83\xdb\xa7\x16\xd2\x76\x50\x67\xc5\xe1\xa6\x4a\xa6\xa5\x32\x48\x42\x7d\x1b\xe3\x0b\x4a\xeb\x15\x94\x9e\x4b\x41\xc7\x73\x1a\xa1\x54\x7b\x94\x5f\x63\xec\xc0\x06\x65\x16\x56\x26\x1f\x67\xa4\xe2\xa0\xec\xe5\x4e\x17\x55\xd8\x89\x2f\x1b\x59\x57\x5b\xd4\x61\xfb\xcb\xaf\x03\x5d\xb7\x36\x0a\x0f\x09\xfc\x18\x81\x46\x5b\x6b\x09\xec\x8e\x41\x0c\xed\x8a\x13\xea\x16\xfd\xf0\xa5\x5a\x8e\x76\x93\x71\x8b\x21\xfd\xf0\x95\xba\x5c\xcf\x0d\x21\xa8\xc1\x1f\x55\xad\x4d\x18\x45\x10\x03\x9b\x93\xec\x08\xe6\x93\x90\xb5\xc5\xaf\xa1\x9e\x30\x55\x32\x23\xd4\xa8\xb7\xbe\x4e\x57\x7d\x30\x55\xb2\x42\x63\x78\xee\x9f\x30\x74\x07\x02\xc3\x13\xa1\x8a\x35\x0b\xd3\x8c\x5b\xde\xdd\x2e\x2a\xde\xf7\x7d\xf1\x16\x57\xf8\x0f\xae\x57\x2f\xe9\x1a\x13\x7e\x27\x5b\x5d\xe3\xe2\x0a\xd2\x32\x0f\xc2\xa7\xc1\x7f\xb4\xb7\xe9\xbe\x36\x45\xb8\xa2\x96\xfe\x95\x8e\x20\xba\x05\xe7\x74\x1d\x5d\xfb\x6a\x92\xba\x1d\x2c\xe1\xfe\x87\x31\x73\x0d\xc6\x14\x62\x37\xe8\xe7\x6b\x03\x17\x97\xb6\x7f\x76\x4a\xc3\x79\x2c\xf9\x7a\x13\xb8\xa7\x21\xb5\x6c\x87\xd5\x64\x32\xa6\xdd\x70\xc6\x89\x6b\x2a\x22\x58\x89\xf5\xea\x6e\xdd\xf4\x01\x50\x1f\xf4\xb1\xfb\x35\x85\x1e\xb6\x1a\x66\x4b\xf6\x96\xd3\x91\xe1\xfa\xf1\xf8\x7b\x16\x32\x9e\x5a\xf1\x22\xec\x71\xb2\x43\xcc\x58\x34\x98\x8d\x64\xc5\xbb\x7c\x97\x33\x32\x47\x89\x9a\x5b\xa5\x37\xb5\x41\x4d\xb3\x6f\x7c\x8a\xb7\x42\x67\xf4\xa4\x47\x33\x6f\xfc\x39\x2e\x8d\x7b\x65\x84\x55\xfa\xf8\x3e\x36\x87\x67\xfe\x4b\xc5\x27\x34\x75\x69\x37\xa2\x6a\x5b\xfd\x5d\x94\x94\x31\x69\x32\x5e\x33\xd9\x70\x56\x5c\x3f\x67\xea\x20\xff\x17\x6d\x9f\xf4\x26\x73\xd7\x59\xef\x67\xa5\x04\x9f\xb1\x31\xdf\x7d\x1a\xf8\x2f\x15\xef\xa8\xd8\x01\xb7\xcf\x25\x7e\xf1\x56\x07\xc5\x67\xb9\x2a\x84\xa5\xaf\x81\x66\xfd\xfa\xb8\xe9\x15\x51\x70\xd9\x94\xb5\x5b\xed\xae\xf1\xd8\x71\x8e\xa3\x2f\x27\x69\x1b\x0f\xa3\xe1\xb8\x1e\xd1\x7e\xe1\x65\x8d\xee\x22\xdf\x45\xfe\xd6\x5e\xc3\x2f\x5e\x61\xf5\x5c\x5e\xf3\x0e\x8a\xf2\x7a\x4e\xb7\x97\x86\xdc\x2b\x7e\xf7\x05\x15\x03\x9b\x99\x97\x7c\x46\xb7\xf7\x6c\x94\x82\x14\x70\x54\xce\xe0\x78\x03\x4f\x0d\xda\x9f\xad\xd5\x62\x5b\x5b\x0c\x99\xd1\x29\xbb\x75\x72\xd1\x1b\xe9\x7d\xdb\x0d\x6e\x38\x5b\x7d\x58\x3d\x0a\x6b\xd6\x21\xd9\x70\xb6\x63\x60\x51\x1b\x73\xf6\x23\xf6\x06\x3b\xb5\xdf\x80\x39\xf8\xae\xb4\x0b\x0e\x85\xc6\x5d\xc2\x02\x9f\x28\x60\x4b\x5a\x13\x55\x0e\x46\xa7\xed\xa2\xaf\x1c\x30\xe0\xa5\x4d\x18\xf9\x62\xb3\x06\x3b\xe3\xcb\x60\xf0\xea\x1d\xff\xe8\x70\xdd\xb3\xb8\xb9\x39\x45\x61\xb4\xf8\x2f\x00\x00\xff\xff\xa1\x1f\xc3\x77\x83\x0b\x00\x00") + +func htdocsAssetsJsAppJsBytes() ([]byte, error) { + return bindataRead( + _htdocsAssetsJsAppJs, + "htdocs/assets/js/app.js", + ) +} + +func htdocsAssetsJsAppJs() (*asset, error) { + bytes, err := htdocsAssetsJsAppJsBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "htdocs/assets/js/app.js", size: 2947, mode: os.FileMode(511), modTime: time.Unix(1599887151, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +// Asset loads and returns the asset for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func Asset(name string) ([]byte, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err) + } + return a.bytes, nil + } + return nil, fmt.Errorf("Asset %s not found", name) +} + +// MustAsset is like Asset but panics when Asset would return an error. +// It simplifies safe initialization of global variables. +func MustAsset(name string) []byte { + a, err := Asset(name) + if err != nil { + panic("asset: Asset(" + name + "): " + err.Error()) + } + + return a +} + +// AssetInfo loads and returns the asset info for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func AssetInfo(name string) (os.FileInfo, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err) + } + return a.info, nil + } + return nil, fmt.Errorf("AssetInfo %s not found", name) +} + +// AssetNames returns the names of the assets. +func AssetNames() []string { + names := make([]string, 0, len(_bindata)) + for name := range _bindata { + names = append(names, name) + } + return names +} + +// _bindata is a table, holding each asset generator, mapped to its name. +var _bindata = map[string]func() (*asset, error){ + "htdocs/assets/css/theme.css": htdocsAssetsCssThemeCss, + "htdocs/assets/images/apple-touch-icon.png": htdocsAssetsImagesAppleTouchIconPng, + "htdocs/assets/images/favicon-16x16.png": htdocsAssetsImagesFavicon16x16Png, + "htdocs/assets/images/favicon-32x32.png": htdocsAssetsImagesFavicon32x32Png, + "htdocs/assets/images/favicon.ico": htdocsAssetsImagesFaviconIco, + "htdocs/assets/js/app.js": htdocsAssetsJsAppJs, +} + +// AssetDir returns the file names below a certain +// directory embedded in the file by go-bindata. +// For example if you run go-bindata on data/... and data contains the +// following hierarchy: +// data/ +// foo.txt +// img/ +// a.png +// b.png +// then AssetDir("data") would return []string{"foo.txt", "img"} +// AssetDir("data/img") would return []string{"a.png", "b.png"} +// AssetDir("foo.txt") and AssetDir("notexist") would return an error +// AssetDir("") will return []string{"data"}. +func AssetDir(name string) ([]string, error) { + node := _bintree + if len(name) != 0 { + cannonicalName := strings.Replace(name, "\\", "/", -1) + pathList := strings.Split(cannonicalName, "/") + for _, p := range pathList { + node = node.Children[p] + if node == nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + } + } + if node.Func != nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + rv := make([]string, 0, len(node.Children)) + for childName := range node.Children { + rv = append(rv, childName) + } + return rv, nil +} + +type bintree struct { + Func func() (*asset, error) + Children map[string]*bintree +} + +var _bintree = &bintree{nil, map[string]*bintree{ + "htdocs": &bintree{nil, map[string]*bintree{ + "assets": &bintree{nil, map[string]*bintree{ + "css": &bintree{nil, map[string]*bintree{ + "theme.css": &bintree{htdocsAssetsCssThemeCss, map[string]*bintree{}}, + }}, + "images": &bintree{nil, map[string]*bintree{ + "apple-touch-icon.png": &bintree{htdocsAssetsImagesAppleTouchIconPng, map[string]*bintree{}}, + "favicon-16x16.png": &bintree{htdocsAssetsImagesFavicon16x16Png, map[string]*bintree{}}, + "favicon-32x32.png": &bintree{htdocsAssetsImagesFavicon32x32Png, map[string]*bintree{}}, + "favicon.ico": &bintree{htdocsAssetsImagesFaviconIco, map[string]*bintree{}}, + }}, + "js": &bintree{nil, map[string]*bintree{ + "app.js": &bintree{htdocsAssetsJsAppJs, map[string]*bintree{}}, + }}, + }}, + }}, +}} + +// RestoreAsset restores an asset under the given directory +func RestoreAsset(dir, name string) error { + data, err := Asset(name) + if err != nil { + return err + } + info, err := AssetInfo(name) + if err != nil { + return err + } + err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) + if err != nil { + return err + } + err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode()) + if err != nil { + return err + } + err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime()) + if err != nil { + return err + } + return nil +} + +// RestoreAssets restores an asset under the given directory recursively +func RestoreAssets(dir, name string) error { + children, err := AssetDir(name) + // File + if err != nil { + return RestoreAsset(dir, name) + } + // Dir + for _, child := range children { + err = RestoreAssets(dir, filepath.Join(name, child)) + if err != nil { + return err + } + } + return nil +} + +func _filePath(dir, name string) string { + cannonicalName := strings.Replace(name, "\\", "/", -1) + return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...) +} + +func assetFS() *assetfs.AssetFS { + assetInfo := func(path string) (os.FileInfo, error) { + return os.Stat(path) + } + for k := range _bintree.Children { + return &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: assetInfo, Prefix: k} + } + panic("unreachable") +} diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..87ffea0 --- /dev/null +++ b/build.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash + +OUTPUT_DIR=$1 +if [[ -z ${OUTPUT_DIR} ]]; then + OUTPUT_DIR="build" +fi + +BUILD_NUMBER=$(date -u '+%Y%m%d.%H%M.%S') +BUILD_COMMIT=$(git rev-list -1 HEAD) +BUILD_VERSION=$(git tag | tail -1) + +BINARY_NAME="gohits" + +function pack() { + PACKAGE_NAME=${BINARY_NAME}-${BUILD_VERSION}-${1} + PACKAGE_DIR=${OUTPUT_DIR}/${PACKAGE_NAME} + OUTPUT_BINARY=${OUTPUT_DIR}/${2} + + echo "Signing ${OUTPUT_BINARY}" + mkdir ${PACKAGE_DIR} + cp -r ./htdocs ${PACKAGE_DIR} + + md5sum ${OUTPUT_BINARY} | sed 's/\ .*\// /g' >> ${PACKAGE_DIR}/md5.hash + sha1sum ${OUTPUT_BINARY} | sed 's/\ .*\// /g' >> ${PACKAGE_DIR}/sha1.hash + sha256sum ${OUTPUT_BINARY} | sed 's/\ .*\// /g' >> ${PACKAGE_DIR}/sha256.hash + sha512sum ${OUTPUT_BINARY} | sed 's/\ .*\// /g' >> ${PACKAGE_DIR}/sha512.hash + + cat ${PACKAGE_DIR}/sha256.hash + + echo "Packing ${PACKAGE_NAME}" + cp ${OUTPUT_BINARY} ${PACKAGE_DIR} + cp README.md ${PACKAGE_DIR} + cp LICENSE ${PACKAGE_DIR} + + cd ${OUTPUT_DIR} + sync + tar --owner=0 --group=0 -czf ${PACKAGE_NAME}.tar.gz ${PACKAGE_NAME} + rm -rf ${PACKAGE_NAME} + cd - > /dev/null +} + +echo "-------------------------------------" +echo "Build directory: ${OUTPUT_DIR}" +echo "Build version: ${BUILD_VERSION}" +echo "Build number: ${BUILD_NUMBER}" +echo "Build commit: ${BUILD_COMMIT}" +echo "-------------------------------------" +echo "" + +echo "-------------------------------------" +echo "Build preparation" +echo "Generating assets.." +go-bindata-assetfs htdocs/assets/... +echo "-------------------------------------" +echo "" + +echo "-------------------------------------" +echo "Building individual distributions" +echo "Building for Linux.." +go build -ldflags "-w -s -X main.buildNumber=${BUILD_NUMBER} -X main.buildVersion=${BUILD_VERSION}" -o ${OUTPUT_DIR}/${BINARY_NAME} +sleep 1 +pack linux-amd64 ${BINARY_NAME} +echo "-------------------------------------" + +echo "" +echo "Building for Windows 64bit.." +GOOS=windows GOARCH=amd64 go build -ldflags "-w -s -X main.buildNumber=${BUILD_NUMBER} -X main.buildVersion=${BUILD_VERSION}" -o ${OUTPUT_DIR}/${BINARY_NAME}_x86.exe +sleep 1 +pack windows-x86 ${BINARY_NAME}_x86.exe +echo "-------------------------------------" + +echo "" +echo "Building for Windows 32bit.." +GOOS=windows GOARCH=386 go build -ldflags "-w -s -X main.buildNumber=${BUILD_NUMBER} -X main.buildVersion=${BUILD_VERSION}" -o ${OUTPUT_DIR}/${BINARY_NAME}_i686.exe +sleep 1 +pack windows-i686 ${BINARY_NAME}_i686.exe +echo "-------------------------------------" + +echo "" +echo "Building for OsX 64bit.." +GOOS=darwin GOARCH=amd64 go build -ldflags "-w -s -X main.buildNumber=${BUILD_NUMBER} -X main.buildVersion=${BUILD_VERSION}" -o ${OUTPUT_DIR}/${BINARY_NAME}-darwin +sleep 1 +pack darwin-amd64 ${BINARY_NAME}-darwin +echo "-------------------------------------" +echo "" + +echo "Build finished!" +echo "" diff --git a/htdocs/assets/css/theme.css b/htdocs/assets/css/theme.css new file mode 100644 index 0000000..e69de29 diff --git a/htdocs/assets/images/apple-touch-icon.png b/htdocs/assets/images/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0013958b0675f252bbcf2adbdd34b71ab274f596 GIT binary patch literal 6127 zcmdVe6B>Pe( zgYw1snF6z2V)%fI+HWdNR+7wb2%7(jm^D%jm7Hv@f_IKAkELW1slAzwxv<;2_9I_N3_HGZ>rL9L+^~<)13)j@uBwnLAgNleTLeZbmjd|-Df%HLd zo)`&%wCD$VOf8(mUkwbu(`v9)C696uICC2Ypp7qYabHueVllA;pywq@)=UKs!7uoR z0Z2m-1{bR=H1!x9CpWnNDdFqUKKakzS}FziG9eC8%$6EhaBhA3hd`q>B_LyIwK_2K z=JarRb?<oQTtF!*u-yW@B% z`;{?TLX=J|St=i$*k)06FPY-8ids21rDuL8{HU{_Y?R>iC06ZP#`hO{ zv>r?1xBxApz6Uv~#DK*xLnv7SExnR*}v`W0&(i)30Qg#`HH z_N=Bq@NBlsY-^+%M9Gz^g&0q9qL2f+NG&p4%kpE@9x4Ri%v6U1u~`T9hMK-`j^tIy zE^qnQkzj;qHvjl+3avE&jqEv?QFDx-#_|K6v7yW6On&~m7M!CWZY0e*U@++!dWhh| z4$OL+{I(IpvM!u!OF_5$TR<0@ihq}c8#=d*7CLZiu?QkR#>9lhKbtlE@vRgH-K?9X zASap_9e1vu{ToIZSt_Y+woe7bqdzU#Vx9>AzaV)H?j*cT)zVtKKwjc}oMK2sW4h-~hMqR@A zzj6r=rX&()=<3?Wz?uk$f=dTW=MT^6_&TfA;wL4;-&yT(dw&cQ;Nx4EBWs*@tH#53yzryybE9gz(iZC;{YFnUe`y+0s zeo&!_$ooTb;Gr`%=T=o~4#hc3YlLv_f!1bd)qvi@T&Dx+oTxEu_qv_9t)~{FRkH!FIe~kWWbFUG*xnKCVdIB z+bk(cv)(eTys((nIOadtr;%j^3Bxg7p{~O$Ho=zjKCID;=`p&LVic7Gm@@v~C|5hh zpQUICRJ;hAe=!`gpG_k4mvPT)zbAew^>OjEkg!}OSEQp{HYq1VWL;+<6ApWhvt}5M zZLio?U1x5*R&cNUk@UJQVXZmjN;WoXV0q`hIZP^>#=itNDZUPp+55Pk^FfrcnW^A@ z2fzA89bK9)grvCn1=!H;?4!!};NL6o`XC6s%GDJTC-Q*3aF=ck+FGPCFKpoXfJU@! znU&$4J*kWE7bGX=7iP?U6 z8nV@m$}q!psXQ6b0ky%f^ScIJqZ@yv2smwf!7H?N-=r|ss@(yey7=nb9>62vYD zA7)An#2j0Y3A4R2%5YTiZXXo9;%lye?_2pjwBV~5JV%S?v zs=9*~XTp&8g%LfPT)zqaoRV{0bDSiRlTevp3;7)zb8Onj{saPfKdm%W<1yqTC4?_e zq$uwMeJk1xlSLTV1Zt+b4U_TJEpHGRV>Lc-pQ+LbvCNgcv2;?i2}`L;G)L@12H=mD z+?W-QAt8o=MqZDOFXa4do{%pCzfn)F$+S$8{XW=8OgS==@SLihQ*y$DWN4pZQ2>Wt zC>8eKP95I*e^x_A&MtI!gJJAVHKtN^VCSF{WQFuHEm@5yy{ne}1Gi>H+&px|NgGp1A{ES$amNIWCG4+EB-W^|yvmM21DzS6PypQ@s`J5%5N#YdQERQhoe zB2Q#YjVg6P0N%uQe}j}x-(FrYaNkzkl+yC48YQigz;q{Vwhb1;#t7+ zABA2t*R@G&hy8cy$)g~+>GBx_+qd@FMub+tdJ!-#-!sv!&*Q|i`vSN#aggob~zR5erIH&s+^TzjtoKA^*h@ru*Wn_L`?+!&O zX+=`yoK?=yUW#7J#CfCO!zeR$@%=k5r+WNb`objS7&uObfEVIcsm0>h7yOkC)Otvq zwBZ+<$0zx>{MrjFx?y4o1C<40bf`@$b!_!$b#unTPmwdzoh6MQ5a|KeALd+gcXOY~ z^58CbKyN~;!?*Ma)6>Yk;xS$p=5FA%^pm2*qT#Tq@}&q3^N>vY8(lhQFUXvkMQno% zRmy`R4!w-`2n06hK2k0fzrQ~~DMNb%@~LtB(oJO+TMqIHV3 zXd8#|yov3V4~rJ61jKX95o(}J#QWg`t#>@C=u7tnn4oUx9+$WjtB)%Gzf(y^*IY%X z*V~wqEjK6CY4SuDV`se*Eves9TbOytFwdnZ8d{hW;oH$UDOD2QusNjH+vi1No>Erd z+4q+RbAC>%oq6JTeMVNC;q2Mr*WxrBcU8-TWfeUM2VZL;$Jmtf_iZAw@sA3KVkWKp zX-&j(vFmS|2fL?JQBw@zkh+_`59n#p77OKmmVl5(lQ;P#rN_LgA4z@1R8JS_@Rm)J zMq}qpmm5+eVpMPh8d6m6d3ZwtO=BrK@rdtAj#jT)A=po9zo{8!OCj88A!}E@Aq&G> z;I~_uV|(@5l=^75eK})u4*CkyZVy5oqe(IRr76F-c}4Y&FqlADII$^rZ39c1x;!hc zvJCCO0{kwF>6jaV$EDF-I!@?VJSJHB#;DQVb#mcFPH$;c`f4Xx#@)3D}_2hUHyfK>WyRM{f%4#2HH}lYMr&ioGX> z+&uAM-?vjV)F%N0v9cb@Yp0_$zRe6;k;)#rP)C4m8``y<9lH&V`ie8Kb&*n6(dRrg zC3T1iE4se@m=2sh^jkov2)j0IEwI7SR~Id-Q851Yc(`*@5qW`r%a8)t_!as6#Nj9I z<=fltsC1kGml^YZkhSji(1Re8B6uW!k)pru(tSwuvHuL%}mL;1YDKv zEYd8l2@4XHtwc*(i;BaQgivYf;qM>e@n=|!RVSYrw9e?Q9r49;S*0s zkB7U2+j+9XrqQx0G7qx_hhIMw8Xt%}fJ)&r3Yr!%EL>%LME1_P-RnB28HbfEO{2tu zD}3oc^-;yG$+gRG1u1PI1YfF95S2TZe@@gzC$e63@KuWn*=Nrll@U0zszJ9BbjGKK?6Sa$zy{*>d2syXu0lJGCvz;D? z;pMzh)ZY4a-*KA5{|`rgV&g;adRDQk@82-J#~K||-EngpLYNDSAzqwQ%MH>+ zI})!fuf_{ymjVk<+4SB&Q|*F>Ycq>RJEO@&_AdNXHlz0`o+Vn1TidO<<9I}IMTnvD z+1gv{6KXTP1uyZ+!w!-8;l{GJ$_!$8G@oLmF+JBVKKktYJo-u&6S~(JuSK_XzB8)- z%OqW+{@`X$roHf;TIuD}H|AK{ap9IY<<18EbP*=Ia3V#}r#V((UZAB;m9~pnZBB#m z5?f3NQmyZrA7Ha5A-l}a>t~jlzJHJ%(-J~lD=`)+VZ6C3>-6o)1|zU0e(vm5&_m=R zKkLa^6viQEeco5DqK%d=IOR@5F0#eo|H9ImG+OEf5cKGtdNG zTvSYf=RIVh`Ihtp72Whf$S6Y?clyVQrTpI3S+T{23-@aO{Y8bN1Ts(w$)knNDhUr)pIFN&o#B=?gVu(M>V8tErAENpoeN4X} z4|%qs79rWjQpJmt&6Qkn614U~#R)04A48h-e9z})N2&Hy>w6H-6k@h_+~YQGbMaU^ zlM}Sr=oVKapW1p?cj?h&!ucPClHVN*qZAAN_H&3f;r-?RJ;dEdt;nhaMmKE8p zd8nh}(v(_*L!d(AB2rLACIAW4m@eDLeb;oI@%NSw{`6^PGHoCy=A%iINHP%t;q3bZ zxOsp}Tv3uhzlMw#5Z+W)YQC_+o;yqbn(3cdcAtenhLJjf&{k|0()r|NVAlq{bzZ2l z?!gP1mlyZ|Gum?N8u43Z#&`%^Mfug|&~b;ILSXR%F~jXjdcKWFKH5LJxTv<%bK69t zJ<(eX<#dsLxA07M4>D8z6ZY>K`t zM)8Kq_#2baKi(8Fx2{v6%5Yl*LA?SBF#z&*g5iI>86tO!B}XB~sN#paxC*KW*4ijm z>!5g}pzP5vh$_k6*tB;4BU34Y*w%2FeIz{{1bq1oPy@xEMT_X7+w#451oL9j=5qto z7}-ZCe;eR2sr#1dFQw?4L0N>ig7nc?v|Xv&&f5#a(`c1?qMNnb!litRRhJU!sGXhG>HFPKP(S#6{O&TM62qfTr?r;y+-U zFUV6Re@>)-4h8`lMy`0EA89l~EvKI{_Ou9m-5d0%3JQ?ORDPeKdf;)a(f zzBe&6^#A; zC?l%36w-~@^xCU@!aB}~ge0Q0k?SzBd(%#Y2Hjp}lk=R0LD z-Ti@KTvO&;_4njVW$2vJ(ZYyYNnHGKa}V!M)5pJ(Av;)t2rkmv8% z+i#B#fnIKd%uG=(X{o`C14b1aycKg6`9mu(sf4`-!=op31aZO5e*=4!s2}-gU}Y_( JS_PYk{{v0prRe|w literal 0 HcmV?d00001 diff --git a/htdocs/assets/images/favicon-16x16.png b/htdocs/assets/images/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..7e8ce45eeeba455acce5543915f5f375c06ac68d GIT binary patch literal 341 zcmV-b0jmCqP)r6FQijTlJ#>=n9xcD-l z9B?i#k$vkD3|nJYfSS%R1wWaaKt)cXVY}Zu&WU!vf>-9)sLo9?Z0`OEj<)$!2llSD zQk!Sjg_?scUfMr}JPfbWEDLq{c4MnY;O?JP@Pd_Q>bEwWxufP1o z7f%r?eEr8woB@S_>|~9=Gz>;*z|10&5OKTSQMHjOxNDL;wQd4#AoFOjl;6r$$dajK()s4Bv}pt8-! zv!N&{*6Gmc({nu66Dj;$o)6c5Gkb-Ep4&oT1QA~GHjFb6dj?`Yn8b86llo{D>)|vq lo(rmg|1+M$2ohsF{|(OZJhxTXMQs28002ovPDHLkV1ny;U`7A{ literal 0 HcmV?d00001 diff --git a/htdocs/assets/images/favicon.ico b/htdocs/assets/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..08f1d1e85512581bea877028018990cbaa2190b4 GIT binary patch literal 15406 zcmeGiS&S4#w26NCwSM?x;?97IcqOQ)iJ;(xMnx1OL;^+(3K|tJJRoW`8Wa6M6Qhw? z76>5AynyH>h$|(j7VAci%)^IEQrA_IGy-w33AV`9 zV^%cDkm|F_PD(5V9L!SEs*+qy~98A&^9m+Y;nq|?u}p` zZYq@jS_9_MHm9t$y$9O%*9zsX7Rt@9g8u6VPI;htp|^jC^v-eU z(c0#M*7{DN-1-*M`+lL^@;c}p^PMvBCWSoV^GG4@Qr(*hW#p}S=ohD){Id?so))Lf z+f`zIF1p9($#pPxE_#G*NPmlN3T0H%J`=`o9DcMvi4uS`1nHa~s|lUJFU#j}@p2ji z$HFlSW0$wbT`=05c@Nn5{ls(8{Q6_PMPmo`7fg=Jv=IjFxQmA2*)i1T{2*8A4>g`U?-zsCr*R1{xJ_czIHs=#=qEy49ccwyzo`` zfu$V-+f+gQd71}Kwg^v&z2AD^$^FYY7?OX~df$;kG$=|HPB5on~;ijltoR6A$CaCwHCu zpVlS5`hGEd)mZG~Au+se48+$xB80og3jV!1@_*-vQa=IZ=V76I#8L0#>mC)u*Nq2d z!xLgS%CE9F%M8Va{9Gxx(7$Yy9U!w#L=2(d*Euri>xi-=&m~nUo+a@NIxkV zU7vc|r@HH%{h|2&v&?~)-hWnm`ZxA`<+P`J7^jQp%BD$dZ?%-exCe~&z=`)Z?#-5V zob4059nWcRu+$#52h(27>v=A(91WIy0LO>|@q*U}`K71l!J50*d?4=t`MaRdUUSoV zG5&M+&;gSN*Bv+->A(9o8!JDy$S-iI)RUhCU9{2*`1$!s5G=_Hs;cCg>gmy%P#5wr z(Ler@a^*ghCEB8q>fLfAJcDQ^+Tq8muRx#6claUQ<@PyX_$JXzbBHdio{V&x6+}C? zF-%$qoJU6m($409^1cgY-Kbdl2d%F5Rj&r}jP5Iq0bZr*M?iNVyMYW;pbXRwM}PLU z+|DSS*IoxP`ub}BEh2mzqp$ci=zCv$LuvfQBOtNwd$7|6^SSHo^KhK&#qAJA{v-08 z+i4w)-Cy{RgLLa%pD>Y@Q6+ zOv3SJS^9~hF$2yw@;Os%fhRi|gZPFC!v5vD(To<3pT#+=Tc?WgSBwJkoAH#jQlO&0 zo{!% zd|mJipuS~1%Y1Ds2Y(pI%Wli#m$3QJ#xr()2I=EFA$_bp_eQdW{YGyQ&0`(drW50n zb4%K9^v;srVx~{*0=@I&+}_S~CTs=;>o-)2Uuv2Q`d?)+OCk`zCbT^Aba{GkrTxIjK;{I`+toT9JKiNOo4BBsG z6ZD>EzO1(wv){aLPI!BIui;Kam)i8Qu>Yd=8`Q%s(0FrMfQPJI32VtQ8I!`|^Y_@)AiPt2F}gkr-yk!|nCf6^yo*XJyjOz~(9 z-~IaD!{~m2@^9<^fMuLUTMpk0``LRW&no_&K9H^!RBc+*YxA5D9h43qwy=*xQGk zeM_CdDG*R$L2wPcRsD8fnVW)*g*qGllq0vb%7 literal 0 HcmV?d00001 diff --git a/htdocs/assets/js/app.js b/htdocs/assets/js/app.js new file mode 100644 index 0000000..2a1e86f --- /dev/null +++ b/htdocs/assets/js/app.js @@ -0,0 +1,87 @@ + +(function(){ + let schema = "ws"; + if (location.protocol === "https:") schema = "wss"; + let ws_url = schema+'://'+location.hostname+(location.port ? ':'+location.port: '')+"/ws"; + let domain = location.protocol+'//'+location.hostname+(location.port ? ':'+location.port: ''); + + let doms = document.getElementsByClassName("domain-name"); + for(let i = 0; i < doms.length; i++){ + doms[i].innerHTML = domain; + } + + let socket = null; + let msg = JSON.stringify({ + name: "subscribe", + payload: "all", + }); + let html = ""; + let list = []; + let subscribed = false; + + function connect_socket() { + socket = new WebSocket(ws_url); + socket.onclose = function(event){ + connect_socket(); + }; + socket.onopen = function(event){ + socket.send(msg); + }; + listen(); + } + + function fix_number(number) { + if (number <= 9) return "0" + number; + return number; + } + + function get_date(date) { + return fix_number(date.getHours()) + ":" + fix_number(date.getMinutes()) + ":" + fix_number(date.getSeconds()) + } + + function listen() { + socket.onmessage = function (event) { + if (event.data.length <= 3) return; + if (!subscribed) { + subscribed = true; + return + } + list.push([new Date(), event.data]); + if (list.length > 15) { + list.shift(); + } + html = ""; + for (let i = list.length - 1; i >= 0; i--) { + html += get_date(list[i][0]) + " " + list[i][1] + "
"; + } + document.getElementById("activity-feed").innerHTML = html; + } + } + + let generator_username = document.getElementById("generator-username"); + let generator_repository = document.getElementById("generator-repository"); + + let generator_result_image = document.getElementById("generator-result-image"); + let generator_result_markdown = document.getElementById("generator-result-markdown"); + let generator_result_html = document.getElementById("generator-result-html"); + let image_url = ""; + let username = "webklex"; + let repository = "gohits"; + + generator_username.onchange = generate; + generator_repository.onchange = generate; + + function generate(){ + if (generator_username.value.length > 0) username = generator_username.value; + if (generator_repository.value.length > 0) repository = generator_repository.value; + + image_url = domain + "/svg/" + username + "/" + repository; + generator_result_image.setAttribute("src", image_url); + generator_result_markdown.innerHTML = "[![Hits](" + image_url + ")](" + domain + ")"; + generator_result_html.innerHTML = '<a href="' + domain + '"><img src="' + image_url + '" alt="Hits"/></a>'; + } + + connect_socket(); + generate(); + +})(); \ No newline at end of file diff --git a/htdocs/template/index.tmpl b/htdocs/template/index.tmpl new file mode 100644 index 0000000..73e7788 --- /dev/null +++ b/htdocs/template/index.tmpl @@ -0,0 +1,67 @@ +{{ define "index" }} + {{ template "layout/header" }} + +
+

+ GoHits visitor counter +

+

+ An easy way to track your page or project views ("hits") of any GitHub or online project. +

+ Hits + Total Downloads + Latest Stable Version + License + Demo status +
+ Fork on Github +
+ +
+
+
+
+

Generate your own

+ +
+ + +
+ +
+ + +
+
+
+ Markdown: +
+ +
+
+ HTML: +
+ +
+
+ Shield / Badge: +
+ Hits +
+
+
+ +
+
+
+

Latest activities

+ +
+
+
+
+ + {{ template "layout/footer" }} +{{ end }} \ No newline at end of file diff --git a/htdocs/template/layout/footer.tmpl b/htdocs/template/layout/footer.tmpl new file mode 100644 index 0000000..dd0c545 --- /dev/null +++ b/htdocs/template/layout/footer.tmpl @@ -0,0 +1,30 @@ +{{ define "layout/footer" }} + +
+

+ Your privacy matters! +
+ That's why this application only saves a sha256 hashed string based on your current ip +
+ and user agent for a short period of time (~20 minutes). +
+
+ © 2020 webklex.com +

+
+ + + + + + + + +{{ end }} \ No newline at end of file diff --git a/htdocs/template/layout/header.tmpl b/htdocs/template/layout/header.tmpl new file mode 100644 index 0000000..474b9b6 --- /dev/null +++ b/htdocs/template/layout/header.tmpl @@ -0,0 +1,24 @@ +{{ define "layout/header" }} + + + + + + + + + + + + + + GoHits project visitor counter + + + + + + +
+{{ end }} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..ccce7f8 --- /dev/null +++ b/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "./server" + "./utils/config" + "flag" + "fmt" + _ "github.com/elazarl/go-bindata-assetfs" +) + +var buildNumber string +var buildVersion string + +func main() { + c := config.DefaultConfig() + c.AddFlags(flag.CommandLine) + + sv := flag.Bool("version", false, "Show version and exit") + flag.Parse() + + c.Build = config.Build{ + Number: buildNumber, + Version: buildVersion, + } + + if *sv { + fmt.Printf("GoHits version: %s\n", c.Build.Version) + fmt.Printf("GoHits build number: %s\n", c.Build.Number) + return + } + + c.Load(c.File) + + if c.SaveConfigFlag { + if _, err := c.Save(); err != nil { + print(err) + } + } + + s := server.NewServerConfig(c, assetFS()) + s.Start() +} diff --git a/server/api.go b/server/api.go new file mode 100644 index 0000000..60bcacc --- /dev/null +++ b/server/api.go @@ -0,0 +1,112 @@ +package server + +import ( + "../utils/counter" + "crypto/sha256" + "encoding/json" + "encoding/xml" + "fmt" + "github.com/go-web/httpmux" + "io" + "net" + "net/http" +) + +func (s *Server) handleRequest(writer writerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writer(w, r) + } +} + +func (s *Server) indexResponse(r *http.Request) interface{} { + return nil +} + +func (s *Server) jsonResponse(w http.ResponseWriter, r *http.Request) { + content, err := json.MarshalIndent(s.getSection(r), "", "\t") + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + if n, err := io.WriteString(w, string(content)); err != nil || n <= 0 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } +} + +func (s *Server) xmlResponse(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + + x := xml.NewEncoder(w) + x.Indent("", "\t") + if err := x.Encode(s.getSection(r)); err != nil { + fmt.Println(err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + if n, err := w.Write([]byte{'\n'}); err != nil || n <= 0 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } +} + +func (s *Server) csvResponse(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/csv") + if n, err := io.WriteString(w, s.getSection(r).String()); err != nil || n <= 0 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } +} + +func (s *Server) badgeResponse(w http.ResponseWriter, r *http.Request) { + + host := "" + host, _, _ = net.SplitHostPort(r.RemoteAddr) + h := sha256.New() + h.Write([]byte(host + r.Header.Get("User-Agent"))) + token := fmt.Sprintf("%x", h.Sum(nil)) + + section := s.getSection(r) + entry := counter.NewEntry(token) + + if s.Counter.AddEntry(section, entry) { + s.activities <- section + } + + total := float64(section.Total) + + counterStr := fmt.Sprintf("%.0f", total) + if total > 1000000 { + counterStr = fmt.Sprintf("%.2fm", total/1000000) + } else if total > 1000 { + counterStr = fmt.Sprintf("%.0fk", total/1000) + } + + badge := fmt.Sprintf(` + + + + + + + hits + %s + + + `, counterStr) + + w.Header().Set("Content-Type", "image/svg+xml") + if n, err := io.WriteString(w, badge); err != nil || n <= 0 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } +} + +func (s *Server) getSection(r *http.Request) *counter.Section { + username := httpmux.Params(r).ByName("username") + repository := httpmux.Params(r).ByName("repository") + + return s.Counter.GetSection(username, repository) +} diff --git a/server/backend.go b/server/backend.go new file mode 100644 index 0000000..c5e7aa7 --- /dev/null +++ b/server/backend.go @@ -0,0 +1,243 @@ +package server + +import ( + "../utils/log" + "github.com/go-web/httplog" + "github.com/go-web/httpmux" + "github.com/rs/cors" + "net/http" + "strings" + + "github.com/fiorix/go-listener/listener" +) + +type writerFunc func(w http.ResponseWriter, r *http.Request) + +func (s *Server) NewHandler() (http.Handler, error) { + s.initCors() + mc := httpmux.DefaultConfig + if err := s.initMiddlewares(&mc); err != nil { + return nil, err + } + + mux := httpmux.NewHandler(&mc) + + mux.HandleFunc("GET", "/", func(w http.ResponseWriter, req *http.Request) { + if err := s.template.ExecuteTemplate(w, "index", s.indexResponse(req)); err != nil { + log.Error(err) + } + }) + + mux.GET("/svg/:username/:repository", s.registerHandler(s.badgeResponse)) + mux.GET("/json/:username/:repository", s.registerHandler(s.jsonResponse)) + mux.GET("/xml/:username/:repository", s.registerHandler(s.xmlResponse)) + mux.GET("/csv/:username/:repository", s.registerHandler(s.csvResponse)) + + mux.GET("/ws", s.registerSocketHandler()) + + return mux, nil +} + +func (s *Server) registerHandler(writer writerFunc) http.HandlerFunc { + return s.Api.cors.Handler(s.handleRequest(writer)).ServeHTTP +} + +func (s *Server) registerSocketHandler() http.HandlerFunc { + return s.Api.cors.Handler(s.socketHandler()).ServeHTTP +} + +func (s *Server) socketHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ws, err := s.Upgrader.Upgrade(w, r, nil) + if err != nil { + log.Error("upgrade:", err) + return + } + + client := NewClient(s, ws) + s.register <- client + + // Allow collection of memory referenced by the caller by doing all work in + // new goroutines. + + client.Listen() + client.SendString("") + } +} + +func (s *Server) initCors() { + s.Api.cors = cors.New(cors.Options{ + AllowedOrigins: strings.Split(s.Config.CORSOrigin, ","), + AllowedMethods: []string{"GET"}, + AllowCredentials: true, + }) +} + +func (s *Server) listenerOpts() []listener.Option { + var opts []listener.Option + if s.Config.FastOpen { + opts = append(opts, listener.FastOpen()) + } + if s.Config.Naggle { + opts = append(opts, listener.Naggle()) + } + return opts +} + +func (s *Server) runServer(f http.Handler) { + if !s.Config.Silent { + log.Info("http server starting on", s.Config.ServerAddr) + } + ln, err := listener.New(s.Config.ServerAddr, s.listenerOpts()...) + if err != nil { + log.Fatal(err) + } + srv := &http.Server{ + Handler: f, + ReadTimeout: s.Config.ReadTimeout, + WriteTimeout: s.Config.WriteTimeout, + ErrorLog: s.Config.ErrorLogger(), + } + log.Fatal(srv.Serve(ln)) +} + +func (s *Server) runTLSServer(f http.Handler) { + log.Info("https server starting on", s.Config.TLSServerAddr) + opts := s.listenerOpts() + if s.Config.HTTP2 { + opts = append(opts, listener.HTTP2()) + } + if s.Config.LetsEncrypt { + if s.Config.LetsEncryptHosts == "" { + log.Fatal("must set at least one host using --letsencrypt-hosts") + } + opts = append(opts, listener.LetsEncrypt( + s.Config.LetsEncryptCacheDir, + s.Config.LetsEncryptEmail, + strings.Split(s.Config.LetsEncryptHosts, ",")..., + )) + } else { + opts = append(opts, listener.TLS(s.Config.TLSCertFile, s.Config.TLSKeyFile)) + } + ln, err := listener.New(s.Config.TLSServerAddr, opts...) + if err != nil { + log.Fatal(err) + } + srv := &http.Server{ + Addr: s.Config.TLSServerAddr, + Handler: f, + ReadTimeout: s.Config.ReadTimeout, + WriteTimeout: s.Config.WriteTimeout, + ErrorLog: s.Config.ErrorLogger(), + TLSConfig: ln.TLSConfig(), + } + log.Fatal(srv.Serve(ln)) +} + +func (s *Server) initMiddlewares(mc *httpmux.Config) error { + mc.Prefix = s.Config.APIPrefix + mc.NotFound = s.guiMiddleware(s.Config.GuiDir) + if s.Config.UseXForwardedFor { + mc.UseFunc(httplog.UseXForwardedFor) + } + if !s.Config.Silent { + mc.UseFunc(httplog.ApacheCombinedFormat(s.Config.AccessLogger())) + } + if s.Config.HSTS != "" { + mc.UseFunc(hstsMiddleware(s.Config.HSTS)) + } + if s.Config.RateLimitLimit > 0 { + mc.Use(s.rateLimitMiddleware) + } + return nil +} + +func (s *Server) messageHandler(message *Message) { + cmd := &Command{} + message.Decode(cmd) + switch cmd.Name { + case "unsubscribe": + sectionKey := "" + if cmd.Payload == "all" { + sectionKey = cmd.Payload + }else{ + if section := s.Counter.GetSectionByKey(sectionKey); section != nil { + sectionKey = section.GetKey() + } + } + if len(sectionKey) > 0 { + + s.mx.Lock() + if clients, ok := s.subscriptions[sectionKey]; ok { + if _, ok := clients[message.Client]; ok { + delete(s.subscriptions[sectionKey], message.Client) + } + if len(s.subscriptions[sectionKey]) == 0 { + delete(s.subscriptions, sectionKey) + } + } + s.mx.Unlock() + + message.Client.SendString("successfully unsubscribed from " + sectionKey) + }else{ + message.Client.SendString("invalid command") + } + case "subscribe": + sectionKey := "" + if cmd.Payload == "all" { + sectionKey = cmd.Payload + }else{ + if section := s.Counter.GetSectionByKey(sectionKey); section != nil { + sectionKey = section.GetKey() + } + } + if len(sectionKey) > 0 { + + s.mx.Lock() + if _, ok := s.subscriptions[sectionKey]; !ok { + s.subscriptions[sectionKey] = make(map[*Client]bool) + } + s.subscriptions[sectionKey][message.Client] = true + s.mx.Unlock() + + message.Client.SendString("successfully subscribed to " + sectionKey) + }else{ + message.Client.SendString("invalid command") + } + default: + message.Client.SendString("invalid command") + } +} + +func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + limiter := s.RateLimit.GetLimiter(r.RemoteAddr) + if !limiter.Allow() { + http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) + return + } + + next.ServeHTTP(w, r) + }) +} + +func hstsMiddleware(policy string) httpmux.MiddlewareFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil { + return + } + w.Header().Set("Strict-Transport-Security", policy) + next(w, r) + } + } +} + +func (s *Server) guiMiddleware(path string) http.Handler { + handler := http.NotFoundHandler() + if path != "" { + handler = http.FileServer(s.assets) + } + + return handler +} diff --git a/server/client.go b/server/client.go new file mode 100644 index 0000000..7360541 --- /dev/null +++ b/server/client.go @@ -0,0 +1,160 @@ +package server + +import ( + "../utils/log" + "github.com/gorilla/websocket" + "time" +) + +// Client is a middleman between the websocket connection and the node. +type Client struct { + Server *Server + + // The websocket connection. + Conn *websocket.Conn + + // Buffered channel of outbound messages. + send chan *Message + + received chan *Message +} + +func NewClient(server *Server, conn *websocket.Conn) *Client { + c := &Client{ + Server: server, + Conn: conn, + send: make(chan *Message, 256), + } + return c +} + +func (c *Client) NewMessage() *Message { + return NewMessage(c, []byte("")) +} + +func (c *Client) Send(message *Message) { + message.Client = c + select { + case <-c.send: + return + default: + } + + c.send <- message + return +} + +func (c *Client) SendBytes(payload []byte) { + message := NewMessage(c, payload) + c.Send(message) + return +} + +func (c *Client) SendString(payload string) { + c.SendBytes([]byte(payload)) + return +} + +func (c *Client) GetSend() chan *Message { + return c.send +} + +func (c *Client) IsClosed(ch <-chan *Message) bool { + select { + case <-ch: + return true + default: + } + + return false +} + +func (c *Client) Close() error { + if !c.IsClosed(c.send) && c.send != nil { + close(c.send) + } + if !c.IsClosed(c.received) && c.received != nil { + close(c.received) + } + + return c.Conn.Close() +} + +func (c *Client) Listen() { + // CONN -----> NODE + go c.writePump() + + // NODE <----- CONN + go c.readPump() +} + +// readPump pumps messages from the websocket connection to the node. +// +// The application runs readPump in a per-connection goroutine. The application +// ensures that there is at most one reader on a connection by executing all +// reads from this goroutine. +func (c *Client) readPump() { + defer func() { + c.Server.unregister <- c + _ = c.Close() + }() + c.Conn.SetReadLimit(c.Server.Config.MaxMessageSize) + + _ = c.Conn.SetReadDeadline(time.Now().Add(c.Server.Config.PongWait)) + c.Conn.SetPongHandler(func(string) error { + if err := c.Conn.SetReadDeadline(time.Now().Add(c.Server.Config.PongWait)); err != nil { + log.Error("error: ", err) + _ = c.Close() + } + return nil + }) + + for { + _, text, err := c.Conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Error("error: ", err) + return + } + break + } + + message := NewMessage(c, text) + c.Server.messageHandler(message) + } +} + +// writePump pumps messages from the node to the websocket connection. +// +// A goroutine running writePump is started for each connection. The +// application ensures that there is at most one writer to a connection by +// executing all writes from this goroutine. +func (c *Client) writePump() { + ticker := time.NewTicker(c.Server.Config.PingPeriod) + defer func() { + ticker.Stop() + _ = c.Close() + }() + for { + select { + case message, ok := <-c.send: + _ = c.Conn.SetWriteDeadline(time.Now().Add(c.Server.Config.WriteWait)) + if !ok { + _ = c.Conn.WriteMessage(websocket.CloseMessage, []byte{}) + log.Error("Socket closed") + return + } + + if err := c.Conn.WriteMessage(websocket.TextMessage, message.Payload); err != nil { + log.Error("Socket error: ", err) + return + } + case <-ticker.C: + _ = c.Conn.SetWriteDeadline(time.Now().Add(c.Server.Config.WriteWait)) + if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + log.Error("Socket error: ", err) + return + } + } + } +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..742666d --- /dev/null +++ b/server/main.go @@ -0,0 +1,196 @@ +package server + +import ( + "../utils/config" + "../utils/counter" + "../utils/filesystem" + "../utils/log" + assetfs "github.com/elazarl/go-bindata-assetfs" + "github.com/gorilla/websocket" + "github.com/rs/cors" + "io/ioutil" + olog "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "text/template" +) + +// Create a custom visitor struct which holds the rate limiter for each +// visitor and the last time that the visitor was seen. + +type Server struct { + Config *config.Config + Counter *counter.Counter + + Host string + Port int + + // Registered clients. + clients map[*Client]bool + subscriptions map[string]map[*Client]bool + + // Register requests from the clients. + register chan *Client + // Unregister requests from clients. + unregister chan *Client + + activities chan *counter.Section + + RateLimit *RateLimit + Visitors map[string]*Visitor + mx *sync.RWMutex + + template *template.Template + assets *assetfs.AssetFS + + Upgrader websocket.Upgrader + + Api *ApiHandler +} + +type ApiHandler struct { + cors *cors.Cors +} + +func NewServerConfig(c *config.Config, assets *assetfs.AssetFS) *Server { + parts := strings.Split(c.ServerAddr, ":") + host := parts[0] + port, err := strconv.Atoi(parts[1]) + + if err != nil || port <= 0 { + print("Invalid Socket provided") + os.Exit(1) + } + + defaultConfig := config.DefaultConfig() + if c.PingPeriod <= 0 { + c.PingPeriod = defaultConfig.PingPeriod + } + if c.PongWait <= 0 { + c.PongWait = defaultConfig.PongWait + } + if c.PongWait <= c.PingPeriod { + c.PongWait = c.PingPeriod + defaultConfig.PongWait + } + + s := &Server{ + Config: c, + Counter: counter.NewCounter(c.SessionLifetime), + + Host: host, + Port: port, + + register: make(chan *Client), + unregister: make(chan *Client), + activities: make(chan *counter.Section), + clients: make(map[*Client]bool), + subscriptions: make(map[string]map[*Client]bool), + + Upgrader: websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + EnableCompression: true, + CheckOrigin: func(r *http.Request) bool { + return true + }, + }, + + RateLimit: NewRateLimit(c.RateLimitLimit, c.RateLimitBurst, c.RateLimitInterval), + Api: &ApiHandler{}, + mx: &sync.RWMutex{}, + } + + s.assets = assets + s.template = ParseTemplates("htdocs/template") + + c.LogOutput = os.Stdout + + if c.LogToStdout { + olog.SetOutput(c.LogOutput) + } else if c.LogOutputFile != "" { + _, _ = filesystem.MakeDir(c.LogOutputFile) + _ = ioutil.WriteFile(c.LogOutputFile, []byte(""), 0644) + c.LogOutput, _ = os.OpenFile(c.LogOutputFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + olog.SetOutput(c.LogOutput) + } + if !c.LogTimestamp { + olog.SetFlags(0) + } + + return s +} + +func (s *Server) listen() { + for { + select { + // Register new clients + case client := <-s.register: + s.clients[client] = true + log.Info("Client connected!") + // Register new clients + case section := <-s.activities: + sectionKey := section.GetKey() + if subscribers, ok := s.subscriptions[sectionKey]; ok { + for client, state := range subscribers { + if state { + client.SendString(sectionKey) + } + } + } + if subscribers, ok := s.subscriptions["all"]; ok { + for client, state := range subscribers { + if state { + client.SendString(sectionKey) + } + } + } + // Unregister an existing client + case client := <-s.unregister: + if _, ok := s.clients[client]; ok { + delete(s.clients, client) + _ = client.Close() + log.Info("Client disconnected!") + } + } + } +} + +func (s *Server) Start() { + f, err := s.NewHandler() + if err != nil { + log.Fatal(err) + } + go s.Counter.Run() + go s.listen() + if s.Config.ServerAddr != "" { + go s.runServer(f) + } + if s.Config.TLSServerAddr != "" { + go s.runTLSServer(f) + } + select {} +} + +func ParseTemplates(_path string) *template.Template { + tpl := template.New("") + err := filepath.Walk(_path, func(path string, info os.FileInfo, err error) error { + if strings.Contains(path, ".tmpl") { + _, err = tpl.ParseFiles(path) + if err != nil { + log.Error(err) + } + } + + return err + }) + + if err != nil { + panic(err) + } + + return tpl +} diff --git a/server/message.go b/server/message.go new file mode 100644 index 0000000..42502b7 --- /dev/null +++ b/server/message.go @@ -0,0 +1,46 @@ +package server + +import ( + "../utils/log" + "encoding/json" +) + +type Message struct { + Payload []byte + Client *Client +} + +type Command struct { + Name string `json:"name"` + Payload string `json:"payload"` +} + +func NewMessage(client *Client, payload []byte) *Message { + m := &Message{ + Payload: payload, + Client: client, + } + return m +} + +func (m *Message) Decode(v interface{}) bool { + if err := json.Unmarshal(m.Payload, v); err != nil { + log.Error(err) + return false + } + return true +} + +func (m *Message) Encode(v interface{}) bool { + b, err := json.Marshal(v) + if err != nil { + log.Error(err) + return false + } + m.Payload = b + return true +} + +func (m *Message) Send() { + m.Client.Send(m) +} diff --git a/server/rate_limiter.go b/server/rate_limiter.go new file mode 100644 index 0000000..c173e54 --- /dev/null +++ b/server/rate_limiter.go @@ -0,0 +1,79 @@ +package server + +import ( + "sync" + "time" + + "golang.org/x/time/rate" +) + +// Create a custom visitor struct which holds the rate limiter for each +// visitor and the last time that the visitor was seen. +type Visitor struct { + limiter *rate.Limiter + lastSeen time.Time +} + +type RateLimit struct { + Limit rate.Limit + Burst int + Interval time.Duration + Mutex *sync.RWMutex + Visitors map[string]*Visitor +} + +func NewRateLimit(limit int, burst int, interval time.Duration) *RateLimit { + conf := &RateLimit{ + Limit: rate.Limit(limit), + Burst: burst, + Interval: interval, + Visitors: make(map[string]*Visitor), + Mutex: &sync.RWMutex{}, + } + go conf.cleanupVisitors() + + return conf +} + +// GetLimiter returns the rate limiter for the provided IP address if it exists. +// Otherwise calls AddIP to add IP address to the map +func (i *RateLimit) GetLimiter(ip string) *rate.Limiter { + i.Mutex.Lock() + visitor, exists := i.Visitors[ip] + + if !exists { + i.Mutex.Unlock() + return i.AddIP(ip) + } + + i.Mutex.Unlock() + + return visitor.limiter +} + +// AddIP creates a new rate limiter and adds it to the ips map, +// using the IP address as the key +func (i *RateLimit) AddIP(ip string) *rate.Limiter { + i.Mutex.Lock() + defer i.Mutex.Unlock() + + limiter := rate.NewLimiter(i.Limit, i.Burst) + + i.Visitors[ip] = &Visitor{limiter, time.Now()} + + return limiter +} + +func (i *RateLimit) cleanupVisitors() { + for { + time.Sleep(time.Minute) + + i.Mutex.Lock() + for ip, v := range i.Visitors { + if time.Now().Sub(v.lastSeen) > i.Interval { + delete(i.Visitors, ip) + } + } + i.Mutex.Unlock() + } +} diff --git a/utils/config/config.go b/utils/config/config.go new file mode 100644 index 0000000..e40e1b9 --- /dev/null +++ b/utils/config/config.go @@ -0,0 +1,202 @@ +package config + +import ( + "../filesystem" + "encoding/json" + "flag" + "fmt" + "github.com/kelseyhightower/envconfig" + "io" + "io/ioutil" + "log" + "os" + "path" + "time" +) + +func NewConfig() *Config { + return &Config{} +} + +func DefaultConfig() *Config { + dir, _ := os.Getwd() + + return &Config{ + FastOpen: false, + Naggle: false, + ServerAddr: "localhost:8080", + HTTP2: true, + HSTS: "", + + TLSCertFile: "cert.pem", + TLSKeyFile: "key.pem", + LetsEncrypt: false, + LetsEncryptCacheDir: ".", + LetsEncryptEmail: "", + LetsEncryptHosts: "", + + APIPrefix: "/", + CORSOrigin: "*", + ReadTimeout: 30 * time.Second, + WriteTimeout: 15 * time.Second, + RateLimitLimit: 1, + RateLimitBurst: 3, + LogTimestamp: true, + RateLimitInterval: 3 * time.Minute, + + SessionLifetime: 20 * time.Minute, + WriteWait: 10 * time.Second, + ReadWait: 10 * time.Second, + MaxMessageSize: 8192, + PongWait: 24 * time.Second, + PingPeriod: 12 * time.Second, + CloseGracePeriod: 6 * time.Second, + + RootDir: dir, + GuiDir: "gui", + File: path.Join(dir, "conf", "settings.config"), + SaveConfigFlag: false, + Silent: false, + } +} + +// AddFlags adds configuration flags to the given FlagSet. +func (c *Config) AddFlags(fs *flag.FlagSet) { + defer envconfig.Process("gohits", c) + + fs.StringVar(&c.ServerAddr, "http", c.ServerAddr, "Address in form of ip:port to listen") + fs.StringVar(&c.TLSServerAddr, "https", c.TLSServerAddr, "Address in form of ip:port to listen") + + fs.BoolVar(&c.FastOpen, "tcp-fast-open", c.FastOpen, "Enable TCP fast open") + fs.BoolVar(&c.Naggle, "tcp-naggle", c.Naggle, "Enable TCP Nagle's algorithm (disables NO_DELAY)") + fs.BoolVar(&c.HTTP2, "http2", c.HTTP2, "Enable HTTP/2 when TLS is enabled") + + fs.StringVar(&c.HSTS, "hsts", c.HSTS, "Set HSTS to the value provided on all responses") + fs.StringVar(&c.TLSCertFile, "cert", c.TLSCertFile, "X.509 certificate file for HTTPS server") + fs.StringVar(&c.TLSKeyFile, "key", c.TLSKeyFile, "X.509 key file for HTTPS server") + + fs.BoolVar(&c.LetsEncrypt, "letsencrypt", c.LetsEncrypt, "Enable automatic TLS using letsencrypt.org") + fs.StringVar(&c.LetsEncryptEmail, "letsencrypt-email", c.LetsEncryptEmail, "Optional email to register with letsencrypt (default is anonymous)") + fs.StringVar(&c.LetsEncryptHosts, "letsencrypt-hosts", c.LetsEncryptHosts, "Comma separated list of hosts for the certificate (required)") + fs.StringVar(&c.LetsEncryptCacheDir, "letsencrypt-cert-dir", c.LetsEncryptCacheDir, "Letsencrypt cert dir") + + fs.StringVar(&c.APIPrefix, "api-prefix", c.APIPrefix, "API endpoint prefix") + fs.StringVar(&c.CORSOrigin, "cors-origin", c.CORSOrigin, "Comma separated list of CORS origins endpoints") + fs.BoolVar(&c.UseXForwardedFor, "use-x-forwarded-for", c.UseXForwardedFor, "Use the X-Forwarded-For header when available (e.g. behind proxy)") + + fs.StringVar(&c.GuiDir, "gui", c.GuiDir, "Web gui directory") + + fs.DurationVar(&c.SessionLifetime, "session-lifetime", c.SessionLifetime, "Session lifetime of an counted visitor") + fs.DurationVar(&c.PongWait, "pong-wait", c.PongWait, "Websocket pong wait duration") + fs.DurationVar(&c.PingPeriod, "ping-period", c.PingPeriod, "Send pings to peer with this period. Must be less than pong-wait.") + + fs.DurationVar(&c.WriteTimeout, "write-timeout", c.WriteTimeout, "Write timeout for HTTP and HTTPS client connections") + fs.BoolVar(&c.LogToStdout, "logtostdout", c.LogToStdout, "Log to stdout instead of stderr") + fs.StringVar(&c.LogOutputFile, "log-file", c.LogOutputFile, "Log output file") + fs.BoolVar(&c.LogTimestamp, "logtimestamp", c.LogTimestamp, "Prefix non-access logs with timestamp") + + fs.IntVar(&c.RateLimitBurst, "quota-burst", c.RateLimitBurst, "Max requests per source IP per request burst") + fs.DurationVar(&c.RateLimitInterval, "quota-interval", c.RateLimitInterval, "Quota expiration interval, per source IP querying the API") + fs.IntVar(&c.RateLimitLimit, "quota-max", c.RateLimitLimit, "Max requests per source IP per interval; set 0 to turn quotas off") + + fs.DurationVar(&c.ReadTimeout, "read-timeout", c.ReadTimeout, "Read timeout for HTTP and HTTPS client connections") + + fs.BoolVar(&c.Silent, "silent", c.Silent, "Disable HTTP and HTTPS log request details") + fs.StringVar(&c.File, "config", c.File, "Config file") + fs.BoolVar(&c.SaveConfigFlag, "save", c.SaveConfigFlag, "Save config") +} + +func NewConfigFromFile(configFile string) *Config { + config := DefaultConfig() + + if configFile != "" { + config.Load(configFile) + config.File = configFile + } + + return config +} + +func (c *Config) initFile(filename string) { + filesystem.CreateDirectory("conf") + if len(filename) == 0 { + dir, _ := os.Getwd() + filename = path.Join(dir, "conf", "settings.config") + + c.Load(filename) + } + c.File = filename +} + +func (c *Config) Load(filename string) bool { + c.initFile(filename) + + if _, err := os.Stat(filename); err == nil { + + content, err := ioutil.ReadFile(filename) + if err != nil { + + if !c.Silent { + log.Printf("[error] Config file failed to load: %s", err.Error()) + } + return false + } + + err = json.Unmarshal(content, c) + if err != nil { + if !c.Silent { + log.Printf("[error] Config file failed to load: %s", err.Error()) + } + return false + } + + if !c.Silent { + log.Printf("[info] Config file loaded successfully") + } + + } else { + _, _ = c.Save() + } + return true +} + +func (c *Config) Save() (bool, error) { + if len(c.File) == 0 { + c.initFile("") + } + + file, err := json.MarshalIndent(c, "", "\t") + if err != nil { + if !c.Silent { + fmt.Println(err) + } + return false, err + } + + err = ioutil.WriteFile(c.File, file, 0644) + if err != nil { + panic(err) + return false, err + } + + if !c.Silent { + log.Printf("[info] Config file saved under: %s", c.File) + } + + return true, nil +} + +func (c *Config) logWriter() io.Writer { + return c.LogOutput +} + +func (c *Config) ErrorLogger() *log.Logger { + if c.LogTimestamp { + return log.New(c.logWriter(), "[error] ", log.LstdFlags) + } + return log.New(c.logWriter(), "[error] ", 0) +} + +func (c *Config) AccessLogger() *log.Logger { + return log.New(c.logWriter(), "[access] ", 0) +} diff --git a/utils/config/struct.go b/utils/config/struct.go new file mode 100644 index 0000000..1ec6e75 --- /dev/null +++ b/utils/config/struct.go @@ -0,0 +1,67 @@ +package config + +import ( + "os" + "time" +) + +type Build struct { + Number string `json:"number"` + Version string `json:"version"` +} + +type Config struct { + FastOpen bool `json:"TCP_FAST_OPEN"` + Naggle bool `json:"TCP_NAGGLE"` + ServerAddr string `json:"HTTP"` + HTTP2 bool `json:"HTTP2"` + HSTS string `json:"HSTS"` + + TLSServerAddr string `json:"HTTPS"` + TLSCertFile string `json:"CERT"` + TLSKeyFile string `json:"KEY"` + + LetsEncrypt bool `json:"LETSENCRYPT"` + LetsEncryptCacheDir string `json:"LETSENCRYPT_CERT_DIR"` + LetsEncryptEmail string `json:"LETSENCRYPT_EMAIL"` + LetsEncryptHosts string `json:"LETSENCRYPT_HOSTS"` + + APIPrefix string `json:"API_PREFIX"` + CORSOrigin string `json:"CORS_ORIGIN"` + ReadTimeout time.Duration `json:"READ_TIMEOUT"` + WriteTimeout time.Duration `json:"WRITE_TIMEOUT"` + UseXForwardedFor bool `json:"USE_X_FORWARDED_FOR"` + Silent bool `json:"SILENT"` + LogToStdout bool `json:"LOG_STDOUT"` + LogOutputFile string `json:"LOG_FILE"` + LogTimestamp bool `json:"LOG_TIMESTAMP"` + + RateLimitInterval time.Duration `json:"QUOTA_INTERVAL"` + RateLimitLimit int `json:"QUOTA_MAX"` + RateLimitBurst int `json:"QUOTA_BURST"` + + // Time allowed to write a message to the peer. + WriteWait time.Duration `json:"WRITE_WAIT"` + ReadWait time.Duration `json:"READ_WAIT"` + + SessionLifetime time.Duration `json:"SESSION_LIFETIME"` + + // Maximum message size allowed from peer. + MaxMessageSize int64 `json:"MAX_MESSAGE_SIZE"` + // Time allowed to read the next pong message from the peer. + PongWait time.Duration `json:"PONG_WAIT"` + // Send pings to peer with this period. Must be less than pongWait. + PingPeriod time.Duration `json:"PING_PERIOD"` + // Time to wait before force close on connection. + CloseGracePeriod time.Duration `json:"CLOSE_GRACE_PERIOD"` + + GuiDir string `json:"GUI"` + + File string `json:"-"` + RootDir string `json:"-"` + SaveConfigFlag bool `json:"-"` + RunSetupFlag bool `json:"-"` + Build Build `json:"-"` + + LogOutput *os.File `json:"-"` +} diff --git a/utils/counter/main.go b/utils/counter/main.go new file mode 100644 index 0000000..35b3760 --- /dev/null +++ b/utils/counter/main.go @@ -0,0 +1,103 @@ +package counter + +import ( + "sync" + "time" +) + +func NewCounter(duration time.Duration) *Counter { + c := &Counter{ + Duration: duration, + mx: &sync.RWMutex{}, + } + if c.Sections == nil { + c.Sections = make(map[string]*Section) + } + for k, _ := range c.Sections { + c.Sections[k].Entries = make(map[string]*Entry) + } + return c +} + +func NewEntry(hash string) *Entry { + c := &Entry{ + Hash: hash, + Timestamp: time.Now(), + } + return c +} + +func (c *Counter) GetSection(username string, repository string) *Section { + sectionKey := username + "/" + repository + c.mx.Lock() + if _, ok := c.Sections[sectionKey]; !ok { + c.Sections[sectionKey] = NewSection(username, repository) + } + c.mx.Unlock() + return c.Sections[sectionKey] +} + +func (c *Counter) GetSectionByKey(sectionKey string) *Section { + if _, ok := c.Sections[sectionKey]; !ok { + return nil + } + return c.Sections[sectionKey] +} + +func (c *Counter) Run() { + t := time.NewTicker(c.Duration) + defer func() { + t.Stop() + }() + + for { + select { + case <-t.C: + for sectionKey, section := range c.Sections { + // Delete possible junk sections + if section.Total == 1 && time.Now().After(section.CreatedAt.Add(24*time.Hour)) { + c.RemoveSection(sectionKey) + }else{ + _ = section.Save() + if time.Now().After(section.UpdatedAt.Add(c.Duration)) { + c.RemoveSection(sectionKey) + } + } + for hash, entry := range section.Entries { + if time.Now().After(entry.Timestamp.Add(c.Duration)) { + c.RemoveEntry(section, hash) + } + } + } + } + } +} + +func (c *Counter) RemoveEntry(section *Section, hash string) { + c.mx.Lock() + if _, ok := section.Entries[hash]; !ok { + delete(section.Entries, hash) + } + c.mx.Unlock() +} + +func (c *Counter) RemoveSection(sectionKey string) { + c.mx.Lock() + if _, ok := c.Sections[sectionKey]; !ok { + delete(c.Sections, sectionKey) + } + c.mx.Unlock() +} + +func (c *Counter) AddEntry(section *Section, entry *Entry) bool { + sectionKey := section.GetKey() + + c.mx.Lock() + if _, ok := c.Sections[sectionKey]; !ok { + c.Sections[sectionKey] = section + } + result := c.Sections[sectionKey].AddEntry(entry) + c.mx.Unlock() + + return result +} \ No newline at end of file diff --git a/utils/counter/section.go b/utils/counter/section.go new file mode 100644 index 0000000..de63def --- /dev/null +++ b/utils/counter/section.go @@ -0,0 +1,106 @@ +package counter + +import ( + "../filesystem" + "crypto/sha256" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path" + "strings" + "time" +) + +func NewSection(username string, repository string) *Section { + c := &Section{ + Username: username, + Repository: repository, + Total: 0, + CreatedAt: time.Now(), + Entries: make(map[string]*Entry), + } + _ = c.Load("") + return c +} + +func (s *Section) GetKey() string { + return s.Username + "/" + s.Repository +} + +func (s *Section) String() string { + dateFormat := "2006-01-02 15:04:05" + return strings.Join([]string{ + s.Username, + s.Repository, + fmt.Sprintf("%d", s.Total), + s.CreatedAt.Format(dateFormat), + s.UpdatedAt.Format(dateFormat), + }, ",") +} + +func (s *Section) GetToken() string { + h := sha256.New() + h.Write([]byte(s.GetKey())) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func (s *Section) AddEntry(entry *Entry) bool { + if _, ok := s.Entries[entry.Hash]; !ok { + s.Entries[entry.Hash] = entry + s.Total += 1 + s.UpdatedAt = time.Now() + return true + } + return false +} + +func (s *Section) initFile(filename string) { + filesystem.CreateDirectory("data") + if len(filename) == 0 { + dir, _ := os.Getwd() + filename = path.Join(dir, "data", s.GetToken() + ".json") + _ = s.Load(filename) + } + s.File = filename +} + + +func (s *Section) Load(filename string) error { + s.initFile(filename) + + if _, err := os.Stat(filename); err == nil { + + content, err := ioutil.ReadFile(filename) + if err != nil { + return err + } + + err = json.Unmarshal(content, s) + if err != nil { + return err + } + + } else { + _ = s.Save() + } + return nil +} + +func (s *Section) Save() error { + if len(s.File) == 0 { + s.initFile("") + } + + file, err := json.MarshalIndent(s, "", "\t") + if err != nil { + return err + } + + err = ioutil.WriteFile(s.File, file, 0644) + if err != nil { + return err + } + + return nil +} \ No newline at end of file diff --git a/utils/counter/struct.go b/utils/counter/struct.go new file mode 100644 index 0000000..9a99db0 --- /dev/null +++ b/utils/counter/struct.go @@ -0,0 +1,30 @@ +package counter + +import ( + "encoding/xml" + "sync" + "time" +) + +type Counter struct { + Sections map[string]*Section `json:"sections"` + File string `json:"-"` + Duration time.Duration `json:"-"` + mx *sync.RWMutex `json:"-"` +} + +type Section struct { + XMLName xml.Name `xml:"Section" json:"-"` + Username string `json:"username"` + Repository string `json:"repository"` + Total int64 `json:"total"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Entries map[string]*Entry `xml:"-" json:"-"` + File string `xml:"-" json:"-"` +} + +type Entry struct { + Hash string + Timestamp time.Time +} diff --git a/utils/filesystem/filesystem.go b/utils/filesystem/filesystem.go new file mode 100644 index 0000000..2b99a0a --- /dev/null +++ b/utils/filesystem/filesystem.go @@ -0,0 +1,45 @@ +package filesystem + +import ( + "os" + "path/filepath" +) + +func MakeDir(filename string) (dbdir string, err error) { + dbdir = filepath.Dir(filename) + _, err = os.Stat(dbdir) + if err != nil { + err = os.MkdirAll(dbdir, 0755) + if err != nil { + return "", err + } + } + return dbdir, nil +} + +func RenameFile(fromName string, toName string) error { + if err := os.Rename(toName, toName+".bak"); err != nil { + } + if _, err := MakeDir(toName); err != nil { + return err + } + return os.Rename(fromName, toName) +} + +func CreateDirectory(dirName string) bool { + src, err := os.Stat(dirName) + + if os.IsNotExist(err) { + errDir := os.MkdirAll(dirName, 0755) + if errDir != nil { + panic(err) + } + return true + } + + if src.Mode().IsRegular() { + return false + } + + return false +} diff --git a/utils/log/log.go b/utils/log/log.go new file mode 100644 index 0000000..8c76284 --- /dev/null +++ b/utils/log/log.go @@ -0,0 +1,50 @@ +package log + +import ( + "fmt" + "github.com/fatih/color" + "os" + "time" +) + +type Config struct { + Silent bool +} + +var Log = Config{Silent: false} + +func (c *Config) log(_type string, a ...interface{}) { + if c.Silent { + return + } + + _datetime := time.Now().Format("02.01.2006 15:04:05") + message := fmt.Sprintf("[%s] [%s] %s", _type, _datetime, a[0]) + switch _type { + case "error": + color.Magenta(message) + case "update": + color.Cyan(message) + case "success": + color.Green(message) + default: + color.Cyan(message) + } +} + +func Success(v ...interface{}) { + Log.log("success", v) +} + +func Info(v ...interface{}) { + Log.log("info", v) +} + +func Error(v ...interface{}) { + Log.log("error", v) +} + +func Fatal(v ...interface{}) { + Error(v) + os.Exit(1) +}