├── .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 | ![](static/images/screenshot1.png) 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: "![]({{ file.Url }})" 130 | video_templates: 131 | - title: "URL" 132 | template: "{{ file.Url }}" 133 | - title: "Simple Markdown" 134 | template: "![]({{ file.Url }})" 135 | multi_templates: 136 | - title: "2 in a row" 137 | template: "{% for file in files %}![]({{ file.Url }}) {% 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 | ![](static/images/screenshot2.png) 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: "![]({{ file.Url }})" 46 | - title: "Text Width" 47 | template: "{% verbatim %}{{{{% endverbatim %} ![]({{ file.Url }}) {% verbatim %}}}}{% endverbatim %}" 48 | - title: "Full Width" 49 | template: "{% verbatim %}{{{{% endverbatim %}.block-media.block-media__full ![]({{ file.Url }}) {% verbatim %}}}}{% endverbatim %}" 50 | - title: "Right" 51 | template: "{% verbatim %}{{{{% endverbatim %}.block-side.block-side__right ![]({{ file.Url }}) {% verbatim %}}}}{% endverbatim %}" 52 | - title: "Left" 53 | template: "{% verbatim %}{{{{% endverbatim %}.block-side.block-side__left ![]({{ file.Url }}) {% verbatim %}}}}{% endverbatim %}" 54 | - title: "75% center" 55 | template: "{% verbatim %}{{{{% endverbatim %}.block-media.block-media__body.width-75 {% for file in files %}![]({{ file.Url }}) {% endfor %} {% verbatim %}}}}{% endverbatim %}" 56 | - title: "50% center" 57 | template: "{% verbatim %}{{{{% endverbatim %}.block-media.block-media__body.width-50 {% for file in files %}![]({{ file.Url }}) {% endfor %} {% verbatim %}}}}{% endverbatim %}" 58 | video_templates: 59 | - title: "URL" 60 | template: "{{ file.Url }}" 61 | - title: "Simple Markdown" 62 | template: "![]({{ file.Url }})" 63 | multi_templates: 64 | - title: "2 in a row" 65 | template: "{% verbatim %}{{{{% endverbatim %}.block-media.block-media__2 {% for file in files %}![]({{ file.Url }}) {% endfor %} {% verbatim %}}}}{% endverbatim %}" 66 | - title: "3 in a row" 67 | template: "{% verbatim %}{{{{% endverbatim %}.block-media.block-media__3-full {% for file in files %}![]({{ file.Url }}) {% 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 |
16 | 17 | 19 | 20 |
21 |
22 | {% else %} 23 |
24 |
25 | 29 | 30 |
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 |
8 | 9 | {% if file.IsImage %} 10 | Uploaded image preview 11 | {% endif %} 12 | {% if file.IsVideo %} 13 | 14 | {% endif %} 15 | 16 | 17 |
18 | {{ file.Url }} 19 |
20 | 21 |
22 | {{ file.Mime }} | {{ bytesHumanize(file.Size) }} 23 |
24 | 25 |
26 | {% if file.IsImage %} 27 | {% for template in meta.ImageTemplates %} 28 |
29 |
{{ template.Title }}
30 | 31 |
32 | {% endfor %} 33 | {% endif %} 34 | 35 | {% if file.IsVideo %} 36 | {% for template in meta.VideoTemplates %} 37 |
38 |
{{ template.Title }}
39 | 40 |
41 | {% endfor %} 42 | {% endif %} 43 |
44 |
45 | {% endfor %} 46 | 47 | {% if files|length > 1 %} 48 |
49 |
50 | Multiple file templates 51 |
52 | 53 | {% for template in meta.MultiTemplates %} 54 |
55 |
{{ template.Title }}
56 | 57 |
58 | {% endfor %} 59 |
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 --------------------------------------------------------------------------------