├── .github
└── workflows
│ └── deploy.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── docker-compose.example.yml
├── etc
└── pepic
│ └── config.yml
├── go.mod
├── go.sum
├── html
├── base.html
├── error.html
├── index.html
└── meta.html
├── main.go
├── pepic
├── cmd
│ ├── root.go
│ └── serve.go
├── config
│ └── app.go
├── entity
│ └── file.go
├── handler
│ ├── errors.go
│ ├── handler.go
│ ├── index.go
│ ├── meta.go
│ ├── proxy.go
│ └── upload.go
├── processing
│ ├── image.go
│ ├── processing.go
│ └── video.go
├── storage
│ ├── fs.go
│ └── storage.go
├── template
│ └── renderer.go
└── utils
│ ├── file.go
│ └── media.go
└── static
├── css
└── style.css
├── favicon
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── apple-touch-icon.png
├── favicon-16x16.png
├── favicon-32x32.png
└── favicon.ico
└── images
├── logo.png
├── screenshot1.png
└── screenshot2.png
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Docker image
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build:
10 | name: Build image
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v2
14 | with:
15 | ref: 'master'
16 | - run: docker login ghcr.io -u $GITHUB_ACTOR -p ${{ secrets.TOKEN }}
17 | - run: docker build -t ghcr.io/$GITHUB_ACTOR/pepic:latest .
18 | - run: docker image push ghcr.io/$GITHUB_ACTOR/pepic:latest
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Edit at https://www.toptal.com/developers/gitignore?templates=go,macos,intellij+all
2 |
3 | ### Go ###
4 | # Binaries for programs and plugins
5 | *.exe
6 | *.exe~
7 | *.dll
8 | *.so
9 | *.dylib
10 |
11 | # Test binary, built with `go test -c`
12 | *.test
13 |
14 | # Output of the go coverage tool, specifically when used with LiteIDE
15 | *.out
16 |
17 | # Dependency directories (remove the comment below to include it)
18 | # vendor/
19 |
20 | ### Go Patch ###
21 | /vendor/
22 | /Godeps/
23 |
24 | ### Intellij+all ###
25 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
26 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
27 |
28 | # User-specific stuff
29 | .idea/**/workspace.xml
30 | .idea/**/tasks.xml
31 | .idea/**/usage.statistics.xml
32 | .idea/**/dictionaries
33 | .idea/**/shelf
34 |
35 | # Generated files
36 | .idea/**/contentModel.xml
37 |
38 | # Sensitive or high-churn files
39 | .idea/**/dataSources/
40 | .idea/**/dataSources.ids
41 | .idea/**/dataSources.local.xml
42 | .idea/**/sqlDataSources.xml
43 | .idea/**/dynamic.xml
44 | .idea/**/uiDesigner.xml
45 | .idea/**/dbnavigator.xml
46 |
47 | # Gradle
48 | .idea/**/gradle.xml
49 | .idea/**/libraries
50 |
51 | # Gradle and Maven with auto-import
52 | # When using Gradle or Maven with auto-import, you should exclude module files,
53 | # since they will be recreated, and may cause churn. Uncomment if using
54 | # auto-import.
55 | # .idea/artifacts
56 | # .idea/compiler.xml
57 | # .idea/jarRepositories.xml
58 | # .idea/modules.xml
59 | # .idea/*.iml
60 | # .idea/modules
61 | # *.iml
62 | # *.ipr
63 |
64 | # CMake
65 | cmake-build-*/
66 |
67 | # Mongo Explorer plugin
68 | .idea/**/mongoSettings.xml
69 |
70 | # File-based project format
71 | *.iws
72 |
73 | # IntelliJ
74 | out/
75 |
76 | # mpeltonen/sbt-idea plugin
77 | .idea_modules/
78 |
79 | # JIRA plugin
80 | atlassian-ide-plugin.xml
81 |
82 | # Cursive Clojure plugin
83 | .idea/replstate.xml
84 |
85 | # Crashlytics plugin (for Android Studio and IntelliJ)
86 | com_crashlytics_export_strings.xml
87 | crashlytics.properties
88 | crashlytics-build.properties
89 | fabric.properties
90 |
91 | # Editor-based Rest Client
92 | .idea/httpRequests
93 |
94 | # Android studio 3.1+ serialized cache file
95 | .idea/caches/build_file_checksums.ser
96 |
97 | ### Intellij+all Patch ###
98 | # Ignores the whole .idea folder and all .iml files
99 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
100 |
101 | .idea/
102 |
103 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
104 |
105 | *.iml
106 | modules.xml
107 | .idea/misc.xml
108 | *.ipr
109 |
110 | # Sonarlint plugin
111 | .idea/sonarlint
112 |
113 | ### macOS ###
114 | # General
115 | .DS_Store
116 | .AppleDouble
117 | .LSOverride
118 |
119 | # Icon must end with two \r
120 | Icon
121 |
122 | # Thumbnails
123 | ._*
124 |
125 | # Files that might appear in the root of a volume
126 | .DocumentRevisions-V100
127 | .fseventsd
128 | .Spotlight-V100
129 | .TemporaryItems
130 | .Trashes
131 | .VolumeIcon.icns
132 | .com.apple.timemachine.donotpresent
133 |
134 | # Directories potentially created on remote AFP share
135 | .AppleDB
136 | .AppleDesktop
137 | Network Trash Folder
138 | Temporary Items
139 | .apdisk
140 |
141 | # Exclude upload folders
142 | uploads/*
143 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:edge AS builder
2 |
3 | ENV GOOS=linux
4 | ENV CGO_CFLAGS_ALLOW="-Xpreprocessor"
5 |
6 | RUN apk add --no-cache go gcc g++ vips-dev git
7 | COPY . /build
8 | WORKDIR /build
9 | RUN go get
10 | RUN go build -a -o /build/app -ldflags="-s -w -h" .
11 |
12 | FROM alpine:latest
13 |
14 | RUN apk --no-cache add ca-certificates mailcap ffmpeg vips
15 | COPY --from=builder /build/app /app/pepic
16 | COPY html /app/html
17 | COPY static /app/static
18 | COPY etc/pepic /etc/pepic
19 | WORKDIR /app
20 |
21 | EXPOSE 8118
22 |
23 | ENTRYPOINT ["/app/pepic", "serve", "--config", "/etc/pepic/config.yml"]
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Vasily Zubarev, me@vas3k.ru
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |

4 |
PEPIC
5 |
6 |
7 | Pepic is a small self-hosted media proxy that helps me to upload, store, serve and convert pictures and videos on my own servers.
8 |
9 | Currently, I use it as a main storage for media files in my [pet-projects](https://github.com/vas3k/vas3k.club) and on [my blog](https://vas3k.blog).
10 |
11 | Pepic can upload and optimize media files in-flight to save you money and bandwidth. It's highly recommended to set ip up in combination with Cloudflare CDN for better caching.
12 |
13 | Internally it uses [ffmpeg](https://ffmpeg.org/download.html) for videos and [vips](https://libvips.github.io/libvips/install.html) for images, which makes it quite fast and supports many media file formats.
14 |
15 | Images: **JPG, PNG, GIF, WEBP, SVG, HEIF, TIFF, AVIF, etc**
16 |
17 | Video: **basically everything ffmpeg supports**
18 |
19 | Pepic is open source, however it's not meant to be used by anyone. Only if you're brave (like me). Scroll down this README for better alternatives.
20 |
21 |
22 | ## Features
23 |
24 | - **Local file storage**: Upload files as multipart/form-data or as a simple byte stream and store them to a local directory.
25 | - **Automatic GIF to video conversion**: Because GIFs suck, they slow down web pages and don't support hardware acceleration.
26 | - **Image and video transcoding and quality optimization**: Transcode and optimize media files on upload or on-the-fly. If you are doing a public storage, you can pre-set your own quality settings to save disk space and bandwidth.
27 | - **Dynamic resizing**: Easily resize images just by modifying original URL. You can automatically generate image/video previews on demand without uploading multiple versions of the file.
28 | - **High performance**: Pepic uses native libraries like `ffmpeg` and `vips` for video and image processing to ensure high performance and fast processing times.
29 | - **Local and containerized environments**: Designed to run smoothly in both local environments and within Docker containers, making it versatile for development and deployment.
30 | - **Custom configuration**: Flexible configuration options through [config.yml](etc/pepic/config.yml), allowing adjustments to image size, quality, automatic conversion, templates, etc.
31 |
32 | 
33 |
34 | ## 🤖 How to Run
35 |
36 | 1. Install `vips` and `ffmpeg` first, as they are two main external dependencies
37 |
38 | ```bash
39 | brew install vips ffmpeg
40 | ```
41 |
42 | 2. Clone this repo
43 |
44 | ```bash
45 | git clone git@github.com:vas3k/pepic.git
46 | cd pepic
47 | ```
48 |
49 | 3. Run the following command to build and start the app
50 |
51 | ```bash
52 | go run main.go serve --config ./etc/pepic/config.yml
53 | ```
54 |
55 | > ⚠️ If you're getting `invalid flag in pkg-config` error, run `brew install pkg-config` and `export CGO_CFLAGS_ALLOW="-Xpreprocessor"`. Then try `go run` again.
56 |
57 | 3. Go to [localhost:8118](http://localhost:8118) and enjoy!
58 |
59 | ## 🐳 Using Docker Compose
60 |
61 | You can find [docker-compose.example.yml](./docker-compose.example.yml) in this repo and adapt it to your own needs.
62 |
63 | 1. Get [Docker](https://www.docker.com/get-started) and [Docker Compose](https://www.digitalocean.com/community/tutorial-collections/how-to-install-docker-compose)
64 |
65 | 2. Download the Docker Compose example file and save it as `docker-compose.yml` on your local machine
66 |
67 | ```bash
68 | curl https://raw.githubusercontent.com/vas3k/pepic/master/docker-compose.example.yml -o docker-compose.yml
69 | ```
70 |
71 | 3. Now run it
72 |
73 | ```bash
74 | docker-compose up
75 | ```
76 |
77 | 4. Go to [http://localhost:8118](http://localhost:8118) and try uploading something. You should see uploaded images or videos in the local directory (`./uploads`) after that.
78 |
79 |
80 | ## 🧶 Usage
81 |
82 | ### Configuration options
83 |
84 | ```yaml
85 | global:
86 | host: 0.0.0.0
87 | port: 8118
88 | base_url: "http://0.0.0.0:8118/" # trailing slash is important
89 | secret_code: "" # secret word to protect you from strangers (don't use your password here, it's stored as plain text)
90 | max_upload_size: "500M" # number + K, M, G, T or P
91 | file_tree_split_chars: 3 # abcde.jpg -> ab/cd/e.jpg (never change this after release!)
92 |
93 | storage:
94 | type: fs # only "fs" (file system) is supported for now, but you can code your own storage class
95 | dir: uploads/ # relative or absolute path for actual files (back it up!)
96 |
97 | images:
98 | store_originals: false # use "true" if you want byte-by-byte match of uploaded files (useful for photo blogs)
99 | original_length: 1900 # long side length in px to auto-resize originals (only if store_originals=false)
100 | auto_convert: false # mime type to auto-convert uploaded images ("image/jpeg", "image/png" or false)
101 | live_resize: true # enables special URLs that return resized images (increases storage usage)
102 | jpeg_quality: 95 # default quality for any saved jpegs
103 | png_compression: 0 # 0 - default, -1 - no compression, -2 - best speed, -3 - best compression (yes, with minus)
104 | gif_convert: "video/mp4" # video format for auto-converting gifs (ignored on store_originals=true)
105 |
106 | videos:
107 | store_originals: false # use "true" if you want to store original files (browser compatibility is on you)
108 | original_length: 720 # resize uploaded videos (only if store_originals=false)
109 | live_resize: false # turned off by default to save disk space and your cpu (always returns original)
110 | auto_convert: "video/mp4" # mime type to auto-convert uploaded images (for example "video/mp4")
111 | ffmpeg:
112 | temp_dir: "/tmp" # temp directory for video transcoding
113 | preset: "slow" # ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo
114 | crf: 24 # quality factor — 0-51, where 0 is lossless, 51 — pixelated shit. 23-28 recommended.
115 | buffer_size: 1024000 # other standard ffmpeg params, you can google them
116 | video_codec: "libx264"
117 | video_bitrate: "1024k"
118 | video_profile: "main"
119 | audio_codec: "aac"
120 | audio_bitrate: "128k"
121 | mov_flags: "+faststart"
122 | pix_fmt: "yuv420p"
123 |
124 | meta: # optional, only if you use web interface
125 | image_templates: # add your custom templates here for easier copy-paste
126 | - title: "URL"
127 | template: "{{ file.Url }}"
128 | - title: "Simple Markdown"
129 | template: ""
130 | video_templates:
131 | - title: "URL"
132 | template: "{{ file.Url }}"
133 | - title: "Simple Markdown"
134 | template: ""
135 | multi_templates:
136 | - title: "2 in a row"
137 | template: "{% for file in files %} {% endfor %}"
138 |
139 | ```
140 |
141 | ### Resizing images on demand
142 |
143 | If your image URL looks like this: **`https://imgs.com/file.jpg`**
144 |
145 | Add /500/ to its URL to get 500px (on the long side) version: **`https://imgs.com/500/file.jpg`**
146 |
147 | Works only if `live_resize` option is set to `true`. If `live_resize=false` — it returns the original version. Same for video transcoding (where it's off by default).
148 |
149 | > ⚠️ **Note:** Each resized version is saved as a separate file (with the same hash). When the same version of the file is requested again, Pepic just read it from disk and does not waste CPU time resizing it again. However, if you have many resized versions stored, this can eat quite a bit of disk space. Be careful.
150 |
151 |
152 | ### Converting file formats on demand
153 |
154 | // Not implemented yet, sorry... PRs are welcome
155 |
156 |
157 | ### GIF to video conversion
158 |
159 | Because GIFs are terrible, Pepic automatically converts them to mp4 videos by default. You can change it to any other format you like using `gif_convert` setting in `config.yml`.
160 |
161 | You can disable this behavior only if you set the `store_originals=true` flag, then GIF files will be saved "as is".
162 |
163 | 
164 |
165 |
166 | ## 🚢 Production Deployment
167 |
168 | > ⚠️ If you plan to host anything bigger than a blog, always put Pepic behind a CDN. CloudFlare offers a free one if you don't hate big corporations :D
169 |
170 | Let's say, you want to host it on `https://media.mydomain.org`
171 |
172 | 1. Modify `etc/pepic/config.yml` to your taste
173 |
174 | ```yaml
175 | global:
176 | host: 0.0.0.0
177 | port: 8118 # internal host and port, leave it as it is
178 | base_url: "https://media.mydomain.org"
179 | secret_code: "secretpass"
180 | max_upload_size: "500M"
181 | ```
182 |
183 | 2. Build and run production version of the Docker container
184 |
185 | Don't forget to mount upload volume to store files on host (or you can lose those files when the container is killed).
186 |
187 | ```bash
188 | docker run -p 8118:8118 -v /host/dir/uploads:/app/uploads --restart=unless-stopped $(docker build -q .)
189 | ```
190 |
191 | If you prefer docker-compose, you can use it too. Check out the included [docker-compose.example.yml](docker-compose.example.yml).
192 | You can easily transform it into your favourite k8s config or whatever is fashionable this summer.
193 |
194 | > 👍 Don't forget to periodically backup the `/host/dir/uploads` directory just in case :)
195 |
196 | 3. Use nginx, traefik, k8s or your other favourite proxy
197 |
198 | Just proxy all calls from the domain (media.mydomain.org) to Pepic backend (0.0.0.0:8118). Don't forget to set `max file size` and `proxy timeot` directives to avoid gateway errors on big files (especially videos).
199 |
200 | Here's an example for nginx:
201 |
202 | ```nginx
203 | server {
204 | listen 80;
205 | server_name media.mydomain.org;
206 |
207 | client_max_body_size 500M;
208 | real_ip_header X-Real-IP;
209 |
210 | location / {
211 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
212 |
213 | proxy_read_timeout 300;
214 | proxy_connect_timeout 300;
215 | proxy_send_timeout 300;
216 | send_timeout 300;
217 |
218 | proxy_set_header Host $http_host;
219 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
220 | proxy_set_header X-Forwarded-Proto $scheme;
221 | proxy_redirect off;
222 | proxy_buffering off;
223 |
224 | proxy_pass http://0.0.0.0:8118;
225 | }
226 | }
227 | ```
228 |
229 | ## 😍 Contributions
230 |
231 | Contributions are welcome.
232 |
233 | Open an [Issue](https://github.com/vas3k/vas3k.club/issues) if you want to report a bug or propose an idea.
234 |
235 | ## ✅ TODO
236 |
237 | - [ ] Tests :D
238 | - [ ] Upload by URL
239 | - [ ] Crop, rotate and other useful transformations (face blur? pre-loader generator?)
240 | - [ ] Live conversion by changing file's extension
241 | - [ ] Set format and media quality during the upload (using GET/POST params?)
242 |
243 |
244 | ## 🤔 Alternatives
245 |
246 | After reading all this, you probably realized how bad it is and looking for other alternatives. Here's my recommendations:
247 |
248 | - [imgproxy](https://github.com/imgproxy/imgproxy)
249 | - [imaginary](https://github.com/h2non/imaginary)
250 | - [flyimg](https://github.com/flyimg/flyimg)
251 |
252 |
253 | ## 👩💼 License
254 |
255 | It's [MIT](LICENSE).
256 |
257 | Contact me if you have any questions — [me@vas3k.ru](mailto:me@vas3k.ru).
258 |
259 | ❤️
260 |
--------------------------------------------------------------------------------
/docker-compose.example.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 | services:
3 | app:
4 | image: ghcr.io/vas3k/pepic:${GITHUB_SHA:-latest}
5 | environment: # check out pepic/config/app.go for more env variables
6 | - BASE_URL=https://i.vas3k.ru/
7 | - STORAGE_DIR=/uploads
8 | - SECRET_CODE=wowsosecret
9 | - IMAGE_ORIGINAL_LENGTH=1400
10 | - MAX_UPLOAD_SIZE=100M
11 | volumes:
12 | - ./uploads:/uploads
13 | ports:
14 | - 8118:8118
15 | user: "1000" # set your real uid (by default only root can read the uploaded files)
16 | restart: unless-stopped
17 | logging:
18 | driver: "json-file"
19 | options:
20 | max-size: "100M"
21 |
--------------------------------------------------------------------------------
/etc/pepic/config.yml:
--------------------------------------------------------------------------------
1 | global:
2 | host: 0.0.0.0
3 | port: 8118
4 | base_url: "http://0.0.0.0:8118/" # trailing slash is important
5 | secret_code: "" # secret word to protect you from strangers (don't use your password here, it's stored as plain text)
6 | max_upload_size: "500M" # number + K, M, G, T or P
7 | file_tree_split_chars: 3 # abcde.jpg -> ab/cd/e.jpg (never change this after release!)
8 |
9 | storage:
10 | type: fs
11 | dir: uploads/
12 |
13 | images:
14 | store_originals: false # use "true" if you want byte-by-byte match of uploaded files (useful for photo blogs)
15 | original_length: 1800 # auto-resize originals (only if store_originals=false)
16 | auto_convert: false # mime type to auto-convert uploaded images ("image/jpeg", "image/png" or false)
17 | live_resize: true # enables special URLs that return resized images (increases storage usage)
18 | jpeg_quality: 95 # default quality of any saved jpeg
19 | png_compression: 0 # 0 - default, -1 - no compression, -2 - best speed, -3 - best compression (yes, with minus)
20 | gif_convert: "video/mp4" # video format for auto-converting gifs (turned off then store_originals=true)
21 |
22 | videos:
23 | store_originals: false # use "true" if you want to store original files (browser compatibility is on you)
24 | original_length: 720 # resize uploaded videos (only if store_originals=false)
25 | live_resize: false # turned off by default to save disk space and your cpu (always returns original)
26 | auto_convert: "video/mp4" # mime type to auto-convert uploaded images (for example "video/mp4")
27 | ffmpeg:
28 | temp_dir: "/tmp" # temp directory for transcoding
29 | preset: "slow" # ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo
30 | crf: 24 # quality factor — 0-51, where 0 is lossless, 51 — pixelated shit. 23-28 recommended.
31 | buffer_size: 1024000
32 | video_codec: "libx264"
33 | video_bitrate: "1024k"
34 | video_profile: "main"
35 | audio_codec: "aac"
36 | audio_bitrate: "128k"
37 | mov_flags: "+faststart"
38 | pix_fmt: "yuv420p"
39 |
40 | meta: # optional, only if you use web interface
41 | image_templates: # add your custom templates here for easier copy-paste
42 | - title: "URL"
43 | template: "{{ file.Url }}"
44 | - title: "Simple Markdown"
45 | template: ""
46 | - title: "Text Width"
47 | template: "{% verbatim %}{{{{% endverbatim %}  {% verbatim %}}}}{% endverbatim %}"
48 | - title: "Full Width"
49 | template: "{% verbatim %}{{{{% endverbatim %}.block-media.block-media__full  {% verbatim %}}}}{% endverbatim %}"
50 | - title: "Right"
51 | template: "{% verbatim %}{{{{% endverbatim %}.block-side.block-side__right  {% verbatim %}}}}{% endverbatim %}"
52 | - title: "Left"
53 | template: "{% verbatim %}{{{{% endverbatim %}.block-side.block-side__left  {% verbatim %}}}}{% endverbatim %}"
54 | - title: "75% center"
55 | template: "{% verbatim %}{{{{% endverbatim %}.block-media.block-media__body.width-75 {% for file in files %} {% endfor %} {% verbatim %}}}}{% endverbatim %}"
56 | - title: "50% center"
57 | template: "{% verbatim %}{{{{% endverbatim %}.block-media.block-media__body.width-50 {% for file in files %} {% endfor %} {% verbatim %}}}}{% endverbatim %}"
58 | video_templates:
59 | - title: "URL"
60 | template: "{{ file.Url }}"
61 | - title: "Simple Markdown"
62 | template: ""
63 | multi_templates:
64 | - title: "2 in a row"
65 | template: "{% verbatim %}{{{{% endverbatim %}.block-media.block-media__2 {% for file in files %} {% endfor %} {% verbatim %}}}}{% endverbatim %}"
66 | - title: "3 in a row"
67 | template: "{% verbatim %}{{{{% endverbatim %}.block-media.block-media__3-full {% for file in files %} {% endfor %} {% verbatim %}}}}{% endverbatim %}"
68 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/vas3k/pepic
2 |
3 | go 1.22
4 |
5 | require (
6 | github.com/flosch/pongo2 v0.0.0-20200529170236-5abacdfa4915
7 | github.com/h2non/bimg v1.1.9
8 | github.com/ilyakaznacheev/cleanenv v1.2.3
9 | github.com/labstack/echo/v4 v4.12.0
10 | github.com/spf13/cobra v1.1.1
11 | github.com/xfrr/goffmpeg v1.0.0
12 | )
13 |
14 | require (
15 | github.com/BurntSushi/toml v0.3.1 // indirect
16 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
17 | github.com/inconshreveable/mousetrap v1.0.0 // indirect
18 | github.com/joho/godotenv v1.3.0 // indirect
19 | github.com/labstack/gommon v0.4.2 // indirect
20 | github.com/mattn/go-colorable v0.1.13 // indirect
21 | github.com/mattn/go-isatty v0.0.20 // indirect
22 | github.com/spf13/pflag v1.0.5 // indirect
23 | github.com/valyala/bytebufferpool v1.0.0 // indirect
24 | github.com/valyala/fasttemplate v1.2.2 // indirect
25 | golang.org/x/crypto v0.22.0 // indirect
26 | golang.org/x/net v0.24.0 // indirect
27 | golang.org/x/sys v0.19.0 // indirect
28 | golang.org/x/text v0.14.0 // indirect
29 | golang.org/x/time v0.5.0 // indirect
30 | gopkg.in/yaml.v2 v2.2.8 // indirect
31 | olympos.io/encoding/edn v0.0.0-20200308123125-93e3b8dd0e24 // indirect
32 | )
33 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
3 | cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
4 | cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
5 | cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
6 | cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
7 | cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
8 | cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
9 | cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
10 | cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
11 | cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
12 | cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
13 | dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
14 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
15 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
16 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
17 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
18 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
19 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
20 | github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
21 | github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
22 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
23 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
24 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
25 | github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
26 | github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
27 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
28 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
29 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
30 | github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
31 | github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
32 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
33 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
34 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
35 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
36 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
37 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
38 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
39 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
40 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
41 | github.com/flosch/pongo2 v0.0.0-20200529170236-5abacdfa4915 h1:rNVrewdFbSujcoKZifC6cHJfqCTbCIR7XTLHW5TqUWU=
42 | github.com/flosch/pongo2 v0.0.0-20200529170236-5abacdfa4915/go.mod h1:fB4mx6dzqFinCxIf3a7Mf5yLk+18Bia9mPAnuejcvDA=
43 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
44 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
45 | github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
46 | github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
47 | github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
48 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
49 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
50 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
51 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
52 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
53 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
54 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
55 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
56 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
57 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
58 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
59 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
60 | github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
61 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
62 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
63 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
64 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
65 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
66 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
67 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
68 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
69 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
70 | github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
71 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
72 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
73 | github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
74 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
75 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
76 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
77 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
78 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
79 | github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
80 | github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8=
81 | github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
82 | github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
83 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
84 | github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
85 | github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
86 | github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
87 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
88 | github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
89 | github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
90 | github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
91 | github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
92 | github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
93 | github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
94 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
95 | github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
96 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
97 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
98 | github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
99 | github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
100 | github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
101 | github.com/ilyakaznacheev/cleanenv v1.2.3 h1:EjZD1ATWWHNSGjc+W0LcGuoRBdsJHN3VVQQMB2EIDDQ=
102 | github.com/ilyakaznacheev/cleanenv v1.2.3/go.mod h1:/i3yhzwZ3s7hacNERGFwvlhwXMDcaqwIzmayEhbRplk=
103 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
104 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
105 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
106 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
107 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
108 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
109 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
110 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
111 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
112 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
113 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
114 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
115 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
116 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
117 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
118 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
119 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
120 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
121 | github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
122 | github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
123 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
124 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
125 | github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
126 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
127 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
128 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
129 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
130 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
131 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
132 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
133 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
134 | github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
135 | github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
136 | github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
137 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
138 | github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
139 | github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
140 | github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
141 | github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
142 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
143 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
144 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
145 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
146 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
147 | github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
148 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
149 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
150 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
151 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
152 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
153 | github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
154 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
155 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
156 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
157 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
158 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
159 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
160 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
161 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
162 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
163 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
164 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
165 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
166 | github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
167 | github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
168 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
169 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
170 | github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
171 | github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
172 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
173 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
174 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
175 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
176 | github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4=
177 | github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
178 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
179 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
180 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
181 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
182 | github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
183 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
184 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
185 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
186 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
187 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
188 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
189 | github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
190 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
191 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
192 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
193 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
194 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
195 | github.com/xfrr/goffmpeg v1.0.0 h1:trxuLNb9ys50YlV7gTVNAII9J0r00WWqCGTE46Gc3XU=
196 | github.com/xfrr/goffmpeg v1.0.0/go.mod h1:zjLRiirHnip+/hVAT3lVE3QZ6SGynr0hcctUMNNISdQ=
197 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
198 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
199 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
200 | go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
201 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
202 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
203 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
204 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
205 | golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
206 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
207 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
208 | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
209 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
210 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
211 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
212 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
213 | golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
214 | golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
215 | golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
216 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
217 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
218 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
219 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
220 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
221 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
222 | golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
223 | golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
224 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
225 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
226 | golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
227 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
228 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
229 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
230 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
231 | golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
232 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
233 | golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
234 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
235 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
236 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
237 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
238 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
239 | golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
240 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
241 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
242 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
243 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
244 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
245 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
246 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
247 | golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
248 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
249 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
250 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
251 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
252 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
253 | golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
254 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
255 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
256 | golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
257 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
258 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
259 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
260 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
261 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
262 | golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
263 | golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
264 | golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
265 | golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
266 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
267 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
268 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
269 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
270 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
271 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
272 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
273 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
274 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
275 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
276 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
277 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
278 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
279 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
280 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
281 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
282 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
283 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
284 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
285 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
286 | golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
287 | golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
288 | golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
289 | golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
290 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
291 | golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
292 | golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
293 | golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
294 | golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
295 | golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
296 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
297 | google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
298 | google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
299 | google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
300 | google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
301 | google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
302 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
303 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
304 | google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
305 | google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
306 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
307 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
308 | google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
309 | google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
310 | google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
311 | google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
312 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
313 | google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
314 | google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
315 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
316 | google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
317 | google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
318 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
319 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
320 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
321 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
322 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
323 | gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
324 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
325 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
326 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
327 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
328 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
329 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
330 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
331 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
332 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
333 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
334 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
335 | honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
336 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
337 | olympos.io/encoding/edn v0.0.0-20200308123125-93e3b8dd0e24 h1:sreVOrDp0/ezb0CHKVek/l7YwpxPJqv+jT3izfSphA4=
338 | olympos.io/encoding/edn v0.0.0-20200308123125-93e3b8dd0e24/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=
339 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
340 |
--------------------------------------------------------------------------------
/html/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {% block title %}{% endblock %} | PEPIC
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {% block main %}{% endblock %}
17 |
18 |
19 |
--------------------------------------------------------------------------------
/html/error.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Error {{ Code }}{% endblock %}
4 |
5 | {% block main %}
6 |
9 |
10 |
11 |
12 |
{{ Code }}
13 |
{{ Message }}
14 |
15 |
16 | {% endblock %}
17 |
--------------------------------------------------------------------------------
/html/index.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Upload{% endblock %}
4 |
5 | {% block main %}
6 |
9 |
10 | {% if isAuthorized %}
11 |
12 |
13 |
🎥 🗃 🖼
Drag files or click here...
14 |
15 |
21 |
22 | {% else %}
23 |
24 |
31 |
32 | {% endif %}
33 | {% endblock %}
34 |
--------------------------------------------------------------------------------
/html/meta.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block title %}Meta{% endblock %}
4 |
5 | {% block main %}
6 | {% for file in files %}
7 |
45 | {% endfor %}
46 |
47 | {% if files|length > 1 %}
48 |
60 | {% endif %}
61 |
62 |
65 | {% endblock %}
66 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/vas3k/pepic/pepic/cmd"
7 | )
8 |
9 | func main() {
10 | err := cmd.Execute()
11 | if err != nil {
12 | log.Fatal(err)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/pepic/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/ilyakaznacheev/cleanenv"
7 | "github.com/spf13/cobra"
8 | "github.com/vas3k/pepic/pepic/config"
9 | )
10 |
11 | var (
12 | configFile string
13 | rootCmd = &cobra.Command{
14 | Use: "pepic",
15 | RunE: func(cmd *cobra.Command, args []string) error {
16 | return cmd.Usage()
17 | },
18 | }
19 | )
20 |
21 | func Execute() error {
22 | return rootCmd.Execute()
23 | }
24 |
25 | func init() {
26 | cobra.OnInitialize(initConfig)
27 | rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file")
28 |
29 | rootCmd.AddCommand(serveCmd)
30 | // Add more commands here
31 | }
32 |
33 | func initConfig() {
34 | var err error
35 |
36 | if configFile != "" {
37 | err = cleanenv.ReadConfig(configFile, &config.App)
38 | } else {
39 | err = cleanenv.ReadConfig("/etc/pepic/config.yml", &config.App)
40 | }
41 |
42 | if err != nil {
43 | log.Fatalf("config error: %s", err)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/pepic/cmd/serve.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/labstack/echo/v4"
7 | "github.com/vas3k/pepic/pepic/config"
8 | "github.com/vas3k/pepic/pepic/handler"
9 | "github.com/vas3k/pepic/pepic/processing"
10 | "github.com/vas3k/pepic/pepic/storage"
11 | "github.com/vas3k/pepic/pepic/template"
12 |
13 | "github.com/labstack/echo/v4/middleware"
14 | "github.com/spf13/cobra"
15 | )
16 |
17 | var serveCmd = &cobra.Command{
18 | Use: "serve",
19 | Short: "`serve` starts server on configured port",
20 | RunE: func(cmd *cobra.Command, args []string) error {
21 | e := echo.New()
22 |
23 | // main app handler
24 | h := &handler.PepicHandler{
25 | Processing: processing.Processing{
26 | Image: processing.NewImageBackend(),
27 | Video: processing.NewVideoBackend(),
28 | },
29 | Storage: storage.NewStorage(
30 | storage.NewFileSystemBackend(config.App.Storage.Dir),
31 | ),
32 | }
33 |
34 | // logging, limiting, panic recovery and other useful middlewares
35 | e.Use(middleware.Recover())
36 | e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
37 | Format: "${remote_ip} - [${time_rfc3339}] \"${method} ${uri}\" ${status} ${bytes_out} \"-\" \"${user_agent}\" \n",
38 | }))
39 |
40 | if config.App.Global.MaxUploadSize != "" {
41 | // limit uploads size if needed
42 | e.Use(middleware.BodyLimit(config.App.Global.MaxUploadSize))
43 | }
44 |
45 | // json/html error handler
46 | e.HTTPErrorHandler = h.ErrorHandler
47 |
48 | // template
49 | e.Renderer = template.NewTemplateRenderer("html")
50 |
51 | // serve static files
52 | e.Static("/static", "static/")
53 | e.Static("/favicon", "static/favicon")
54 | e.File("/favicon.ico", "static/favicon/favicon.ico")
55 |
56 | // register routes
57 | e.GET("/", h.Index)
58 | e.POST("/upload/multipart/", h.UploadMultipart)
59 | e.POST("/upload/bytes/", h.UploadBodyBytes)
60 | e.GET("/meta/:name", h.GetMeta)
61 | e.GET("/:length/:name", h.GetResizedFile)
62 | e.GET("/:name", h.GetOriginalFile)
63 |
64 | // start server
65 | address := fmt.Sprintf("%s:%d", config.App.Global.Host, config.App.Global.Port)
66 | return e.Start(address)
67 | },
68 | }
69 |
--------------------------------------------------------------------------------
/pepic/config/app.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | type Config struct {
4 | Global struct {
5 | Host string `yaml:"host" env:"HOST" env-default:"0.0.0.0"`
6 | Port int `yaml:"port" env:"PORT" env-default:"8118"`
7 | BaseUrl string `yaml:"base_url" env:"BASE_URL" env-default:"http://0.0.0.0:8118/"`
8 | SecretCode string `yaml:"secret_code" env:"SECRET_CODE" env-default:""`
9 | MaxUploadSize string `yaml:"max_upload_size" env:"MAX_UPLOAD_SIZE" env-default:""`
10 | FileTreeSplitChars int `yaml:"file_tree_split_chars" env:"FILE_TREE_SPLIT_CHARS" env-default:"3"`
11 | } `yaml:"global"`
12 |
13 | Storage struct {
14 | Type string `yaml:"type" env:"STORAGE_TYPE" env-default:"provider"`
15 | Dir string `yaml:"dir" env:"STORAGE_DIR" env-default:"uploads/"`
16 | } `yaml:"storage"`
17 |
18 | Images struct {
19 | StoreOriginals bool `yaml:"store_originals" env:"IMAGE_STORE_ORIGINALS" env-default:"false"`
20 | OriginalLength int `yaml:"original_length" env:"IMAGE_ORIGINAL_LENGTH" env-default:"1800"`
21 | LiveResize bool `yaml:"live_resize" env:"IMAGE_LIVE_RESIZE" env-default:"true"`
22 | AutoConvert string `yaml:"auto_convert" env:"IMAGE_AUTO_CONVERT" env-default:"false"`
23 | JPEGQuality int `yaml:"jpeg_quality" env:"IMAGE_JPEG_QUALITY" env-default:"95"`
24 | PNGCompression int `yaml:"png_compression" env:"IMAGE_PNG_COMPRESSION" env-default:"0"`
25 | GIFConvert string `yaml:"gif_convert" env:"IMAGE_GIF_CONVERT" env-default:"video/mp4"`
26 | } `yaml:"images"`
27 |
28 | Videos struct {
29 | StoreOriginals bool `yaml:"store_originals" env:"VIDEO_STORE_ORIGINALS" env-default:"false"`
30 | OriginalLength int `yaml:"original_length" env:"VIDEO_ORIGINAL_LENGTH" env-default:"480"`
31 | LiveResize bool `yaml:"live_resize" env:"VIDEO_LIVE_RESIZE" env-default:"false"`
32 | AutoConvert string `yaml:"auto_convert" env:"VIDEO_AUTO_CONVERT" env-default:"video/mp4"`
33 | FFmpeg struct {
34 | TempDir string `yaml:"temp_dir" env:"VIDEO_FFMPEG_TEMP_DIR" env-default:"/tmp"`
35 | Preset string `yaml:"preset" env:"VIDEO_FFMPEG_PRESET" env-default:"slow"`
36 | CRF int `yaml:"crf" env:"VIDEO_FFMPEG_CRF" env-default:"24"`
37 | BufferSize int `yaml:"buffer_size" env:"VIDEO_FFMPEG_BUFFER_SIZE" env-default:"1024000"`
38 | VideoCodec string `yaml:"video_codec" env:"VIDEO_FFMPEG_VIDEO_CODEC" env-default:"libx264"`
39 | VideoBitrate string `yaml:"video_bitrate" env:"VIDEO_FFMPEG_VIDEO_BITRATE" env-default:"1024k"`
40 | VideoProfile string `yaml:"video_profile" env:"VIDEO_FFMPEG_VIDEO_PROFILE" env-default:"main"`
41 | AudioCodec string `yaml:"audio_codec" env:"VIDEO_FFMPEG_AUDIO_CODEC" env-default:"aac"`
42 | AudioBitrate string `yaml:"audio_bitrate" env:"VIDEO_FFMPEG_AUDIO_BITRATE" env-default:"128k"`
43 | MovFlags string `yaml:"mov_flags" env:"VIDEO_FFMPEG_MOV_FLAGS" env-default:"+faststart"`
44 | PixFmt string `yaml:"pix_fmt" env:"VIDEO_FFMPEG_PIX_FMT" env-default:"yuv420p"`
45 | } `yaml:"ffmpeg"`
46 | } `yaml:"videos"`
47 |
48 | Meta struct {
49 | ImageTemplates []struct {
50 | Title string `yaml:"title"`
51 | Template string `yaml:"template"`
52 | } `yaml:"image_templates" env:"META_IMAGE_TEMPLATES"`
53 |
54 | VideoTemplates []struct {
55 | Title string `yaml:"title"`
56 | Template string `yaml:"template"`
57 | } `yaml:"video_templates" env:"META_VIDEO_TEMPLATES"`
58 |
59 | MultiTemplates []struct {
60 | Title string `yaml:"title"`
61 | Template string `yaml:"template"`
62 | } `yaml:"multi_templates" env:"META_MULTI_TEMPLATES"`
63 | } `yaml:"meta"`
64 | }
65 |
66 | var App Config
67 |
--------------------------------------------------------------------------------
/pepic/entity/file.go:
--------------------------------------------------------------------------------
1 | package entity
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/vas3k/pepic/pepic/config"
7 | )
8 |
9 | type ProcessingFile struct {
10 | Filename string
11 | Mime string
12 | Path string
13 | Bytes []byte
14 | Size int64
15 | }
16 |
17 | func (p *ProcessingFile) Url() string {
18 | return config.App.Global.BaseUrl + p.Filename
19 | }
20 |
21 | func (p *ProcessingFile) IsGIF() bool {
22 | return p.Mime == "image/gif"
23 | }
24 |
25 | func (p *ProcessingFile) IsImage() bool {
26 | return strings.HasPrefix(p.Mime, "image/")
27 | }
28 |
29 | func (p *ProcessingFile) IsVideo() bool {
30 | return strings.HasPrefix(p.Mime, "video/")
31 | }
32 |
--------------------------------------------------------------------------------
/pepic/handler/errors.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/http"
7 |
8 | "github.com/labstack/echo/v4"
9 | )
10 |
11 | type JSONError struct {
12 | Code int `json:"code"`
13 | Message string `json:"message"`
14 | }
15 |
16 | func (h *PepicHandler) ErrorHandler(err error, c echo.Context) {
17 | code := http.StatusInternalServerError
18 | message := err.Error()
19 | if httpError, ok := err.(*echo.HTTPError); ok {
20 | code = httpError.Code
21 | message = fmt.Sprintf("%v", httpError.Message)
22 | }
23 |
24 | log.Printf("Error %d: %s", code, message)
25 |
26 | acceptContent := c.Request().Header.Get("Accept")
27 | if acceptContent == "application/json" {
28 | c.JSON(code, struct {
29 | error JSONError
30 | }{
31 | error: JSONError{
32 | Code: code,
33 | Message: message,
34 | },
35 | })
36 | } else {
37 | c.Render(code, "error.html", map[string]interface{}{
38 | "Code": code,
39 | "Message": message,
40 | })
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/pepic/handler/handler.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/labstack/echo/v4"
9 | "github.com/vas3k/pepic/pepic/config"
10 | "github.com/vas3k/pepic/pepic/processing"
11 | "github.com/vas3k/pepic/pepic/storage"
12 | )
13 |
14 | type PepicHandler struct {
15 | Processing processing.Processing
16 | Storage storage.Storage
17 | }
18 |
19 | const SecretCodeKey = "code"
20 | const SecretCodeCookieTTL = 30 * 24 * time.Hour
21 |
22 | // Auth for poor people
23 | // We simply store secret code in http-only cookies. Works for us.
24 | // You'd better think about extra protection layer on top.
25 | // API gateway or nginx + basic auth will suffice.
26 | func (h *PepicHandler) checkSecretCode(c echo.Context) (string, error) {
27 | var code string
28 |
29 | // ignore code check if it's not configured
30 | if config.App.Global.SecretCode != "" {
31 | cookie, err := c.Cookie(SecretCodeKey)
32 | if err != nil || cookie.Value == "" {
33 | code = c.QueryParam(SecretCodeKey)
34 | if code == "" {
35 | code = c.FormValue(SecretCodeKey)
36 | }
37 | } else {
38 | code = cookie.Value
39 | }
40 |
41 | if code != config.App.Global.SecretCode {
42 | return code, errors.New("secret code is invalid")
43 | }
44 |
45 | newCookie := new(http.Cookie)
46 | newCookie.Name = SecretCodeKey
47 | newCookie.Value = code
48 | newCookie.Expires = time.Now().Add(SecretCodeCookieTTL)
49 | newCookie.HttpOnly = true
50 | c.SetCookie(newCookie)
51 | }
52 |
53 | return code, nil
54 | }
55 |
--------------------------------------------------------------------------------
/pepic/handler/index.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/labstack/echo/v4"
7 | )
8 |
9 | // GET /
10 | // Index page
11 | // Shows a form for uploading a file or entering a secret code (if configured)
12 | func (h *PepicHandler) Index(c echo.Context) error {
13 | code, codeErr := h.checkSecretCode(c)
14 | return c.Render(http.StatusOK, "index.html", map[string]interface{}{
15 | "isAuthorized": codeErr == nil,
16 | "secretCode": code,
17 | })
18 | }
19 |
--------------------------------------------------------------------------------
/pepic/handler/meta.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 |
7 | "github.com/labstack/echo/v4"
8 | "github.com/vas3k/pepic/pepic/config"
9 | "github.com/vas3k/pepic/pepic/entity"
10 | )
11 |
12 | // GET /meta/:name
13 | // Render HTML page with uploaded images/videos and pre-defined templates for them
14 | func (h *PepicHandler) GetMeta(c echo.Context) error {
15 | names := strings.Split(c.Param("name"), ",")
16 | var files []*entity.ProcessingFile
17 |
18 | for _, name := range names {
19 | file, err := h.Storage.GetFile("orig", name)
20 | if err != nil {
21 | return echo.NewHTTPError(http.StatusNotFound, "File not found")
22 | }
23 | files = append(files, file)
24 | }
25 |
26 | return c.Render(http.StatusOK, "meta.html", map[string]interface{}{
27 | "files": files,
28 | "host": c.Request().URL,
29 | "meta": config.App.Meta,
30 | })
31 | }
32 |
--------------------------------------------------------------------------------
/pepic/handler/proxy.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "path"
7 | "strconv"
8 |
9 | "github.com/labstack/echo/v4"
10 | "github.com/vas3k/pepic/pepic/config"
11 | "github.com/vas3k/pepic/pepic/entity"
12 | )
13 |
14 | const MinLength = 200
15 |
16 | // GET /:name
17 | // Returns originally stored file
18 | func (h *PepicHandler) GetOriginalFile(c echo.Context) error {
19 | file, err := h.Storage.GetFile("orig", c.Param("name"))
20 | if err != nil {
21 | return echo.NewHTTPError(http.StatusNotFound, "File not found")
22 | }
23 |
24 | return h.Storage.Proxy(c, file.Path)
25 | }
26 |
27 | // GET /:length/:name
28 | // Resizes the file, stores and returns it
29 | func (h *PepicHandler) GetResizedFile(c echo.Context) error {
30 | lengthString := c.Param("length")
31 | if lengthString == "full" {
32 | return h.GetOriginalFile(c)
33 | }
34 |
35 | length, err := strconv.Atoi(lengthString)
36 | if err != nil {
37 | return echo.NewHTTPError(http.StatusBadRequest, "Bad 'length' parameter. Need an integer!")
38 | }
39 |
40 | // return original image if requested size is bigger
41 | if length >= config.App.Images.OriginalLength {
42 | return h.GetOriginalFile(c)
43 | }
44 |
45 | // do not return too small images
46 | if length < MinLength {
47 | length = MinLength
48 | }
49 |
50 | // resize and store the resized one
51 | filename := c.Param("name")
52 | file, err := h.resizeFile(filename, length)
53 | if err != nil {
54 | return echo.NewHTTPError(http.StatusInternalServerError, err)
55 | }
56 |
57 | return h.Storage.Proxy(c, file.Path)
58 | }
59 |
60 | func (h *PepicHandler) resizeFile(filename string, length int) (*entity.ProcessingFile, error) {
61 | resizePath := path.Join("resize", strconv.Itoa(length))
62 | file, err := h.Storage.GetFile(resizePath, filename)
63 | if err == nil {
64 | // resized file already exists, just return it
65 | return file, nil
66 | }
67 | if file == nil {
68 | return nil, errors.New("file is empty or corrupted")
69 | }
70 |
71 | if file.IsImage() {
72 | if config.App.Images.LiveResize {
73 | err := h.Storage.ReadFileBytes(file, "orig")
74 | if err != nil {
75 | return file, err
76 | }
77 |
78 | err = h.Processing.Image.Resize(file, length)
79 | if err != nil {
80 | return file, err
81 | }
82 |
83 | err = h.Storage.StoreFile(file, resizePath)
84 | if err != nil {
85 | return file, err
86 | }
87 | }
88 | return file, nil
89 | }
90 |
91 | if file.IsVideo() {
92 | if config.App.Videos.LiveResize {
93 | err := h.Storage.ReadFileBytes(file, "orig")
94 | if err != nil {
95 | return file, err
96 | }
97 |
98 | err = h.Processing.Video.Transcode(file, length)
99 | if err != nil {
100 | return file, err
101 | }
102 |
103 | err = h.Storage.StoreFile(file, resizePath)
104 | if err != nil {
105 | return file, err
106 | }
107 | }
108 | return file, nil
109 | }
110 |
111 | return nil, errors.New("file does not exist")
112 | }
113 |
--------------------------------------------------------------------------------
/pepic/handler/upload.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "io"
5 | "log"
6 | "mime/multipart"
7 | "net/http"
8 | "strings"
9 |
10 | "github.com/labstack/echo/v4"
11 | "github.com/vas3k/pepic/pepic/config"
12 | "github.com/vas3k/pepic/pepic/entity"
13 | "github.com/vas3k/pepic/pepic/utils"
14 | )
15 |
16 | type UploadResult struct {
17 | Filename string `json:"filename"`
18 | Url string `json:"url"`
19 | }
20 |
21 | // POST /upload/multipart/
22 | // Handles multipart upload
23 | func (h *PepicHandler) UploadMultipart(c echo.Context) error {
24 | if _, err := h.checkSecretCode(c); err != nil {
25 | return echo.NewHTTPError(http.StatusUnauthorized, "Secret code required")
26 | }
27 |
28 | form, err := c.MultipartForm()
29 | if err != nil {
30 | return echo.NewHTTPError(http.StatusBadRequest, err)
31 | }
32 |
33 | var uploaded []UploadResult
34 |
35 | for _, multipartHeader := range form.File["media"] {
36 | log.Printf("Processing file: %s", multipartHeader.Filename)
37 |
38 | bytes, err := multipartToBytes(multipartHeader)
39 | if err != nil {
40 | return echo.NewHTTPError(http.StatusBadRequest, err)
41 | }
42 |
43 | result, err := h.uploadBytes(multipartHeader.Filename, bytes)
44 | if err != nil {
45 | return echo.NewHTTPError(http.StatusInternalServerError, err)
46 | }
47 |
48 | uploaded = append(uploaded, UploadResult{
49 | Filename: result.Filename,
50 | Url: result.Url(),
51 | })
52 | }
53 |
54 | if len(uploaded) == 0 {
55 | return echo.NewHTTPError(http.StatusBadRequest, "No files to upload")
56 | }
57 |
58 | return renderUploadResults(uploaded, c)
59 | }
60 |
61 | // POST /upload/bytes/
62 | // Handles raw bytes upload from body
63 | func (h *PepicHandler) UploadBodyBytes(c echo.Context) error {
64 | if _, err := h.checkSecretCode(c); err != nil {
65 | return echo.NewHTTPError(http.StatusUnauthorized, "Secret code required")
66 | }
67 |
68 | var bytes []byte
69 | if c.Request().Body != nil {
70 | bytes, _ = io.ReadAll(c.Request().Body)
71 | }
72 |
73 | result, err := h.uploadBytes("", bytes)
74 | if err != nil {
75 | return echo.NewHTTPError(http.StatusInternalServerError, err)
76 | }
77 |
78 | return renderUploadResults([]UploadResult{
79 | {Url: "/" + result.Filename},
80 | }, c)
81 | }
82 |
83 | func (h *PepicHandler) uploadBytes(filename string, bytes []byte) (*entity.ProcessingFile, error) {
84 | var err error
85 |
86 | file := &entity.ProcessingFile{
87 | Filename: filename,
88 | Mime: utils.DetectMimeType(filename, bytes),
89 | Bytes: bytes,
90 | }
91 |
92 | log.Printf("Processing %s file", file.Mime)
93 |
94 | hashedFilename, err := utils.CalculateHashName(file)
95 | if err != nil {
96 | return file, err
97 | }
98 |
99 | file.Filename = hashedFilename
100 |
101 | if !config.App.Images.StoreOriginals {
102 | if file.IsGIF() {
103 | log.Printf("Converting GIF to video...")
104 | err = h.Processing.Video.Convert(file, config.App.Images.GIFConvert)
105 | if err != nil {
106 | return file, err
107 | }
108 | }
109 |
110 | if file.IsVideo() || file.IsGIF() {
111 | log.Printf("Processing video...")
112 |
113 | err = h.Processing.Video.Transcode(file, config.App.Videos.OriginalLength)
114 | if err != nil {
115 | return file, err
116 | }
117 |
118 | if config.App.Videos.AutoConvert != "false" {
119 | err = h.Processing.Video.Convert(file, config.App.Videos.AutoConvert)
120 | if err != nil {
121 | return file, err
122 | }
123 | }
124 | }
125 |
126 | if file.IsImage() {
127 | log.Printf("Processing image...")
128 |
129 | err = h.Processing.Image.AutoRotate(file)
130 | if err != nil {
131 | return file, err
132 | }
133 |
134 | err = h.Processing.Image.Resize(file, config.App.Images.OriginalLength)
135 | if err != nil {
136 | return file, err
137 | }
138 |
139 | if config.App.Images.AutoConvert != "false" {
140 | err := h.Processing.Image.Convert(file, config.App.Images.AutoConvert)
141 | if err != nil {
142 | return file, err
143 | }
144 | }
145 | }
146 | }
147 |
148 | err = h.Storage.StoreFile(file, "orig")
149 | if err != nil {
150 | return file, err
151 | }
152 |
153 | return file, nil
154 | }
155 |
156 | func multipartToBytes(multipartFile *multipart.FileHeader) ([]byte, error) {
157 | src, err := multipartFile.Open()
158 | if err != nil {
159 | return nil, err
160 | }
161 | defer src.Close()
162 | return io.ReadAll(src)
163 | }
164 |
165 | func renderUploadResults(results []UploadResult, c echo.Context) error {
166 | accept := c.Request().Header.Get("Accept")
167 |
168 | // on json upload - return structured results
169 | if strings.HasPrefix(accept, "application/json") {
170 | var urls []string
171 | for _, result := range results {
172 | urls = append(urls, result.Url)
173 | }
174 | return c.JSON(http.StatusCreated, map[string]interface{}{
175 | "uploaded": urls,
176 | })
177 | }
178 |
179 | // on simple html upload - redirect to meta page
180 | var filenames []string
181 | for _, result := range results {
182 | filenames = append(filenames, result.Filename)
183 | }
184 | return c.Redirect(http.StatusFound, "/meta/"+strings.Join(filenames, ","))
185 | }
186 |
--------------------------------------------------------------------------------
/pepic/processing/image.go:
--------------------------------------------------------------------------------
1 | package processing
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "fmt"
7 | "image"
8 | "image/color"
9 | "image/draw"
10 | "image/jpeg"
11 | _ "image/png"
12 | "log"
13 |
14 | "github.com/h2non/bimg"
15 | "github.com/vas3k/pepic/pepic/config"
16 | "github.com/vas3k/pepic/pepic/entity"
17 | "github.com/vas3k/pepic/pepic/utils"
18 | )
19 |
20 | type ImageBackend interface {
21 | AutoRotate(file *entity.ProcessingFile) error
22 | Resize(file *entity.ProcessingFile, maxLength int) error
23 | Convert(file *entity.ProcessingFile, newMimeType string) error
24 | }
25 |
26 | type imageBackend struct {
27 | }
28 |
29 | func NewImageBackend() ImageBackend {
30 | return &imageBackend{}
31 | }
32 |
33 | func (i *imageBackend) AutoRotate(file *entity.ProcessingFile) error {
34 | log.Printf("Auto-rotate image '%s'", file.Filename)
35 | if file.Bytes == nil {
36 | return errors.New("file data is empty, try reading it first")
37 | }
38 |
39 | img := bimg.NewImage(file.Bytes)
40 | rotatedImg, err := img.AutoRotate()
41 | if err != nil {
42 | return err
43 | }
44 |
45 | file.Bytes = rotatedImg
46 |
47 | return nil
48 | }
49 |
50 | func (i *imageBackend) Resize(file *entity.ProcessingFile, maxLength int) error {
51 | log.Printf("Resizing image '%s' to %d px", file.Filename, maxLength)
52 | if file.Bytes == nil {
53 | return errors.New("file data is empty, try reading it first")
54 | }
55 |
56 | img := bimg.NewImage(file.Bytes)
57 | origSize, err := img.Size()
58 | if err != nil {
59 | return err
60 | }
61 |
62 | width, height := utils.FitSize(origSize.Width, origSize.Height, maxLength)
63 | log.Printf("Orig width '%d' height %d px", origSize.Width, origSize.Height)
64 | resizedImg, err := img.Process(bimg.Options{
65 | Width: width,
66 | Height: height,
67 | Embed: true,
68 | StripMetadata: true,
69 | Quality: config.App.Images.JPEGQuality,
70 | Compression: config.App.Images.PNGCompression,
71 | })
72 | if err != nil {
73 | return err
74 | }
75 |
76 | file.Bytes = resizedImg
77 |
78 | return nil
79 | }
80 |
81 | func (i *imageBackend) Convert(file *entity.ProcessingFile, newMimeType string) error {
82 | log.Printf("Converting image '%s' to %s", file.Filename, newMimeType)
83 | if file.Bytes == nil {
84 | return errors.New("file data is empty, try reading it first")
85 | }
86 |
87 | newImgType, err := i.mimeTypeToImageType(newMimeType)
88 | if err != nil {
89 | return err
90 | }
91 |
92 | img := bimg.NewImage(file.Bytes)
93 |
94 | // fix PNG -> JPG transparency if needed
95 | if bimg.DetermineImageType(file.Bytes) == bimg.PNG && newImgType == bimg.JPEG {
96 | img = i.fixPNGTransparency(img)
97 | }
98 |
99 | convertedImg, err := img.Process(bimg.Options{
100 | Type: newImgType,
101 | StripMetadata: true,
102 | Quality: config.App.Images.JPEGQuality,
103 | Compression: config.App.Images.PNGCompression,
104 | })
105 | if err != nil {
106 | return err
107 | }
108 |
109 | newExt, _ := utils.ExtensionByMimeType(newMimeType)
110 | file.Bytes = convertedImg
111 | file.Mime = newMimeType
112 | file.Filename = utils.ReplaceExt(file.Filename, newExt)
113 | if file.Path != "" {
114 | file.Path = utils.ReplaceExt(file.Path, newExt)
115 | }
116 |
117 | return nil
118 | }
119 |
120 | func (i imageBackend) fixPNGTransparency(img *bimg.Image) *bimg.Image {
121 | // convert to go image because bimg has no drawing features
122 | origImg, _, err := image.Decode(bytes.NewReader(img.Image()))
123 | if err != nil {
124 | return img
125 | }
126 |
127 | // draw white square and paste image on top of it
128 | newImg := image.NewRGBA(origImg.Bounds())
129 | draw.Draw(newImg, newImg.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
130 | draw.Draw(newImg, newImg.Bounds(), origImg, origImg.Bounds().Min, draw.Over)
131 |
132 | // convert back to bytes
133 | buf := new(bytes.Buffer)
134 | err = jpeg.Encode(buf, newImg, nil)
135 | if err != nil {
136 | return img
137 | }
138 | return bimg.NewImage(buf.Bytes())
139 | }
140 |
141 | func (i imageBackend) mimeTypeToImageType(mimeType string) (bimg.ImageType, error) {
142 | mapping := map[string]bimg.ImageType{
143 | "image/jpeg": bimg.JPEG,
144 | "image/pjpeg": bimg.JPEG,
145 | "image/webp": bimg.WEBP,
146 | "image/png": bimg.PNG,
147 | "image/tiff": bimg.TIFF,
148 | "image/gif": bimg.GIF,
149 | "image/svg": bimg.SVG,
150 | "image/heic": bimg.HEIF,
151 | "image/heif": bimg.HEIF,
152 | "image/avif": bimg.AVIF,
153 | }
154 | if imageType, ok := mapping[mimeType]; ok {
155 | return imageType, nil
156 | } else {
157 | return bimg.UNKNOWN, fmt.Errorf("'%s' is not supported", mimeType)
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/pepic/processing/processing.go:
--------------------------------------------------------------------------------
1 | package processing
2 |
3 | type Processing struct {
4 | Image ImageBackend
5 | Video VideoBackend
6 | }
7 |
--------------------------------------------------------------------------------
/pepic/processing/video.go:
--------------------------------------------------------------------------------
1 | package processing
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log"
7 | "os"
8 | "path"
9 |
10 | "github.com/vas3k/pepic/pepic/config"
11 | "github.com/vas3k/pepic/pepic/entity"
12 | "github.com/vas3k/pepic/pepic/utils"
13 | "github.com/xfrr/goffmpeg/transcoder"
14 | )
15 |
16 | type VideoBackend interface {
17 | Transcode(file *entity.ProcessingFile, maxLength int) error
18 | Convert(file *entity.ProcessingFile, newMimeType string) error
19 | }
20 |
21 | type videoBackend struct {
22 | }
23 |
24 | func NewVideoBackend() VideoBackend {
25 | return &videoBackend{}
26 | }
27 |
28 | func (v *videoBackend) Transcode(file *entity.ProcessingFile, maxLength int) error {
29 | log.Printf("Transcoding video '%s' to %d px", file.Filename, maxLength)
30 | if file.Bytes == nil {
31 | return errors.New("file data is empty, try reading it first")
32 | }
33 |
34 | // save bytes to disc because ffmpeg works with filenames
35 | tempOrigFile := path.Join(config.App.Videos.FFmpeg.TempDir, file.Filename)
36 | dst, err := os.Create(tempOrigFile)
37 | if err != nil {
38 | return err
39 | }
40 | defer dst.Close()
41 | defer os.Remove(tempOrigFile)
42 |
43 | _, err = dst.Write(file.Bytes)
44 | if err != nil {
45 | return err
46 | }
47 |
48 | // create temp file output
49 | tempTransFile := path.Join(config.App.Videos.FFmpeg.TempDir, fmt.Sprintf("trans_%s", file.Filename))
50 | dst, err = os.Create(tempTransFile)
51 | if err != nil {
52 | return err
53 | }
54 | defer dst.Close()
55 | defer os.Remove(tempTransFile)
56 |
57 | // create and configure video transcoder
58 | trans, err := v.initTranscoder(tempOrigFile, tempTransFile)
59 | if err != nil {
60 | return err
61 | }
62 |
63 | // add resize filter
64 | trans.MediaFile().SetVideoFilter(fmt.Sprintf("scale=trunc(oh*a/2)*2:%d", maxLength))
65 |
66 | // run transcoding and monitor the process
67 | done := v.runTranscoder(trans)
68 | err = <-done
69 | if err != nil {
70 | return err
71 | }
72 |
73 | // load transcoded video back to memory and remove temp files (deferred)
74 | file.Bytes, err = os.ReadFile(tempTransFile)
75 | if err != nil {
76 | return err
77 | }
78 |
79 | return nil
80 | }
81 |
82 | func (v *videoBackend) Convert(file *entity.ProcessingFile, newMimeType string) error {
83 | log.Printf("Converting video '%s' to %s", file.Filename, newMimeType)
84 | if file.Bytes == nil {
85 | return errors.New("file data is empty, try reading it first")
86 | }
87 |
88 | // save bytes to disc because ffmpeg works with filenames
89 | tempOrigFile := path.Join(config.App.Videos.FFmpeg.TempDir, file.Filename)
90 | dst, err := os.Create(tempOrigFile)
91 | if err != nil {
92 | return err
93 | }
94 | defer dst.Close()
95 | defer os.Remove(tempOrigFile)
96 |
97 | _, err = dst.Write(file.Bytes)
98 | if err != nil {
99 | return err
100 | }
101 |
102 | // create temp file output
103 | newExt, _ := utils.ExtensionByMimeType(newMimeType)
104 | convFilename := utils.ReplaceExt(file.Filename, newExt)
105 | tempTransFile := path.Join(config.App.Videos.FFmpeg.TempDir, fmt.Sprintf("conv_%s", convFilename))
106 | dst, err = os.Create(tempTransFile)
107 | if err != nil {
108 | return err
109 | }
110 | defer dst.Close()
111 | defer os.Remove(tempTransFile)
112 |
113 | // create and configure video transcoder
114 | trans, err := v.initTranscoder(tempOrigFile, tempTransFile)
115 | if err != nil {
116 | return err
117 | }
118 |
119 | // run transcoding and monitor the process
120 | done := v.runTranscoder(trans)
121 | err = <-done
122 | if err != nil {
123 | return err
124 | }
125 |
126 | // load transcoded video back to memory and remove temp files (deferred)
127 | file.Bytes, err = os.ReadFile(tempTransFile)
128 | if err != nil {
129 | return err
130 | }
131 |
132 | file.Mime = newMimeType
133 | file.Filename = convFilename
134 | if file.Path != "" {
135 | file.Path = utils.ReplaceExt(file.Path, newExt)
136 | }
137 |
138 | return nil
139 | }
140 |
141 | func (v *videoBackend) initTranscoder(inputPath string, outputPath string) (*transcoder.Transcoder, error) {
142 | trans := new(transcoder.Transcoder)
143 | err := trans.Initialize(inputPath, outputPath)
144 | if err != nil {
145 | return nil, err
146 | }
147 |
148 | trans.MediaFile().SetPreset(config.App.Videos.FFmpeg.Preset)
149 | trans.MediaFile().SetCRF(uint32(config.App.Videos.FFmpeg.CRF))
150 | trans.MediaFile().SetVideoCodec(config.App.Videos.FFmpeg.VideoCodec)
151 | trans.MediaFile().SetVideoBitRate(config.App.Videos.FFmpeg.VideoBitrate)
152 | trans.MediaFile().SetVideoProfile(config.App.Videos.FFmpeg.VideoProfile)
153 | trans.MediaFile().SetAudioCodec(config.App.Videos.FFmpeg.AudioCodec)
154 | trans.MediaFile().SetAudioBitRate(config.App.Videos.FFmpeg.AudioBitrate)
155 | trans.MediaFile().SetBufferSize(config.App.Videos.FFmpeg.BufferSize)
156 | trans.MediaFile().SetMovFlags(config.App.Videos.FFmpeg.MovFlags)
157 | trans.MediaFile().SetPixFmt(config.App.Videos.FFmpeg.PixFmt)
158 |
159 | return trans, nil
160 | }
161 |
162 | func (v *videoBackend) runTranscoder(trans *transcoder.Transcoder) <-chan error {
163 | done := trans.Run(true)
164 | progress := trans.Output()
165 | for msg := range progress {
166 | log.Print(msg)
167 | }
168 | return done
169 | }
170 |
--------------------------------------------------------------------------------
/pepic/storage/fs.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "github.com/labstack/echo/v4"
5 | "io/ioutil"
6 | "os"
7 | "path"
8 | )
9 |
10 | type FileSystemBackend struct {
11 | dir string
12 | }
13 |
14 | func NewFileSystemBackend(dir string) *FileSystemBackend {
15 | fs := new(FileSystemBackend)
16 | fs.dir = dir
17 | return fs
18 | }
19 |
20 | func (fs *FileSystemBackend) PutObject(objectName string, data []byte) (string, error) {
21 | fullPath := path.Join(fs.dir, objectName)
22 |
23 | if err := os.MkdirAll(path.Dir(fullPath), os.ModePerm); err != nil {
24 | return "", err
25 | }
26 |
27 | dst, err := os.Create(fullPath)
28 |
29 | if err != nil {
30 | return "", err
31 | }
32 |
33 | defer dst.Close()
34 |
35 | if _, err = dst.Write(data); err != nil {
36 | return "", err
37 | }
38 |
39 | return fullPath, nil
40 | }
41 |
42 | func (fs *FileSystemBackend) GetObject(objectName string) ([]byte, error) {
43 | fullPath := path.Join(fs.dir, objectName)
44 |
45 | data, err := ioutil.ReadFile(fullPath)
46 | if err != nil {
47 | return nil, err
48 | }
49 |
50 | return data, nil
51 | }
52 |
53 | func (fs *FileSystemBackend) IsExists(objectName string) bool {
54 | fullPath := path.Join(fs.dir, objectName)
55 |
56 | if _, err := os.Stat(fullPath); os.IsNotExist(err) {
57 | return false
58 | }
59 |
60 | return true
61 | }
62 |
63 | func (fs *FileSystemBackend) Size(objectName string) int64 {
64 | file, err := os.Open(path.Join(fs.dir, objectName))
65 | defer file.Close()
66 | if err != nil {
67 | return 0
68 | }
69 |
70 | info, err := file.Stat()
71 | if err != nil {
72 | return 0
73 | }
74 | return info.Size()
75 | }
76 |
77 | func (fs *FileSystemBackend) Proxy(c echo.Context, objectName string) error {
78 | return c.File(path.Join(fs.dir, objectName))
79 | }
80 |
--------------------------------------------------------------------------------
/pepic/storage/storage.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "errors"
5 | "log"
6 | "mime"
7 | "path"
8 |
9 | "github.com/labstack/echo/v4"
10 | "github.com/vas3k/pepic/pepic/entity"
11 | "github.com/vas3k/pepic/pepic/utils"
12 | )
13 |
14 | type Backend interface {
15 | PutObject(objectName string, data []byte) (string, error)
16 | GetObject(objectName string) ([]byte, error)
17 | Size(objectName string) int64
18 | IsExists(objectName string) bool
19 | Proxy(c echo.Context, objectName string) error
20 | }
21 |
22 | type Storage interface {
23 | GetFile(directory string, filename string) (*entity.ProcessingFile, error)
24 | ReadFileBytes(file *entity.ProcessingFile, directories ...string) error
25 | StoreFile(file *entity.ProcessingFile, directories ...string) error
26 | Proxy(c echo.Context, objectName string) error
27 | }
28 |
29 | type storage struct {
30 | Backend Backend
31 | }
32 |
33 | func (s *storage) GetFile(directory string, filename string) (*entity.ProcessingFile, error) {
34 | canonicalFilename := utils.CanonizeFileName(filename)
35 | filePath := path.Join(directory, canonicalFilename)
36 | file := &entity.ProcessingFile{
37 | Filename: filename,
38 | Path: filePath,
39 | Mime: mime.TypeByExtension(path.Ext(canonicalFilename)),
40 | }
41 |
42 | if !s.Backend.IsExists(filePath) {
43 | log.Printf("File does not exists %s", filePath)
44 | return file, errors.New("file does not exists")
45 | }
46 |
47 | file.Size = s.Backend.Size(filePath)
48 |
49 | return file, nil
50 | }
51 |
52 | func (s *storage) ReadFileBytes(file *entity.ProcessingFile, directories ...string) error {
53 | log.Printf("Reading file contents: %s", file.Filename)
54 | canonicalPath := path.Join(
55 | path.Join(directories...),
56 | utils.CanonizeFileName(file.Filename),
57 | )
58 |
59 | data, err := s.Backend.GetObject(canonicalPath)
60 | if err != nil {
61 | log.Fatalf("Error getting file '%s' from storage: %s", canonicalPath, err)
62 | return err
63 | }
64 |
65 | file.Bytes = data
66 | file.Size = int64(len(data))
67 |
68 | return nil
69 | }
70 |
71 | func (s *storage) StoreFile(file *entity.ProcessingFile, directories ...string) error {
72 | log.Printf("Storing file data: %s", file.Filename)
73 | canonicalPath := path.Join(
74 | path.Join(directories...),
75 | utils.CanonizeFileName(file.Filename),
76 | )
77 |
78 | _, err := s.Backend.PutObject(canonicalPath, file.Bytes)
79 | if err != nil {
80 | log.Fatalf("Error writing file '%s' to storage: %s", canonicalPath, err)
81 | return err
82 | }
83 |
84 | file.Path = canonicalPath
85 |
86 | return nil
87 | }
88 |
89 | func (s *storage) Proxy(c echo.Context, objectName string) error {
90 | return s.Backend.Proxy(c, objectName)
91 | }
92 |
93 | func NewStorage(backend Backend) Storage {
94 | return &storage{
95 | Backend: backend,
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/pepic/template/renderer.go:
--------------------------------------------------------------------------------
1 | package template
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "io"
7 | "os"
8 | "path"
9 | "path/filepath"
10 |
11 | "github.com/flosch/pongo2"
12 | "github.com/labstack/echo/v4"
13 | "github.com/vas3k/pepic/pepic/entity"
14 | )
15 |
16 | type Renderer struct {
17 | templates map[string]*pongo2.Template
18 | }
19 |
20 | func (t *Renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
21 | viewContext, isMap := data.(map[string]interface{})
22 | if !isMap {
23 | return errors.New("template context should be a map")
24 | }
25 | viewContext["reverse"] = c.Echo().Reverse
26 | viewContext["renderFileTemplate"] = func(text string, file *entity.ProcessingFile) string {
27 | tpl, err := pongo2.FromString(text)
28 | if err != nil {
29 | return "ERROR"
30 | }
31 | result, _ := tpl.Execute(pongo2.Context{"file": file})
32 | return result
33 | }
34 | viewContext["renderMultipleFileTemplate"] = func(text string, files []*entity.ProcessingFile) string {
35 | tpl, err := pongo2.FromString(text)
36 | if err != nil {
37 | return "ERROR"
38 | }
39 | result, _ := tpl.Execute(pongo2.Context{"files": files})
40 | return result
41 | }
42 | viewContext["bytesHumanize"] = func(b int64) string {
43 | const unit = 1000
44 | if b < unit {
45 | return fmt.Sprintf("%d B", b)
46 | }
47 | div, exp := int64(unit), 0
48 | for n := b / unit; n >= unit; n /= unit {
49 | div *= unit
50 | exp++
51 | }
52 | return fmt.Sprintf("%.1f %cB",
53 | float64(b)/float64(div), "kMGTPE"[exp])
54 | }
55 | return t.templates[name].ExecuteWriter(viewContext, w)
56 | }
57 |
58 | func NewTemplateRenderer(templatesPath string) *Renderer {
59 | templates := make(map[string]*pongo2.Template)
60 | filepath.Walk(templatesPath, func(file string, info os.FileInfo, err error) error {
61 | if path.Ext(file) == ".html" {
62 | templates[path.Base(file)] = pongo2.Must(pongo2.FromFile(file))
63 | }
64 | return nil
65 | })
66 |
67 | renderer := new(Renderer)
68 | renderer.templates = templates
69 | return renderer
70 | }
71 |
--------------------------------------------------------------------------------
/pepic/utils/file.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "crypto/sha256"
5 | "encoding/hex"
6 | "log"
7 | "mime"
8 | "path"
9 | "strings"
10 |
11 | "github.com/vas3k/pepic/pepic/config"
12 | "github.com/vas3k/pepic/pepic/entity"
13 | )
14 |
15 | var canonicalExtensions = map[string]string{
16 | ".jpeg": ".jpg",
17 | ".jpe": ".jpg",
18 | ".jfif": ".jpg",
19 | ".qt": ".mov",
20 | ".moov": ".mov",
21 | ".f4v": ".mp4",
22 | }
23 |
24 | func ExtensionByMimeType(mimeType string) (string, error) {
25 | exts, err := mime.ExtensionsByType(mimeType)
26 | if err != nil {
27 | return "", err
28 | }
29 |
30 | var ext string
31 | if val, ok := canonicalExtensions[exts[0]]; ok {
32 | ext = val
33 | } else {
34 | ext = exts[0]
35 | }
36 |
37 | return ext, nil
38 | }
39 |
40 | func SplitFileNameNGrams(filename string, n int, stop int) string {
41 | ext := path.Ext(filename)
42 | base := strings.TrimSuffix(filename, ext)
43 |
44 | var resultBuilder strings.Builder
45 | resultBuilder.Grow(len(base) + 5)
46 | for i, r := range base {
47 | resultBuilder.WriteRune(r)
48 | if (i+1)%n == 0 && i < len(base)-1 {
49 | resultBuilder.WriteRune('/')
50 | }
51 | if i >= stop {
52 | resultBuilder.WriteString(base[i+1:])
53 | break
54 | }
55 | }
56 | return resultBuilder.String() + ext
57 | }
58 |
59 | func ReplaceExt(filename string, newExt string) string {
60 | ext := path.Ext(filename)
61 | return filename[:len(filename)-len(ext)] + newExt
62 | }
63 |
64 | func CalculateHashName(file *entity.ProcessingFile) (string, error) {
65 | log.Printf("Calculating file name: %s", file.Filename)
66 | ext, err := ExtensionByMimeType(file.Mime)
67 | if err != nil {
68 | return "", err
69 | }
70 |
71 | sum := sha256.Sum256(file.Bytes)
72 |
73 | return strings.ToLower(hex.EncodeToString(sum[:]) + ext), nil
74 | }
75 |
76 | func CanonizeFileName(filename string) string {
77 | return SplitFileNameNGrams(filename, config.App.Global.FileTreeSplitChars, 10)
78 | }
79 |
--------------------------------------------------------------------------------
/pepic/utils/media.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "mime"
5 | "net/http"
6 | "path"
7 | )
8 |
9 | func DetectMimeType(filename string, data []byte) string {
10 | mimeType := http.DetectContentType(data)
11 | if mimeType == "application/octet-stream" {
12 | mimeType = mime.TypeByExtension(path.Ext(filename))
13 | }
14 | return mimeType
15 | }
16 |
17 | func FitSize(origWidth int, origHeight int, length int) (int, int) {
18 | if origWidth > origHeight {
19 | return length, (origHeight * length) / origWidth
20 | } else if origWidth < origHeight {
21 | return (origWidth * length) / origHeight, length
22 | } else {
23 | return length, length
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/static/css/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --sans-font: "Ubuntu", Helvetica, Verdana, sans-serif;
3 |
4 | --bg-color: #4D535A;
5 |
6 | --text-color: #FCFBF8;
7 | --brighter-text-color: #FFFFFF;
8 |
9 | --link-color: #FCFBF8;
10 | --link-hover-color: #FFFFFF;
11 | --visited-link-color: #FCFBF8;
12 |
13 | --block-bg-color: #22272C;
14 | --block-shadow: 10px 25px 40px rgba(128, 137, 149, 0.2);
15 | --block-border-radius: 50px;
16 |
17 | --button-color: #ededf6;
18 | --button-bg-color: #573DAB;
19 | --button-border: solid 2px #573DAB;
20 | --button-hover-color: #573DAB;
21 | --button-hover-bg-color: #ededf6;
22 | --button-hover-border: solid 2px #573DAB;
23 | --button-border-radius: 25px;
24 | }
25 |
26 | html, body {
27 | font-family: var(--sans-font);
28 | font-size: 18px;
29 | font-weight: 400;
30 | line-height: 1.42;
31 | color: var(--text-color);
32 | background-color: var(--bg-color);
33 | text-rendering: optimizeLegibility;
34 | -webkit-font-smoothing: antialiased;
35 | -moz-osx-font-smoothing: grayscale;
36 | position: relative;
37 | margin: 0;
38 | padding: 0;
39 | width: 100%;
40 | box-sizing: border-box;
41 | }
42 |
43 | a {
44 | color: var(--link-color);
45 | transition: color linear .1s;
46 | }
47 |
48 | a:hover {
49 | color: var(--link-hover-color);
50 | }
51 |
52 | img {
53 | max-width: 100%;
54 | }
55 |
56 | .block {
57 | padding: 30px;
58 | margin-bottom: 30px;
59 | box-sizing: border-box;
60 | box-shadow: var(--block-shadow);
61 | background-color: var(--block-bg-color);
62 | border-radius: var(--block-border-radius);
63 | }
64 |
65 | .logo {
66 | font-size: 42px;
67 | font-weight: bold;
68 | width: 200px;
69 | padding: 20px;
70 | margin: 70px auto;
71 | text-align: center;
72 | }
73 |
74 | .logo img {
75 | display: inline-block;
76 | vertical-align: middle;
77 | max-width: 52px;
78 | position: relative;
79 | top: -7px;
80 | box-shadow: var(--block-shadow);
81 | }
82 |
83 | .logo a {
84 | text-decoration: none;
85 | }
86 |
87 | .button {
88 | display: inline-block;
89 | padding: 0.4em 1em;
90 | box-sizing: border-box;
91 | text-decoration: none;
92 | border-radius: var(--button-border-radius);
93 | background-color: var(--button-bg-color);
94 | border: var(--button-border);
95 | color: var(--button-color);
96 | text-align: center;
97 | cursor: pointer;
98 | line-height: 1em;
99 | font-weight: 500;
100 | }
101 |
102 | .button-round {
103 | border-radius: 50%;
104 | }
105 |
106 | .button:hover {
107 | color: var(--button-hover-color);
108 | background-color: var(--button-hover-bg-color);
109 | border: var(--button-hover-border);
110 | }
111 |
112 | .upload, .error, .auth {
113 | position: relative;
114 | display: block;
115 | margin: 0 auto;
116 | max-width: 650px;
117 | box-sizing: border-box;
118 | height: 450px;
119 | color: var(--text-color);
120 | background-color: var(--block-bg-color);
121 | box-shadow: var(--block-shadow);
122 | border-radius: var(--block-border-radius);
123 | }
124 |
125 | .upload-placeholder {
126 | font-size: 40px;
127 | opacity: 0.8;
128 | line-height: 2.0em;
129 | display: flex;
130 | justify-content: center;
131 | align-items: center;
132 | text-align: center;
133 | position: absolute;
134 | top: 0;
135 | left: 0;
136 | right: 0;
137 | bottom: 0;
138 | z-index: 1;
139 | padding-bottom: 60px;
140 | }
141 |
142 | .upload-placeholder:hover {
143 | opacity: 1.0;
144 | }
145 |
146 | .upload-form, .upload-file {
147 | position: absolute;
148 | cursor: pointer;
149 | top: 0;
150 | left: 0;
151 | right: 0;
152 | bottom: 0;
153 | z-index: 10;
154 | width: 100%;
155 | outline: none;
156 | text-align: center;
157 | box-sizing: border-box;
158 | }
159 |
160 | .upload-file {
161 | opacity: 0;
162 | }
163 |
164 | .upload-submit {
165 | font-size: 180%;
166 | position: absolute;
167 | bottom: 40px;
168 | left: 50%;
169 | transform: translateX(-50%);
170 | z-index: 100;
171 | }
172 |
173 | .auth {
174 | display: flex;
175 | justify-content: center;
176 | align-items: center;
177 | text-align: center;
178 | max-width: 400px;
179 | height: 300px;
180 | font-size: 150%;
181 | padding: 40px;
182 | }
183 |
184 | .auth-form {
185 | display: block;
186 | }
187 |
188 | .auth-input {
189 | padding: 5px;
190 | display: block;
191 | width: 100%;
192 | font-size: 120%;
193 | box-sizing: border-box;
194 | margin: 20px auto;
195 | text-align: center;
196 | border: var(--button-border)
197 | }
198 |
199 | .auth-submit {
200 | font-size: 100%;
201 | }
202 |
203 | .error {
204 | display: flex;
205 | justify-content: center;
206 | align-items: center;
207 | text-align: center;
208 | height: 300px;
209 | }
210 |
211 | .error-title {
212 | font-weight: 700;
213 | font-size: 400%;
214 | padding-bottom: 20px;
215 | }
216 |
217 | .error-message {
218 | font-size: 150%;
219 | }
220 |
221 | .meta {
222 | display: block;
223 | margin: 50px auto;
224 | width: 100%;
225 | max-width: 800px;
226 | box-sizing: border-box;
227 | padding: 40px;
228 | min-height: 450px;
229 | color: var(--text-color);
230 | background-color: var(--block-bg-color);
231 | box-shadow: var(--block-shadow);
232 | border-radius: var(--block-border-radius);
233 | word-break: break-word;
234 | }
235 |
236 | .meta-media {
237 | display: block;
238 | width: 100%;
239 | margin: 0 auto;
240 | border: var(--button-border);
241 | text-align: center;
242 | border-width: 5px;
243 | box-sizing: border-box;
244 | }
245 |
246 | .meta-media:hover {
247 | border-color: var(--button-hover-bg-color);
248 | }
249 |
250 | .meta-title {
251 | display: block;
252 | text-align: center;
253 | font-size: 130%;
254 | padding: 30px 0;
255 | }
256 |
257 | .meta-info {
258 | display: block;
259 | text-align: center;
260 | opacity: 0.7;
261 | padding-bottom: 30px;
262 | }
263 |
264 | .meta-blocks {
265 | width: 100%;
266 | }
267 |
268 | .meta-block {
269 | display: block;
270 | padding-top: 20px;
271 | }
272 |
273 | .meta-block-title {
274 | display: block;
275 | padding-bottom: 10px;
276 | font-size: 110%;
277 | }
278 |
279 | .meta-block-template {
280 | width: 100%;
281 | height: 60px;
282 | padding: 10px;
283 | box-sizing: border-box;
284 | }
--------------------------------------------------------------------------------
/static/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vas3k/pepic/559ba26db60d8433fb0b055e2aef28be6f52bc35/static/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/static/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vas3k/pepic/559ba26db60d8433fb0b055e2aef28be6f52bc35/static/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/static/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vas3k/pepic/559ba26db60d8433fb0b055e2aef28be6f52bc35/static/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/static/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vas3k/pepic/559ba26db60d8433fb0b055e2aef28be6f52bc35/static/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/static/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vas3k/pepic/559ba26db60d8433fb0b055e2aef28be6f52bc35/static/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/static/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vas3k/pepic/559ba26db60d8433fb0b055e2aef28be6f52bc35/static/favicon/favicon.ico
--------------------------------------------------------------------------------
/static/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vas3k/pepic/559ba26db60d8433fb0b055e2aef28be6f52bc35/static/images/logo.png
--------------------------------------------------------------------------------
/static/images/screenshot1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vas3k/pepic/559ba26db60d8433fb0b055e2aef28be6f52bc35/static/images/screenshot1.png
--------------------------------------------------------------------------------
/static/images/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vas3k/pepic/559ba26db60d8433fb0b055e2aef28be6f52bc35/static/images/screenshot2.png
--------------------------------------------------------------------------------