mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
424 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08408ae005 | ||
|
|
d470279fb7 | ||
|
|
8260458f8f | ||
|
|
0512dd1e86 | ||
|
|
4972019cb9 | ||
|
|
5e615cab52 | ||
|
|
64ea2b6d3b | ||
|
|
27018fa8e2 | ||
|
|
263d634801 | ||
|
|
c61c33f71c | ||
|
|
bfb30b8fed | ||
|
|
ca3e27937e | ||
|
|
50c34a4186 | ||
|
|
84875aaf82 | ||
|
|
b7a8b95820 | ||
|
|
5cad5ca11a | ||
|
|
7598de29dd | ||
|
|
af6ffe67c8 | ||
|
|
6944c4b3bf | ||
|
|
24817d5707 | ||
|
|
3d855712ee | ||
|
|
872263d253 | ||
|
|
1c491fb1c5 | ||
|
|
29374c579a | ||
|
|
206d7b6ca9 | ||
|
|
ab6ca8c894 | ||
|
|
e7f5fde051 | ||
|
|
030b358b0e | ||
|
|
4b1791d35c | ||
|
|
b788cf823d | ||
|
|
11cbc0288f | ||
|
|
2269c80b02 | ||
|
|
c922cc042e | ||
|
|
9cb6f44f72 | ||
|
|
f376cd5ab1 | ||
|
|
8b8a02b68c | ||
|
|
5759b1336a | ||
|
|
76f17cb6d7 | ||
|
|
7149c421cf | ||
|
|
353c599767 | ||
|
|
285c43dab7 | ||
|
|
bd5933f4c2 | ||
|
|
0faac08237 | ||
|
|
14f94b1566 | ||
|
|
415e2ff265 | ||
|
|
26e97358d1 | ||
|
|
0ed6fb33c5 | ||
|
|
df98e7512b | ||
|
|
99ede6477f | ||
|
|
ee0debe14a | ||
|
|
dd1e11f102 | ||
|
|
ed47493459 | ||
|
|
5b8391067d | ||
|
|
4f41a59ceb | ||
|
|
4525cb38e4 | ||
|
|
704dfc25e6 | ||
|
|
7022ebe4ca | ||
|
|
094863f0ea | ||
|
|
a6282125cf | ||
|
|
63387a09af | ||
|
|
d4c47cecf2 | ||
|
|
b839069af1 | ||
|
|
e3c98100cc | ||
|
|
afcce04650 | ||
|
|
5fb35c2d48 | ||
|
|
dfe89f0d83 | ||
|
|
c500ec071e | ||
|
|
6e47f16fe1 | ||
|
|
13e6c2dbeb | ||
|
|
9d27dfb41c | ||
|
|
9529905875 | ||
|
|
eef7fc4b51 | ||
|
|
a88435f6b6 | ||
|
|
0b22598783 | ||
|
|
40cc019a51 | ||
|
|
781094ab05 | ||
|
|
21cfe2e535 | ||
|
|
4f72684080 | ||
|
|
5ac514366e | ||
|
|
d5d6285eef | ||
|
|
0e34b41bce | ||
|
|
e157066bcc | ||
|
|
f43a63ac6e | ||
|
|
a5828fa01a | ||
|
|
662d3ff795 | ||
|
|
a97f05db3b | ||
|
|
5a439ca70c | ||
|
|
551faca3a3 | ||
|
|
c25ffe9639 | ||
|
|
900021ec1b | ||
|
|
cc1ad16688 | ||
|
|
fabac9fcd9 | ||
|
|
da170b9b16 | ||
|
|
0e417430b8 | ||
|
|
0cfac31bca | ||
|
|
0f5e8fd314 | ||
|
|
e457155950 | ||
|
|
5a98790dd3 | ||
|
|
e188f65e7c | ||
|
|
6bf3ea3c46 | ||
|
|
fa33ac57fb | ||
|
|
0bed9eea09 | ||
|
|
36e9f7a8cb | ||
|
|
b155dbef6a | ||
|
|
e48b428725 | ||
|
|
68884c9126 | ||
|
|
a713976f9c | ||
|
|
51d7306380 | ||
|
|
0b83f75ec8 | ||
|
|
0a80635943 | ||
|
|
e6a5f4b203 | ||
|
|
061b2fdbcb | ||
|
|
7922e32618 | ||
|
|
0949d10da6 | ||
|
|
0c8ad9262e | ||
|
|
f841aef82d | ||
|
|
d42e51315d | ||
|
|
e761b73cfd | ||
|
|
1a9c9a7767 | ||
|
|
eb7ce7cef1 | ||
|
|
68fd8dcd76 | ||
|
|
ce8bb2567c | ||
|
|
ab3d9bc120 | ||
|
|
432ec161c1 | ||
|
|
fcaf4a9b51 | ||
|
|
cbb7e6599b | ||
|
|
8f89a144ca | ||
|
|
50bd05b351 | ||
|
|
4e7f5401ec | ||
|
|
3fd56e8c36 | ||
|
|
daf028ab2b | ||
|
|
5467620b32 | ||
|
|
a3cae0f094 | ||
|
|
f74eea4314 | ||
|
|
04e930a766 | ||
|
|
89ac13e805 | ||
|
|
0b86d38517 | ||
|
|
7e76809860 | ||
|
|
4d46b98e7f | ||
|
|
6c1a46aee5 | ||
|
|
a7cef5a1c6 | ||
|
|
8a218a6626 | ||
|
|
542fe18dcd | ||
|
|
25f18ac951 | ||
|
|
5540438595 | ||
|
|
b25456835a | ||
|
|
f86091382f | ||
|
|
dbdf8871cc | ||
|
|
1e6926ee2d | ||
|
|
24cbe98ac2 | ||
|
|
c590b026d6 | ||
|
|
d28221aec9 | ||
|
|
df0c6c8c2d | ||
|
|
876f0f9a14 | ||
|
|
f9f0fc1d67 | ||
|
|
42e357341a | ||
|
|
edc5402aad | ||
|
|
3f61ffa6fb | ||
|
|
1c22b889fe | ||
|
|
e80f5939b2 | ||
|
|
c068088cc1 | ||
|
|
1767f371f7 | ||
|
|
5d5caf64fa | ||
|
|
e095b9419a | ||
|
|
a5ef072f56 | ||
|
|
5f6a2456d4 | ||
|
|
b0f24889f4 | ||
|
|
0a69f0fd51 | ||
|
|
43c0142682 | ||
|
|
1ff16cc54b | ||
|
|
3609cf54c0 | ||
|
|
198038f5ea | ||
|
|
b5a3df85a8 | ||
|
|
6e56c6e0d4 | ||
|
|
568f3d6007 | ||
|
|
6b505a1d06 | ||
|
|
acc7e91c55 | ||
|
|
8e43df8d95 | ||
|
|
ca81b382d3 | ||
|
|
a60d7976f5 | ||
|
|
46cf63da37 | ||
|
|
099079b962 | ||
|
|
5b8121516f | ||
|
|
23eacaa78a | ||
|
|
659292a3f1 | ||
|
|
f88756d3a8 | ||
|
|
81e7bab22a | ||
|
|
1eeb5fc2b0 | ||
|
|
126cdd73e4 | ||
|
|
2251267ae4 | ||
|
|
f3e4b6d9a2 | ||
|
|
b22379537b | ||
|
|
feb5bb0653 | ||
|
|
ebfe99be79 | ||
|
|
49c1b939d5 | ||
|
|
84a37f124b | ||
|
|
f0f48f4ddf | ||
|
|
cf15e3a9a1 | ||
|
|
927f98c66b | ||
|
|
782f6ac27e | ||
|
|
5140e4ec9a | ||
|
|
e255f9bfc5 | ||
|
|
9e9b369dc2 | ||
|
|
765dc734a4 | ||
|
|
3abe8a1103 | ||
|
|
73333074fd | ||
|
|
a415786e1c | ||
|
|
a3a1077dea | ||
|
|
58a812c2c9 | ||
|
|
7e69ec4367 | ||
|
|
4623c01896 | ||
|
|
e85322de62 | ||
|
|
090ff3da13 | ||
|
|
fe068b1bfb | ||
|
|
d3b0a685b9 | ||
|
|
245c04fc5a | ||
|
|
94acb0c6ae | ||
|
|
ad4a16cc4e | ||
|
|
dae39113f6 | ||
|
|
1e1cb8fe57 | ||
|
|
606254d941 | ||
|
|
8fd51b9e9e | ||
|
|
f90442cfb1 | ||
|
|
2a44c0f65a | ||
|
|
cd8d2b5e95 | ||
|
|
8d89b2e80a | ||
|
|
3255f8af0c | ||
|
|
ca0784fa59 | ||
|
|
827060b6ce | ||
|
|
60c069cb1f | ||
|
|
70dfea5876 | ||
|
|
ab475d65ee | ||
|
|
fa9963a737 | ||
|
|
d65d9c9a4c | ||
|
|
6f8c7a613f | ||
|
|
1b45c927b2 | ||
|
|
ab1cc896de | ||
|
|
4b91600e35 | ||
|
|
69e43e40eb | ||
|
|
930622ea3c | ||
|
|
7ac104c3c6 | ||
|
|
c47dc5ecdb | ||
|
|
a3f05c1146 | ||
|
|
04b1fa378d | ||
|
|
cbc9d9ab8c | ||
|
|
7f61c63425 | ||
|
|
3d379dec58 | ||
|
|
9968a816db | ||
|
|
8f721cd12a | ||
|
|
d45f2e1ec7 | ||
|
|
7e0f1794a5 | ||
|
|
20959fc68f | ||
|
|
a942224f46 | ||
|
|
fef16f6b28 | ||
|
|
1abc4d0365 | ||
|
|
862761d46f | ||
|
|
94743a16be | ||
|
|
f6729834b6 | ||
|
|
1b4c435900 | ||
|
|
24e6a86bf5 | ||
|
|
88f74d5e9c | ||
|
|
171f93662f | ||
|
|
c5429f7efa | ||
|
|
36847e6a06 | ||
|
|
438fd7d825 | ||
|
|
767b61a3bb | ||
|
|
dd2542dcd6 | ||
|
|
f8b63d487a | ||
|
|
fcd0feb7f6 | ||
|
|
8287cf386b | ||
|
|
8415bee5da | ||
|
|
30eb48321c | ||
|
|
7cff2e01a4 | ||
|
|
fa94341b60 | ||
|
|
a1b34d5e7b | ||
|
|
14ef3cf07c | ||
|
|
db6cddefd4 | ||
|
|
95c6ecb724 | ||
|
|
fc27f6b3f0 | ||
|
|
2ce0bb2caa | ||
|
|
94e1c006ab | ||
|
|
fc2e3586a2 | ||
|
|
bca8b6b606 | ||
|
|
bb6a3471d5 | ||
|
|
09d07e4890 | ||
|
|
8826ad8990 | ||
|
|
b350edc250 | ||
|
|
af326c0b1e | ||
|
|
edc346a6b3 | ||
|
|
1e3fc97f2b | ||
|
|
51b78ef5f3 | ||
|
|
4e4036382b | ||
|
|
daf7cb9d6b | ||
|
|
08c97f280f | ||
|
|
9c836a34ee | ||
|
|
e839430270 | ||
|
|
cf5e530ed9 | ||
|
|
49ac5e647f | ||
|
|
5a7b3ded20 | ||
|
|
bcf06f0d69 | ||
|
|
4f3fe28905 | ||
|
|
f695861731 | ||
|
|
2fee966fc8 | ||
|
|
6964124c1b | ||
|
|
f9e2d91e48 | ||
|
|
2c483e7f7c | ||
|
|
3d078108a3 | ||
|
|
794967bd9f | ||
|
|
554474776c | ||
|
|
81c5f9cd4e | ||
|
|
e4735401a7 | ||
|
|
758c3be3c5 | ||
|
|
2be9ae7868 | ||
|
|
3ee4540f7d | ||
|
|
84c1175379 | ||
|
|
ad4d87a138 | ||
|
|
5feff0d0ec | ||
|
|
08e7a2c065 | ||
|
|
a524521fdb | ||
|
|
0b2312a407 | ||
|
|
c7d9e24c00 | ||
|
|
0d827e490e | ||
|
|
6402125233 | ||
|
|
7bfbda4490 | ||
|
|
f6b357691c | ||
|
|
0c1e4e6888 | ||
|
|
c7ecdcf625 | ||
|
|
957b9b1cf4 | ||
|
|
1b4eed1a6e | ||
|
|
acd47b870f | ||
|
|
9ab8d03697 | ||
|
|
9c6f23d7cc | ||
|
|
9af00d6d58 | ||
|
|
186c09f29b | ||
|
|
70b185a37a | ||
|
|
5af01505c2 | ||
|
|
5f6ab2fc08 | ||
|
|
c8f7ea934c | ||
|
|
a4aa8dafac | ||
|
|
8ca9866440 | ||
|
|
97eff7f21d | ||
|
|
e30fd8eb9b | ||
|
|
6fa48c9222 | ||
|
|
c36c7144cb | ||
|
|
bbd0a893b1 | ||
|
|
cfea2b8d9d | ||
|
|
0fcbe821cd | ||
|
|
ca41164980 | ||
|
|
cf20e2a510 | ||
|
|
02f1231e7d | ||
|
|
6846e7941f | ||
|
|
719287c3a6 | ||
|
|
194670c3bf | ||
|
|
c4222e0981 | ||
|
|
61dc4b8bb8 | ||
|
|
df357c1008 | ||
|
|
871b52a288 | ||
|
|
8a4a043e9f | ||
|
|
c0297aa061 | ||
|
|
2aaa2eeaff | ||
|
|
5f62e5bacf | ||
|
|
ab78559710 | ||
|
|
0d75bb1d3e | ||
|
|
6441062e7f | ||
|
|
4827a45f22 | ||
|
|
40eef457ab | ||
|
|
c22a4d109b | ||
|
|
571c3e04c4 | ||
|
|
f9fb97a572 | ||
|
|
e6484bb2d5 | ||
|
|
19f51fdc1f | ||
|
|
25f7ba3ee6 | ||
|
|
a3cd1ff4e1 | ||
|
|
f62c717f79 | ||
|
|
3ed99133d4 | ||
|
|
ab25cc331d | ||
|
|
2648992a9b | ||
|
|
7bcc3d081e | ||
|
|
affd1c45ca | ||
|
|
8c7458812d | ||
|
|
053908c772 | ||
|
|
d623736f9f | ||
|
|
a3e416ff68 | ||
|
|
8bf207b9f7 | ||
|
|
e1520d5e7e | ||
|
|
4f89c9f7c6 | ||
|
|
d34fb92211 | ||
|
|
3f7b006d16 | ||
|
|
a15cf33691 | ||
|
|
4c1699df6c | ||
|
|
404535c512 | ||
|
|
2cedcecc38 | ||
|
|
ba7b02d8d4 | ||
|
|
6aa3c16646 | ||
|
|
5a03e17e00 | ||
|
|
417d145b43 | ||
|
|
e5636a2afa | ||
|
|
5291d2d129 | ||
|
|
c6f6a87499 | ||
|
|
1ef21c6ed6 | ||
|
|
6ee1932cbf | ||
|
|
83cf43a314 | ||
|
|
52c14a79a1 | ||
|
|
152db0551d | ||
|
|
3a2467ccfe | ||
|
|
5a28f3597c | ||
|
|
9ce179f91b | ||
|
|
7aeb072cc3 | ||
|
|
8a652761a3 | ||
|
|
be667575d5 | ||
|
|
59ca8a44a0 | ||
|
|
c9f0d3bfd8 | ||
|
|
06e7a1fda1 | ||
|
|
495203c112 | ||
|
|
c1f6100382 | ||
|
|
223c0de35a | ||
|
|
e3366d0b62 | ||
|
|
5100693789 | ||
|
|
faec55dd01 | ||
|
|
2871fb1ffe | ||
|
|
810d35ffbe | ||
|
|
4997b73bac | ||
|
|
ef9801b1fd | ||
|
|
1f2317dc74 |
@@ -1,64 +0,0 @@
|
||||
FROM {{ if eq "CI" .Orbit.Image }}golang:1.10-stretch{{ else }}debian:stretch-slim{{ end }}
|
||||
|
||||
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Libraries
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs all required libraries.
|
||||
# |
|
||||
|
||||
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y xvfb wkhtmltopdf pdftk unoconv ttf-mscorefonts-installer &&\
|
||||
ln -s /usr/bin/xvfb-run /usr/local/bin/xvfb-run &&\
|
||||
ln -s /usr/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf &&\
|
||||
ln -s /usr/bin/pdftk /usr/local/bin/pdftk &&\
|
||||
ln -s /usr/bin/unoconv /usr/local/bin/unoconv
|
||||
|
||||
RUN apt-get install -y curl gnupg &&\
|
||||
curl -sL https://deb.nodesource.com/setup_8.x | bash - &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y nodejs
|
||||
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y yarn bzip2 &&\
|
||||
yarn global add markdown-pdf --prefix /usr/local
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Gotenberg
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | All Gotenberg related stuff.
|
||||
# |
|
||||
|
||||
{{- if eq "CI" .Orbit.Image }}
|
||||
|
||||
WORKDIR /go/src/github.com/thecodingmachine/gotenberg
|
||||
|
||||
# Installs lint dependencies.
|
||||
RUN go get -u gopkg.in/alecthomas/gometalinter.v2 &&\
|
||||
gometalinter.v2 --install
|
||||
|
||||
# Copies our Go source.
|
||||
COPY . .
|
||||
|
||||
# Installs project dependencies.
|
||||
RUN go get -d -v ./...
|
||||
|
||||
ENTRYPOINT [".ci/docker-entrypoint.sh"]
|
||||
{{- else }}
|
||||
|
||||
COPY .ci/gotenberg /usr/bin/gotenberg
|
||||
RUN ln -s /usr/bin/gotenberg /usr/local/bin/gotenberg
|
||||
|
||||
COPY .ci/gotenberg.yml /gotenberg/gotenberg.yml
|
||||
|
||||
WORKDIR /gotenberg
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["gotenberg"]
|
||||
{{- end }}
|
||||
@@ -1,136 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/38133342-11df3bd8-340f-11e8-9fe4-50baecdceeca.png" alt="Gotenberg's logo" width="250" height="250" />
|
||||
</p>
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A stateless API for converting Markdown files, HTML files and Office documents to PDF</p>
|
||||
<p align="center">
|
||||
<a href="https://microbadger.com/images/thecodingmachine/gotenberg:{{ .Orbit.Latest }}">
|
||||
<img src="https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:{{ .Orbit.Latest }}.svg" alt="MicroBadger layers">
|
||||
</a>
|
||||
<a href="https://travis-ci.org/thecodingmachine/gotenberg">
|
||||
<img src="https://travis-ci.org/thecodingmachine/gotenberg.svg?branch={{ .Orbit.Branch }}" alt="Travis CI">
|
||||
</a>
|
||||
<a href="https://godoc.org/github.com/thecodingmachine/gotenberg">
|
||||
<img src="https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg" alt="GoDoc">
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/thecodingmachine/gotenberg">
|
||||
<img src="https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg" alt="Go Report Card">
|
||||
</a>
|
||||
<a href="https://codecov.io/gh/thecodingmachine/gotenberg/branch/{{ .Orbit.Branch }}">
|
||||
<img src="https://codecov.io/gh/thecodingmachine/gotenberg/branch/{{ .Orbit.Branch }}/graph/badge.svg" alt="Codecov">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
At TheCodingMachine, we build a lot of web applications (intranets, extranets and so on) which require to generate PDF from
|
||||
various sources. Each time, we ended up using some well known libraries like **wkhtmltopdf** or **unoconv** and kind of lost time by
|
||||
reimplementing a solution from a project to another project. Meh.
|
||||
|
||||
# Menu
|
||||
|
||||
* [Usage](#usage)
|
||||
* [Security](#security)
|
||||
* [Scalability](#scalability)
|
||||
* [Custom implementation](#custom-implementation)
|
||||
* [Clients](#clients)
|
||||
|
||||
## Usage
|
||||
|
||||
Let's say you're starting the API using this simple command:
|
||||
|
||||
```sh
|
||||
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:{{ .Orbit.Latest }}
|
||||
```
|
||||
|
||||
The API is now available on your host under `http://127.0.0.1:3000`.
|
||||
|
||||
It accepts `POST` requests with a `multipart/form-data` Content-Type. Your form data should provide one or more files to convert.
|
||||
It currently accepts the following:
|
||||
|
||||
* Markdown files
|
||||
* HTML files
|
||||
* Office documents (.docx, .doc, .odt, .pptx, .ppt, .odp and so on)
|
||||
* PDF files (if more than one file to convert)
|
||||
|
||||
**Heads up:** the API relies on the file extension to determine which library to use for conversion.
|
||||
|
||||
There are two use cases:
|
||||
|
||||
* If you send one file, it will convert it and return the resulting PDF
|
||||
* If many files, it will convert them to PDF, merge the resulting PDFs into a single PDF and return it
|
||||
|
||||
### Examples:
|
||||
|
||||
* One file
|
||||
|
||||
```sh
|
||||
$ curl --request POST \
|
||||
--url http://127.0.0.1:3000 \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form files=@file.docx \
|
||||
> result.pdf
|
||||
```
|
||||
|
||||
* Many files
|
||||
|
||||
```sh
|
||||
$ curl --request POST \
|
||||
--url http://127.0.0.1:3000 \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form files=@file.md \
|
||||
--form files=@file.html \
|
||||
--form files=@file.pdf \
|
||||
--form files=@file.docx \
|
||||
> result.pdf
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
The API does not provide any authentication mechanisms. Make sure to not put it on a public facing port and your client(s) should always
|
||||
controls what is sent to the API.
|
||||
|
||||
## Scalability
|
||||
|
||||
Some libraries like **unoconv** cannot perform concurrent conversions. That's why the API does only one conversion at a time.
|
||||
If your API is under heavy load, a request will take time to be processed.
|
||||
|
||||
Fortunately, you may pass through this limitation by scaling the API.
|
||||
|
||||
In the following example, I'll demonstrate how to do some vertical scaling (= on the same machine) with Docker Compose, but of course horizontal scaling works too!
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
|
||||
# your others services
|
||||
|
||||
gotenberg:
|
||||
image: gotenberg:1.0.0
|
||||
```
|
||||
|
||||
You may now launch your services using:
|
||||
|
||||
```bash
|
||||
docker-compose up --scale gotenberg=your_number_of_instances
|
||||
```
|
||||
|
||||
When requesting the Gotenberg service with your client(s), Docker will automatically redirect a request to a Gotenberg container
|
||||
according to the round-robin strategy.
|
||||
|
||||
## Custom implementation
|
||||
|
||||
The API relies on a simple YAML configuration file called `gotenberg.yml`. It allows you to tweak some values and even provides you
|
||||
a way to change the commands called for each kind of conversion. The configuration file should be located under `/gotenberg` in your container.
|
||||
|
||||
The default configuration is located here: [.ci/gotenberg.yml](.ci/gotenberg.yml)
|
||||
|
||||
## Clients
|
||||
|
||||
* https://github.com/thecodingmachine/gotenberg-php-client (PHP client)
|
||||
* Add your own client by submitting a [pull request](../../pulls)!
|
||||
|
||||
---
|
||||
|
||||
Would you like to update this documentation ? Feel free to open an [issue](../../issues).
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -xe
|
||||
|
||||
# Statically checking Go source for errors and warnings.
|
||||
gometalinter.v2 --disable-all -E vet -E gofmt -E misspell -E ineffassign -E goimports -E deadcode -E gocyclo --vendor ./...;
|
||||
|
||||
# Running tests according to current Gotenberg version.
|
||||
if [[ "$VERSION" == "snapshot" ]]; then
|
||||
for d in $(go list ./... | grep -v vendor); do
|
||||
go test -race -cover -covermode=atomic $d;
|
||||
done
|
||||
else
|
||||
echo "" > .ci/coverage.txt;
|
||||
for d in $(go list ./... | grep -v vendor); do
|
||||
go test -race -coverprofile=profile.out -covermode=atomic $d;
|
||||
if [ -f profile.out ]; then
|
||||
cat profile.out >> .ci/coverage.txt;
|
||||
rm profile.out;
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Builds the Linux binary.
|
||||
if [ -f .ci/gotenberg ]; then
|
||||
rm .ci/gotenberg
|
||||
fi
|
||||
|
||||
env GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" && mv gotenberg .ci/;
|
||||
|
||||
# Bye!
|
||||
exit 0;
|
||||
@@ -1,33 +0,0 @@
|
||||
# The port the application will listen to.
|
||||
port: 3000
|
||||
|
||||
logs:
|
||||
# Accepted values, in order of severity: DEBUG, INFO, WARN, ERROR, FATAL, PANIC.
|
||||
# Messages at and above the selected level will be logged.
|
||||
level: "INFO"
|
||||
|
||||
# Accepted values: text, json.
|
||||
# When a TTY is not attached, the output will be in the defined format.
|
||||
format: "text"
|
||||
|
||||
# You don't like a library which is used for a conversion? You may provide here your own implementation.
|
||||
commands:
|
||||
|
||||
markdown:
|
||||
# Duration in seconds after which the command will be killed if it has not finished.
|
||||
timeout: 30
|
||||
# The command template: you have access to FilePath and ResultFilePath variables.
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
|
||||
merge:
|
||||
timeout: 30
|
||||
# Unlike others commands' templates, you have access to FilesPaths instead of FilePath: it gathers all PDF files which should be merged.
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
.git
|
||||
46
.github/CODE_OF_CONDUCT.md
vendored
46
.github/CODE_OF_CONDUCT.md
vendored
@@ -1,46 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at neuhart.julien@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
|
||||
|
||||
[homepage]: http://contributor-covenant.org
|
||||
[version]: http://contributor-covenant.org/version/1/4/
|
||||
33
.github/CONTRIBUTING.md
vendored
33
.github/CONTRIBUTING.md
vendored
@@ -1,33 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
Hi! Thank your for considering contributing to Gotenberg. You'll
|
||||
find below useful information about how to contribute to the Gotenberg project.
|
||||
|
||||
## Contributing code
|
||||
|
||||
### Install from sources
|
||||
|
||||
1. Fork this repository
|
||||
2. Clone it to your local Go environment (requires *Go* >= 1.10)
|
||||
3. Install the latest [Orbit](https://github.com/gulien/orbit) release
|
||||
|
||||
### Working with git
|
||||
|
||||
1. Create your feature branch (`git checkout -b my-new-feature`)
|
||||
2. Commit your changes (`git commit -am 'Add some feature'`)
|
||||
3. Push to the branch (`git push origin my-new-feature`)
|
||||
4. Create a new pull request
|
||||
|
||||
## Reporting bugs and feature request
|
||||
|
||||
Your issue or feature request may already be reported!
|
||||
Please search on the [issue tracker](../../../issues) before creating one.
|
||||
|
||||
If you do not find any relevant issue or feature request, feel free to
|
||||
add a new one!
|
||||
|
||||
## Additional resources
|
||||
|
||||
* [Code of conduct](CODE_OF_CONDUCT.md)
|
||||
* [Issue template](ISSUE_TEMPLATE.md)
|
||||
* [Pull request template](PULL_REQUEST_TEMPLATE.md)
|
||||
32
.github/ISSUE_TEMPLATE.md
vendored
32
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,32 +0,0 @@
|
||||
Your issue may already be reported!
|
||||
Please search on the [issue tracker](../../../issues) before creating one.
|
||||
|
||||
## Expected Behavior
|
||||
<!--- If you're describing a bug, tell us what should happen -->
|
||||
<!--- If you're suggesting a change/improvement, tell us how it should work -->
|
||||
|
||||
## Current Behavior
|
||||
<!--- If describing a bug, tell us what happens instead of the expected behavior -->
|
||||
<!--- If suggesting a change/improvement, explain the difference from current behavior -->
|
||||
|
||||
## Possible Solution
|
||||
<!--- Not obligatory, but suggest a fix/reason for the bug, -->
|
||||
<!--- or ideas how to implement the addition or change -->
|
||||
|
||||
## Steps to Reproduce (for bugs)
|
||||
<!--- Provide a link to a live example, or an unambiguous set of steps to -->
|
||||
<!--- reproduce this bug. Include code to reproduce, if relevant -->
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
|
||||
## Context
|
||||
<!--- How has this issue affected you? What are you trying to accomplish? -->
|
||||
<!--- Providing context helps us come up with a solution that is most useful in the real world -->
|
||||
|
||||
## Your Environment
|
||||
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
||||
* Version used:
|
||||
* Operating System and version:
|
||||
* Link to your project:
|
||||
44
.github/PULL_REQUEST_TEMPLATE.md
vendored
44
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,44 +0,0 @@
|
||||
A similar PR may already be submitted!
|
||||
Please search among the [pull requests](../../../pulls) before creating one.
|
||||
|
||||
Thanks for submitting a pull request! Please provide enough information so that others can review your pull request:
|
||||
|
||||
For more information, see the [CONTRIBUTING](CONTRIBUTING.md) guide.
|
||||
|
||||
**Summary**
|
||||
|
||||
<!-- Summary of the PR -->
|
||||
|
||||
This PR fixes/implements the following **bugs/features**
|
||||
|
||||
* [ ] Bug 1
|
||||
* [ ] Bug 2
|
||||
* [ ] Feature 1
|
||||
* [ ] Feature 2
|
||||
* [ ] Breaking changes
|
||||
|
||||
<!-- You can skip this if you're fixing a typo or adding an app to the Showcase. -->
|
||||
|
||||
Explain the **motivation** for making this change. What existing problem does the pull request solve?
|
||||
|
||||
<!-- Example: When "Adding a function to do X", explain why it is necessary to have a way to do X. -->
|
||||
|
||||
**Test plan (required)**
|
||||
|
||||
Demonstrate the code is solid. Example: The exact commands you ran and their output.
|
||||
|
||||
<!-- Make sure tests pass on both Travis and AppVeyor. -->
|
||||
|
||||
**Closing issues**
|
||||
|
||||
<!-- Put `closes #XXXX` in your comment to auto-close the issue that your PR fixes (if such). -->
|
||||
Fixes #
|
||||
|
||||
**Checklist**
|
||||
|
||||
- [ ] Have you followed the guidelines in our [CONTRIBUTING](CONTRIBUTING.md) guide?
|
||||
- [ ] Have you lint your code locally prior to submission (`orbit run fmt`)?
|
||||
- [ ] Have you written new tests for your core changes, as applicable?
|
||||
- [ ] Have you successfully ran tests with your changes locally (`orbit run ci`)?
|
||||
- [ ] I have squashed any insignificant commits
|
||||
- [ ] This change has comments for package types, values, functions, and non-obvious lines of code
|
||||
18
.github/stale.yml
vendored
Normal file
18
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- bug
|
||||
- documentation
|
||||
- enhancement
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
27
.github/workflows/continuous_delivery.yml
vendored
Normal file
27
.github/workflows/continuous_delivery.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Continuous Delivery
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
|
||||
release:
|
||||
name: Release Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v2
|
||||
- name: Log in to Docker Hub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }}
|
||||
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REPOSITORY=thecodingmachine
|
||||
43
.github/workflows/continuous_integration.yml
vendored
Normal file
43
.github/workflows/continuous_integration.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v2
|
||||
- name: Run linters
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
version: v1.46
|
||||
|
||||
tests:
|
||||
needs:
|
||||
- Lint
|
||||
name: Tests
|
||||
# TODO: once arm64 actions are available, also run the tests on this architecture.
|
||||
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v2
|
||||
- name: Build testing environment
|
||||
run: make build build-tests
|
||||
- name: Run tests
|
||||
run: |
|
||||
make tests-once
|
||||
bash <(curl -s https://codecov.io/bash)
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,2 @@
|
||||
.ci/coverage.txt
|
||||
.ci/gotenberg
|
||||
/coverage.html
|
||||
/coverage.txt
|
||||
30
.golangci.yml
Normal file
30
.golangci.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- bodyclose
|
||||
- deadcode
|
||||
- errcheck
|
||||
- gofmt
|
||||
- goimports
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- misspell
|
||||
- prealloc
|
||||
- staticcheck
|
||||
- structcheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
|
||||
run:
|
||||
deadline: 5m
|
||||
issues-exit-code: 1
|
||||
tests: false
|
||||
|
||||
output:
|
||||
format: 'colored-line-number'
|
||||
print-issued-lines: true
|
||||
print-linter-name: true
|
||||
23
.travis.yml
23
.travis.yml
@@ -1,23 +0,0 @@
|
||||
sudo: required
|
||||
|
||||
language: generic
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
env:
|
||||
global:
|
||||
- secure: d6pliM7iR7KJ3TQkr1C0tHi0HgU6Vdmc/ycw11XrXs794/dgj6g9UDrhgcvT2mTzOVIUNouHx7gyEWAlwJv7p8PPyguHJfIsyqMWfaXNPoytTx1ETUiygl1G/UsvXVdry6I5tZF/U+/PJ3hBXoAvo8EOFe0/MxxvP3ghCeeCpeEwEdoYP2jbADGffywVUNI7OEdmS29A28GusXXp2cWCIZYSq5Gn5iu1ZMPaJA6SkRTXpS+iVVLUvlbz5HW09ETFfurTvsdz5Ze4JICiuQt///CR1uwdxD8K7Ils/mkenaMtja9NHA9RdQBa51Azn8iCLsObCTYc52Zb6atbCi/X4ae9M8GA8qaaLigNz5G4v1BHneJylYfWKj5GwqUA3VmRu1mlEw7d2sN2uFRmgIl5kgsYYyOVOLBOKaJWhyBhNnR4U8KeqwUkOnlSdmNZnjfexXXBEcKHen1SozpXwMnzZh8LZuN6BNYd6TVpKmjulGI3vqNMM0c4JJZ9pSm4q5FjbNGQArwCsujDDwY4bCGZSJJTPMXjYOrx4yJ1RY+0TpyV9oDU3ogEqaWnhrkgrphfiYBlDY2bEvrxMTMukLCxJb0Nru6LGCpg4X1P9hu9r0gceqUSnBgdJI4rYTNddenxlEd4j/Thqv835stJWz6YoV+rfvERuSUpntl8xvrk3U8=
|
||||
- secure: CmLh169xwt3OC7l19lDUwdZYfw+l/+XXKjTGxnuJiXHa9hAH+nc4mEUSmqHMHwjquyYE6LnSVGZ94LH8sCaGed/TMm0Lj3DZtZ0VsjYu3sKsNrVJn5WdYEah+GvNJ8i8uzvHnGk1L2fAagLpXFUB2K9UraA1CFiHs1fF5Fua7Ra0mE7lMz0MFvyvzcmc9u8nMz71K2YVsXqn+I/rv8onJCTtc/Ds0hVrBXwG5Fh0T3xHqjOhME9ZI2hRHnnQVdBojSY3USifQ//qAKowfBVc0pebEtgFpyXx/I17A6xCm0KVtnA2tNqu1+XFkjWaBzryaLE9ujhJcTRC/YK0JUZ+QxfYbD5C7jVy9Jlx1lOEjJ42B/ImNxPP+9p3wi2MWNbcbZJPxIe635fnT17lI7CLK2Qg1xjfTQ4WTEKW5IhnYMiyfRKiripFh4PCkDm8cJlTbpntsX1n0uheydbsgf/11vS7o1WGhVQ8+Jelc4DvIbBHCqienQEOLJDBwm7FiK5JbwZJ6ZAGeDM/IhGU1h9PNjVbHUyJhUxYoPYUE9QsZFT7ENCrRQpq49KOzXBRfffyJNJRh+Ep9X5YoW0dUp0MNu5B7z2+6w9X9n/C+loa11xF42bFzniCvvbSOQ37J+EngDinVxJkNPeyivR4fye2ENhIAjAEjouTJLOSXL9nAOU=
|
||||
|
||||
before_install:
|
||||
- wget -qO- https://github.com/gulien/orbit/releases/download/v3.0.0/orbit_Linux_x86_64.tar.gz | tar xvz -C .
|
||||
- sudo mv ./orbit /usr/local/bin && chmod +x /usr/local/bin/orbit
|
||||
|
||||
script:
|
||||
- orbit run ci -p "Version,${TRAVIS_BRANCH}"
|
||||
- orbit run build -p "Version,${TRAVIS_BRANCH}"
|
||||
# Uploads coverage.
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
# Let's push to Docker hub only if not in a pull request and the branch name matches the semver format.
|
||||
- if [[ "$TRAVIS_PULL_REQUEST" = false && "$TRAVIS_BRANCH" =~ ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$ ]]; then docker login -u $DOCKER_USER -p $DOCKER_PASS && docker push thecodingmachine/gotenberg:${TRAVIS_BRANCH}; fi
|
||||
46
Dockerfile
46
Dockerfile
@@ -1,46 +0,0 @@
|
||||
FROM debian:stretch-slim
|
||||
|
||||
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Libraries
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs all required libraries.
|
||||
# |
|
||||
|
||||
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y xvfb wkhtmltopdf pdftk unoconv ttf-mscorefonts-installer &&\
|
||||
ln -s /usr/bin/xvfb-run /usr/local/bin/xvfb-run &&\
|
||||
ln -s /usr/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf &&\
|
||||
ln -s /usr/bin/pdftk /usr/local/bin/pdftk &&\
|
||||
ln -s /usr/bin/unoconv /usr/local/bin/unoconv
|
||||
|
||||
RUN apt-get install -y curl gnupg &&\
|
||||
curl -sL https://deb.nodesource.com/setup_8.x | bash - &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y nodejs
|
||||
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y yarn bzip2 &&\
|
||||
yarn global add markdown-pdf --prefix /usr/local
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Gotenberg
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | All Gotenberg related stuff.
|
||||
# |
|
||||
|
||||
COPY .ci/gotenberg /usr/bin/gotenberg
|
||||
RUN ln -s /usr/bin/gotenberg /usr/local/bin/gotenberg
|
||||
|
||||
COPY .ci/gotenberg.yml /gotenberg/gotenberg.yml
|
||||
|
||||
WORKDIR /gotenberg
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["gotenberg"]
|
||||
@@ -1,50 +0,0 @@
|
||||
FROM golang:1.10-stretch
|
||||
|
||||
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Libraries
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs all required libraries.
|
||||
# |
|
||||
|
||||
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y xvfb wkhtmltopdf pdftk unoconv ttf-mscorefonts-installer &&\
|
||||
ln -s /usr/bin/xvfb-run /usr/local/bin/xvfb-run &&\
|
||||
ln -s /usr/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf &&\
|
||||
ln -s /usr/bin/pdftk /usr/local/bin/pdftk &&\
|
||||
ln -s /usr/bin/unoconv /usr/local/bin/unoconv
|
||||
|
||||
RUN apt-get install -y curl gnupg &&\
|
||||
curl -sL https://deb.nodesource.com/setup_8.x | bash - &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y nodejs
|
||||
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y yarn bzip2 &&\
|
||||
yarn global add markdown-pdf --prefix /usr/local
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Gotenberg
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | All Gotenberg related stuff.
|
||||
# |
|
||||
|
||||
WORKDIR /go/src/github.com/thecodingmachine/gotenberg
|
||||
|
||||
# Installs lint dependencies.
|
||||
RUN go get -u gopkg.in/alecthomas/gometalinter.v2 &&\
|
||||
gometalinter.v2 --install
|
||||
|
||||
# Copies our Go source.
|
||||
COPY . .
|
||||
|
||||
# Installs project dependencies.
|
||||
RUN go get -d -v ./...
|
||||
|
||||
ENTRYPOINT [".ci/docker-entrypoint.sh"]
|
||||
60
Gopkg.lock
generated
60
Gopkg.lock
generated
@@ -1,60 +0,0 @@
|
||||
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
|
||||
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/gorilla/context"
|
||||
packages = ["."]
|
||||
revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
|
||||
version = "v1.1"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/gorilla/mux"
|
||||
packages = ["."]
|
||||
revision = "53c1911da2b537f792e7cafcb446b05ffe33b996"
|
||||
version = "v1.6.1"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/justinas/alice"
|
||||
packages = ["."]
|
||||
revision = "03f45bd4b7dad4734bc4620e46a35789349abb20"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/satori/go.uuid"
|
||||
packages = ["."]
|
||||
revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3"
|
||||
version = "v1.2.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/sirupsen/logrus"
|
||||
packages = ["."]
|
||||
revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc"
|
||||
version = "v1.0.5"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/crypto"
|
||||
packages = ["ssh/terminal"]
|
||||
revision = "88942b9c40a4c9d203b82b3731787b672d6e809b"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/sys"
|
||||
packages = [
|
||||
"unix",
|
||||
"windows"
|
||||
]
|
||||
revision = "13d03a9a82fba647c21a0ef8fba44a795d0f0835"
|
||||
|
||||
[[projects]]
|
||||
name = "gopkg.in/yaml.v2"
|
||||
packages = ["."]
|
||||
revision = "86f5ed62f8a0ee96bd888d2efdfd6d4fb100a4eb"
|
||||
version = "v2.2.0"
|
||||
|
||||
[solve-meta]
|
||||
analyzer-name = "dep"
|
||||
analyzer-version = 1
|
||||
inputs-digest = "9d7a607d9b015e46c535126cbc62648ce78459507cd523d6ff0e048051cf3228"
|
||||
solver-name = "gps-cdcl"
|
||||
solver-version = 1
|
||||
50
Gopkg.toml
50
Gopkg.toml
@@ -1,50 +0,0 @@
|
||||
# Gopkg.toml example
|
||||
#
|
||||
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
|
||||
# for detailed Gopkg.toml documentation.
|
||||
#
|
||||
# required = ["github.com/user/thing/cmd/thing"]
|
||||
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
|
||||
#
|
||||
# [[constraint]]
|
||||
# name = "github.com/user/project"
|
||||
# version = "1.0.0"
|
||||
#
|
||||
# [[constraint]]
|
||||
# name = "github.com/user/project2"
|
||||
# branch = "dev"
|
||||
# source = "github.com/myfork/project2"
|
||||
#
|
||||
# [[override]]
|
||||
# name = "github.com/x/y"
|
||||
# version = "2.4.0"
|
||||
#
|
||||
# [prune]
|
||||
# non-go = false
|
||||
# go-tests = true
|
||||
# unused-packages = true
|
||||
|
||||
|
||||
[prune]
|
||||
go-tests = true
|
||||
unused-packages = true
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/satori/go.uuid"
|
||||
version = "1.2.0"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/gorilla/mux"
|
||||
version = "1.6.1"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/justinas/alice"
|
||||
|
||||
[[constraint]]
|
||||
name = "gopkg.in/yaml.v2"
|
||||
version = "2.2.0"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/sirupsen/logrus"
|
||||
version = "1.0.5"
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 TheCodingMachine
|
||||
Copyright (c) 2022 Julien Neuhart
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
152
Makefile
Normal file
152
Makefile
Normal file
@@ -0,0 +1,152 @@
|
||||
.PHONY: help
|
||||
help: ## Show the help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: it
|
||||
it: build build-tests ## Initialize the development environment
|
||||
|
||||
GOLANG_VERSION=1.18
|
||||
DOCKER_REPOSITORY=gotenberg
|
||||
GOTENBERG_VERSION=snapshot
|
||||
GOTENBERG_USER_GID=1001
|
||||
GOTENBERG_USER_UID=1001
|
||||
NOTO_COLOR_EMOJI_VERSION=v2.034 # See https://github.com/googlefonts/noto-emoji/releases.
|
||||
PDFTK_VERSION=v3.3.2 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||
GOLANGCI_LINT_VERSION=v1.46.2 # See https://github.com/golangci/golangci-lint/releases.
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the Gotenberg's Docker image
|
||||
docker build \
|
||||
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
|
||||
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
|
||||
--build-arg GOTENBERG_USER_GID=$(GOTENBERG_USER_GID) \
|
||||
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
|
||||
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
|
||||
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
|
||||
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
||||
-f build/Dockerfile .
|
||||
|
||||
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
|
||||
API_PORT=3000
|
||||
API_PORT_FROM_ENV=
|
||||
API_TIMEOUT=30s
|
||||
API_ROOT_PATH=/
|
||||
API_TRACE_HEADER=Gotenberg-Trace
|
||||
API_DISABLE_HEALTH_CHECK_LOGGING=false
|
||||
CHROMIUM_INCOGNITO=false
|
||||
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
|
||||
CHROMIUM_DISABLE_WEB_SECURITY=false
|
||||
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
||||
CHROMIUM_PROXY_SERVER=
|
||||
CHROMIUM_ALLOW_LIST=
|
||||
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
|
||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||
CHROMIUM_DISABLE_ROUTES=false
|
||||
LIBREOFFICE_DISABLES_ROUTES=false
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=auto
|
||||
PDFENGINES_ENGINES=
|
||||
PDFENGINES_DISABLE_ROUTES=false
|
||||
PROMETHEUS_NAMESPACE=gotenberg
|
||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
|
||||
PROMETHEUS_DISABLE_COLLECT=false
|
||||
UNO_LISTENER_START_TIMEOUT=10s
|
||||
UNO_LISTENER_RESTART_THRESHOLD=10
|
||||
WEBHOOK_ALLOW_LIST=
|
||||
WEBHOOK_DENY_LIST=
|
||||
WEBHOOK_ERROR_ALLOW_LIST=
|
||||
WEBHOOK_ERROR_DENY_LIST=
|
||||
WEBHOOK_MAX_RETRY=4
|
||||
WEBHOOK_RETRY_MIN_WAIT=1s
|
||||
WEBHOOK_RETRY_MAX_WAIT=30s
|
||||
WEBHOOK_CLIENT_TIMEOUT=30s
|
||||
WEBHOOK_DISABLE=false
|
||||
|
||||
.PHONY: run
|
||||
run: ## Start a Gotenberg container
|
||||
docker run --rm -it \
|
||||
-p $(API_PORT):$(API_PORT) \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
||||
gotenberg \
|
||||
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
|
||||
--api-port=$(API_PORT) \
|
||||
--api-port-from-env=$(API_PORT_FROM_ENV) \
|
||||
--api-timeout=$(API_TIMEOUT) \
|
||||
--api-root-path=$(API_ROOT_PATH) \
|
||||
--api-trace-header=$(API_TRACE_HEADER) \
|
||||
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
|
||||
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
|
||||
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
|
||||
--chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \
|
||||
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
|
||||
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
|
||||
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
|
||||
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
|
||||
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
|
||||
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
|
||||
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLES_ROUTES) \
|
||||
--log-level=$(LOG_LEVEL) \
|
||||
--log-format=$(LOG_FORMAT) \
|
||||
--pdfengines-engines=$(PDFENGINES_ENGINES) \
|
||||
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
|
||||
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
|
||||
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
|
||||
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
|
||||
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
|
||||
--uno-listener-start-timeout=$(UNO_LISTENER_START_TIMEOUT) \
|
||||
--uno-listener-restart-threshold=$(UNO_LISTENER_RESTART_THRESHOLD) \
|
||||
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
|
||||
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
|
||||
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
|
||||
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
|
||||
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
|
||||
--webhook-retry-min-wait=$(WEBHOOK_RETRY_MIN_WAIT) \
|
||||
--webhook-retry-max-wait=$(WEBHOOK_RETRY_MAX_WAIT) \
|
||||
--webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \
|
||||
--webhook-disable=$(WEBHOOK_DISABLE)
|
||||
|
||||
.PHONY: build-tests
|
||||
build-tests: ## Build the tests' Docker image
|
||||
docker build \
|
||||
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
|
||||
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
|
||||
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
|
||||
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
|
||||
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
-f test/Dockerfile .
|
||||
|
||||
.PHONY: tests
|
||||
tests: ## Start the testing environment
|
||||
docker run --rm -it \
|
||||
-v $(PWD):/tests \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
bash
|
||||
|
||||
.PHONY: tests-once
|
||||
tests-once: ## Run the tests once (prefer the "tests" command while developing)
|
||||
docker run --rm \
|
||||
-v $(PWD):/tests \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
gotest
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ## Format the code and "optimize" the dependencies
|
||||
go fmt ./...
|
||||
go mod tidy
|
||||
|
||||
.PHONY: godoc
|
||||
godoc: ## Run a webserver with Gotenberg godoc (go get golang.org/x/tools/cmd/godoc)
|
||||
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v7)
|
||||
godoc -http=:6060
|
||||
|
||||
.PHONY: release
|
||||
release: ## Build the Gotenberg's Docker image for many platforms, then push it to a Docker repository
|
||||
./scripts/release.sh \
|
||||
$(GOLANG_VERSION) \
|
||||
$(GOTENBERG_VERSION) \
|
||||
$(GOTENBERG_USER_GID) \
|
||||
$(GOTENBERG_USER_UID) \
|
||||
$(NOTO_COLOR_EMOJI_VERSION) \
|
||||
$(PDFTK_VERSION) \
|
||||
$(DOCKER_REPOSITORY)
|
||||
145
README.md
145
README.md
@@ -1,136 +1,45 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/38133342-11df3bd8-340f-11e8-9fe4-50baecdceeca.png" alt="Gotenberg's logo" width="250" height="250" />
|
||||
</p>
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A stateless API for converting Markdown files, HTML files and Office documents to PDF</p>
|
||||
<p align="center">
|
||||
<a href="https://microbadger.com/images/thecodingmachine/gotenberg:1.0.0">
|
||||
<img src="https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:1.0.0.svg" alt="MicroBadger layers">
|
||||
</a>
|
||||
<a href="https://travis-ci.org/thecodingmachine/gotenberg">
|
||||
<img src="https://travis-ci.org/thecodingmachine/gotenberg.svg?branch=1.0.0" alt="Travis CI">
|
||||
</a>
|
||||
<a href="https://godoc.org/github.com/thecodingmachine/gotenberg">
|
||||
<img src="https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg" alt="GoDoc">
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/thecodingmachine/gotenberg">
|
||||
<img src="https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg" alt="Go Report Card">
|
||||
</a>
|
||||
<a href="https://codecov.io/gh/thecodingmachine/gotenberg/branch/1.0.0">
|
||||
<img src="https://codecov.io/gh/thecodingmachine/gotenberg/branch/1.0.0/graph/badge.svg" alt="Codecov">
|
||||
</a>
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A Docker-powered stateless API for PDF files</p>
|
||||
<p align="center"><a href="https://gotenberg.dev/docs/about">Documentation</a> · 🔥 <a href="https://gotenberg.dev/docs/get-started/live-demo">Live Demo</a></p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
At TheCodingMachine, we build a lot of web applications (intranets, extranets and so on) which require to generate PDF from
|
||||
various sources. Each time, we ended up using some well known libraries like **wkhtmltopdf** or **unoconv** and kind of lost time by
|
||||
reimplementing a solution from a project to another project. Meh.
|
||||
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
|
||||
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
|
||||
|
||||
# Menu
|
||||
## Quick Start
|
||||
|
||||
* [Usage](#usage)
|
||||
* [Security](#security)
|
||||
* [Scalability](#scalability)
|
||||
* [Custom implementation](#custom-implementation)
|
||||
* [Clients](#clients)
|
||||
Open a terminal and run the following command:
|
||||
|
||||
## Usage
|
||||
|
||||
Let's say you're starting the API using this simple command:
|
||||
|
||||
```sh
|
||||
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:1.0.0
|
||||
```
|
||||
docker run --rm -p 3000:3000 gotenberg/gotenberg:7
|
||||
```
|
||||
|
||||
The API is now available on your host under `http://127.0.0.1:3000`.
|
||||
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
|
||||
|
||||
It accepts `POST` requests with a `multipart/form-data` Content-Type. Your form data should provide one or more files to convert.
|
||||
It currently accepts the following:
|
||||
|
||||
* Markdown files
|
||||
* HTML files
|
||||
* Office documents (.docx, .doc, .odt, .pptx, .ppt, .odp and so on)
|
||||
* PDF files (if more than one file to convert)
|
||||
|
||||
**Heads up:** the API relies on the file extension to determine which library to use for conversion.
|
||||
|
||||
There are two use cases:
|
||||
|
||||
* If you send one file, it will convert it and return the resulting PDF
|
||||
* If many files, it will convert them to PDF, merge the resulting PDFs into a single PDF and return it
|
||||
|
||||
### Examples:
|
||||
|
||||
* One file
|
||||
|
||||
```sh
|
||||
$ curl --request POST \
|
||||
--url http://127.0.0.1:3000 \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form files=@file.docx \
|
||||
> result.pdf
|
||||
```
|
||||
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:7
|
||||
```
|
||||
|
||||
* Many files
|
||||
The API is now available on your host at http://localhost:3000.
|
||||
|
||||
```sh
|
||||
$ curl --request POST \
|
||||
--url http://127.0.0.1:3000 \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form files=@file.md \
|
||||
--form files=@file.html \
|
||||
--form files=@file.pdf \
|
||||
--form files=@file.docx \
|
||||
> result.pdf
|
||||
```
|
||||
Head to the [documentation](https://gotenberg.dev/docs/about) to learn how to interact with it 🚀
|
||||
|
||||
## Security
|
||||
## Sponsors
|
||||
|
||||
The API does not provide any authentication mechanisms. Make sure to not put it on a public facing port and your client(s) should always
|
||||
controls what is sent to the API.
|
||||
<p align="center">
|
||||
<a href="https://thecodingmachine.com">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="429" height="210" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Scalability
|
||||
## Badges
|
||||
|
||||
Some libraries like **unoconv** cannot perform concurrent conversions. That's why the API does only one conversion at a time.
|
||||
If your API is under heavy load, a request will take time to be processed.
|
||||
|
||||
Fortunately, you may pass through this limitation by scaling the API.
|
||||
|
||||
In the following example, I'll demonstrate how to do some vertical scaling (= on the same machine) with Docker Compose, but of course horizontal scaling works too!
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
|
||||
# your others services
|
||||
|
||||
gotenberg:
|
||||
image: gotenberg:1.0.0
|
||||
```
|
||||
|
||||
You may now launch your services using:
|
||||
|
||||
```bash
|
||||
docker-compose up --scale gotenberg=your_number_of_instances
|
||||
```
|
||||
|
||||
When requesting the Gotenberg service with your client(s), Docker will automatically redirect a request to a Gotenberg container
|
||||
according to the round-robin strategy.
|
||||
|
||||
## Custom implementation
|
||||
|
||||
The API relies on a simple YAML configuration file called `gotenberg.yml`. It allows you to tweak some values and even provides you
|
||||
a way to change the commands called for each kind of conversion. The configuration file should be located under `/gotenberg` in your container.
|
||||
|
||||
The default configuration is located here: [.ci/gotenberg.yml](.ci/gotenberg.yml)
|
||||
|
||||
## Clients
|
||||
|
||||
* https://github.com/thecodingmachine/gotenberg-php-client (PHP client)
|
||||
* Add your own client by submitting a [pull request](../../pulls)!
|
||||
|
||||
---
|
||||
|
||||
Would you like to update this documentation ? Feel free to open an [issue](../../issues).
|
||||
[](https://hub.docker.com/r/gotenberg/gotenberg)
|
||||
[](https://hub.docker.com/r/thecodingmachine/gotenberg)
|
||||
[](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml)
|
||||
[](https://pkg.go.dev/github.com/gotenberg/gotenberg/v7)
|
||||
[](https://codecov.io/gh/gotenberg/gotenberg)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: {
|
||||
value: [
|
||||
...
|
||||
}
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 0
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 0
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 0
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 0
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 0
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "DEBUG"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "text"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output {{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
BIN
_tests/file.docx
BIN
_tests/file.docx
Binary file not shown.
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Some title</title>
|
||||
<meta name="author" content="Julien Neuhart">
|
||||
<meta name="description" content="Simple HTML page">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<p>Hi! I have been converted from an HTML page!</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
# Hi, I've been converted from a markdown file!
|
||||
BIN
_tests/file.pdf
BIN
_tests/file.pdf
Binary file not shown.
@@ -1,254 +0,0 @@
|
||||
/*
|
||||
Package config contains all the logic allowing us to instantiate the application's configuration.
|
||||
|
||||
The application's configuration is loaded from a YAML file named gotenberg.yml.
|
||||
It should be located where the user starts the application from the CLI.
|
||||
*/
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"text/template"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type (
|
||||
// AppConfig gathers all data required to instantiate the application.
|
||||
AppConfig struct {
|
||||
// Port is the port which the application will listen to.
|
||||
Port string
|
||||
// Logs contains the logging configuration.
|
||||
Logs struct {
|
||||
// Level is the level of messages which will be logged.
|
||||
Level logrus.Level
|
||||
// Formatter defines the logging format when a TTY is not attached.
|
||||
Formatter logrus.Formatter
|
||||
}
|
||||
// CommandsConfig is... an instance of CommandsConfig.
|
||||
CommandsConfig *CommandsConfig
|
||||
}
|
||||
|
||||
// CommandsConfig gathers all commands' configurations as defined
|
||||
// by the user in the gotenberg.yml file.
|
||||
CommandsConfig struct {
|
||||
// Markdown is the command's configuration for converting
|
||||
// an Markdown file to PDF.
|
||||
Markdown *CommandConfig
|
||||
// HTML is the command's configuration for converting
|
||||
// an HTML file to PDF.
|
||||
HTML *CommandConfig
|
||||
// Office is the command's configuration for converting
|
||||
// an Office document to PDF.
|
||||
Office *CommandConfig
|
||||
// Merge is the command's configuration for merging
|
||||
// multiple PDF files into one PDF file.
|
||||
Merge *CommandConfig
|
||||
}
|
||||
|
||||
// CommandConfig is a command's configuration.
|
||||
CommandConfig struct {
|
||||
// Timeout is the duration in seconds after which the command's process will be killed
|
||||
// if it does not finish before.
|
||||
Timeout int
|
||||
// Template is the data-driven template of the command.
|
||||
Template *template.Template
|
||||
}
|
||||
)
|
||||
|
||||
// NewAppConfig instantiates the application's configuration.
|
||||
// If something bad happens here, the application should not start.
|
||||
func NewAppConfig(configurationFilePath string) (*AppConfig, error) {
|
||||
fileConfig, err := loadFileConfig(configurationFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &AppConfig{}
|
||||
c.Port = fileConfig.Port
|
||||
|
||||
if err := makeLogs(c, fileConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := makeCommandsConfig(c, fileConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// fileConfig gathers all data coming from the configuration file gotenberg.yml.
|
||||
type fileConfig struct {
|
||||
Port string `yaml:"port"`
|
||||
Logs struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
} `yaml:"logs"`
|
||||
Commands struct {
|
||||
Markdown struct {
|
||||
Timeout int `yaml:"timeout"`
|
||||
Template string `yaml:"template"`
|
||||
} `yaml:"markdown"`
|
||||
HTML struct {
|
||||
Timeout int
|
||||
Template string
|
||||
} `yaml:"html"`
|
||||
Office struct {
|
||||
Timeout int `yaml:"timeout"`
|
||||
Template string `yaml:"template"`
|
||||
} `yaml:"office"`
|
||||
Merge struct {
|
||||
Timeout int `yaml:"timeout"`
|
||||
Template string `yaml:"template"`
|
||||
} `yaml:"merge"`
|
||||
} `yaml:"commands"`
|
||||
}
|
||||
|
||||
// loadFileConfig instantiates a fileConfig instance by loading
|
||||
// the configuration file gotenberg.yml.
|
||||
func loadFileConfig(configurationFilePath string) (*fileConfig, error) {
|
||||
c := &fileConfig{}
|
||||
|
||||
data, err := ioutil.ReadFile(configurationFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// makeLogs is a simple wrapper which populates all data related
|
||||
// to application's logging.
|
||||
func makeLogs(appConfig *AppConfig, fileConfig *fileConfig) error {
|
||||
lvl, err := getLoggingLevelFromFileConfig(fileConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
formatter, err := getLoggingFormatterFromFileConfig(fileConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appConfig.Logs.Level = lvl
|
||||
appConfig.Logs.Formatter = formatter
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// levels associates logging levels as defined in the configuration file gotenberg.yml
|
||||
// with its counterpart from the logrus library.
|
||||
var levels = map[string]logrus.Level{
|
||||
"DEBUG": logrus.DebugLevel,
|
||||
"INFO": logrus.InfoLevel,
|
||||
"WARN": logrus.WarnLevel,
|
||||
"ERROR": logrus.ErrorLevel,
|
||||
"FATAL": logrus.FatalLevel,
|
||||
"PANIC": logrus.PanicLevel,
|
||||
}
|
||||
|
||||
type wrongLoggingLevelError struct{}
|
||||
|
||||
const wrongLoggingLevelErrorMessage = "Accepted values for logging level: DEBUG, INFO, WARN, ERROR, FATAL, PANIC"
|
||||
|
||||
func (e *wrongLoggingLevelError) Error() string {
|
||||
return wrongLoggingLevelErrorMessage
|
||||
}
|
||||
|
||||
// getLoggingLevelFromFileConfig returns a logrus level if a matching was found
|
||||
// with the one defined by the user.
|
||||
// If no match, throws an error.
|
||||
func getLoggingLevelFromFileConfig(c *fileConfig) (logrus.Level, error) {
|
||||
l, ok := levels[c.Logs.Level]
|
||||
if !ok {
|
||||
return 999, &wrongLoggingLevelError{}
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// levels associates logging formats as defined in the configuration file gotenberg.yml
|
||||
// with its counterpart from the logrus library.
|
||||
var formatters = map[string]logrus.Formatter{
|
||||
"text": &logrus.TextFormatter{},
|
||||
"json": &logrus.JSONFormatter{},
|
||||
}
|
||||
|
||||
type wrongLoggingFormatError struct{}
|
||||
|
||||
const wrongLoggingFormatErrorMessage = "Accepted value for logging format: text, json"
|
||||
|
||||
func (e *wrongLoggingFormatError) Error() string {
|
||||
return wrongLoggingFormatErrorMessage
|
||||
}
|
||||
|
||||
// getLoggingLevelFromFileConfig returns a logrus Formatter if a matching was found
|
||||
// with the format defined by the user.
|
||||
// If no match, throws an error.
|
||||
func getLoggingFormatterFromFileConfig(c *fileConfig) (logrus.Formatter, error) {
|
||||
f, ok := formatters[c.Logs.Format]
|
||||
if !ok {
|
||||
return nil, &wrongLoggingFormatError{}
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// makeCommandsConfigs is a simple wrapper which populates all data related
|
||||
// to commands' configurations.
|
||||
func makeCommandsConfig(appConfig *AppConfig, fileConfig *fileConfig) error {
|
||||
appConfig.CommandsConfig = &CommandsConfig{}
|
||||
appConfig.CommandsConfig.Markdown = &CommandConfig{}
|
||||
appConfig.CommandsConfig.HTML = &CommandConfig{}
|
||||
appConfig.CommandsConfig.Office = &CommandConfig{}
|
||||
appConfig.CommandsConfig.Merge = &CommandConfig{}
|
||||
|
||||
appConfig.CommandsConfig.Markdown.Timeout = fileConfig.Commands.Markdown.Timeout
|
||||
appConfig.CommandsConfig.HTML.Timeout = fileConfig.Commands.HTML.Timeout
|
||||
appConfig.CommandsConfig.Office.Timeout = fileConfig.Commands.Office.Timeout
|
||||
appConfig.CommandsConfig.Merge.Timeout = fileConfig.Commands.Merge.Timeout
|
||||
|
||||
tmplMarkdown, err := getCommandTemplate(fileConfig.Commands.Markdown.Template, "Markdown")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmplHTML, err := getCommandTemplate(fileConfig.Commands.HTML.Template, "HTML")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmplOffice, err := getCommandTemplate(fileConfig.Commands.Office.Template, "Office")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmplMerge, err := getCommandTemplate(fileConfig.Commands.Merge.Template, "Merge")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appConfig.CommandsConfig.Markdown.Template = tmplMarkdown
|
||||
appConfig.CommandsConfig.HTML.Template = tmplHTML
|
||||
appConfig.CommandsConfig.Office.Template = tmplOffice
|
||||
appConfig.CommandsConfig.Merge.Template = tmplMerge
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCommandTemplate is a simple helper for parsing a command template as defined by the user.
|
||||
// If the user gives us a wrong template, throws an error.
|
||||
func getCommandTemplate(command string, commandName string) (*template.Template, error) {
|
||||
t, err := template.New(commandName).Parse(command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewAppConfig(t *testing.T) {
|
||||
var path string
|
||||
|
||||
// case 1: uses an empty configuration file path.
|
||||
if _, err := NewAppConfig(""); err == nil {
|
||||
t.Error("AppConfig should not have been instantiated by using an empty configuration file path")
|
||||
}
|
||||
|
||||
// case 2: uses a broken configuration file.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/broken-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 3: uses a configuration file with a wrong logging level.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-logging-level-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 4: uses a configuration file with a wrong logging format.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-logging-format-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 5: uses a configuration file with a wrong markdown command template.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-markdown-command-template-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 6: uses a configuration file with a wrong HTML command template.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-html-command-template-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 7: uses a configuration file with a wrong Office command template.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-office-command-template-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 8: uses a configuration file with a wrong merge command template.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-merge-command-template-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 9: uses a correct configuration file.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err != nil {
|
||||
t.Errorf("AppConfig should have been instantiated with '%s'", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongLoggingLevelError(t *testing.T) {
|
||||
err := &wrongLoggingLevelError{}
|
||||
if err.Error() != wrongLoggingLevelErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), wrongLoggingLevelErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongLoggingFormatError(t *testing.T) {
|
||||
err := &wrongLoggingFormatError{}
|
||||
if err.Error() != wrongLoggingFormatErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), wrongLoggingFormatErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Package context provides functions for setting and getting values from
|
||||
// a request's context.
|
||||
package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/converter"
|
||||
)
|
||||
|
||||
type key uint32
|
||||
|
||||
const (
|
||||
converterKey key = iota
|
||||
resultFilePathKey
|
||||
)
|
||||
|
||||
// WithConverter populates a request's context with the given converter
|
||||
// and returns the updated request.
|
||||
func WithConverter(r *http.Request, converter *converter.Converter) *http.Request {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, converterKey, converter)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type converterNotFoundError struct{}
|
||||
|
||||
const converterNotFoundErrorMessage = "The converter was not found in request context"
|
||||
|
||||
func (e *converterNotFoundError) Error() string {
|
||||
return converterNotFoundErrorMessage
|
||||
}
|
||||
|
||||
// GetConverter returns the converter if found in
|
||||
// the request's context. Otherwise throws an error.
|
||||
func GetConverter(r *http.Request) (*converter.Converter, error) {
|
||||
c, ok := r.Context().Value(converterKey).(*converter.Converter)
|
||||
if !ok {
|
||||
return nil, &converterNotFoundError{}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// WithResultFilePath populates a request's context with the given result file path
|
||||
// and returns the updated request.
|
||||
func WithResultFilePath(r *http.Request, resultFilePath string) *http.Request {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, resultFilePathKey, resultFilePath)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type resultFilePathNotFoundError struct{}
|
||||
|
||||
const resultFilePathNotFoundErrorMessage = "The result file path was not found in request context"
|
||||
|
||||
func (e *resultFilePathNotFoundError) Error() string {
|
||||
return resultFilePathNotFoundErrorMessage
|
||||
}
|
||||
|
||||
// GetResultFilePath returns the result file path if found in
|
||||
// the request's context. Otherwise throws an error.
|
||||
func GetResultFilePath(r *http.Request) (string, error) {
|
||||
path, ok := r.Context().Value(resultFilePathKey).(string)
|
||||
if !ok {
|
||||
return "", &resultFilePathNotFoundError{}
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/converter"
|
||||
)
|
||||
|
||||
func TestWithConverter(t *testing.T) {
|
||||
req := WithConverter(httptest.NewRequest(http.MethodPost, "/", nil), &converter.Converter{})
|
||||
if c, _ := req.Context().Value(converterKey).(*converter.Converter); c == nil {
|
||||
t.Errorf("Context returned a wrong converter: got '%v' want not nil", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConverter(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
// case 1: uses a request without a converter entry in its context.
|
||||
if _, err := GetConverter(req); err == nil {
|
||||
t.Error("Context should not have a converter entry")
|
||||
}
|
||||
|
||||
// case 2: uses a request with a converter entry in its context.
|
||||
req = WithConverter(req, &converter.Converter{})
|
||||
if _, err := GetConverter(req); err != nil {
|
||||
t.Error("Context should have a converter entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithResultFilePath(t *testing.T) {
|
||||
filePath := "file.pdf"
|
||||
req := WithResultFilePath(httptest.NewRequest(http.MethodPost, "/", nil), filePath)
|
||||
if path, _ := req.Context().Value(resultFilePathKey).(string); path != filePath {
|
||||
t.Errorf("Context returned a wrong result file path: got '%s' want '%s'", path, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetResultFilePath(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
// case 1: uses a request without a result file path entry in its context.
|
||||
if _, err := GetResultFilePath(req); err == nil {
|
||||
t.Error("Context should not have a result file path entry")
|
||||
}
|
||||
|
||||
// case 2: uses a request with a result file path entry in its context.
|
||||
req = WithResultFilePath(req, "file.pdf")
|
||||
if _, err := GetResultFilePath(req); err != nil {
|
||||
t.Error("Context should have a result file path entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConverterNotFoundError(t *testing.T) {
|
||||
err := &converterNotFoundError{}
|
||||
if err.Error() != converterNotFoundErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), converterNotFoundErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultFilePathNotFoundError(t *testing.T) {
|
||||
err := &resultFilePathNotFoundError{}
|
||||
if err.Error() != resultFilePathNotFoundErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), resultFilePathNotFoundErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Package converter implements a solution for converting one or more files to PDF.
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
|
||||
"github.com/thecodingmachine/gotenberg/app/converter/process"
|
||||
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
// Converter handles conversion into PDF of files coming from a request.
|
||||
type Converter struct {
|
||||
files []*gfile.File
|
||||
workingDir string
|
||||
}
|
||||
|
||||
// NoFileToConvertError is raided when a request has not file attached to it.
|
||||
type NoFileToConvertError struct{}
|
||||
|
||||
const noFileToConvertErrorMessage = "No file to convert"
|
||||
|
||||
func (e *NoFileToConvertError) Error() string {
|
||||
return noFileToConvertErrorMessage
|
||||
}
|
||||
|
||||
// NewConverter instantiates a converter by parsing a request.
|
||||
func NewConverter(r *http.Request) (*Converter, error) {
|
||||
c := &Converter{
|
||||
workingDir: fmt.Sprintf("./%s/", uuid.NewV4().String()),
|
||||
}
|
||||
|
||||
if err := os.Mkdir(c.workingDir, 0666); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
fileName := part.FileName()
|
||||
if fileName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := gfile.NewFile(c.workingDir, part, fileName)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
c.files = append(c.files, f)
|
||||
}
|
||||
|
||||
if len(c.files) == 0 {
|
||||
return c, &NoFileToConvertError{}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Convert converts its associated files to PDF. If more than one file,
|
||||
// it will merge all of them into one unique PDF file.
|
||||
// Returns the new file path or an error if something bad happened.
|
||||
func (c *Converter) Convert() (string, error) {
|
||||
var filesPaths []string
|
||||
for _, f := range c.files {
|
||||
if f.Type != gfile.PDFType {
|
||||
path, err := process.Unconv(c.workingDir, f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filesPaths = append(filesPaths, path)
|
||||
} else {
|
||||
filesPaths = append(filesPaths, f.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filesPaths) == 1 {
|
||||
return filesPaths[0], nil
|
||||
}
|
||||
|
||||
path, err := process.Merge(c.workingDir, filesPaths)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Clear removes all file inside its working directory.
|
||||
func (c *Converter) Clear() error {
|
||||
return os.RemoveAll(c.workingDir)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/config"
|
||||
"github.com/thecodingmachine/gotenberg/app/converter/process"
|
||||
)
|
||||
|
||||
func makeRequest(filesPaths ...string) *http.Request {
|
||||
r, w := io.Pipe()
|
||||
mpw := multipart.NewWriter(w)
|
||||
|
||||
go func() {
|
||||
var part io.Writer
|
||||
defer w.Close()
|
||||
|
||||
if len(filesPaths) == 0 {
|
||||
part, _ = mpw.CreateFormField("foo")
|
||||
part.Write([]byte("bar"))
|
||||
} else {
|
||||
for _, filePath := range filesPaths {
|
||||
file, _ := os.Open(filePath)
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, _ := file.Stat()
|
||||
part, _ = mpw.CreateFormFile("files", fileInfo.Name())
|
||||
io.Copy(part, file)
|
||||
}
|
||||
}
|
||||
|
||||
mpw.Close()
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", r)
|
||||
req.Header.Set("Content-Type", mpw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func loadCommandConfigs(configurationFilePath string) {
|
||||
path, _ := filepath.Abs(configurationFilePath)
|
||||
c, _ := config.NewAppConfig(path)
|
||||
process.Load(c.CommandsConfig)
|
||||
}
|
||||
|
||||
func TestNewConverter(t *testing.T) {
|
||||
var (
|
||||
path string
|
||||
oPath string
|
||||
)
|
||||
|
||||
// case 1: uses a request with a single file.
|
||||
path, _ = filepath.Abs("../../_tests/file.docx")
|
||||
if _, err := NewConverter(makeRequest(path)); err != nil {
|
||||
t.Errorf("Converter should have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 2: uses a request with wrong file type.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
|
||||
if _, err := NewConverter(makeRequest(path)); err == nil {
|
||||
t.Errorf("Converter should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 3: uses a request with two files.
|
||||
path, _ = filepath.Abs("../../_tests/file.pdf")
|
||||
oPath, _ = filepath.Abs("../../_tests/file.docx")
|
||||
if _, err := NewConverter(makeRequest(path, oPath)); err != nil {
|
||||
t.Errorf("Converter should have been instantiated with '%s' and '%s'", path, oPath)
|
||||
}
|
||||
|
||||
// case 4: uses a request with one Office file type and one wrong file type.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
|
||||
oPath, _ = filepath.Abs("../../_tests/file.docx")
|
||||
if _, err := NewConverter(makeRequest(path, oPath)); err == nil {
|
||||
t.Errorf("Converter should not have been instantiated with '%s' and '%s'", path, oPath)
|
||||
}
|
||||
|
||||
// case 5: uses a request with no file.
|
||||
if _, err := NewConverter(makeRequest()); err == nil {
|
||||
t.Error("Converter should not have been instantiated with no file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvert(t *testing.T) {
|
||||
var (
|
||||
path string
|
||||
oPath string
|
||||
c *Converter
|
||||
)
|
||||
|
||||
loadCommandConfigs("../../_tests/configurations/gotenberg.yml")
|
||||
|
||||
// case 1: uses a request with a single file.
|
||||
path, _ = filepath.Abs("../../_tests/file.docx")
|
||||
c, _ = NewConverter(makeRequest(path))
|
||||
if _, err := c.Convert(); err != nil {
|
||||
t.Errorf("Converter should have been able to convert '%s' to PDF", path)
|
||||
}
|
||||
|
||||
// case 2: uses a request with two files.
|
||||
path, _ = filepath.Abs("../../_tests/file.pdf")
|
||||
oPath, _ = filepath.Abs("../../_tests/file.docx")
|
||||
c, _ = NewConverter(makeRequest(path, oPath))
|
||||
if _, err := c.Convert(); err != nil {
|
||||
t.Errorf("Converter should have been able to convert '%s' and '%s' to PDF", path, oPath)
|
||||
}
|
||||
|
||||
loadCommandConfigs("../../_tests/configurations/timeout-gotenberg.yml")
|
||||
|
||||
// case 3: uses a request with a single file and a configuration with an unsuitable timeout for the conversion commands.
|
||||
path, _ = filepath.Abs("../../_tests/file.docx")
|
||||
c, _ = NewConverter(makeRequest(path))
|
||||
if _, err := c.Convert(); err == nil {
|
||||
t.Errorf("Converter should not have been able to convert '%s' to PDF", path)
|
||||
}
|
||||
|
||||
loadCommandConfigs("../../_tests/configurations/merge-timeout-gotenberg.yml")
|
||||
|
||||
// case 4: uses a request with two files and a configuration with an unsuitable timeout for the merge command.
|
||||
path, _ = filepath.Abs("../../_tests/file.pdf")
|
||||
oPath, _ = filepath.Abs("../../_tests/file.docx")
|
||||
c, _ = NewConverter(makeRequest(path, oPath))
|
||||
if _, err := c.Convert(); err == nil {
|
||||
t.Errorf("Converter should not have been able to merge '%s' and '%s' into PDF", path, oPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClear(t *testing.T) {
|
||||
path, _ := filepath.Abs("../../_tests/file.docx")
|
||||
c, _ := NewConverter(makeRequest(path))
|
||||
if err := c.Clear(); err != nil {
|
||||
t.Error("Converter should have been able to clear itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoFileToConvertError(t *testing.T) {
|
||||
err := &NoFileToConvertError{}
|
||||
if err.Error() != noFileToConvertErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), noFileToConvertErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// Package file implements a solution for handling files coming from a request.
|
||||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
// File represents a file which has been created
|
||||
// from a request.
|
||||
type File struct {
|
||||
// Type is the kind of file.
|
||||
Type Type
|
||||
// Path is the file path.
|
||||
Path string
|
||||
}
|
||||
|
||||
// Type represents what kind of file we're dealing with.
|
||||
type Type uint32
|
||||
|
||||
const (
|
||||
// PDFType represents a... PDF file.
|
||||
PDFType Type = iota
|
||||
// MarkdownType represents a... Markdown file.
|
||||
MarkdownType
|
||||
// HTMLType represents an... HTML file.
|
||||
HTMLType
|
||||
// OfficeType represents an... Office document.
|
||||
OfficeType
|
||||
)
|
||||
|
||||
// filesTypes associates a file extension with its file kind counterpart.
|
||||
var filesTypes = map[string]Type{
|
||||
".pdf": PDFType,
|
||||
".md": MarkdownType,
|
||||
".htm": HTMLType,
|
||||
".html": HTMLType,
|
||||
".doc": OfficeType,
|
||||
".docx": OfficeType,
|
||||
".odt": OfficeType,
|
||||
".xls": OfficeType,
|
||||
".xlsx": OfficeType,
|
||||
".ods": OfficeType,
|
||||
".ppt": OfficeType,
|
||||
".pptx": OfficeType,
|
||||
".odp": OfficeType,
|
||||
}
|
||||
|
||||
type fileTypeNotFoundError struct {
|
||||
fileName string
|
||||
}
|
||||
|
||||
func (e *fileTypeNotFoundError) Error() string {
|
||||
return fmt.Sprintf("File type was not found for '%s'", e.fileName)
|
||||
}
|
||||
|
||||
// NewFile creates a file in the considered directory.
|
||||
// Returns a *File instance or an error if something bad happened.
|
||||
func NewFile(workingDir string, r io.Reader, fileName string) (*File, error) {
|
||||
ext := filepath.Ext(fileName)
|
||||
|
||||
t, ok := filesTypes[ext]
|
||||
if !ok {
|
||||
return nil, &fileTypeNotFoundError{fileName: fileName}
|
||||
}
|
||||
|
||||
f := &File{
|
||||
Path: MakeFilePath(workingDir, ext),
|
||||
Type: t,
|
||||
}
|
||||
|
||||
file, err := os.Create(f.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// resets the read pointer.
|
||||
file.Seek(0, 0)
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// MakeFilePath is a simple helper which generates a random file name
|
||||
// and associates it with the considered directory to make a path.
|
||||
func MakeFilePath(workingDir string, ext string) string {
|
||||
return fmt.Sprintf("%s%s%s", workingDir, uuid.NewV4().String(), ext)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewFile(t *testing.T) {
|
||||
workingDir := "test"
|
||||
os.Mkdir(workingDir, 0666)
|
||||
|
||||
// case 1: uses a wrong file name.
|
||||
if _, err := NewFile(workingDir, new(bytes.Buffer), "file.yml"); err == nil {
|
||||
t.Error("File should not have been instantiated with an empty buffer")
|
||||
}
|
||||
|
||||
// case 2: uses a reader from a correct file type.
|
||||
filePath, _ := filepath.Abs("../../../_tests/file.pdf")
|
||||
r, _ := os.Open(filePath)
|
||||
defer r.Close()
|
||||
if _, err := NewFile(workingDir, r, "file.pdf"); err != nil {
|
||||
t.Errorf("File should have been instantiated using a reader from '%s'", filePath)
|
||||
}
|
||||
|
||||
os.RemoveAll(workingDir)
|
||||
}
|
||||
func TestFileTypeNotFoundError(t *testing.T) {
|
||||
fileName := "file.wp"
|
||||
err := &fileTypeNotFoundError{fileName: fileName}
|
||||
expected := fmt.Sprintf("File type was not found for '%s'", fileName)
|
||||
if err.Error() != expected {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// Package process handles all commands executions.
|
||||
package process
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/config"
|
||||
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
|
||||
)
|
||||
|
||||
type runner struct {
|
||||
mu sync.Mutex
|
||||
commandsConfig *config.CommandsConfig
|
||||
}
|
||||
|
||||
var forest = &runner{}
|
||||
|
||||
type commandTimeoutError struct {
|
||||
command string
|
||||
timeout int
|
||||
}
|
||||
|
||||
func (e *commandTimeoutError) Error() string {
|
||||
return fmt.Sprintf("The command '%s' has reached the %d second(s) timeout", e.command, e.timeout)
|
||||
}
|
||||
|
||||
// run runs the given command. If timeout is reached or
|
||||
// something bad happened, returns an error.
|
||||
func (r *runner) run(command string, timeout int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
cmd := exec.Command("/bin/sh", "-c", command)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
// wait for the process to finish or kill it after a timeout.
|
||||
select {
|
||||
case <-time.After(time.Duration(timeout) * time.Second):
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
return err
|
||||
}
|
||||
return &commandTimeoutError{
|
||||
command: command,
|
||||
timeout: timeout,
|
||||
}
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads the commands configuration coming from the application configuration.
|
||||
func Load(config *config.CommandsConfig) {
|
||||
forest.commandsConfig = config
|
||||
}
|
||||
|
||||
// conversionData will be applied to the data-driven templates of conversions commands.
|
||||
type conversionData struct {
|
||||
FilePath string
|
||||
ResultFilePath string
|
||||
}
|
||||
|
||||
type impossibleConversionError struct{}
|
||||
|
||||
const impossibleConversionErrorMessage = "Impossible conversion"
|
||||
|
||||
func (e *impossibleConversionError) Error() string {
|
||||
return impossibleConversionErrorMessage
|
||||
}
|
||||
|
||||
// Unconv converts a file to PDF and returns the new file path.
|
||||
func Unconv(workingDir string, file *gfile.File) (string, error) {
|
||||
cmdData := &conversionData{
|
||||
FilePath: file.Path,
|
||||
ResultFilePath: gfile.MakeFilePath(workingDir, ".pdf"),
|
||||
}
|
||||
|
||||
var (
|
||||
cmdTimeout int
|
||||
cmdTemplate *template.Template
|
||||
)
|
||||
|
||||
switch file.Type {
|
||||
case gfile.MarkdownType:
|
||||
cmdTimeout = forest.commandsConfig.Markdown.Timeout
|
||||
cmdTemplate = forest.commandsConfig.Markdown.Template
|
||||
break
|
||||
case gfile.HTMLType:
|
||||
cmdTimeout = forest.commandsConfig.HTML.Timeout
|
||||
cmdTemplate = forest.commandsConfig.HTML.Template
|
||||
break
|
||||
case gfile.OfficeType:
|
||||
cmdTimeout = forest.commandsConfig.Office.Timeout
|
||||
cmdTemplate = forest.commandsConfig.Office.Template
|
||||
break
|
||||
default:
|
||||
return "", &impossibleConversionError{}
|
||||
}
|
||||
|
||||
var data bytes.Buffer
|
||||
if err := cmdTemplate.Execute(&data, cmdData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err := forest.run(data.String(), cmdTimeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cmdData.ResultFilePath, nil
|
||||
}
|
||||
|
||||
// mergeData will be applied to the data-driven template of the merge command.
|
||||
type mergeData struct {
|
||||
FilesPaths []string
|
||||
ResultFilePath string
|
||||
}
|
||||
|
||||
// Merge merges many PDF files to one unique PDF file and returns the new file path.
|
||||
func Merge(workingDir string, filesPaths []string) (string, error) {
|
||||
cmdData := &mergeData{
|
||||
FilesPaths: filesPaths,
|
||||
ResultFilePath: gfile.MakeFilePath(workingDir, ".pdf"),
|
||||
}
|
||||
|
||||
cmdTimeout := forest.commandsConfig.Merge.Timeout
|
||||
cmdTemplate := forest.commandsConfig.Merge.Template
|
||||
|
||||
var data bytes.Buffer
|
||||
if err := cmdTemplate.Execute(&data, cmdData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err := forest.run(data.String(), cmdTimeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cmdData.ResultFilePath, nil
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/config"
|
||||
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
|
||||
)
|
||||
|
||||
func makeFile(workingDir string, fileName string) *gfile.File {
|
||||
filePath := fmt.Sprintf("%s%s", "../../../_tests/", fileName)
|
||||
absPath, _ := filepath.Abs(filePath)
|
||||
|
||||
r, _ := os.Open(absPath)
|
||||
defer r.Close()
|
||||
|
||||
f, _ := gfile.NewFile(workingDir, r, fileName)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func loadCommandConfigs(configurationFilePath string) {
|
||||
path, _ := filepath.Abs(configurationFilePath)
|
||||
c, _ := config.NewAppConfig(path)
|
||||
Load(c.CommandsConfig)
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
path, _ := filepath.Abs("../../../_tests/configurations/gotenberg.yml")
|
||||
c, _ := config.NewAppConfig(path)
|
||||
Load(c.CommandsConfig)
|
||||
|
||||
if c.CommandsConfig != forest.commandsConfig {
|
||||
t.Error("Commands configuration should have been loaded correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun(t *testing.T) {
|
||||
var cmd string
|
||||
|
||||
// case 1: uses a simple command.
|
||||
cmd = "echo Hello world"
|
||||
if err := forest.run(cmd, 30); err != nil {
|
||||
t.Errorf("Command '%s' should have worked", cmd)
|
||||
}
|
||||
|
||||
// case 2: uses a simple command but with an unsuitable timeout.
|
||||
cmd = "sleep 5"
|
||||
if err := forest.run(cmd, 0); err == nil {
|
||||
t.Errorf("Command '%s' should not have worked", cmd)
|
||||
}
|
||||
|
||||
// case 3: uses a broken command.
|
||||
cmd = "helloworld"
|
||||
if err := forest.run(cmd, 30); err == nil {
|
||||
t.Errorf("Command '%s' should not have worked", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconv(t *testing.T) {
|
||||
var file *gfile.File
|
||||
|
||||
workingDir := "test"
|
||||
os.Mkdir(workingDir, 0666)
|
||||
|
||||
loadCommandConfigs("../../../_tests/configurations/gotenberg.yml")
|
||||
|
||||
// case 1: uses an Markdown file type.
|
||||
file = makeFile(workingDir, "file.md")
|
||||
if _, err := Unconv(workingDir, file); err != nil {
|
||||
t.Errorf("Converting '%s' to PDF should have worked", file.Path)
|
||||
}
|
||||
|
||||
// case 2: uses an HTML file type.
|
||||
file = makeFile(workingDir, "file.html")
|
||||
if _, err := Unconv(workingDir, file); err != nil {
|
||||
t.Errorf("Converting '%s' to PDF should have worked", file.Path)
|
||||
}
|
||||
|
||||
// case 3: uses an Office file type.
|
||||
file = makeFile(workingDir, "file.docx")
|
||||
if _, err := Unconv(workingDir, file); err != nil {
|
||||
t.Errorf("Converting '%s' to PDF should have worked", file.Path)
|
||||
}
|
||||
|
||||
// case 4: uses a PDF file type.
|
||||
file = makeFile(workingDir, "file.pdf")
|
||||
if _, err := Unconv(workingDir, file); err == nil {
|
||||
t.Errorf("Converting '%s' to PDF should not have worked", file.Path)
|
||||
}
|
||||
|
||||
loadCommandConfigs("../../../_tests/configurations/timeout-gotenberg.yml")
|
||||
|
||||
// case 5: uses a command with an unsuitable timeout.
|
||||
file = makeFile(workingDir, "file.docx")
|
||||
if _, err := Unconv(workingDir, makeFile(workingDir, "file.docx")); err == nil {
|
||||
t.Errorf("Converting '%s' to PDF should have reached timeout", file.Path)
|
||||
}
|
||||
|
||||
os.RemoveAll(workingDir)
|
||||
}
|
||||
|
||||
func TestMerge(t *testing.T) {
|
||||
workingDir := "test"
|
||||
os.Mkdir(workingDir, 0666)
|
||||
|
||||
loadCommandConfigs("../../../_tests/configurations/gotenberg.yml")
|
||||
|
||||
var filesPaths []string
|
||||
path, _ := filepath.Abs("../../../_tests/file.pdf")
|
||||
filesPaths = append(filesPaths, path)
|
||||
filesPaths = append(filesPaths, path)
|
||||
|
||||
// case 1: simple merge.
|
||||
if _, err := Merge(workingDir, filesPaths); err != nil {
|
||||
t.Error("Merge should have worked")
|
||||
}
|
||||
|
||||
loadCommandConfigs("../../../_tests/configurations/timeout-gotenberg.yml")
|
||||
|
||||
// case 2: uses a command with an unsuitable timeout.
|
||||
if _, err := Merge(workingDir, filesPaths); err == nil {
|
||||
t.Error("Merge should have reached timeout")
|
||||
}
|
||||
|
||||
os.RemoveAll(workingDir)
|
||||
}
|
||||
|
||||
func TestImpossibleConversionError(t *testing.T) {
|
||||
err := &impossibleConversionError{}
|
||||
if err.Error() != impossibleConversionErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), impossibleConversionErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandTimeoutError(t *testing.T) {
|
||||
err := &commandTimeoutError{
|
||||
command: "echo hello",
|
||||
timeout: 30,
|
||||
}
|
||||
expected := fmt.Sprintf("The command '%s' has reached the %d second(s) timeout", err.command, err.timeout)
|
||||
if err.Error() != expected {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
|
||||
}
|
||||
}
|
||||
153
app/handlers.go
153
app/handlers.go
@@ -1,153 +0,0 @@
|
||||
// Package app implements all functions on which a request will pass through.
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/context"
|
||||
"github.com/thecodingmachine/gotenberg/app/converter"
|
||||
ghttp "github.com/thecodingmachine/gotenberg/app/http"
|
||||
"github.com/thecodingmachine/gotenberg/app/logger"
|
||||
|
||||
"github.com/justinas/alice"
|
||||
)
|
||||
|
||||
// GetHandlersChain returns the handlers chaining
|
||||
// thanks to the alice library.
|
||||
func GetHandlersChain() http.Handler {
|
||||
return alice.New(enforceContentLengthHandler, enforceContentTypeHandler, convertHandler).ThenFunc(serveHandler)
|
||||
}
|
||||
|
||||
type requestHasNoContentError struct{}
|
||||
|
||||
func (e *requestHasNoContentError) Error() string {
|
||||
return "Request has not content"
|
||||
}
|
||||
|
||||
// enforeContentLengthHandler checks if the request has content.
|
||||
func enforceContentLengthHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ContentLength == 0 {
|
||||
e := &requestHasNoContentError{}
|
||||
http.Error(w, e.Error(), http.StatusBadRequest)
|
||||
logger.Error(e)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// enforceContentTypeHandler checks if the "Content-Type" entry
|
||||
// from the request's header matches the allowed content type.
|
||||
func enforceContentTypeHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := ghttp.CheckAuthorizedContentType(r.Header); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnsupportedMediaType)
|
||||
logger.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// convertHandler is in charge of converting the file(s) from the request to PDF.
|
||||
func convertHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := converter.NewConverter(r)
|
||||
if err != nil {
|
||||
if _, ok := err.(*converter.NoFileToConvertError); ok {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
logger.Error(err)
|
||||
|
||||
if c != nil {
|
||||
r = context.WithConverter(r, c)
|
||||
cleanup(r)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
r = context.WithConverter(r, c)
|
||||
|
||||
path, err := c.Convert()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
logger.Error(err)
|
||||
cleanup(r)
|
||||
return
|
||||
}
|
||||
|
||||
r = context.WithResultFilePath(r, path)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// serveHandler simply serves the created PDF.
|
||||
func serveHandler(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := context.GetResultFilePath(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
logger.Error(err)
|
||||
cleanup(r)
|
||||
return
|
||||
}
|
||||
|
||||
reader, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
logger.Error(err)
|
||||
cleanup(r)
|
||||
return
|
||||
}
|
||||
|
||||
defer reader.Close()
|
||||
|
||||
resultFileInfo, err := reader.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
logger.Error(err)
|
||||
cleanup(r)
|
||||
return
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", resultFileInfo.Name()))
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", resultFileInfo.Size()))
|
||||
_, err := io.Copy(w, reader)
|
||||
|
||||
done <- err
|
||||
}()
|
||||
|
||||
err = <-done
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
logger.Error(err)
|
||||
}
|
||||
|
||||
cleanup(r)
|
||||
}
|
||||
|
||||
// cleanup removes all files created during the conversion.
|
||||
func cleanup(r *http.Request) {
|
||||
c, err := context.GetConverter(r)
|
||||
if err != nil {
|
||||
logger.Warn(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.Clear(); err != nil {
|
||||
logger.Warn(err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/config"
|
||||
"github.com/thecodingmachine/gotenberg/app/context"
|
||||
"github.com/thecodingmachine/gotenberg/app/converter/process"
|
||||
|
||||
"github.com/justinas/alice"
|
||||
)
|
||||
|
||||
func makeRequest(filesPaths ...string) *http.Request {
|
||||
r, w := io.Pipe()
|
||||
mpw := multipart.NewWriter(w)
|
||||
|
||||
go func() {
|
||||
var part io.Writer
|
||||
defer w.Close()
|
||||
|
||||
if len(filesPaths) == 0 {
|
||||
part, _ = mpw.CreateFormField("foo")
|
||||
part.Write([]byte("bar"))
|
||||
} else {
|
||||
for _, filePath := range filesPaths {
|
||||
file, _ := os.Open(filePath)
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, _ := file.Stat()
|
||||
part, _ = mpw.CreateFormFile("files", fileInfo.Name())
|
||||
io.Copy(part, file)
|
||||
}
|
||||
}
|
||||
|
||||
mpw.Close()
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", r)
|
||||
req.Header.Set("Content-Type", mpw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func loadCommandConfigs(configurationFilePath string) {
|
||||
path, _ := filepath.Abs(configurationFilePath)
|
||||
c, _ := config.NewAppConfig(path)
|
||||
process.Load(c.CommandsConfig)
|
||||
}
|
||||
|
||||
func fakeSuccessHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func TestGetHandlersChain(t *testing.T) {
|
||||
// dumb test to improve code coverage...
|
||||
if GetHandlersChain() == nil {
|
||||
t.Errorf("Handler chains should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceContentLengthHandler(t *testing.T) {
|
||||
var (
|
||||
req *http.Request
|
||||
rr *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
h := alice.New(enforceContentLengthHandler).ThenFunc(fakeSuccessHandler)
|
||||
|
||||
// case 1: sends an empty request.
|
||||
req = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusBadRequest {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// case 2: sends a real body.
|
||||
path, _ := filepath.Abs("../_tests/file.docx")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, makeRequest(path))
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceContentTypeHandler(t *testing.T) {
|
||||
var (
|
||||
req *http.Request
|
||||
rr *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
h := alice.New(enforceContentTypeHandler).ThenFunc(fakeSuccessHandler)
|
||||
|
||||
// case 1: sends a wrong content type.
|
||||
req = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("Content-Type", "application/pdf")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusUnsupportedMediaType)
|
||||
}
|
||||
|
||||
// case 2: sends a good content type.
|
||||
path, _ := filepath.Abs("../_tests/file.docx")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, makeRequest(path))
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Handler returned wrong a status code: got '%v' want '%v'", status, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertHandler(t *testing.T) {
|
||||
var (
|
||||
req *http.Request
|
||||
rr *httptest.ResponseRecorder
|
||||
path string
|
||||
oPath string
|
||||
)
|
||||
|
||||
h := alice.New(convertHandler).ThenFunc(fakeSuccessHandler)
|
||||
|
||||
// case 1: sends a request without body.
|
||||
req = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusInternalServerError {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// case 2: sends a request with no file.
|
||||
req = makeRequest()
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusBadRequest {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
loadCommandConfigs("../_tests/configurations/merge-timeout-gotenberg.yml")
|
||||
|
||||
// case 3: sends a request with two files and using an unsuitable timeout for merge commande.
|
||||
path, _ = filepath.Abs("../_tests/file.pdf")
|
||||
oPath, _ = filepath.Abs("../_tests/file.docx")
|
||||
req = makeRequest(path, oPath)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusInternalServerError {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
loadCommandConfigs("../_tests/configurations/gotenberg.yml")
|
||||
|
||||
// case 4: sends a request with two files.
|
||||
path, _ = filepath.Abs("../_tests/file.pdf")
|
||||
oPath, _ = filepath.Abs("../_tests/file.docx")
|
||||
req = makeRequest(path, oPath)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
|
||||
}
|
||||
|
||||
// case 5: sends five requests (almost) simultany.
|
||||
path, _ = filepath.Abs("../_tests/file.docx")
|
||||
filesPaths := []string{
|
||||
path,
|
||||
path,
|
||||
path,
|
||||
path,
|
||||
path,
|
||||
}
|
||||
|
||||
for i := 0; i < len(filesPaths); i++ {
|
||||
go func(i int) {
|
||||
req := makeRequest(filesPaths[i])
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHandler(t *testing.T) {
|
||||
var (
|
||||
req *http.Request
|
||||
rr *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
h := alice.New().ThenFunc(serveHandler)
|
||||
|
||||
// case 1: sends a request without a result file path entry in its context.
|
||||
req = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusInternalServerError {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// case 2: sends a request with a wrong result file path entry in its context.
|
||||
req = context.WithResultFilePath(httptest.NewRequest(http.MethodPost, "/", nil), "file")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusInternalServerError {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// case 3: sends a request with a correct result file path entry in its context.
|
||||
path, _ := filepath.Abs("../_tests/file.pdf")
|
||||
req = context.WithResultFilePath(httptest.NewRequest(http.MethodPost, "/", nil), path)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Package http provides functions for detecting a request or a file content type.
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContentType is a string which represents a content type.
|
||||
type ContentType string
|
||||
|
||||
// MultipartFormDataContentType represents... the multipart form data content type.
|
||||
const MultipartFormDataContentType ContentType = "multipart/form-data"
|
||||
|
||||
type notAuthorizedContentTypeError struct{}
|
||||
|
||||
func (e *notAuthorizedContentTypeError) Error() string {
|
||||
return fmt.Sprintf("Accepted value for 'Content-Type': %s", MultipartFormDataContentType)
|
||||
}
|
||||
|
||||
// CheckAuthorizedContentType checks if the request header header has an authorized content type.
|
||||
// If no authorized content type found, throws an error.
|
||||
func CheckAuthorizedContentType(h http.Header) error {
|
||||
ct := findContentType(h.Get("Content-Type"), MultipartFormDataContentType)
|
||||
if ct == "" {
|
||||
return ¬AuthorizedContentTypeError{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findContentType parses a string representing a content type and tries to find
|
||||
// one of the given content types.
|
||||
func findContentType(requestContentType string, contentTypes ...ContentType) ContentType {
|
||||
for _, ct := range contentTypes {
|
||||
if i := strings.IndexRune(requestContentType, ';'); i != -1 {
|
||||
requestContentType = requestContentType[0:i]
|
||||
}
|
||||
|
||||
if requestContentType == string(ct) {
|
||||
return ct
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckAuthorizedContentType(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
// case 1: uses a request without a content type entry in its header.
|
||||
if err := CheckAuthorizedContentType(req.Header); err == nil {
|
||||
t.Error("Function should not have been able to retrieve an authorized content type from request's header")
|
||||
}
|
||||
|
||||
// case 2: uses a request with a content type entry in its header.
|
||||
req.Header.Set("Content-Type", string(MultipartFormDataContentType))
|
||||
if err := CheckAuthorizedContentType(req.Header); err != nil {
|
||||
t.Error("Function should have been able to retrieve an authorized content type from request's header")
|
||||
}
|
||||
|
||||
// case 3: uses a request with a composed content type entry in its header.
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary=—-WebKitFormBoundary7MA4YWxkTrZu0gW")
|
||||
if err := CheckAuthorizedContentType(req.Header); err != nil {
|
||||
t.Error("Function should have been able to retrieve an authorized content type from request's header")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestNotAuthorizedContentTypeError(t *testing.T) {
|
||||
err := ¬AuthorizedContentTypeError{}
|
||||
expected := fmt.Sprintf("Accepted value for 'Content-Type': %s", MultipartFormDataContentType)
|
||||
if err.Error() != expected {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Package logger implements a simple wrapper of the logrus library.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// logger wraps a logrus.Logger instance.
|
||||
type logger struct {
|
||||
logger *logrus.Logger
|
||||
}
|
||||
|
||||
// log is our logger instance used across the application.
|
||||
var log = newLogger()
|
||||
|
||||
// newLogger instantiates a logger instance with default values.
|
||||
func newLogger() *logger {
|
||||
l := &logger{
|
||||
logger: logrus.New(),
|
||||
}
|
||||
|
||||
l.logger.Out = os.Stdout
|
||||
l.logger.Level = logrus.InfoLevel
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// SetLevel updates the level of messages which will be logged.
|
||||
func SetLevel(level logrus.Level) {
|
||||
log.logger.SetLevel(level)
|
||||
}
|
||||
|
||||
// SetFormatter updates the output format.
|
||||
// When a TTY is not attached, the output will be in the defined format.
|
||||
func SetFormatter(formatter logrus.Formatter) {
|
||||
log.logger.Formatter = formatter
|
||||
}
|
||||
|
||||
// Debug is a wrapper of the logrus Debug function.
|
||||
func Debug(message string) {
|
||||
log.logger.Debug(message)
|
||||
}
|
||||
|
||||
// Debugf is a wrapper of the logrus Debugf function.
|
||||
func Debugf(format string, args ...interface{}) {
|
||||
log.logger.Debugf(format, args)
|
||||
}
|
||||
|
||||
// Info is a wrapper of the logrus Info function.
|
||||
func Info(message string) {
|
||||
log.logger.Info(message)
|
||||
}
|
||||
|
||||
// Infof is a wrapper of the logrus Infof function.
|
||||
func Infof(format string, args ...interface{}) {
|
||||
log.logger.Infof(format, args)
|
||||
}
|
||||
|
||||
// Warn is a wrapper of the logrus Warn function.
|
||||
func Warn(message string) {
|
||||
log.logger.Warn(message)
|
||||
}
|
||||
|
||||
// Error is a wrapper of the logrus Error function.
|
||||
func Error(err error) {
|
||||
log.logger.Error(err.Error())
|
||||
}
|
||||
|
||||
// Fatal is a wrapper of the logrus Fatal function.
|
||||
func Fatal(err error) {
|
||||
log.logger.Fatal(err.Error())
|
||||
}
|
||||
|
||||
// Panic is a wrapper of the logrus Panic function.
|
||||
func Panic(err error) {
|
||||
log.logger.Panic(err.Error())
|
||||
}
|
||||
171
build/Dockerfile
Normal file
171
build/Dockerfile
Normal file
@@ -0,0 +1,171 @@
|
||||
# Note: ARG instructions do not create additional layers.
|
||||
# Instead, next layers will concatenate them.
|
||||
ARG GOLANG_VERSION
|
||||
# Note: we have to repeat ARG instructions in each build stage that uses them.
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM golang:$GOLANG_VERSION AS builder
|
||||
|
||||
ENV CGO_ENABLED 0
|
||||
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /home
|
||||
|
||||
# Install module dependencies.
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download &&\
|
||||
go mod verify
|
||||
|
||||
# Copy the source code.
|
||||
COPY cmd ./cmd
|
||||
COPY pkg ./pkg
|
||||
|
||||
# Build the binary.
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v7/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
|
||||
|
||||
FROM debian:11-slim
|
||||
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
LABEL author="Julien Neuhart" \
|
||||
description="A Docker-powered stateless API for PDF files." \
|
||||
github="https://github.com/gotenberg/gotenberg" \
|
||||
version="$GOTENBERG_VERSION" \
|
||||
website="https://gotenberg.dev"
|
||||
|
||||
# Improve fonts subpixel hinting and smoothing.
|
||||
# Credits:
|
||||
# https://github.com/arachnys/athenapdf/issues/69.
|
||||
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
|
||||
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
||||
|
||||
# Simple wrapper around Java and PDFtk.
|
||||
COPY build/pdftk.sh /usr/bin/pdftk
|
||||
|
||||
# Setup the Docker image.
|
||||
ARG GOTENBERG_USER_GID
|
||||
ARG GOTENBERG_USER_UID
|
||||
ARG NOTO_COLOR_EMOJI_VERSION
|
||||
ARG PDFTK_VERSION
|
||||
|
||||
# Script for installing either Google Chrome stable on amd64 architecture or
|
||||
# Chromium on other architectures.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/328.
|
||||
COPY build/install-chromium.sh /tmp/install-chromium.sh
|
||||
|
||||
RUN \
|
||||
# Create a non-root user.
|
||||
# All processes in the Docker container will run with this dedicated user.
|
||||
groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\
|
||||
useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\
|
||||
mkdir /home/gotenberg &&\
|
||||
chown gotenberg: /home/gotenberg &&\
|
||||
# Install dependencies required for the next instructions or debugging.
|
||||
# Note: tini is a helper for reaping zombie processes.
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg htop tini python3 default-jre-headless &&\
|
||||
ln -s /usr/bin/htop /usr/bin/top &&\
|
||||
# Install fonts.
|
||||
# Credits:
|
||||
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile.
|
||||
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html.
|
||||
curl -o ./ttf-mscorefonts-installer_3.8_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8_all.deb &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
./ttf-mscorefonts-installer_3.8_all.deb \
|
||||
culmus \
|
||||
fonts-beng \
|
||||
fonts-hosny-amiri \
|
||||
fonts-lklug-sinhala \
|
||||
fonts-lohit-guru \
|
||||
fonts-lohit-knda \
|
||||
fonts-samyak-gujr \
|
||||
fonts-samyak-mlym \
|
||||
fonts-samyak-taml \
|
||||
fonts-sarai \
|
||||
fonts-sil-abyssinica \
|
||||
fonts-sil-padauk \
|
||||
fonts-telu \
|
||||
fonts-thai-tlwg \
|
||||
ttf-wqy-zenhei \
|
||||
fonts-arphic-ukai \
|
||||
fonts-arphic-uming \
|
||||
fonts-ipafont-mincho \
|
||||
fonts-ipafont-gothic \
|
||||
fonts-unfonts-core \
|
||||
# LibreOffice recommends.
|
||||
fonts-crosextra-caladea \
|
||||
fonts-crosextra-carlito \
|
||||
fonts-dejavu \
|
||||
fonts-dejavu-extra \
|
||||
fonts-liberation \
|
||||
fonts-liberation2 \
|
||||
fonts-linuxlibertine \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-core \
|
||||
fonts-noto-mono \
|
||||
fonts-noto-ui-core \
|
||||
fonts-sil-gentium \
|
||||
fonts-sil-gentium-basic &&\
|
||||
rm -f ./ttf-mscorefonts-installer_3.8_all.deb &&\
|
||||
# Add Color and Black-and-White Noto emoji font.
|
||||
# Credits:
|
||||
# https://github.com/gotenberg/gotenberg/pull/325.
|
||||
# https://github.com/googlefonts/noto-emoji.
|
||||
curl -Ls "https://github.com/googlefonts/noto-emoji/raw/$NOTO_COLOR_EMOJI_VERSION/fonts/NotoColorEmoji.ttf" -o /usr/local/share/fonts/NotoColorEmoji.ttf &&\
|
||||
# Install Google Chrome / Chromium.
|
||||
/tmp/install-chromium.sh &&\
|
||||
# Install LibreOffice.
|
||||
# Note: we use the bullseye-backports distribution to get the latest LibreOffice version.
|
||||
# See:
|
||||
# https://github.com/gotenberg/gotenberg/pull/322.
|
||||
# https://github.com/gotenberg/gotenberg/issues/403.
|
||||
echo "deb https://httpredir.debian.org/debian/ bullseye-backports main contrib non-free" >> /etc/apt/sources.list &&\
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t bullseye-backports libreoffice &&\
|
||||
# Download unoconv (Python script).
|
||||
curl -Ls https://raw.githubusercontent.com/dagwieers/unoconv/master/unoconv -o /usr/bin/unoconv &&\
|
||||
chmod +x /usr/bin/unoconv &&\
|
||||
# unoconv will look for the Python binary, which has to be at version 3.
|
||||
ln -s /usr/bin/python3 /usr/bin/python &&\
|
||||
# Download PDFtk.
|
||||
# See https://github.com/gotenberg/gotenberg/pull/273. \
|
||||
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
|
||||
chmod a+x /usr/bin/pdftk-all.jar &&\
|
||||
# Download QPDF.
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\
|
||||
# See https://github.com/nextcloud/docker/issues/380.
|
||||
mkdir -p /usr/share/man/man1mkdir -p /usr/share/man/man1 &&\
|
||||
# Cleanup.
|
||||
# Note: the Debian image does automatically a clean after each install thanks to a hook.
|
||||
# Therefore, there is no need for apt-get clean.
|
||||
# See https://stackoverflow.com/a/24417119/3248473.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* &&\
|
||||
# Print versions of main dependencies.
|
||||
chromium --version &&\
|
||||
libreoffice --version &&\
|
||||
unoconv --version &&\
|
||||
pdftk --version &&\
|
||||
qpdf --version
|
||||
|
||||
# Copy the Gotenberg binary from the builder stage.
|
||||
COPY --from=builder /home/gotenberg /usr/bin/
|
||||
|
||||
# Environment variables required by modules or else.
|
||||
ENV GC_EXCLUDE_SUBSTR "hsperfdata_root,hsperfdata_gotenberg"
|
||||
ENV CHROMIUM_BIN_PATH /usr/bin/chromium
|
||||
ENV UNOCONV_BIN_PATH /usr/bin/unoconv
|
||||
ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin
|
||||
ENV PDFTK_BIN_PATH /usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH /usr/bin/qpdf
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
# Default API port.
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/tini", "--" ]
|
||||
CMD [ "gotenberg" ]
|
||||
15
build/Dockerfile.cloudrun
Normal file
15
build/Dockerfile.cloudrun
Normal file
@@ -0,0 +1,15 @@
|
||||
# Note: ARG instructions do not create additional layers.
|
||||
# Instead, next layers will concatenate them.
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
|
||||
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
|
||||
RUN chown gotenberg: /usr/bin/tini
|
||||
|
||||
USER gotenberg
|
||||
29
build/fonts.conf
Normal file
29
build/fonts.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version='1.0'?>
|
||||
<!DOCTYPE fontconfig SYSTEM 'fonts.dtd'>
|
||||
<fontconfig>
|
||||
<match target="font">
|
||||
<edit mode="assign" name="rgba">
|
||||
<const>rgb</const>
|
||||
</edit>
|
||||
</match>
|
||||
<match target="font">
|
||||
<edit mode="assign" name="hinting">
|
||||
<bool>true</bool>
|
||||
</edit>
|
||||
</match>
|
||||
<match target="font">
|
||||
<edit mode="assign" name="hintstyle">
|
||||
<const>hintslight</const>
|
||||
</edit>
|
||||
</match>
|
||||
<match target="font">
|
||||
<edit mode="assign" name="antialias">
|
||||
<bool>true</bool>
|
||||
</edit>
|
||||
</match>
|
||||
<match target="font">
|
||||
<edit mode="assign" name="lcdfilter">
|
||||
<const>lcddefault</const>
|
||||
</edit>
|
||||
</match>
|
||||
</fontconfig>
|
||||
15
build/install-chromium.sh
Executable file
15
build/install-chromium.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
ARCH=$(dpkg --print-architecture)
|
||||
|
||||
if [[ "$ARCH" == "amd64" ]]; then
|
||||
curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add -
|
||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable
|
||||
mv /usr/bin/google-chrome-stable /usr/bin/chromium
|
||||
else
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium
|
||||
fi
|
||||
3
build/pdftk.sh
Executable file
3
build/pdftk.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
exec java -jar /usr/bin/pdftk-all.jar "$@"
|
||||
158
cmd/gotenberg.go
Normal file
158
cmd/gotenberg.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package gotenbergcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
flag "github.com/spf13/pflag"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
|
||||
// Credits: https://github.com/labstack/echo/blob/v4.3.0/echo.go#L240.
|
||||
const banner = `
|
||||
_____ __ __
|
||||
/ ___/__ / /____ ___ / / ___ _______ _
|
||||
/ (_ / _ \/ __/ -_) _ \/ _ \/ -_) __/ _ '/
|
||||
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
|
||||
/___/
|
||||
|
||||
A Docker-powered stateless API for PDF files.
|
||||
Version: %s
|
||||
-------------------------------------------------------
|
||||
`
|
||||
|
||||
// Version is the... version of the Gotenberg application. We set it at the
|
||||
// build stage of the Docker image.
|
||||
var Version = "snapshot"
|
||||
|
||||
// Run starts the Gotenberg application. Call this in the main of your program.
|
||||
func Run() {
|
||||
fmt.Printf(banner, Version)
|
||||
|
||||
// Create the root FlagSet and adds the modules flags to it.
|
||||
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
|
||||
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
|
||||
|
||||
descriptors := gotenberg.GetModuleDescriptors()
|
||||
var modsInfo string
|
||||
for _, desc := range descriptors {
|
||||
fs.AddFlagSet(desc.FlagSet)
|
||||
modsInfo += desc.ID + " "
|
||||
}
|
||||
|
||||
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo)
|
||||
|
||||
// Parse the flags...
|
||||
err := fs.Parse(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// ...and create a wrapper around those.
|
||||
parsedFlags := gotenberg.ParsedFlags{FlagSet: fs}
|
||||
|
||||
// Get the graceful shutdown duration.
|
||||
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration")
|
||||
|
||||
ctx := gotenberg.NewContext(parsedFlags, descriptors)
|
||||
|
||||
// Start application modules.
|
||||
apps, err := ctx.Modules(new(gotenberg.App))
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, a := range apps {
|
||||
go func(app gotenberg.App) {
|
||||
id := app.(gotenberg.Module).Descriptor().ID
|
||||
err = app.Start()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] starting %s: %s\n", id, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
startupMessage := app.StartupMessage()
|
||||
if startupMessage == "" {
|
||||
fmt.Printf("[SYSTEM] %s: application started\n", id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[SYSTEM] %s: %s\n", id, startupMessage)
|
||||
}(a.(gotenberg.App))
|
||||
}
|
||||
|
||||
// Get modules that want to print system messages.
|
||||
sysLoggers, err := ctx.Modules(new(gotenberg.SystemLogger))
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, l := range sysLoggers {
|
||||
go func(logger gotenberg.SystemLogger) {
|
||||
id := logger.(gotenberg.Module).Descriptor().ID
|
||||
|
||||
for _, message := range logger.SystemMessages() {
|
||||
fmt.Printf("[SYSTEM] %s: %s\n", id, message)
|
||||
}
|
||||
}(l.(gotenberg.SystemLogger))
|
||||
}
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
|
||||
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C).
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
|
||||
// Block until we receive our signal.
|
||||
<-quit
|
||||
|
||||
gracefulShutdownCtx, cancel := context.WithTimeout(context.Background(), gracefulShutdownDuration)
|
||||
defer cancel()
|
||||
|
||||
forceQuit := make(chan os.Signal, 1)
|
||||
signal.Notify(forceQuit, os.Interrupt)
|
||||
|
||||
go func() {
|
||||
// In case of force quit, cancel the context.
|
||||
<-forceQuit
|
||||
cancel()
|
||||
}()
|
||||
|
||||
fmt.Printf("[SYSTEM] graceful shutdown of %s\n", gracefulShutdownDuration)
|
||||
|
||||
eg, _ := errgroup.WithContext(gracefulShutdownCtx)
|
||||
|
||||
for _, a := range apps {
|
||||
eg.Go(func(app gotenberg.App) func() error {
|
||||
return func() error {
|
||||
id := app.(gotenberg.Module).Descriptor().ID
|
||||
|
||||
err = app.Stop(gracefulShutdownCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stopping %s: %w", id, err)
|
||||
}
|
||||
|
||||
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
|
||||
|
||||
return nil
|
||||
}
|
||||
}(a.(gotenberg.App)))
|
||||
}
|
||||
|
||||
err = eg.Wait()
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
12
cmd/gotenberg/main.go
Normal file
12
cmd/gotenberg/main.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
gotenbergcmd "github.com/gotenberg/gotenberg/v7/cmd"
|
||||
|
||||
// Gotenberg modules.
|
||||
_ "github.com/gotenberg/gotenberg/v7/pkg/standard"
|
||||
)
|
||||
|
||||
func main() {
|
||||
gotenbergcmd.Run()
|
||||
}
|
||||
730
docs/openapi.yaml
Normal file
730
docs/openapi.yaml
Normal file
@@ -0,0 +1,730 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Gotenberg
|
||||
version: 7.x
|
||||
license:
|
||||
name: MIT
|
||||
url: 'https://github.com/gotenberg/gotenberg/blob/main/LICENSE'
|
||||
contact:
|
||||
url: 'https://github.com/gotenberg/gotenberg'
|
||||
description: >-
|
||||
A Docker-powered stateless API for PDF files.
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev
|
||||
servers:
|
||||
- url: 'http://localhost:3000'
|
||||
description: Local server with the default Docker image and port
|
||||
tags:
|
||||
- name: chromium
|
||||
description: Operations of the Chromium module
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/chromium
|
||||
- name: libreoffice
|
||||
description: Operations of the Libreoffice module
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/libreoffice
|
||||
- name: pdfengines
|
||||
description: Operations of the PDF Engines module
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/pdf-engines
|
||||
paths:
|
||||
/forms/chromium/convert/url:
|
||||
post:
|
||||
tags:
|
||||
- chromium
|
||||
summary: Convert the contents of a given URL to PDF
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/chromium
|
||||
description: >-
|
||||
Send a URL in your API request via the `url` form field
|
||||
Send a remote URL in your API request via the `remoteURL` parameter, and
|
||||
get the resulting PDF file. The API will fetch the given URL and render
|
||||
the page to PDF using the underlying headless Chrome instance.
|
||||
You can optionally include `header.html` and `footer.html` files as part of the request as well.
|
||||
See externalDocs for more details.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
required: true
|
||||
description: >-
|
||||
The request must be `multipart/form-data` that includes a `url` form field.
|
||||
The API uses a headless Chrome instance to do the conversion, therefore print
|
||||
parameter such as margins and paper size are also accepted as optional parameters.
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/URLConvertRequestBody'
|
||||
examples: { }
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: Bad Request
|
||||
|
||||
/forms/chromium/convert/html:
|
||||
post:
|
||||
tags:
|
||||
- chromium
|
||||
summary: Convert a given HTML file to PDF
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/chromium
|
||||
description: >-
|
||||
Send an HTML file called `index.html` as a multipart form request, and
|
||||
get the resulting PDF file. You can optionally include `header.html` and
|
||||
`footer.html` files as part of the request as well.
|
||||
See externalDocs for more details.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTMLConvertRequestBody'
|
||||
description: >-
|
||||
The request body must have an `index.html` file in the `files` array,
|
||||
as well as all the referred resources on the same level as the
|
||||
`index.html` file. The request can also include `header.html` and
|
||||
`footer.html`, given the limitations in the API description above.
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: Bad Request
|
||||
|
||||
/forms/chromium/convert/markdown:
|
||||
post:
|
||||
tags:
|
||||
- chromium
|
||||
summary: Convert a Markdown file to PDF
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/chromium
|
||||
description: >-
|
||||
Accepts an HTML file called `index.html` plus markdown files as a multipart
|
||||
form request and embeds the markdown files into the HTML file using the Golang template
|
||||
function `toHTML`.
|
||||
The API will convert the markdown to HTML and embed it into your `index.html` file,
|
||||
then render the resulting page. You can include your own styling and more in your HTML file.
|
||||
Refer to the HTML conversion page for all the options you can use when converting
|
||||
Markdown documents as well. You can optionally include `header.html` and
|
||||
`footer.html` files as part of the request as well.
|
||||
See externalDocs for more details.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MarkdownConvertRequestBody'
|
||||
description: >-
|
||||
The request body must have an `index.html` file in the `files` array,
|
||||
as well as all the referred markdown resources on the same level as
|
||||
the `index.html` file.
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: Bad Request
|
||||
|
||||
/forms/libreoffice/convert:
|
||||
post:
|
||||
tags:
|
||||
- libreoffice
|
||||
summary: Convert an Office document to PDF
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/libreoffice
|
||||
description: >-
|
||||
This route accepts multipart/form-data requests and files with the following extensions:
|
||||
|
||||
.bib .doc .xml .docx .fodt .html .ltx .txt .odt .ott .pdb .pdf .psw .rtf
|
||||
.sdw .stw .sxw .uot .vor .wps .epub .png .bmp .emf .eps .fodg .gif .jpg
|
||||
.met .odd .otg .pbm .pct .pgm .ppm .ras .std .svg .svm .swf .sxd .sxw
|
||||
.tiff .xhtml .xpm .fodp .potm .pot .pptx .pps .ppt .pwp .sda .sdd .sti
|
||||
.sxi .uop .wmf .csv .dbf .dif .fods .ods .ots .pxl .sdc .slk .stc .sxc
|
||||
.uos .xls .xlt .xlsx .tif .jpeg .odp
|
||||
|
||||
By default, if you send more than one file to convert, the route returns a ZIP archive of the
|
||||
resulting PDF files. However, you may prefer to merge all the PDF files into an individual PDF file.
|
||||
|
||||
> **Attention:** The files will be merged alphabetically for the
|
||||
resulting PDF.
|
||||
|
||||
You may also specify the page ranges to convert from the incoming Office
|
||||
documents. The expected format is the same as the one from the print
|
||||
options of LibreOffice, e.g. `1-1` or `1-4`.
|
||||
|
||||
> **Attention:** if more than one document, the page ranges will be
|
||||
applied for each document.
|
||||
|
||||
See externalDocs for more details.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OfficeConvertRequestBody'
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: Bad Request, e.g. Both 'pdfFormat' and 'nativePdfA1aFormat' form values are provided
|
||||
|
||||
/forms/pdfengines/merge:
|
||||
post:
|
||||
tags:
|
||||
- pdfengines
|
||||
summary: Merge multiple PDFs into a single PDF
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/pdf-engines
|
||||
description: >-
|
||||
You can send multiple PDF files to this endpoint, the API will merge
|
||||
them into a single PDF and return the resulting PDF file.
|
||||
|
||||
> **Attention:** The PDF files will be merged alphabetically.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: The PDF format of the resulting PDF
|
||||
example: PDF/A-1a
|
||||
required:
|
||||
- files
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: Bad Request
|
||||
|
||||
/forms/pdfengines/convert:
|
||||
post:
|
||||
tags:
|
||||
- pdfengines
|
||||
summary: Convert PDFs into the given formats
|
||||
externalDocs:
|
||||
url: https://gotenberg.dev/docs/modules/pdf-engines
|
||||
description: >-
|
||||
This route accepts PDF files and a form field pdfFormat for converting them into the specified format.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Gotenberg-Output-Filename
|
||||
description: >-
|
||||
By default, the API generates a UUID filename.
|
||||
However, you may also specify the filename per request,
|
||||
thanks to the Gotenberg-Output-Filename header.
|
||||
Caution! The API adds the file extension automatically; you don't have to set it.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- in: header
|
||||
name: Gotenberg-Trace
|
||||
description: >-
|
||||
The trace, or request ID, identifies a request in the logs.
|
||||
|
||||
By default, the API generates a UUID trace for each request.
|
||||
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: The PDF format of the resulting PDF
|
||||
example: PDF/A-1a
|
||||
required:
|
||||
- files
|
||||
- pdfFormat
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessfulPDF'
|
||||
'400':
|
||||
description: >-
|
||||
Bad Request, e.g. Invalid form data: no form file found for extensions: [.pdf]; form value 'pdfFormat' is required
|
||||
|
||||
components:
|
||||
schemas:
|
||||
HTMLConvertRequestBody:
|
||||
title: HTML Conversion Request Body
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
description: >-
|
||||
List of HTML files to be converted to PDF. An `index.html` file is
|
||||
required, and any other resources that are referenced through the
|
||||
HTML file must be included as well. All the referenced files must be
|
||||
on the same level as the `index.html` file.
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
marginTop:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Top margin for the page in inches.
|
||||
marginBottom:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Bottom margin for the page in inches.
|
||||
marginLeft:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Left margin for the page in inches.
|
||||
marginRight:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Right margin for the page in inches.
|
||||
paperWidth:
|
||||
type: number
|
||||
example: 8.27
|
||||
description: >-
|
||||
Paper width to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
paperHeight:
|
||||
type: number
|
||||
example: 11.69
|
||||
description: >-
|
||||
Paper height to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
preferCssPageSize:
|
||||
type: boolean
|
||||
description: >-
|
||||
Define whether to prefer page size as defined by CSS (default false)
|
||||
default: false
|
||||
printBackground:
|
||||
type: boolean
|
||||
description: >-
|
||||
Print the background graphics (default false)
|
||||
default: false
|
||||
landscape:
|
||||
type: boolean
|
||||
example: true
|
||||
default: false
|
||||
description: >-
|
||||
The default orientation for rendering the page is "portrait" mode.
|
||||
By sending "landscape" parameter, you can ask the output to be
|
||||
landscape.
|
||||
scale:
|
||||
type: number
|
||||
minimum: 0.1
|
||||
maximum: 2.0
|
||||
example: 1.5
|
||||
description: >-
|
||||
The scale of the page rendering
|
||||
default: 1.0
|
||||
waitDelay:
|
||||
type: string
|
||||
example: 5s
|
||||
description: >-
|
||||
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
|
||||
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
|
||||
you're trying to generate.
|
||||
waitWindowStatus:
|
||||
type: string
|
||||
example: done
|
||||
description: >-
|
||||
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
|
||||
For instance
|
||||
await promises()
|
||||
window.status = 'ready'
|
||||
Prefer this option over waitDelay.
|
||||
extraHttpHeaders:
|
||||
type: string
|
||||
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
|
||||
nativePageRanges:
|
||||
type: string
|
||||
example: 1-4
|
||||
description: >-
|
||||
The page ranges to be converted to PDF for the incoming Office
|
||||
documents.
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: >-
|
||||
The PDF format of the resulting PDF.
|
||||
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
|
||||
example: PDF/A-1a
|
||||
required:
|
||||
- files
|
||||
MarkdownConvertRequestBody:
|
||||
title: Markdown Conversion Request Body
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
marginTop:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Top margin for the page in inches.
|
||||
marginBottom:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Bottom margin for the page in inches.
|
||||
marginLeft:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Left margin for the page in inches.
|
||||
marginRight:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Right margin for the page in inches.
|
||||
paperWidth:
|
||||
type: number
|
||||
example: 8.27
|
||||
description: >-
|
||||
Paper width to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
paperHeight:
|
||||
type: number
|
||||
example: 11.69
|
||||
description: >-
|
||||
Paper height to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
preferCssPageSize:
|
||||
type: boolean
|
||||
description: >-
|
||||
Define whether to prefer page size as defined by CSS (default false)
|
||||
default: false
|
||||
printBackground:
|
||||
type: boolean
|
||||
description: >-
|
||||
Print the background graphics (default false)
|
||||
default: false
|
||||
landscape:
|
||||
type: boolean
|
||||
example: true
|
||||
default: false
|
||||
description: >-
|
||||
The default orientation for rendering the page is "portrait" mode.
|
||||
By sending "landscape" parameter, you can ask the output to be
|
||||
landscape.
|
||||
scale:
|
||||
type: number
|
||||
minimum: 0.1
|
||||
maximum: 2.0
|
||||
example: 1.5
|
||||
description: >-
|
||||
The scale of the page rendering
|
||||
default: 1.0
|
||||
waitDelay:
|
||||
type: string
|
||||
example: 5s
|
||||
description: >-
|
||||
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
|
||||
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
|
||||
you're trying to generate.
|
||||
waitWindowStatus:
|
||||
type: string
|
||||
example: done
|
||||
description: >-
|
||||
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
|
||||
For instance
|
||||
await promises()
|
||||
window.status = 'ready'
|
||||
Prefer this option over waitDelay.
|
||||
extraHttpHeaders:
|
||||
type: string
|
||||
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
|
||||
nativePageRanges:
|
||||
type: string
|
||||
example: 1-4
|
||||
description: >-
|
||||
The page ranges to be converted to PDF for the incoming Office
|
||||
documents.
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: >-
|
||||
The PDF format of the resulting PDF.
|
||||
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
|
||||
example: PDF/A-1a
|
||||
required:
|
||||
- files
|
||||
URLConvertRequestBody:
|
||||
title: URL Conversion Request Body
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
example: 'https://google.com'
|
||||
files:
|
||||
description: Optional files named header.html and footer.html
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
marginTop:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Top margin for the page in inches.
|
||||
marginBottom:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Bottom margin for the page in inches.
|
||||
marginLeft:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Left margin for the page in inches.
|
||||
marginRight:
|
||||
type: number
|
||||
example: 0
|
||||
default: 1
|
||||
description: Right margin for the page in inches.
|
||||
paperWidth:
|
||||
type: number
|
||||
example: 8.27
|
||||
description: >-
|
||||
Paper width to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
paperHeight:
|
||||
type: number
|
||||
example: 11.69
|
||||
description: >-
|
||||
Paper height to be used while rendering the PDF. The default page
|
||||
size is A4.
|
||||
preferCssPageSize:
|
||||
type: boolean
|
||||
description: >-
|
||||
Define whether to prefer page size as defined by CSS (default false)
|
||||
default: false
|
||||
printBackground:
|
||||
type: boolean
|
||||
description: >-
|
||||
Print the background graphics (default false)
|
||||
default: false
|
||||
landscape:
|
||||
type: boolean
|
||||
example: true
|
||||
default: false
|
||||
description: >-
|
||||
The default orientation for rendering the page is "portrait" mode.
|
||||
By sending "landscape" parameter, you can ask the output to be
|
||||
landscape.
|
||||
scale:
|
||||
type: number
|
||||
minimum: 0.1
|
||||
maximum: 2.0
|
||||
example: 1.5
|
||||
description: >-
|
||||
The scale of the page rendering
|
||||
default: 1.0
|
||||
waitDelay:
|
||||
type: string
|
||||
example: 5s
|
||||
description: >-
|
||||
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
|
||||
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
|
||||
you're trying to generate.
|
||||
waitWindowStatus:
|
||||
type: string
|
||||
example: done
|
||||
description: >-
|
||||
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
|
||||
For instance
|
||||
await promises()
|
||||
window.status = 'ready'
|
||||
Prefer this option over waitDelay.
|
||||
extraHttpHeaders:
|
||||
type: string
|
||||
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
|
||||
nativePageRanges:
|
||||
type: string
|
||||
example: 1-4
|
||||
description: >-
|
||||
The page ranges to be converted to PDF for the incoming Office
|
||||
documents.
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: >-
|
||||
The PDF format of the resulting PDF.
|
||||
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
|
||||
example: PDF/A-1a
|
||||
required:
|
||||
- url
|
||||
OfficeConvertRequestBody:
|
||||
title: Office Conversion Request Body
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
nativePageRanges:
|
||||
type: string
|
||||
example: 1-4
|
||||
description: >-
|
||||
The page ranges to be converted to PDF for the incoming Office
|
||||
documents. **If there are multiple files sent to the API, this page
|
||||
range will apply to all of the documents**. Empty means all pages.
|
||||
nativePdfA1aFormat:
|
||||
type: boolean
|
||||
description: >-
|
||||
Use unoconv to convert the resulting PDF to the 'PDF/A-1a' format.
|
||||
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
|
||||
pdfFormat:
|
||||
type: string
|
||||
description: >-
|
||||
The PDF format of the resulting PDF.
|
||||
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
|
||||
example: PDF/A-1a
|
||||
landscape:
|
||||
type: boolean
|
||||
example: true
|
||||
default: false
|
||||
description: >-
|
||||
The default orientation for rendering the page is "portrait" mode.
|
||||
By sending "landscape" parameter, you can ask the output to be
|
||||
landscape.
|
||||
merge:
|
||||
type: boolean
|
||||
description: >-
|
||||
Merge all PDF files into an individual PDF file.
|
||||
required:
|
||||
- files
|
||||
MergeFilesRequestBody:
|
||||
title: Merge Files Request Body
|
||||
type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
required:
|
||||
- files
|
||||
securitySchemes: { }
|
||||
responses:
|
||||
SuccessfulPDF:
|
||||
description: Resulting PDF file from the conversion.
|
||||
content:
|
||||
application/pdf:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
66
go.mod
Normal file
66
go.mod
Normal file
@@ -0,0 +1,66 @@
|
||||
module github.com/gotenberg/gotenberg/v7
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.6.0
|
||||
github.com/andybalholm/brotli v1.0.4 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20220530001853-c0f376d894d1
|
||||
github.com/chromedp/chromedp v0.8.2
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.1
|
||||
github.com/klauspost/compress v1.15.6 // indirect
|
||||
github.com/klauspost/pgzip v1.2.5 // indirect
|
||||
github.com/labstack/echo/v4 v4.7.2
|
||||
github.com/labstack/gommon v0.3.1
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/mholt/archiver/v3 v3.5.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.18
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/pdfcpu/pdfcpu v0.3.13
|
||||
github.com/pierrec/lz4/v4 v4.1.14 // indirect
|
||||
github.com/prometheus/client_golang v1.12.2
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/ulikunitz/xz v0.5.10 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.8.0
|
||||
go.uber.org/zap v1.21.0
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
|
||||
golang.org/x/image v0.0.0-20220601225756-64ec528b34cd // indirect
|
||||
golang.org/x/net v0.0.0-20220607020251-c690dde0001d
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
|
||||
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467
|
||||
golang.org/x/text v0.3.7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.1.0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650 // indirect
|
||||
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.34.0 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
614
go.sum
Normal file
614
go.sum
Normal file
@@ -0,0 +1,614 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alexliesenfeld/health v0.6.0 h1:HRBTCgybNSe4lqGEk7nU82c3bjwh9W+3b46W6UvD4CQ=
|
||||
github.com/alexliesenfeld/health v0.6.0/go.mod h1:N4NDIeQtlWumG+6z1ne1v62eQxktz5ylEgGgH9emdMw=
|
||||
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20220530001853-c0f376d894d1 h1:dl8AS1mmnSC7kTpRKD4mrDrV3HJqFAm975HWap3HGg0=
|
||||
github.com/chromedp/cdproto v0.0.0-20220530001853-c0f376d894d1/go.mod h1:5Y4sD/eXpwrChIuxhSr/G20n9CdbCmoerOHnuAf0Zr0=
|
||||
github.com/chromedp/chromedp v0.8.2 h1:EYSsSqWuKYwyHZEJpU00kOGOMz5DE0qDVckelzauMFA=
|
||||
github.com/chromedp/chromedp v0.8.2/go.mod h1:vpbCNtfYeOUo2q5reuwX6ZmPpbHRf5PZfAqNR2ObB+g=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
|
||||
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
|
||||
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hhrutter/lzw v0.0.0-20190827003112-58b82c5a41cc/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk=
|
||||
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650 h1:1yY/RQWNSBjJe2GDCIYoLmpWVidrooriUr4QS/zaATQ=
|
||||
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk=
|
||||
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 h1:o1wMw7uTNyA58IlEdDpxIrtFHTgnvYzA8sCQz8luv94=
|
||||
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7/go.mod h1:WkUxfS2JUu3qPo6tRld7ISb8HiC0gVSU91kooBMDVok=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.15.6 h1:6D9PcO8QWu0JyaQ2zUMmu16T1T+zjjEpP91guRsvDfY=
|
||||
github.com/klauspost/compress v1.15.6/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE=
|
||||
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/labstack/echo/v4 v4.7.2 h1:Kv2/p8OaQ+M6Ex4eGimg9b9e6icoxA42JSlOR3msKtI=
|
||||
github.com/labstack/echo/v4 v4.7.2/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
|
||||
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
|
||||
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
|
||||
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.18 h1:6HcxvXDAi3ARt3slx6nTesbvorIc3QeTzBNRvWktHBo=
|
||||
github.com/microcosm-cc/bluemonday v1.0.18/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5 h1:1SoBaSPudixRecmlHXb/GxmaD3fLMtHIDN13QujwQuc=
|
||||
github.com/pdfcpu/pdfcpu v0.3.13 h1:VFon2Yo1PJt+sA57vPAeXWGLSZ7Ux3Jl4h02M0+s3dg=
|
||||
github.com/pdfcpu/pdfcpu v0.3.13/go.mod h1:UJc5xsXg0fpmjp1zOPdyYcAQArc/Zf3V0nv5URe+9fg=
|
||||
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
|
||||
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
|
||||
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
|
||||
github.com/prometheus/client_golang v1.12.2 h1:51L9cDoUHVrXx4zWYlcLQIZ+d+VXHgqnYKkIuq4g/34=
|
||||
github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
|
||||
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
|
||||
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
|
||||
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
|
||||
github.com/prometheus/common v0.34.0 h1:RBmGO9d/FVjqHT0yUGQwBJhkwKV+wPCn7KGpvfab0uE=
|
||||
github.com/prometheus/common v0.34.0/go.mod h1:gB3sOl7P0TvJabZpLY5uQMpUqRCPPCyRLCZYc7JZTNE=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
|
||||
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8=
|
||||
github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
|
||||
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20190823064033-3a9bac650e44/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20220601225756-64ec528b34cd h1:9NbNcTg//wfC5JskFW4Z3sqwVnjmJKHxLAol1bW2qgw=
|
||||
golang.org/x/image v0.0.0-20220601225756-64ec528b34cd/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220607020251-c690dde0001d h1:4SFsTMi4UahlKoloni7L4eYzhFRifURQLw+yv0QDCx8=
|
||||
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8=
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
|
||||
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
90
main.go
90
main.go
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
Package main handles the application startup and shutdown.
|
||||
|
||||
Gotenberg is a stateless API for converting Markdown files, HTML files and Office documents to PDF.
|
||||
|
||||
For more information, go to https://github.com/thecodingmachine/gotenberg.
|
||||
*/
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app"
|
||||
"github.com/thecodingmachine/gotenberg/app/config"
|
||||
"github.com/thecodingmachine/gotenberg/app/converter/process"
|
||||
"github.com/thecodingmachine/gotenberg/app/logger"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// version will be set on build time.
|
||||
var version = "master"
|
||||
|
||||
// defaultConfigurationFilePath is our default configuration file to parse.
|
||||
const defaultConfigurationFilePath = "gotenberg.yml"
|
||||
|
||||
// main initializes the application, starts it, and handles
|
||||
// graceful shutdown.
|
||||
func main() {
|
||||
c, err := config.NewAppConfig(defaultConfigurationFilePath)
|
||||
if err != nil {
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.Fatal(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// defines our application logging.
|
||||
logger.SetLevel(c.Logs.Level)
|
||||
logger.SetFormatter(c.Logs.Formatter)
|
||||
|
||||
// defines our application router.
|
||||
r := mux.NewRouter()
|
||||
r.Handle("/", app.GetHandlersChain()).Methods(http.MethodPost)
|
||||
|
||||
// defines our server.
|
||||
s := &http.Server{
|
||||
Addr: fmt.Sprintf(":%s", c.Port),
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
process.Load(c.CommandsConfig)
|
||||
logger.Infof("Starting Gotenberg version %s", version)
|
||||
logger.Infof("Listening on port %s", c.Port)
|
||||
|
||||
// runs our server in a goroutine so that it doesn't block.
|
||||
go func() {
|
||||
if err = s.ListenAndServe(); err != nil {
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.Panic(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
// we'll accept graceful shutdowns when quit via SIGINT (Ctrl+C)
|
||||
// SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught.
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
|
||||
// blocks until we receive our signal.
|
||||
<-quit
|
||||
|
||||
// creates a deadline to wait for.
|
||||
var wait time.Duration
|
||||
ctx, cancel := context.WithTimeout(context.Background(), wait)
|
||||
defer cancel()
|
||||
|
||||
// doesn't block if no connections, but will otherwise wait
|
||||
// until the timeout deadline.
|
||||
s.Shutdown(ctx)
|
||||
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.Info("Bye!")
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
payload:
|
||||
|
||||
- key: Version
|
||||
value: snapshot
|
||||
|
||||
- key: Latest
|
||||
value: 1.0.0
|
||||
29
orbit.yml
29
orbit.yml
@@ -1,29 +0,0 @@
|
||||
tasks:
|
||||
|
||||
- use: fmt
|
||||
short: Runs go fmt ./...
|
||||
run:
|
||||
- go fmt ./...
|
||||
|
||||
- use: generate
|
||||
short: Generates all files from blueprints
|
||||
run:
|
||||
- BRANCH="$(git symbolic-ref --short HEAD)"; orbit generate -f .blueprints/README.blueprint.md -o README.md -p "Branch,${BRANCH}"
|
||||
- orbit generate -f .blueprints/Dockerfile.blueprint -o Dockerfile.ci -p "Image,CI"
|
||||
- orbit generate -f .blueprints/Dockerfile.blueprint -o Dockerfile -p "Image,MAIN"
|
||||
|
||||
- use: ci
|
||||
short: Runs CI process inside a container
|
||||
run:
|
||||
- docker build -t thecodingmachine/gotenberg:ci -f Dockerfile.ci .
|
||||
- docker run --rm -e "VERSION={{ .Orbit.Version }}" -v "$(pwd)/.ci:/go/src/github.com/thecodingmachine/gotenberg/.ci" thecodingmachine/gotenberg:ci
|
||||
|
||||
- use: build
|
||||
short: Builds the main Docker image
|
||||
run:
|
||||
- docker build . -t thecodingmachine/gotenberg:{{ .Orbit.Version }}
|
||||
|
||||
- use: up
|
||||
short: Starts the {{ .Orbit.Version }} version of the Gotenberg image
|
||||
run:
|
||||
- docker run --rm -p 3000:3000/tcp thecodingmachine/gotenberg:{{ .Orbit.Version }}
|
||||
206
pkg/gotenberg/cmd.go
Normal file
206
pkg/gotenberg/cmd.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Cmd wraps an exec.Cmd.
|
||||
type Cmd struct {
|
||||
ctx context.Context
|
||||
logger *zap.Logger
|
||||
process *exec.Cmd
|
||||
}
|
||||
|
||||
// Command creates a Cmd without a context. It configures the internal
|
||||
// exec.Cmd of Cmd so that we may kill its unix process and all its children
|
||||
// without creating orphans.
|
||||
//
|
||||
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
|
||||
func Command(logger *zap.Logger, binPath string, args ...string) Cmd {
|
||||
cmd := exec.Command(binPath, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
return Cmd{
|
||||
ctx: nil,
|
||||
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
|
||||
process: cmd,
|
||||
}
|
||||
}
|
||||
|
||||
// CommandContext creates a Cmd with a context. It configures the internal
|
||||
// exec.Cmd of Cmd so that we may kill its unix process and all its children
|
||||
// without creating orphans.
|
||||
//
|
||||
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
|
||||
func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, args ...string) (Cmd, error) {
|
||||
if ctx == nil {
|
||||
return Cmd{}, errors.New("nil context")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, binPath, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
return Cmd{
|
||||
ctx: ctx,
|
||||
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
|
||||
process: cmd,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start starts the command but does not wait for its completion.
|
||||
func (cmd Cmd) Start() error {
|
||||
err := cmd.pipeOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pipe unix process output: %w", err)
|
||||
}
|
||||
|
||||
cmd.logger.Debug(fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
|
||||
|
||||
err = cmd.process.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("start unix process: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait waits for the command to complete. It should be called when using the
|
||||
// Start method, so that the command does not leak zombies.
|
||||
func (cmd Cmd) Wait() error {
|
||||
err := cmd.process.Wait()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for unix process: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exec executes the command and wait for its completion or until the context
|
||||
// is done. In any case, it kills the unix process and all its children.
|
||||
func (cmd Cmd) Exec() (int, error) {
|
||||
if cmd.ctx == nil {
|
||||
return 10, errors.New("nil context")
|
||||
}
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
if cmd.process.ProcessState == nil {
|
||||
return 131, fmt.Errorf("start command: %w", err)
|
||||
}
|
||||
|
||||
return cmd.process.ProcessState.ExitCode(), fmt.Errorf("start command: %w", err)
|
||||
}
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
errChan <- cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-errChan:
|
||||
errProc := cmd.Kill()
|
||||
if errProc != nil {
|
||||
cmd.logger.Error(errProc.Error())
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if cmd.process.ProcessState == nil {
|
||||
return 131, fmt.Errorf("unix process error: %w", err)
|
||||
}
|
||||
|
||||
return cmd.process.ProcessState.ExitCode(), fmt.Errorf("unix process error: %w", err)
|
||||
case <-cmd.ctx.Done():
|
||||
errProc := cmd.Kill()
|
||||
if errProc != nil {
|
||||
cmd.logger.Error(errProc.Error())
|
||||
}
|
||||
|
||||
return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// pipeOutput creates logs entries according to the process stdout and stderr.
|
||||
// It does nothing if the logging level is not debug.
|
||||
func (cmd Cmd) pipeOutput() error {
|
||||
checkedEntry := cmd.logger.Check(zap.DebugLevel, "check for debug level before piping unix process output")
|
||||
if checkedEntry == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stdout, err := cmd.process.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pipe unix process stdout: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.process.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unix process sdterr: %w", err)
|
||||
}
|
||||
|
||||
// logCommandOutput creates logs entries according to a reader
|
||||
// (either stdout or stderr).
|
||||
logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) {
|
||||
r := bufio.NewReader(reader)
|
||||
defer reader.Close()
|
||||
|
||||
for {
|
||||
line, _, err := r.ReadLine()
|
||||
|
||||
if err != nil {
|
||||
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
|
||||
logger.Error(fmt.Sprintf("pipe unix process output error: %s", err))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if len(line) != 0 {
|
||||
logger.Debug(string(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go logCommandOutput(cmd.logger.Named("stdout"), stdout)
|
||||
go logCommandOutput(cmd.logger.Named("stderr"), stderr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kill kills the unix process and all its children without creating orphans.
|
||||
//
|
||||
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
|
||||
func (cmd Cmd) Kill() error {
|
||||
if cmd.process == nil {
|
||||
// We cannot use the logger here, because for whatever reason using it
|
||||
// result to a panic.
|
||||
// cmd.logger.Debug("no process, skip killing")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL)
|
||||
if err == nil {
|
||||
cmd.logger.Debug("unix process killed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the process does not exist anymore, the error is irrelevant.
|
||||
if strings.Contains(err.Error(), "no such process") {
|
||||
cmd.logger.Debug("unix process already killed")
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("kill unix process: %w", err)
|
||||
}
|
||||
324
pkg/gotenberg/cmd_test.go
Normal file
324
pkg/gotenberg/cmd_test.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestCommand(t *testing.T) {
|
||||
cmd := Command(zap.NewNop(), "foo")
|
||||
|
||||
if !cmd.process.SysProcAttr.Setpgid {
|
||||
t.Error("expected cmd.process.SysProcAttr.Setpgid to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
expectCommandContextErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
ctx: context.Background(),
|
||||
},
|
||||
{
|
||||
name: "nil context",
|
||||
expectCommandContextErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
|
||||
|
||||
if err == nil && !cmd.process.SysProcAttr.Setpgid {
|
||||
t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true")
|
||||
}
|
||||
|
||||
if tc.expectCommandContextErr && err == nil {
|
||||
t.Error("expected error from CommandContext(), but got none")
|
||||
}
|
||||
|
||||
if !tc.expectCommandContextErr && err != nil {
|
||||
t.Errorf("expected no error from CommandContext(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Start(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
expectStartErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
},
|
||||
{
|
||||
name: "start error",
|
||||
cmd: Command(zap.NewNop(), "foo"),
|
||||
expectStartErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.cmd.Start()
|
||||
|
||||
if tc.expectStartErr && err == nil {
|
||||
t.Error("expected error from cmd.Start(), but got none")
|
||||
}
|
||||
|
||||
if !tc.expectStartErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.Start(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Wait(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
expectWaitErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: func() Cmd {
|
||||
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.Start(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "wait error",
|
||||
cmd: func() Cmd {
|
||||
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
|
||||
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.Start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = cmd.Kill()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.Kill(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectWaitErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.cmd.Wait()
|
||||
|
||||
if tc.expectWaitErr && err == nil {
|
||||
t.Error("expected error from cmd.Wait(), but got none")
|
||||
}
|
||||
|
||||
if !tc.expectWaitErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.Wait(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Exec(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
timeout time.Duration
|
||||
expectExecErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: func() Cmd {
|
||||
cmd, err := CommandContext(context.Background(), zap.NewNop(), "echo", "Hello", "World")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from CommandContext(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "nil context",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
expectExecErr: true,
|
||||
},
|
||||
{
|
||||
name: "start error",
|
||||
cmd: func() Cmd {
|
||||
cmd, err := CommandContext(context.Background(), zap.NewNop(), "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from CommandContext(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectExecErr: true,
|
||||
},
|
||||
{
|
||||
name: "context done",
|
||||
cmd: Command(zap.NewNop(), "sleep", "2"),
|
||||
timeout: time.Duration(1) * time.Second,
|
||||
expectExecErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.timeout > 0 {
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
|
||||
defer cancel()
|
||||
|
||||
tc.cmd.ctx = ctx
|
||||
}
|
||||
|
||||
_, err := tc.cmd.Exec()
|
||||
|
||||
if tc.expectExecErr && err == nil {
|
||||
t.Error("expected error from cmd.Exec(), but got none")
|
||||
}
|
||||
|
||||
if !tc.expectExecErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.Exec(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_pipeOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
run bool
|
||||
expectPipeOutputErr bool
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
|
||||
run: true,
|
||||
},
|
||||
{
|
||||
name: "no debug, no pipe",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
},
|
||||
{
|
||||
name: "stdout already piped",
|
||||
cmd: func() Cmd {
|
||||
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
|
||||
|
||||
_, err := cmd.process.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.process.StdoutPipe(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectPipeOutputErr: true,
|
||||
},
|
||||
{
|
||||
name: "stderr already piped",
|
||||
cmd: func() Cmd {
|
||||
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
|
||||
|
||||
_, err := cmd.process.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.process.StderrPipe(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
expectPipeOutputErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.cmd.pipeOutput()
|
||||
|
||||
if tc.run {
|
||||
errStart := tc.cmd.process.Start()
|
||||
if errStart != nil {
|
||||
t.Fatalf("expected no error from tc.cmd.process.Start(), but got: %v", errStart)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectPipeOutputErr && err == nil {
|
||||
t.Error("expected error from cmd.pipeOutput(), but got none")
|
||||
}
|
||||
|
||||
if !tc.expectPipeOutputErr && err != nil {
|
||||
t.Errorf("expected no error from cmd.pipeOutput(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmd_Kill(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd Cmd
|
||||
}{
|
||||
{
|
||||
name: "nominal behavior",
|
||||
cmd: func() Cmd {
|
||||
cmd := Command(zap.NewNop(), "sleep", "60")
|
||||
|
||||
err := cmd.process.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "no process",
|
||||
cmd: Cmd{logger: zap.NewNop()},
|
||||
},
|
||||
{
|
||||
name: "process already killed",
|
||||
cmd: func() Cmd {
|
||||
cmd := Command(zap.NewNop(), "sleep", "60")
|
||||
|
||||
err := cmd.process.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = cmd.Kill()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error from cmd.Kill(), but got: %v", err)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.cmd.Kill()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from cmd.Kill(), but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
125
pkg/gotenberg/context.go
Normal file
125
pkg/gotenberg/context.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// Context is a struct which helps initializing modules. When provisioning, a
|
||||
// module may use the context to get other modules that it needs internally.
|
||||
type Context struct {
|
||||
flags ParsedFlags
|
||||
descriptors []ModuleDescriptor
|
||||
moduleInstances map[string]interface{}
|
||||
}
|
||||
|
||||
// NewContext creates a Context.
|
||||
// In a module, prefer the Provisioner interface to get a Context.
|
||||
func NewContext(
|
||||
flags ParsedFlags,
|
||||
descriptors []ModuleDescriptor,
|
||||
) *Context {
|
||||
return &Context{
|
||||
flags: flags,
|
||||
descriptors: descriptors,
|
||||
moduleInstances: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// ParsedFlags returns the parsed flags.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// flags := ctx.ParsedFlags()
|
||||
// m.foo = flags.RequiredString("foo")
|
||||
// }
|
||||
func (ctx Context) ParsedFlags() ParsedFlags {
|
||||
return ctx.flags
|
||||
}
|
||||
|
||||
// Module returns a module which satisfies the requested interface.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// mod, _ := ctx.Module(new(ModuleInterface))
|
||||
// real := mod.(ModuleInterface)
|
||||
// }
|
||||
//
|
||||
// If the module has not yet been initialized, this method
|
||||
// initializes it. Otherwise, returns the already initialized instance.
|
||||
func (ctx *Context) Module(kind interface{}) (interface{}, error) {
|
||||
mods, err := ctx.Modules(kind)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get module: %w", err)
|
||||
}
|
||||
|
||||
if len(mods) != 1 {
|
||||
return nil, fmt.Errorf("expected to have one and only one %s module", kind)
|
||||
}
|
||||
|
||||
return mods[0], nil
|
||||
}
|
||||
|
||||
// Modules returns the list of modules which satisfies the requested interface.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// mods, _ := ctx.Modules(new(ModuleInterface))
|
||||
// for _, mod := range mods {
|
||||
// real := mod.(ModuleInterface)
|
||||
// // ...
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// If one or more modules have not yet been initialized, this method
|
||||
// initializes them. Otherwise, returns the already initialized instances.
|
||||
func (ctx *Context) Modules(kind interface{}) ([]interface{}, error) {
|
||||
realKind := reflect.TypeOf(kind).Elem()
|
||||
|
||||
var mods []interface{}
|
||||
|
||||
for _, desc := range ctx.descriptors {
|
||||
newInstance := desc.New()
|
||||
|
||||
if ok := reflect.TypeOf(newInstance).Implements(realKind); ok {
|
||||
// The module implements the requested interface.
|
||||
// We check if it has already been initialized.
|
||||
instance, ok := ctx.moduleInstances[desc.ID]
|
||||
|
||||
if ok {
|
||||
mods = append(mods, instance)
|
||||
} else {
|
||||
err := ctx.loadModule(desc.ID, newInstance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mods = append(mods, newInstance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mods, nil
|
||||
}
|
||||
|
||||
// loadModule calls the Provision and/or Validate methods of the requested
|
||||
// module if it satisfies the Provisioner and/or Validator interfaces.
|
||||
func (ctx *Context) loadModule(id string, instance interface{}) error {
|
||||
if prov, ok := instance.(Provisioner); ok {
|
||||
// The instance can be provisioned.
|
||||
err := prov.Provision(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("provision module %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
if validator, ok := instance.(Validator); ok {
|
||||
// The instance can be validated.
|
||||
err := validator.Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate module %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.moduleInstances[id] = instance
|
||||
|
||||
return nil
|
||||
}
|
||||
195
pkg/gotenberg/context_test.go
Normal file
195
pkg/gotenberg/context_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewContext(t *testing.T) {
|
||||
if NewContext(ParsedFlags{}, nil) == nil {
|
||||
t.Error("expected a non-nil value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_ParsedFlags(t *testing.T) {
|
||||
ctx := NewContext(ParsedFlags{}, nil)
|
||||
|
||||
actual := ctx.ParsedFlags()
|
||||
expect := ParsedFlags{}
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_Module(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
mods []ModuleDescriptor
|
||||
kind interface{}
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return errors.New("foo") }
|
||||
|
||||
return []ModuleDescriptor{mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return nil }
|
||||
|
||||
return []ModuleDescriptor{mod.Descriptor(), mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return nil }
|
||||
|
||||
return []ModuleDescriptor{mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
},
|
||||
} {
|
||||
|
||||
ctx := NewContext(ParsedFlags{}, tc.mods)
|
||||
_, err := ctx.Module(tc.kind)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_Modules(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
mods []ModuleDescriptor
|
||||
kind interface{}
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return errors.New("foo") }
|
||||
|
||||
return []ModuleDescriptor{mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return nil }
|
||||
|
||||
return []ModuleDescriptor{mod.Descriptor(), mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
},
|
||||
{
|
||||
mods: func() []ModuleDescriptor {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return nil }
|
||||
|
||||
return []ModuleDescriptor{mod.Descriptor()}
|
||||
}(),
|
||||
kind: new(Provisioner),
|
||||
},
|
||||
} {
|
||||
|
||||
ctx := NewContext(ParsedFlags{}, tc.mods)
|
||||
_, err := ctx.Modules(tc.kind)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_loadModule(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
instance interface{}
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
instance: func() interface{} {
|
||||
mod := struct{ ProtoProvisioner }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.provision = func(ctx *Context) error { return errors.New("foo") }
|
||||
|
||||
return mod
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
instance: func() interface{} {
|
||||
mod := struct{ ProtoValidator }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error { return errors.New("foo") }
|
||||
|
||||
return mod
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
instance: func() interface{} {
|
||||
mod := struct{ ProtoValidator }{}
|
||||
mod.descriptor = func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error { return nil }
|
||||
|
||||
return mod
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
|
||||
ctx := NewContext(ParsedFlags{}, nil)
|
||||
err := ctx.loadModule("foo", tc.instance)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
7
pkg/gotenberg/doc.go
Normal file
7
pkg/gotenberg/doc.go
Normal file
@@ -0,0 +1,7 @@
|
||||
// Package gotenberg provides most of the logic of the module system.
|
||||
//
|
||||
// caddyserver/caddy, licensed under the Apache License 2.0, has significantly
|
||||
// inspired this module system.
|
||||
//
|
||||
// More details are available on https://caddyserver.com/.
|
||||
package gotenberg
|
||||
198
pkg/gotenberg/flags.go
Normal file
198
pkg/gotenberg/flags.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/gommon/bytes"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// ParsedFlags wraps a flag.FlagSet so that retrieving the typed values is
|
||||
// easier.
|
||||
type ParsedFlags struct {
|
||||
*flag.FlagSet
|
||||
}
|
||||
|
||||
// MustString returns the string value of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustString(name string) string {
|
||||
val, err := f.GetString(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// MustDeprecatedString returns the string value of a deprecated flag if it was
|
||||
// explicitly set or the string value of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedString(deprecated string, newName string) string {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustString(deprecated)
|
||||
}
|
||||
|
||||
return f.MustString(newName)
|
||||
}
|
||||
|
||||
// MustStringSlice returns the string slice value of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustStringSlice(name string) []string {
|
||||
val, err := f.GetStringSlice(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// MustDeprecatedStringSlice returns the string slice value of a deprecated
|
||||
// flag if it was explicitly set or the string slice value of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedStringSlice(deprecated string, newName string) []string {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustStringSlice(deprecated)
|
||||
}
|
||||
|
||||
return f.MustStringSlice(newName)
|
||||
}
|
||||
|
||||
// MustBool returns the boolean value of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustBool(name string) bool {
|
||||
val, err := f.GetBool(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// MustDeprecatedBool returns the boolean value of a deprecated flag if it was
|
||||
// explicitly set or the int value of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedBool(deprecated string, newName string) bool {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustBool(deprecated)
|
||||
}
|
||||
|
||||
return f.MustBool(newName)
|
||||
}
|
||||
|
||||
// MustInt returns the int value of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustInt(name string) int {
|
||||
val, err := f.GetInt(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// MustDeprecatedInt returns the int value of a deprecated flag if it was
|
||||
// explicitly set or the int value of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedInt(deprecated string, newName string) int {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustInt(deprecated)
|
||||
}
|
||||
|
||||
return f.MustInt(newName)
|
||||
}
|
||||
|
||||
// MustFloat64 returns the float value of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustFloat64(name string) float64 {
|
||||
val, err := f.GetFloat64(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// MustDeprecatedFloat64 returns the float value of a deprecated flag if it was
|
||||
// explicitly set or the float value of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedFloat64(deprecated string, newName string) float64 {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustFloat64(deprecated)
|
||||
}
|
||||
|
||||
return f.MustFloat64(newName)
|
||||
}
|
||||
|
||||
// MustDuration returns the time.Duration value of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDuration(name string) time.Duration {
|
||||
val, err := f.GetDuration(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// MustDeprecatedDuration returns the time.Duration value of a deprecated flag
|
||||
// if it was explicitly set or the time.Duration value of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string) time.Duration {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustDuration(deprecated)
|
||||
}
|
||||
|
||||
return f.MustDuration(newName)
|
||||
}
|
||||
|
||||
// MustHumanReadableBytesString returns the human-readable bytes string of a
|
||||
// flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustHumanReadableBytesString(name string) string {
|
||||
val, err := f.GetString(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
_, err = bytes.Parse(val)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes
|
||||
// string of a deprecated flag if it was explicitly set or the human-readable
|
||||
// bytes string of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string, newName string) string {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustHumanReadableBytesString(deprecated)
|
||||
}
|
||||
|
||||
return f.MustHumanReadableBytesString(newName)
|
||||
}
|
||||
|
||||
// MustRegexp returns the regular expression of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustRegexp(name string) *regexp.Regexp {
|
||||
val, err := f.GetString(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return regexp.MustCompile(val)
|
||||
}
|
||||
|
||||
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
|
||||
// it was explicitly set or the regular expression of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp.Regexp {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustRegexp(deprecated)
|
||||
}
|
||||
|
||||
return f.MustRegexp(newName)
|
||||
}
|
||||
668
pkg/gotenberg/flags_test.go
Normal file
668
pkg/gotenberg/flags_test.go
Normal file
@@ -0,0 +1,668 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestParsedFlags_MustString(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=foo"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustString(tc.name)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedString(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
rawFlags []string
|
||||
expectValue string
|
||||
}{
|
||||
{
|
||||
rawFlags: []string{"--foo=foo"},
|
||||
expectValue: "foo",
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--bar=bar"},
|
||||
expectValue: "bar",
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--foo=foo", "--bar=bar"},
|
||||
expectValue: "foo",
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
fs.String("bar", "", "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedString("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustStringSlice(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.StringSlice("foo", make([]string, 0), "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=foo", "--foo=bar", "--foo=baz"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustStringSlice(tc.name)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedStringSlice(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
rawFlags []string
|
||||
expectValue []string
|
||||
}{
|
||||
{
|
||||
rawFlags: []string{"--foo=foo"},
|
||||
expectValue: []string{"foo"},
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--bar=bar"},
|
||||
expectValue: []string{"bar"},
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--foo=foo", "--bar=bar"},
|
||||
expectValue: []string{"foo"},
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.StringSlice("foo", make([]string, 0), "")
|
||||
fs.StringSlice("bar", make([]string, 0), "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedStringSlice("foo", "bar")
|
||||
if !reflect.DeepEqual(actual, tc.expectValue) {
|
||||
t.Errorf("test %d: expected %+v but got %+v", i, tc.expectValue, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustBool(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Bool("foo", false, "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=true"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustBool(tc.name)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedBool(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
rawFlags []string
|
||||
expectValue bool
|
||||
}{
|
||||
{
|
||||
rawFlags: []string{"--foo=true"},
|
||||
expectValue: true,
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--bar=false"},
|
||||
expectValue: false,
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--foo=true", "--bar=false"},
|
||||
expectValue: true,
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Bool("foo", false, "")
|
||||
fs.Bool("bar", true, "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedBool("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected %v but got %v", i, tc.expectValue, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustInt(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Int("foo", 0, "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=1"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustInt(tc.name)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedInt(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
rawFlags []string
|
||||
expectValue int
|
||||
}{
|
||||
{
|
||||
rawFlags: []string{"--foo=1"},
|
||||
expectValue: 1,
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--bar=2"},
|
||||
expectValue: 2,
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--foo=1", "--bar=2"},
|
||||
expectValue: 1,
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Int("foo", 0, "")
|
||||
fs.Int("bar", 0, "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedInt("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected %d but got %d", i, tc.expectValue, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustFloat64(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Float64("foo", 1.0, "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=2.0"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustFloat64(tc.name)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedFloat64(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
rawFlags []string
|
||||
expectValue float64
|
||||
}{
|
||||
{
|
||||
rawFlags: []string{"--foo=1.0"},
|
||||
expectValue: 1.0,
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--bar=2.0"},
|
||||
expectValue: 2.0,
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--foo=1.0", "--bar=2.0"},
|
||||
expectValue: 1.0,
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Float64("foo", 0, "")
|
||||
fs.Float64("bar", 0, "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedFloat64("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected %f but got %f", i, tc.expectValue, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDuration(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Duration("foo", time.Duration(1)*time.Second, "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=2m"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustDuration(tc.name)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedDuration(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
rawFlags []string
|
||||
expectValue time.Duration
|
||||
}{
|
||||
{
|
||||
rawFlags: []string{"--foo=1s"},
|
||||
expectValue: time.Duration(1) * time.Second,
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--bar=2s"},
|
||||
expectValue: time.Duration(2) * time.Second,
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--foo=1s", "--bar=2s"},
|
||||
expectValue: time.Duration(1) * time.Second,
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.Duration("foo", 0, "")
|
||||
fs.Duration("bar", 0, "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedDuration("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "1MB", "")
|
||||
fs.String("bar", "1MB", "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=1GB", "--bar=foo"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
name: "baz",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustHumanReadableBytesString(tc.name)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
rawFlags []string
|
||||
expectValue string
|
||||
}{
|
||||
{
|
||||
rawFlags: []string{"--foo=1MB"},
|
||||
expectValue: "1MB",
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--bar=2MB"},
|
||||
expectValue: "2MB",
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--foo=1MB", "--bar=2MB"},
|
||||
expectValue: "1MB",
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
fs.String("bar", "", "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustRegexp(t *testing.T) {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
fs.String("bar", "", "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=", "--bar=*"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
for i, tc := range []struct {
|
||||
name string
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "foo",
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
name: "baz",
|
||||
expectPanic: true,
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
parsedFlags.MustRegexp(tc.name)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
rawFlags []string
|
||||
expectValue *regexp.Regexp
|
||||
}{
|
||||
{
|
||||
rawFlags: []string{"--foo=foo"},
|
||||
expectValue: regexp.MustCompile("foo"),
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--bar=bar"},
|
||||
expectValue: regexp.MustCompile("bar"),
|
||||
},
|
||||
{
|
||||
rawFlags: []string{"--foo=foo", "--bar=bar"},
|
||||
expectValue: regexp.MustCompile("foo"),
|
||||
},
|
||||
} {
|
||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||
fs.String("foo", "", "")
|
||||
fs.String("bar", "", "")
|
||||
|
||||
parsedFlags := ParsedFlags{FlagSet: fs}
|
||||
|
||||
err := parsedFlags.Parse(tc.rawFlags)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedRegexp("foo", "bar")
|
||||
if actual.String() != tc.expectValue.String() {
|
||||
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue.String(), actual.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
33
pkg/gotenberg/fs.go
Normal file
33
pkg/gotenberg/fs.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TmpPath returns the default directory to use for temporary files and
|
||||
// directories. Most if not all files and directories created by the
|
||||
// application and its dependencies must be based on this default directory.
|
||||
func TmpPath() string {
|
||||
return os.TempDir()
|
||||
}
|
||||
|
||||
// NewDirPath returns a random absolute path based on the temporary path.
|
||||
func NewDirPath() string {
|
||||
return fmt.Sprintf("%s/%s", TmpPath(), uuid.New())
|
||||
}
|
||||
|
||||
// MkdirAll creates a random directory based on the temporary path and
|
||||
// returns its absolute path.
|
||||
func MkdirAll() (string, error) {
|
||||
path := NewDirPath()
|
||||
|
||||
err := os.MkdirAll(path, 0755)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create directory %s: %w", path, err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
59
pkg/gotenberg/fs_test.go
Normal file
59
pkg/gotenberg/fs_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTmpPath(t *testing.T) {
|
||||
osTempDir := os.TempDir()
|
||||
tmpPath := TmpPath()
|
||||
|
||||
if tmpPath != osTempDir {
|
||||
t.Errorf("expected path '%s' but got '%s'", osTempDir, tmpPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDirPath(t *testing.T) {
|
||||
newDirPath := NewDirPath()
|
||||
tmpPath := TmpPath()
|
||||
|
||||
if !strings.HasPrefix(newDirPath, tmpPath) {
|
||||
t.Fatalf("expected path '%s' to start with '%s'", newDirPath, tmpPath)
|
||||
}
|
||||
|
||||
newDirPaths := make([]string, 1000)
|
||||
for i := range newDirPaths {
|
||||
newDirPaths[i] = NewDirPath()
|
||||
}
|
||||
|
||||
for i, newDirPath := range newDirPaths {
|
||||
for j, comparison := range newDirPaths {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
|
||||
if newDirPath == comparison {
|
||||
t.Fatalf("expected path '%s' (index %d) to be unique, but found an identical path on index %d", newDirPath, i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMkdirAll(t *testing.T) {
|
||||
path, err := MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
tmpPath := TmpPath()
|
||||
if !strings.HasPrefix(path, tmpPath) {
|
||||
t.Fatalf("expected path '%s' to start with '%s'", path, tmpPath)
|
||||
}
|
||||
|
||||
_, err = os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
t.Errorf("expected path '%s' to exist but got: %v", path, err)
|
||||
}
|
||||
}
|
||||
14
pkg/gotenberg/logging.go
Normal file
14
pkg/gotenberg/logging.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package gotenberg
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
// LoggerProvider is a module interface which exposes a method for creating a
|
||||
// zap.Logger for other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
// logger, _ := provider.(gotenberg.LoggerProvider).Logger(m)
|
||||
// }
|
||||
type LoggerProvider interface {
|
||||
Logger(mod Module) (*zap.Logger, error)
|
||||
}
|
||||
26
pkg/gotenberg/metrics.go
Normal file
26
pkg/gotenberg/metrics.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package gotenberg
|
||||
|
||||
// Metric represents a unitary metric.
|
||||
type Metric struct {
|
||||
// Name is the unique identifier.
|
||||
// Required.
|
||||
Name string
|
||||
|
||||
// Description describes the metric.
|
||||
// Optional.
|
||||
Description string
|
||||
|
||||
// Read returns the current value.
|
||||
// Required.
|
||||
Read func() float64
|
||||
}
|
||||
|
||||
// MetricsProvider is a module interface which provides a list of Metric.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(gotenberg.MetricsProvider))
|
||||
// metrics, _ := provider.(gotenberg.MetricsProvider).Metrics()
|
||||
// }
|
||||
type MetricsProvider interface {
|
||||
Metrics() ([]Metric, error)
|
||||
}
|
||||
66
pkg/gotenberg/mocks.go
Normal file
66
pkg/gotenberg/mocks.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ModuleMock is a mock for the Module interface.
|
||||
type ModuleMock struct {
|
||||
DescriptorMock func() ModuleDescriptor
|
||||
}
|
||||
|
||||
func (mod ModuleMock) Descriptor() ModuleDescriptor {
|
||||
return mod.DescriptorMock()
|
||||
}
|
||||
|
||||
// ValidatorMock is a mock for the Validator interface.
|
||||
type ValidatorMock struct {
|
||||
ValidateMock func() error
|
||||
}
|
||||
|
||||
func (mod ValidatorMock) Validate() error {
|
||||
return mod.ValidateMock()
|
||||
}
|
||||
|
||||
// PDFEngineMock is a mock for the PDFEngine interface.
|
||||
type PDFEngineMock struct {
|
||||
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error
|
||||
}
|
||||
|
||||
func (engine PDFEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return engine.MergeMock(ctx, logger, inputPaths, outputPath)
|
||||
}
|
||||
|
||||
func (engine PDFEngineMock) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return engine.ConvertMock(ctx, logger, format, inputPath, outputPath)
|
||||
}
|
||||
|
||||
// PDFEngineProviderMock is a mock for the PDFEngineProvider interface.
|
||||
type PDFEngineProviderMock struct {
|
||||
PDFEngineMock func() (PDFEngine, error)
|
||||
}
|
||||
|
||||
func (provider PDFEngineProviderMock) PDFEngine() (PDFEngine, error) {
|
||||
return provider.PDFEngineMock()
|
||||
}
|
||||
|
||||
// LoggerProviderMock is a mock for the LoggerProvider interface.
|
||||
type LoggerProviderMock struct {
|
||||
LoggerMock func(mod Module) (*zap.Logger, error)
|
||||
}
|
||||
|
||||
func (provider LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
|
||||
return provider.LoggerMock(mod)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Module = (*ModuleMock)(nil)
|
||||
_ Validator = (*ValidatorMock)(nil)
|
||||
_ PDFEngine = (*PDFEngineMock)(nil)
|
||||
_ PDFEngineProvider = (*PDFEngineProviderMock)(nil)
|
||||
_ LoggerProvider = (*LoggerProviderMock)(nil)
|
||||
)
|
||||
82
pkg/gotenberg/mocks_test.go
Normal file
82
pkg/gotenberg/mocks_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestModuleMock(t *testing.T) {
|
||||
mock := ModuleMock{
|
||||
DescriptorMock: func() ModuleDescriptor {
|
||||
return ModuleDescriptor{ID: "foo", New: func() Module {
|
||||
return nil
|
||||
}}
|
||||
},
|
||||
}
|
||||
|
||||
if mock.Descriptor().ID != "foo" {
|
||||
t.Errorf("expected ID '%s' from mock.Descriptor(), but got '%s'", "foo", mock.Descriptor().ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorMock(t *testing.T) {
|
||||
mock := ValidatorMock{
|
||||
ValidateMock: func() error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Validate(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngineMock(t *testing.T) {
|
||||
mock := PDFEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Merge(), but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.Convert(context.Background(), zap.NewNop(), "", "", "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Convert(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngineProvider(t *testing.T) {
|
||||
mock := PDFEngineProviderMock{
|
||||
PDFEngineMock: func() (PDFEngine, error) {
|
||||
return PDFEngineMock{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.PDFEngine()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.PDFEngine(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerProviderMock(t *testing.T) {
|
||||
mock := LoggerProviderMock{
|
||||
LoggerMock: func(mod Module) (*zap.Logger, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := mock.Logger(ModuleMock{})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from mock.Logger(), but got: %v", err)
|
||||
}
|
||||
}
|
||||
141
pkg/gotenberg/modules.go
Normal file
141
pkg/gotenberg/modules.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Module is a sort of plugin which adds new functionalities to the application
|
||||
// or other modules.
|
||||
//
|
||||
// type YourModule struct {
|
||||
// property string
|
||||
// }
|
||||
//
|
||||
// func (YourModule) Descriptor() gotenberg.ModuleDescriptor {
|
||||
// return gotenberg.ModuleDescriptor{
|
||||
// ID: "your_module",
|
||||
// FlagSet: func() *flag.FlagSet {
|
||||
// fs := flag.NewFlagSet("your_module", flag.ExitOnError)
|
||||
// fs.String("your_module-property", "default value", "flag description")
|
||||
//
|
||||
// return fs
|
||||
// }(),
|
||||
// New: func() gotenberg.Module { return new(YourModule) },
|
||||
// }
|
||||
// }
|
||||
type Module interface {
|
||||
Descriptor() ModuleDescriptor
|
||||
}
|
||||
|
||||
// ModuleDescriptor describes your module for the application.
|
||||
type ModuleDescriptor struct {
|
||||
// ID is the unique name (snake case) of the module.
|
||||
// Required.
|
||||
ID string
|
||||
|
||||
// FlagSet is the definition of the flags of the module.
|
||||
// Optional.
|
||||
FlagSet *flag.FlagSet
|
||||
|
||||
// New returns a new and empty instance of the module's type.
|
||||
// Required.
|
||||
New func() Module
|
||||
}
|
||||
|
||||
// Provisioner is a module interface for modules which have to be initialized
|
||||
// according to flags, environment variables, the context, etc.
|
||||
type Provisioner interface {
|
||||
Provision(*Context) error
|
||||
}
|
||||
|
||||
// Validator is a module interface for modules which have to be validated after
|
||||
// provisioning.
|
||||
type Validator interface {
|
||||
Validate() error
|
||||
}
|
||||
|
||||
// App is a module interface for modules which can be started or stopped by the
|
||||
// application.
|
||||
type App interface {
|
||||
Start() error
|
||||
// StartupMessage returns a custom message to display on startup. If it
|
||||
// returns an empty string, a default startup message is used instead.
|
||||
StartupMessage() string
|
||||
Stop(ctx context.Context) error
|
||||
}
|
||||
|
||||
// SystemLogger is a module interface for modules which want to display
|
||||
// messages on startup.
|
||||
type SystemLogger interface {
|
||||
SystemMessages() []string
|
||||
}
|
||||
|
||||
// MustRegisterModule registers a module.
|
||||
//
|
||||
// To register a module, create an init() method in the module main go file:
|
||||
//
|
||||
// func init() {
|
||||
// gotenberg.MustRegisterModule(YourModule{})
|
||||
// }
|
||||
//
|
||||
// Then, in the main command (github.com/gotenberg/gotenberg/v7/cmd/gotenberg),
|
||||
// import the module:
|
||||
//
|
||||
// imports (
|
||||
// // Gotenberg modules.
|
||||
// _ "your_module_path"
|
||||
// )
|
||||
func MustRegisterModule(mod Module) {
|
||||
desc := mod.Descriptor()
|
||||
|
||||
if desc.ID == "" {
|
||||
panic("module with an empty ID cannot be registered")
|
||||
}
|
||||
|
||||
if desc.New == nil {
|
||||
panic("module New function cannot be nil")
|
||||
}
|
||||
|
||||
if val := desc.New(); val == nil {
|
||||
panic("module New function cannot return a nil instance")
|
||||
}
|
||||
|
||||
descriptorsMu.Lock()
|
||||
defer descriptorsMu.Unlock()
|
||||
|
||||
if _, ok := descriptors[desc.ID]; ok {
|
||||
panic(fmt.Sprintf("module %s is already registered", desc.ID))
|
||||
}
|
||||
|
||||
descriptors[desc.ID] = desc
|
||||
}
|
||||
|
||||
// GetModuleDescriptors returns the descriptors of all registered modules.
|
||||
func GetModuleDescriptors() []ModuleDescriptor {
|
||||
descriptorsMu.RLock()
|
||||
defer descriptorsMu.RUnlock()
|
||||
|
||||
mods := make([]ModuleDescriptor, len(descriptors))
|
||||
i := 0
|
||||
|
||||
for _, desc := range descriptors {
|
||||
mods[i] = desc
|
||||
i++
|
||||
}
|
||||
|
||||
sort.Slice(mods, func(i, j int) bool {
|
||||
return mods[i].ID < mods[j].ID
|
||||
})
|
||||
|
||||
return mods
|
||||
}
|
||||
|
||||
var (
|
||||
descriptors = make(map[string]ModuleDescriptor)
|
||||
descriptorsMu sync.RWMutex
|
||||
)
|
||||
135
pkg/gotenberg/modules_test.go
Normal file
135
pkg/gotenberg/modules_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type ProtoModule struct {
|
||||
descriptor func() ModuleDescriptor
|
||||
}
|
||||
|
||||
func (mod ProtoModule) Descriptor() ModuleDescriptor {
|
||||
return mod.descriptor()
|
||||
}
|
||||
|
||||
type ProtoProvisioner struct {
|
||||
ProtoModule
|
||||
provision func(ctx *Context) error
|
||||
}
|
||||
|
||||
func (mod ProtoProvisioner) Provision(ctx *Context) error {
|
||||
return mod.provision(ctx)
|
||||
}
|
||||
|
||||
type ProtoValidator struct {
|
||||
ProtoModule
|
||||
validate func() error
|
||||
}
|
||||
|
||||
func (mod ProtoValidator) Validate() error {
|
||||
return mod.validate()
|
||||
}
|
||||
|
||||
func TestMustRegisterModule(t *testing.T) {
|
||||
descriptorsMu.RLock()
|
||||
descriptors = map[string]ModuleDescriptor{
|
||||
"a": {ID: "a"},
|
||||
}
|
||||
descriptorsMu.RUnlock()
|
||||
|
||||
for i, tc := range []struct {
|
||||
ID string
|
||||
New func() Module
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
ID: "",
|
||||
New: func() Module { return new(ProtoModule) },
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
ID: "b",
|
||||
New: nil,
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
ID: "b",
|
||||
New: func() Module { return nil },
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
ID: "a",
|
||||
New: func() Module { return new(ProtoModule) },
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
ID: "b",
|
||||
New: func() Module { return new(ProtoModule) },
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
mod := struct{ ProtoModule }{}
|
||||
mod.descriptor = func() ModuleDescriptor { return ModuleDescriptor{ID: tc.ID, New: tc.New} }
|
||||
|
||||
if tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("test %d: expected panic but got none", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if !tc.expectPanic {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("test %d: expected no panic but got: %v", i, r)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
MustRegisterModule(mod)
|
||||
}()
|
||||
}
|
||||
|
||||
descriptorsMu.RLock()
|
||||
descriptors = make(map[string]ModuleDescriptor)
|
||||
descriptorsMu.RUnlock()
|
||||
}
|
||||
|
||||
func TestGetModuleDescriptors(t *testing.T) {
|
||||
descriptorsMu.RLock()
|
||||
descriptors = map[string]ModuleDescriptor{
|
||||
"d": {ID: "d"},
|
||||
"c": {ID: "c"},
|
||||
"b": {ID: "b"},
|
||||
"a": {ID: "a"},
|
||||
}
|
||||
descriptorsMu.RUnlock()
|
||||
|
||||
expect := []ModuleDescriptor{
|
||||
{ID: "a"},
|
||||
{ID: "b"},
|
||||
{ID: "c"},
|
||||
{ID: "d"},
|
||||
}
|
||||
|
||||
actual := GetModuleDescriptors()
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
|
||||
descriptorsMu.RLock()
|
||||
descriptors = make(map[string]ModuleDescriptor)
|
||||
descriptorsMu.RUnlock()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Module = (*ProtoModule)(nil)
|
||||
_ Provisioner = (*ProtoProvisioner)(nil)
|
||||
_ Module = (*ProtoProvisioner)(nil)
|
||||
_ Validator = (*ProtoValidator)(nil)
|
||||
_ Module = (*ProtoValidator)(nil)
|
||||
)
|
||||
52
pkg/gotenberg/pdfengine.go
Normal file
52
pkg/gotenberg/pdfengine.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPDFEngineMethodNotAvailable happens if a PDFEngine method is not
|
||||
// available in the implementation.
|
||||
ErrPDFEngineMethodNotAvailable = errors.New("method not available")
|
||||
|
||||
// ErrPDFFormatNotAvailable happens if a PDFEngine Convert's method does
|
||||
// not handle a specific format.
|
||||
ErrPDFFormatNotAvailable = errors.New("PDF format not available")
|
||||
)
|
||||
|
||||
const (
|
||||
FormatPDFA1a string = "PDF/A-1a"
|
||||
FormatPDFA1b string = "PDF/A-1b"
|
||||
FormatPDFA2a string = "PDF/A-2a"
|
||||
FormatPDFA2b string = "PDF/A-2b"
|
||||
FormatPDFA2u string = "PDF/A-2u"
|
||||
FormatPDFA3a string = "PDF/A-3a"
|
||||
FormatPDFA3b string = "PDF/A-3b"
|
||||
FormatPDFA3u string = "PDF/A-3u"
|
||||
)
|
||||
|
||||
// PDFEngine is a module interface which exposes methods for manipulating one
|
||||
// or more PDFs. Implementations may abstract powerful tools like PDFtk, or
|
||||
// fulfill those methods contracts in Golang directly.
|
||||
type PDFEngine interface {
|
||||
// Merge merges the given PDFs into a unique PDF. The pages' order reflects
|
||||
// order of the given files.
|
||||
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
|
||||
// Convert converts the given PDF to a specific PDF format.
|
||||
Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error
|
||||
}
|
||||
|
||||
// PDFEngineProvider is a module interface which exposes a method for creating a
|
||||
// PDFEngine for other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(gotenberg.PDFEngineProvider))
|
||||
// pdfengines, _ := provider.(gotenberg.PDFEngineProvider).PDFEngine()
|
||||
// }
|
||||
type PDFEngineProvider interface {
|
||||
PDFEngine() (PDFEngine, error)
|
||||
}
|
||||
504
pkg/modules/api/api.go
Normal file
504
pkg/modules/api/api.go
Normal file
@@ -0,0 +1,504 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/gc"
|
||||
"github.com/labstack/echo/v4"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(API{})
|
||||
}
|
||||
|
||||
// API is a module which provides an HTTP server. Other modules may add routes,
|
||||
// middlewares or health checks.
|
||||
type API struct {
|
||||
port int
|
||||
readTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
timeout time.Duration
|
||||
rootPath string
|
||||
traceHeader string
|
||||
disableHealthCheckLogging bool
|
||||
|
||||
routes []Route
|
||||
externalMiddlewares []Middleware
|
||||
healthChecks []health.CheckerOption
|
||||
gcGraceDuration time.Duration
|
||||
logger *zap.Logger
|
||||
srv *echo.Echo
|
||||
}
|
||||
|
||||
// Router is a module interface which adds routes to the API.
|
||||
type Router interface {
|
||||
Routes() ([]Route, error)
|
||||
}
|
||||
|
||||
// Route represents a route from a Router.
|
||||
type Route struct {
|
||||
// Method is the HTTP method of the route (i.e., GET, POST, etc.).
|
||||
// Required.
|
||||
Method string
|
||||
|
||||
// Path is the sub path of the route. Must start with a slash.
|
||||
// Required.
|
||||
Path string
|
||||
|
||||
// IsMultipart tells if the route is "multipart/form-data".
|
||||
// Optional.
|
||||
IsMultipart bool
|
||||
|
||||
// DisableLogging disables the logging for this route.
|
||||
// Optional.
|
||||
DisableLogging bool
|
||||
|
||||
// Handler is the function which handles the request.
|
||||
// Required.
|
||||
Handler echo.HandlerFunc
|
||||
}
|
||||
|
||||
// MiddlewareProvider is a module interface which adds middlewares to the API.
|
||||
type MiddlewareProvider interface {
|
||||
Middlewares() ([]Middleware, error)
|
||||
}
|
||||
|
||||
// MiddlewareStack is a type which helps to determine in which stack the
|
||||
// middlewares provided by the MiddlewareProvider modules should be located.
|
||||
type MiddlewareStack uint32
|
||||
|
||||
const (
|
||||
DefaultStack MiddlewareStack = iota
|
||||
PreRouterStack
|
||||
MultipartStack
|
||||
)
|
||||
|
||||
// MiddlewarePriority is a type which helps to determine the execution order of
|
||||
// middlewares provided by the MiddlewareProvider modules in a stack.
|
||||
type MiddlewarePriority uint32
|
||||
|
||||
const (
|
||||
VeryLowPriority MiddlewarePriority = iota
|
||||
LowPriority
|
||||
MediumPriority
|
||||
HighPriority
|
||||
VeryHighPriority
|
||||
)
|
||||
|
||||
// Middleware is a middleware which can be added to the API's middlewares
|
||||
// chain.
|
||||
//
|
||||
// middleware := Middleware{
|
||||
// Handler: func() echo.MiddlewareFunc {
|
||||
// return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
// return func(c echo.Context) error {
|
||||
// rootPath := c.Get("rootPath").(string)
|
||||
// healthURI := fmt.Sprintf("%shealth", rootPath)
|
||||
//
|
||||
// // Skip the middleware if health check URI.
|
||||
// if c.Request().RequestURI == healthURI {
|
||||
// // Call the next middleware in the chain.
|
||||
// return next(c)
|
||||
// }
|
||||
//
|
||||
// // Your middleware process.
|
||||
// // ...
|
||||
//
|
||||
// // Call the next middleware in the chain.
|
||||
// return next(c)
|
||||
// }
|
||||
// }
|
||||
// }(),
|
||||
// }
|
||||
type Middleware struct {
|
||||
// Stack tells in which stack the middleware should be located.
|
||||
// Default to DefaultStack.
|
||||
// Optional.
|
||||
Stack MiddlewareStack
|
||||
|
||||
// Priority tells if the middleware should be positioned high or not in
|
||||
// its stack.
|
||||
// Default to VeryLowPriority.
|
||||
// Optional.
|
||||
Priority MiddlewarePriority
|
||||
|
||||
// Handler is the function of the middleware.
|
||||
// Required.
|
||||
Handler echo.MiddlewareFunc
|
||||
}
|
||||
|
||||
// HealthChecker is a module interface which allows adding health checks to the
|
||||
// API.
|
||||
//
|
||||
// See https://github.com/alexliesenfeld/health for more details.
|
||||
type HealthChecker interface {
|
||||
Checks() ([]health.CheckerOption, error)
|
||||
}
|
||||
|
||||
// GarbageCollectorGraceDurationIncrementer is a module interface for
|
||||
// increasing the grace duration provided by the API for the garbage collector.
|
||||
type GarbageCollectorGraceDurationIncrementer interface {
|
||||
AddGraceDuration() time.Duration
|
||||
}
|
||||
|
||||
// Descriptor returns an API's module descriptor.
|
||||
func (API) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "api",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
||||
fs.Int("api-port", 3000, "Set the port on which the API should listen")
|
||||
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
|
||||
fs.Duration("api-read-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to read a complete request, including the body")
|
||||
fs.Duration("api-process-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to process a request")
|
||||
fs.Duration("api-write-timeout", time.Duration(30)*time.Second, "Set the maximum duration before timing out writes of the response")
|
||||
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
|
||||
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
||||
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
||||
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
|
||||
|
||||
var err error
|
||||
err = multierr.Append(err, fs.MarkDeprecated("api-read-timeout", "use api-timeout instead"))
|
||||
err = multierr.Append(err, fs.MarkDeprecated("api-process-timeout", "use api-timeout instead"))
|
||||
err = multierr.Append(err, fs.MarkDeprecated("api-write-timeout", "use api-timeout instead"))
|
||||
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("create deprecated flags for the api module: %v", err))
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(API) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (a *API) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
a.port = flags.MustInt("api-port")
|
||||
a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout")
|
||||
a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout")
|
||||
a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout")
|
||||
a.rootPath = flags.MustString("api-root-path")
|
||||
a.traceHeader = flags.MustString("api-trace-header")
|
||||
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
|
||||
|
||||
// Port from env?
|
||||
portEnvVar := flags.MustString("api-port-from-env")
|
||||
if portEnvVar != "" {
|
||||
val, ok := os.LookupEnv(portEnvVar)
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("environment variable '%s' does not exist", portEnvVar)
|
||||
}
|
||||
|
||||
if val == "" {
|
||||
return fmt.Errorf("environment variable '%s' is empty", portEnvVar)
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err)
|
||||
}
|
||||
|
||||
a.port = port
|
||||
}
|
||||
|
||||
// Get routes from modules.
|
||||
mods, err := ctx.Modules(new(Router))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get routers: %w", err)
|
||||
}
|
||||
|
||||
routers := make([]Router, len(mods))
|
||||
for i, router := range mods {
|
||||
routers[i] = router.(Router)
|
||||
}
|
||||
|
||||
for _, router := range routers {
|
||||
routes, err := router.Routes()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get routes: %w", err)
|
||||
}
|
||||
|
||||
a.routes = append(a.routes, routes...)
|
||||
}
|
||||
|
||||
// Get middlewares from modules.
|
||||
mods, err = ctx.Modules(new(MiddlewareProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get middleware providers: %w", err)
|
||||
}
|
||||
|
||||
middlewareProviders := make([]MiddlewareProvider, len(mods))
|
||||
for i, middlewareProvider := range mods {
|
||||
middlewareProviders[i] = middlewareProvider.(MiddlewareProvider)
|
||||
}
|
||||
|
||||
for _, middlewareProvider := range middlewareProviders {
|
||||
middlewares, err := middlewareProvider.Middlewares()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get middlewares: %w", err)
|
||||
}
|
||||
|
||||
a.externalMiddlewares = append(a.externalMiddlewares, middlewares...)
|
||||
}
|
||||
|
||||
// Sort middlewares by priority.
|
||||
sort.Slice(a.externalMiddlewares, func(i, j int) bool {
|
||||
return a.externalMiddlewares[i].Priority > a.externalMiddlewares[j].Priority
|
||||
})
|
||||
|
||||
// Get health checks from modules.
|
||||
mods, err = ctx.Modules(new(HealthChecker))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get health checkers: %w", err)
|
||||
}
|
||||
|
||||
healthCheckers := make([]HealthChecker, len(mods))
|
||||
for i, healthChecker := range mods {
|
||||
healthCheckers[i] = healthChecker.(HealthChecker)
|
||||
}
|
||||
|
||||
for _, healthChecker := range healthCheckers {
|
||||
checks, err := healthChecker.Checks()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get health checks: %w", err)
|
||||
}
|
||||
|
||||
a.healthChecks = append(a.healthChecks, checks...)
|
||||
}
|
||||
|
||||
// Grace duration.
|
||||
a.gcGraceDuration = a.timeout
|
||||
|
||||
mods, err = ctx.Modules(new(GarbageCollectorGraceDurationIncrementer))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get garbage collector grace duration increments: %w", err)
|
||||
}
|
||||
|
||||
for _, incrementer := range mods {
|
||||
a.gcGraceDuration += incrementer.(GarbageCollectorGraceDurationIncrementer).AddGraceDuration()
|
||||
}
|
||||
|
||||
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger provider: %w", err)
|
||||
}
|
||||
|
||||
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get logger: %w", err)
|
||||
}
|
||||
|
||||
a.logger = logger
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (a API) Validate() error {
|
||||
var err error
|
||||
|
||||
if a.port < 1 || a.port > 65535 {
|
||||
err = multierr.Append(err,
|
||||
errors.New("port must be more than 1 and less than 65535"),
|
||||
)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(a.rootPath, "/") {
|
||||
err = multierr.Append(err,
|
||||
errors.New("root path must start with /"),
|
||||
)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(a.rootPath, "/") {
|
||||
err = multierr.Append(err,
|
||||
errors.New("root path must end with /"),
|
||||
)
|
||||
}
|
||||
|
||||
if len(strings.TrimSpace(a.traceHeader)) == 0 {
|
||||
err = multierr.Append(err,
|
||||
errors.New("trace header must not be empty"),
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
routesMap := make(map[string]string, len(a.routes)+1)
|
||||
routesMap["/health"] = "/health"
|
||||
|
||||
for _, route := range a.routes {
|
||||
if route.Path == "" {
|
||||
return errors.New("route with empty path cannot be registered")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(route.Path, "/") {
|
||||
return fmt.Errorf("route '%s' does not start with /", route.Path)
|
||||
}
|
||||
|
||||
if route.IsMultipart && !strings.HasPrefix(route.Path, "/forms") {
|
||||
return fmt.Errorf("multipart/form-data route '%s' does not start with /forms", route.Path)
|
||||
}
|
||||
|
||||
if route.Method == "" {
|
||||
return fmt.Errorf("route '%s' has an empty method", route.Path)
|
||||
}
|
||||
|
||||
if route.Handler == nil {
|
||||
return fmt.Errorf("route '%s' has a nil handler", route.Path)
|
||||
}
|
||||
|
||||
if _, ok := routesMap[route.Path]; ok {
|
||||
return fmt.Errorf("route '%s' is already registered", route.Path)
|
||||
}
|
||||
|
||||
routesMap[route.Path] = route.Path
|
||||
}
|
||||
|
||||
for _, middleware := range a.externalMiddlewares {
|
||||
if middleware.Handler == nil {
|
||||
return errors.New("a middleware has a nil handler")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts the HTTP server.
|
||||
func (a *API) Start() error {
|
||||
a.srv = echo.New()
|
||||
a.srv.HideBanner = true
|
||||
a.srv.HidePort = true
|
||||
a.srv.Server.ReadTimeout = a.readTimeout
|
||||
a.srv.Server.IdleTimeout = a.timeout
|
||||
// See https://github.com/gotenberg/gotenberg/issues/396.
|
||||
a.srv.Server.WriteTimeout = a.writeTimeout + a.writeTimeout
|
||||
a.srv.HTTPErrorHandler = httpErrorHandler()
|
||||
|
||||
// Let's prepare the modules' routes.
|
||||
var disableLoggingForPaths []string
|
||||
for i, route := range a.routes {
|
||||
a.routes[i].Path = strings.TrimPrefix(route.Path, "/")
|
||||
|
||||
if route.DisableLogging {
|
||||
disableLoggingForPaths = append(disableLoggingForPaths, strings.TrimPrefix(route.Path, "/"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the user wish to add logging entries related to the health
|
||||
// check route.
|
||||
if a.disableHealthCheckLogging {
|
||||
disableLoggingForPaths = append(disableLoggingForPaths, "health")
|
||||
}
|
||||
|
||||
// Add the API middlewares.
|
||||
a.srv.Pre(
|
||||
latencyMiddleware(),
|
||||
rootPathMiddleware(a.rootPath),
|
||||
traceMiddleware(a.traceHeader),
|
||||
loggerMiddleware(a.logger, disableLoggingForPaths),
|
||||
)
|
||||
|
||||
// Add the modules' middlewares in their respective stacks.
|
||||
var externalMultipartMiddlewares []Middleware
|
||||
for _, externalMiddleware := range a.externalMiddlewares {
|
||||
switch externalMiddleware.Stack {
|
||||
case PreRouterStack:
|
||||
a.srv.Pre(externalMiddleware.Handler)
|
||||
case MultipartStack:
|
||||
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
|
||||
default:
|
||||
a.srv.Use(externalMiddleware.Handler)
|
||||
}
|
||||
}
|
||||
|
||||
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
|
||||
|
||||
// Add the modules' routes and their specific middlewares.
|
||||
for _, route := range a.routes {
|
||||
var middlewares []echo.MiddlewareFunc
|
||||
|
||||
if route.IsMultipart {
|
||||
middlewares = append(middlewares, contextMiddleware(a.timeout))
|
||||
|
||||
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
|
||||
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
|
||||
}
|
||||
}
|
||||
|
||||
middlewares = append(middlewares, hardTimeoutMiddleware(hardTimeout))
|
||||
|
||||
a.srv.Add(
|
||||
route.Method,
|
||||
fmt.Sprintf("%s%s", a.rootPath, route.Path),
|
||||
route.Handler,
|
||||
middlewares...,
|
||||
)
|
||||
}
|
||||
|
||||
// Let's not forget the health check route.
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||
func() echo.HandlerFunc {
|
||||
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
||||
checker := health.NewChecker(checks...)
|
||||
|
||||
return echo.WrapHandler(health.NewHandler(checker))
|
||||
}(),
|
||||
hardTimeoutMiddleware(hardTimeout),
|
||||
)
|
||||
|
||||
// As the following code is blocking, run it in a goroutine.
|
||||
go func() {
|
||||
server := &http2.Server{}
|
||||
err := a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
a.logger.Fatal(err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartupMessage returns a custom startup message.
|
||||
func (a API) StartupMessage() string {
|
||||
return fmt.Sprintf("server listening on port %d", a.port)
|
||||
}
|
||||
|
||||
// Stop stops the HTTP server.
|
||||
func (a API) Stop(ctx context.Context) error {
|
||||
return a.srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// GraceDuration updates the expiration time of files and directories parsed by
|
||||
// the gc.GarbageCollector.
|
||||
func (a API) GraceDuration() time.Duration {
|
||||
return a.gcGraceDuration
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*API)(nil)
|
||||
_ gotenberg.Provisioner = (*API)(nil)
|
||||
_ gotenberg.Validator = (*API)(nil)
|
||||
_ gotenberg.App = (*API)(nil)
|
||||
_ gc.GarbageCollectorGraceDurationModifier = (*API)(nil)
|
||||
)
|
||||
901
pkg/modules/api/api_test.go
Normal file
901
pkg/modules/api/api_test.go
Normal file
@@ -0,0 +1,901 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ProtoModule struct {
|
||||
descriptor func() gotenberg.ModuleDescriptor
|
||||
}
|
||||
|
||||
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return mod.descriptor()
|
||||
}
|
||||
|
||||
type ProtoValidator struct {
|
||||
ProtoModule
|
||||
validate func() error
|
||||
}
|
||||
|
||||
func (mod ProtoValidator) Validate() error {
|
||||
return mod.validate()
|
||||
}
|
||||
|
||||
type ProtoRouter struct {
|
||||
ProtoValidator
|
||||
routes func() ([]Route, error)
|
||||
}
|
||||
|
||||
func (mod ProtoRouter) Routes() ([]Route, error) {
|
||||
return mod.routes()
|
||||
}
|
||||
|
||||
type ProtoMiddlewareProvider struct {
|
||||
ProtoValidator
|
||||
middlewares func() ([]Middleware, error)
|
||||
}
|
||||
|
||||
func (mod ProtoMiddlewareProvider) Middlewares() ([]Middleware, error) {
|
||||
return mod.middlewares()
|
||||
}
|
||||
|
||||
type ProtoHealthChecker struct {
|
||||
ProtoValidator
|
||||
checks func() ([]health.CheckerOption, error)
|
||||
}
|
||||
|
||||
func (mod ProtoHealthChecker) Checks() ([]health.CheckerOption, error) {
|
||||
return mod.checks()
|
||||
}
|
||||
|
||||
type ProtoGarbageCollectorGraceDurationIncrementer struct {
|
||||
ProtoValidator
|
||||
addGraceDuration func() time.Duration
|
||||
}
|
||||
|
||||
func (mod ProtoGarbageCollectorGraceDurationIncrementer) AddGraceDuration() time.Duration {
|
||||
return mod.addGraceDuration()
|
||||
}
|
||||
|
||||
type ProtoLoggerProvider struct {
|
||||
ProtoModule
|
||||
logger func(mod gotenberg.Module) (*zap.Logger, error)
|
||||
}
|
||||
|
||||
func (factory ProtoLoggerProvider) Logger(mod gotenberg.Module) (*zap.Logger, error) {
|
||||
return factory.logger(mod)
|
||||
}
|
||||
|
||||
func TestAPI_Descriptor(t *testing.T) {
|
||||
descriptor := API{}.Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(API))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_Provision(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx *gotenberg.Context
|
||||
setEnv func(i int)
|
||||
expectPort int
|
||||
expectMiddlewares []Middleware
|
||||
expectGraceDuration time.Duration
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(API).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=FOO"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: fs,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(API).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: fs,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}(),
|
||||
setEnv: func(i int) {
|
||||
err := os.Setenv("PORT", "")
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(API).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: fs,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}(),
|
||||
setEnv: func(i int) {
|
||||
err := os.Setenv("PORT", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(API).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: fs,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}(),
|
||||
setEnv: func(i int) {
|
||||
err := os.Setenv("PORT", "1337")
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
},
|
||||
expectPort: 1337,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoRouter }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
mod.routes = func() ([]Route, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoMiddlewareProvider }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
mod.middlewares = func() ([]Middleware, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoMiddlewareProvider }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error {
|
||||
return nil
|
||||
}
|
||||
mod.middlewares = func() ([]Middleware, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoRouter }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error {
|
||||
return nil
|
||||
}
|
||||
mod.routes = func() ([]Route, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoHealthChecker }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
mod.checks = func() ([]health.CheckerOption, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoHealthChecker }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error {
|
||||
return nil
|
||||
}
|
||||
mod.checks = func() ([]health.CheckerOption, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct {
|
||||
ProtoGarbageCollectorGraceDurationIncrementer
|
||||
}{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
mod.addGraceDuration = func() time.Duration {
|
||||
return 0
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct {
|
||||
ProtoGarbageCollectorGraceDurationIncrementer
|
||||
}{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.validate = func() error {
|
||||
return nil
|
||||
}
|
||||
mod.addGraceDuration = func() time.Duration {
|
||||
return time.Duration(3) * time.Second
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectGraceDuration: time.Duration(33) * time.Second,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoLoggerProvider }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.logger = func(_ gotenberg.Module) (*zap.Logger, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod1 := struct{ ProtoRouter }{}
|
||||
mod1.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
|
||||
}
|
||||
mod1.validate = func() error {
|
||||
return nil
|
||||
}
|
||||
mod1.routes = func() ([]Route, error) {
|
||||
return []Route{{}}, nil
|
||||
}
|
||||
|
||||
mod2 := struct{ ProtoMiddlewareProvider }{}
|
||||
mod2.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
|
||||
}
|
||||
mod2.validate = func() error {
|
||||
return nil
|
||||
}
|
||||
mod2.middlewares = func() ([]Middleware, error) {
|
||||
return []Middleware{
|
||||
{
|
||||
Priority: VeryLowPriority,
|
||||
},
|
||||
{
|
||||
Priority: LowPriority,
|
||||
},
|
||||
{
|
||||
Priority: MediumPriority,
|
||||
},
|
||||
{
|
||||
Priority: HighPriority,
|
||||
},
|
||||
{
|
||||
Priority: VeryHighPriority,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
mod3 := struct{ ProtoHealthChecker }{}
|
||||
mod3.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return mod3 }}
|
||||
}
|
||||
mod3.validate = func() error {
|
||||
return nil
|
||||
}
|
||||
mod3.checks = func() ([]health.CheckerOption, error) {
|
||||
return []health.CheckerOption{health.WithDisabledAutostart()}, nil
|
||||
}
|
||||
|
||||
mod4 := struct{ ProtoLoggerProvider }{}
|
||||
mod4.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "qux", New: func() gotenberg.Module { return mod4 }}
|
||||
}
|
||||
mod4.logger = func(_ gotenberg.Module) (*zap.Logger, error) {
|
||||
return zap.NewNop(), nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(API).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod1.Descriptor(),
|
||||
mod2.Descriptor(),
|
||||
mod3.Descriptor(),
|
||||
mod4.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectMiddlewares: []Middleware{
|
||||
{
|
||||
Priority: VeryHighPriority,
|
||||
},
|
||||
{
|
||||
Priority: HighPriority,
|
||||
},
|
||||
{
|
||||
Priority: MediumPriority,
|
||||
},
|
||||
{
|
||||
Priority: LowPriority,
|
||||
},
|
||||
{
|
||||
Priority: VeryLowPriority,
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
if tc.setEnv != nil {
|
||||
tc.setEnv(i)
|
||||
}
|
||||
|
||||
mod := new(API)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if tc.expectPort != 0 && mod.port != tc.expectPort {
|
||||
t.Errorf("test %d: expected port %d but got %d", i, tc.expectPort, mod.port)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(mod.externalMiddlewares, tc.expectMiddlewares) {
|
||||
t.Errorf("test %d: expected %+v, but got: %+v", i, tc.expectMiddlewares, mod.externalMiddlewares)
|
||||
}
|
||||
|
||||
if tc.expectGraceDuration != 0 && mod.gcGraceDuration != tc.expectGraceDuration {
|
||||
t.Errorf("test %d: expected gc grace duration '%s' but got '%s'", i, tc.expectGraceDuration, mod.gcGraceDuration)
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_Validate(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
port int
|
||||
rootPath string
|
||||
traceHeader string
|
||||
routes []Route
|
||||
middlewares []Middleware
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
port: 0,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
port: 65536,
|
||||
rootPath: "foo",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Path: "",
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Path: "foo",
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Path: "/foo",
|
||||
IsMultipart: true,
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Path: "/forms/foo",
|
||||
IsMultipart: true,
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/foo",
|
||||
IsMultipart: true,
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
middlewares: []Middleware{
|
||||
{
|
||||
Priority: HighPriority,
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
port: 10,
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
},
|
||||
},
|
||||
middlewares: []Middleware{
|
||||
{
|
||||
Priority: HighPriority,
|
||||
Handler: func() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
mod := API{
|
||||
port: tc.port,
|
||||
rootPath: tc.rootPath,
|
||||
traceHeader: tc.traceHeader,
|
||||
routes: tc.routes,
|
||||
externalMiddlewares: tc.middlewares,
|
||||
}
|
||||
|
||||
err := mod.Validate()
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_Start(t *testing.T) {
|
||||
mod := new(API)
|
||||
mod.port = 3000
|
||||
mod.rootPath = "/"
|
||||
mod.disableHealthCheckLogging = true
|
||||
mod.routes = []Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/foo",
|
||||
IsMultipart: true,
|
||||
DisableLogging: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*Context)
|
||||
ctx.outputPaths = []string{
|
||||
"/tests/test/testdata/api/sample1.txt",
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/bar",
|
||||
IsMultipart: true,
|
||||
Handler: func(_ echo.Context) error { return errors.New("foo") },
|
||||
},
|
||||
}
|
||||
mod.externalMiddlewares = []Middleware{
|
||||
{
|
||||
Stack: PreRouterStack,
|
||||
Handler: func() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Stack: MultipartStack,
|
||||
Handler: func() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Stack: DefaultStack,
|
||||
Handler: func() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}(),
|
||||
},
|
||||
{
|
||||
Handler: func() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}(),
|
||||
},
|
||||
}
|
||||
mod.logger = zap.NewNop()
|
||||
|
||||
err := mod.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
// health request.
|
||||
recorder := httptest.NewRecorder()
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
|
||||
mod.srv.ServeHTTP(recorder, healthRequest)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
// "multipart/form-data" request.
|
||||
multipartRequest := func(URL string) *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := writer.WriteField("foo", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
_, err = part.Write([]byte("foo"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, URL, body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo"))
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar"))
|
||||
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code)
|
||||
}
|
||||
|
||||
err = mod.srv.Shutdown(context.TODO())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_StartupMessage(t *testing.T) {
|
||||
mod := API{
|
||||
port: 3000,
|
||||
}
|
||||
|
||||
actual := mod.StartupMessage()
|
||||
expect := "server listening on port 3000"
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_Stop(t *testing.T) {
|
||||
mod := API{
|
||||
port: 3000,
|
||||
routes: []Route{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
err := mod.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = mod.Stop(context.TODO())
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_GraceDuration(t *testing.T) {
|
||||
mod := API{
|
||||
gcGraceDuration: time.Duration(3) * time.Second,
|
||||
}
|
||||
|
||||
expect := time.Duration(3) * time.Second
|
||||
actual := mod.GraceDuration()
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ProtoModule)(nil)
|
||||
_ gotenberg.Validator = (*ProtoValidator)(nil)
|
||||
_ gotenberg.Module = (*ProtoValidator)(nil)
|
||||
_ Router = (*ProtoRouter)(nil)
|
||||
_ gotenberg.Module = (*ProtoRouter)(nil)
|
||||
_ gotenberg.Validator = (*ProtoRouter)(nil)
|
||||
_ MiddlewareProvider = (*ProtoMiddlewareProvider)(nil)
|
||||
_ gotenberg.Module = (*ProtoMiddlewareProvider)(nil)
|
||||
_ gotenberg.Validator = (*ProtoMiddlewareProvider)(nil)
|
||||
_ HealthChecker = (*ProtoHealthChecker)(nil)
|
||||
_ gotenberg.Module = (*ProtoHealthChecker)(nil)
|
||||
_ gotenberg.Validator = (*ProtoHealthChecker)(nil)
|
||||
_ GarbageCollectorGraceDurationIncrementer = (*ProtoGarbageCollectorGraceDurationIncrementer)(nil)
|
||||
_ gotenberg.Module = (*ProtoGarbageCollectorGraceDurationIncrementer)(nil)
|
||||
_ gotenberg.Validator = (*ProtoGarbageCollectorGraceDurationIncrementer)(nil)
|
||||
_ gotenberg.LoggerProvider = (*ProtoLoggerProvider)(nil)
|
||||
_ gotenberg.Module = (*ProtoLoggerProvider)(nil)
|
||||
)
|
||||
278
pkg/modules/api/context.go
Normal file
278
pkg/modules/api/context.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"compress/flate"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mholt/archiver/v3"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrContextAlreadyClosed happens when the context has been canceled.
|
||||
ErrContextAlreadyClosed = errors.New("context already closed")
|
||||
|
||||
// ErrOutOfBoundsOutputPath happens when an output path is not within
|
||||
// context's working directory. It enforces having all the files in the
|
||||
// same directory.
|
||||
ErrOutOfBoundsOutputPath = errors.New("output path is not within context's working directory")
|
||||
)
|
||||
|
||||
// Context is the request context for a "multipart/form-data" requests.
|
||||
type Context struct {
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
|
||||
outputPaths []string
|
||||
|
||||
cancelled bool
|
||||
logger *zap.Logger
|
||||
echoCtx echo.Context
|
||||
context.Context
|
||||
}
|
||||
|
||||
// newContext returns a Context by parsing a "multipart/form-data" request.
|
||||
func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration) (*Context, context.CancelFunc, error) {
|
||||
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
|
||||
|
||||
ctx := &Context{
|
||||
outputPaths: make([]string, 0),
|
||||
cancelled: false,
|
||||
logger: logger,
|
||||
echoCtx: echoCtx,
|
||||
Context: processCtx,
|
||||
}
|
||||
|
||||
// A custom cancel function which removes the context's working directory
|
||||
// when called.
|
||||
cancel := func() context.CancelFunc {
|
||||
return func() {
|
||||
if ctx.cancelled {
|
||||
return
|
||||
}
|
||||
|
||||
processCancel()
|
||||
|
||||
if ctx.dirPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
err := os.RemoveAll(ctx.dirPath)
|
||||
if err != nil {
|
||||
ctx.logger.Error(fmt.Sprintf("remove context's working directory: %s", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx.logger.Debug(fmt.Sprintf("'%s' removed", ctx.dirPath))
|
||||
ctx.cancelled = true
|
||||
}
|
||||
}()
|
||||
|
||||
form, err := echoCtx.MultipartForm()
|
||||
if err != nil {
|
||||
|
||||
if errors.Is(err, http.ErrNotMultipart) {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("get multipart form: %w", err),
|
||||
NewSentinelHTTPError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: want 'multipart/form-data'"),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, http.ErrMissingBoundary) {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("get multipart form: %w", err),
|
||||
NewSentinelHTTPError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: no boundary"),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), io.EOF.Error()) {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("get multipart form: %w", err),
|
||||
NewSentinelHTTPError(http.StatusBadRequest, "Malformed body: it does not match the 'Content-Type' header boundaries"),
|
||||
)
|
||||
}
|
||||
|
||||
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
||||
}
|
||||
|
||||
dirPath, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
return nil, cancel, fmt.Errorf("create working directory: %w", err)
|
||||
}
|
||||
|
||||
ctx.dirPath = dirPath
|
||||
ctx.values = form.Value
|
||||
ctx.files = make(map[string]string)
|
||||
|
||||
copyToDisk := func(fh *multipart.FileHeader) error {
|
||||
// Avoid directory traversal and normalize filename.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/104.
|
||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
|
||||
filename, _, err := transform.String(t, filepath.Base(fh.Filename))
|
||||
if err != nil {
|
||||
return fmt.Errorf("transform filename: %w", err)
|
||||
}
|
||||
|
||||
in, err := fh.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open multipart file: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := in.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close file header: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
||||
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create local file: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close local file: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy multipart file to local file: %w", err)
|
||||
}
|
||||
|
||||
ctx.files[filename] = path
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, files := range form.File {
|
||||
for _, fh := range files {
|
||||
err = copyToDisk(fh)
|
||||
|
||||
if err != nil {
|
||||
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Log().Debug(fmt.Sprintf("form data values: %+v", ctx.values))
|
||||
ctx.Log().Debug(fmt.Sprintf("form data files: %+v", ctx.files))
|
||||
|
||||
return ctx, cancel, err
|
||||
}
|
||||
|
||||
// Request returns the http.Request.
|
||||
func (ctx Context) Request() *http.Request {
|
||||
return ctx.echoCtx.Request()
|
||||
}
|
||||
|
||||
// FormData return a FormData.
|
||||
func (ctx Context) FormData() *FormData {
|
||||
return &FormData{
|
||||
values: ctx.values,
|
||||
files: ctx.files,
|
||||
errors: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratePath generates a path within the context's working directory. It
|
||||
// does not create a file.
|
||||
func (ctx Context) GeneratePath(extension string) string {
|
||||
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New(), extension)
|
||||
}
|
||||
|
||||
// AddOutputPaths adds the given paths. Those paths will be used later to build
|
||||
// the output file.
|
||||
func (ctx *Context) AddOutputPaths(paths ...string) error {
|
||||
if ctx.cancelled {
|
||||
return ErrContextAlreadyClosed
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if !strings.HasPrefix(path, ctx.dirPath) {
|
||||
return ErrOutOfBoundsOutputPath
|
||||
}
|
||||
|
||||
ctx.outputPaths = append(ctx.outputPaths, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Log returns the context zap.Logger.
|
||||
func (ctx Context) Log() *zap.Logger {
|
||||
return ctx.logger
|
||||
}
|
||||
|
||||
// BuildOutputFile builds the output file according to the output paths
|
||||
// registered in the context. If many output paths, an archive is created.
|
||||
func (ctx Context) BuildOutputFile() (string, error) {
|
||||
if ctx.cancelled {
|
||||
return "", ErrContextAlreadyClosed
|
||||
}
|
||||
|
||||
if len(ctx.outputPaths) == 0 {
|
||||
return "", errors.New("no output path")
|
||||
}
|
||||
|
||||
if len(ctx.outputPaths) == 1 {
|
||||
ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0]))
|
||||
|
||||
return ctx.outputPaths[0], nil
|
||||
}
|
||||
|
||||
z := archiver.Zip{
|
||||
CompressionLevel: flate.DefaultCompression,
|
||||
MkdirAll: true,
|
||||
SelectiveCompression: true,
|
||||
ContinueOnError: false,
|
||||
OverwriteExisting: false,
|
||||
ImplicitTopLevelFolder: false,
|
||||
}
|
||||
|
||||
archivePath := ctx.GeneratePath(".zip")
|
||||
|
||||
err := z.Archive(ctx.outputPaths, archivePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("archive output files: %w", err)
|
||||
}
|
||||
|
||||
ctx.logger.Debug(fmt.Sprintf("archive '%s' created", archivePath))
|
||||
|
||||
return archivePath, nil
|
||||
}
|
||||
|
||||
// OutputFilename returns the filename based on the given output path or the
|
||||
// "Gotenberg-Output-Filename" header's value.
|
||||
func (ctx Context) OutputFilename(outputPath string) string {
|
||||
filename := ctx.echoCtx.Request().Header.Get("Gotenberg-Output-Filename")
|
||||
|
||||
if filename == "" {
|
||||
return filepath.Base(outputPath)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||
}
|
||||
336
pkg/modules/api/context_test.go
Normal file
336
pkg/modules/api/context_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestNewContext(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
request *http.Request
|
||||
expectErr bool
|
||||
expectHTTPErr bool
|
||||
expectHTTPStatus int
|
||||
}{
|
||||
{
|
||||
request: httptest.NewRequest(http.MethodPost, "/", nil),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEMultipartForm)
|
||||
|
||||
return req
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := writer.WriteField("foo", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
|
||||
return req
|
||||
}(),
|
||||
expectErr: true,
|
||||
expectHTTPErr: true,
|
||||
expectHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := writer.WriteField("foo", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
_, err = part.Write([]byte("foo"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
|
||||
return req
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
handler := func(c echo.Context) error {
|
||||
_, cancel, err := newContext(c, zap.NewNop(), time.Duration(10)*time.Second)
|
||||
defer cancel()
|
||||
// Context already cancelled.
|
||||
defer cancel()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
err := handler(c)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
var httpErr HTTPError
|
||||
isHTTPErr := errors.As(err, &httpErr)
|
||||
|
||||
if tc.expectHTTPErr && !isHTTPErr {
|
||||
t.Errorf("test %d: expected HTTP error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectHTTPErr && isHTTPErr {
|
||||
t.Errorf("test %d: expected no HTTP error but got one: %v", i, httpErr)
|
||||
}
|
||||
|
||||
if err != nil && tc.expectHTTPErr && isHTTPErr {
|
||||
status, _ := httpErr.HTTPError()
|
||||
if status != tc.expectHTTPStatus {
|
||||
t.Errorf("test %d: expected %d HTTP status code but got %d", i, tc.expectHTTPStatus, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_Request(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
c := echo.New().NewContext(request, recorder)
|
||||
|
||||
ctx := Context{
|
||||
echoCtx: c,
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(ctx.Request(), c.Request()) {
|
||||
t.Errorf("expected %v but got %v", ctx.Request(), c.Request())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_FormData(t *testing.T) {
|
||||
ctx := Context{
|
||||
values: map[string][]string{
|
||||
"foo": {"foo"},
|
||||
},
|
||||
files: map[string]string{
|
||||
"foo.txt": "/foo.txt",
|
||||
},
|
||||
}
|
||||
|
||||
actual := ctx.FormData()
|
||||
expect := &FormData{
|
||||
values: ctx.values,
|
||||
files: ctx.files,
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %+v but got %+v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_GeneratePath(t *testing.T) {
|
||||
ctx := Context{
|
||||
dirPath: "/foo",
|
||||
}
|
||||
|
||||
path := ctx.GeneratePath(".pdf")
|
||||
|
||||
if !strings.HasPrefix(path, ctx.dirPath) {
|
||||
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_AddOutputPaths(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx *Context
|
||||
path string
|
||||
expectCount int
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
ctx: &Context{cancelled: true},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: &Context{dirPath: "/foo"},
|
||||
path: "/bar/foo.txt",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: &Context{dirPath: "/foo"},
|
||||
path: "/foo/foo.txt",
|
||||
expectCount: 1,
|
||||
},
|
||||
} {
|
||||
err := tc.ctx.AddOutputPaths(tc.path)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if len(tc.ctx.outputPaths) != tc.expectCount {
|
||||
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectCount, len(tc.ctx.outputPaths))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_Log(t *testing.T) {
|
||||
expect := zap.NewNop()
|
||||
ctx := Context{logger: expect}
|
||||
actual := ctx.Log()
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_BuildOutputFile(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx *Context
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
ctx: &Context{cancelled: true},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: &Context{},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: &Context{outputPaths: []string{"foo.txt"}},
|
||||
},
|
||||
{
|
||||
ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: &Context{
|
||||
outputPaths: []string{
|
||||
"/tests/test/testdata/api/sample1.txt",
|
||||
"/tests/test/testdata/api/sample1.txt",
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
dirPath, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("%d: expected no erro but got: %v", i, err)
|
||||
}
|
||||
|
||||
tc.ctx.dirPath = dirPath
|
||||
tc.ctx.logger = zap.NewNop()
|
||||
|
||||
_, err = tc.ctx.BuildOutputFile()
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
err = os.RemoveAll(dirPath)
|
||||
if err != nil {
|
||||
t.Fatalf("%d: expected no erro but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_OutputFilename(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx *Context
|
||||
outputPath string
|
||||
expectOutputFilename string
|
||||
}{
|
||||
{
|
||||
ctx: func() *Context {
|
||||
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
|
||||
c.Request().Header.Set("Gotenberg-Output-Filename", "foo")
|
||||
|
||||
return &Context{echoCtx: c}
|
||||
}(),
|
||||
outputPath: "/foo/bar.txt",
|
||||
expectOutputFilename: "foo.txt",
|
||||
},
|
||||
{
|
||||
ctx: func() *Context {
|
||||
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
|
||||
return &Context{echoCtx: c}
|
||||
}(),
|
||||
outputPath: "/foo/foo.txt",
|
||||
expectOutputFilename: "foo.txt",
|
||||
},
|
||||
} {
|
||||
actual := tc.ctx.OutputFilename(tc.outputPath)
|
||||
|
||||
if actual != tc.expectOutputFilename {
|
||||
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectOutputFilename, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
3
pkg/modules/api/doc.go
Normal file
3
pkg/modules/api/doc.go
Normal file
@@ -0,0 +1,3 @@
|
||||
// Package api provides a module which is an HTTP server. Other modules may
|
||||
// add multipart/form-data routes, middlewares, and health checks.
|
||||
package api
|
||||
77
pkg/modules/api/errors.go
Normal file
77
pkg/modules/api/errors.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
// Credits: https://www.joeshaw.org/error-handling-in-go-http-applications.
|
||||
|
||||
// HTTPError is an interface allowing to retrieve the HTTP details of an error.
|
||||
type HTTPError interface {
|
||||
HTTPError() (int, string)
|
||||
}
|
||||
|
||||
// SentinelHTTPError is the HTTP sidekick of an error.
|
||||
type SentinelHTTPError struct {
|
||||
status int
|
||||
message string
|
||||
}
|
||||
|
||||
// NewSentinelHTTPError creates a SentinelHTTPError. The message will be sent
|
||||
// as the response's body if returned from a handler, so make sure to not leak
|
||||
// sensible information.
|
||||
func NewSentinelHTTPError(status int, message string) SentinelHTTPError {
|
||||
return SentinelHTTPError{
|
||||
status: status,
|
||||
message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// Error returns the message.
|
||||
func (err SentinelHTTPError) Error() string {
|
||||
return err.message
|
||||
}
|
||||
|
||||
// HTTPError returns the status and message.
|
||||
func (err SentinelHTTPError) HTTPError() (int, string) {
|
||||
return err.status, err.message
|
||||
}
|
||||
|
||||
// sentinelWrappedError contains both the error which will logged and the
|
||||
// sidekick SentinelHTTPError.
|
||||
type sentinelWrappedError struct {
|
||||
error
|
||||
sentinel SentinelHTTPError
|
||||
}
|
||||
|
||||
func (w sentinelWrappedError) Is(err error) bool {
|
||||
return w.sentinel == err
|
||||
}
|
||||
|
||||
func (w sentinelWrappedError) HTTPError() (int, string) {
|
||||
return w.sentinel.HTTPError()
|
||||
}
|
||||
|
||||
// WrapError wraps the given error with a SentinelHTTPError. The wrapped error
|
||||
// will be displayed in a log, while the SentinelHTTPError will be sent in the
|
||||
// response.
|
||||
//
|
||||
// return api.WrapError(
|
||||
// // This first error will be logged.
|
||||
// fmt.Errorf("my action: %w", err),
|
||||
// // The HTTP error will be sent as a response.
|
||||
// api.NewSentinelHTTPError(
|
||||
// http.StatusForbidden,
|
||||
// "Hey, you did something wrong!"
|
||||
// ),
|
||||
// )
|
||||
func WrapError(err error, sentinel SentinelHTTPError) error {
|
||||
return sentinelWrappedError{
|
||||
error: err,
|
||||
sentinel: sentinel,
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ error = (*SentinelHTTPError)(nil)
|
||||
_ HTTPError = (*SentinelHTTPError)(nil)
|
||||
_ error = (*sentinelWrappedError)(nil)
|
||||
_ HTTPError = (*sentinelWrappedError)(nil)
|
||||
)
|
||||
108
pkg/modules/api/errors_test.go
Normal file
108
pkg/modules/api/errors_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewSentinelHTTPError(t *testing.T) {
|
||||
actual := NewSentinelHTTPError(http.StatusInternalServerError, "foo")
|
||||
expect := SentinelHTTPError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelHTTPError_Error(t *testing.T) {
|
||||
err := SentinelHTTPError{
|
||||
message: "foo",
|
||||
}
|
||||
|
||||
actual := err.Error()
|
||||
expect := "foo"
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelHTTPError_HTTPError(t *testing.T) {
|
||||
actualStatus, actualMessage := SentinelHTTPError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
}.HTTPError()
|
||||
|
||||
expectStatus := http.StatusInternalServerError
|
||||
expectMessage := "foo"
|
||||
|
||||
if actualStatus != expectStatus {
|
||||
t.Errorf("expected %d but got %d", expectStatus, actualStatus)
|
||||
}
|
||||
|
||||
if actualMessage != expectMessage {
|
||||
t.Errorf("expected '%s' but got '%s'", expectMessage, actualMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelWrappedError_Is(t *testing.T) {
|
||||
errSentinel := SentinelHTTPError{}
|
||||
|
||||
err := sentinelWrappedError{
|
||||
error: errors.New("foo"),
|
||||
sentinel: errSentinel,
|
||||
}
|
||||
|
||||
if !err.Is(errSentinel) {
|
||||
t.Error("expected true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelWrappedError_HTTPError(t *testing.T) {
|
||||
expectStatus, expectMessage := SentinelHTTPError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
}.HTTPError()
|
||||
|
||||
actualStatus, actualMessage := sentinelWrappedError{
|
||||
error: errors.New("foo"),
|
||||
sentinel: SentinelHTTPError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
},
|
||||
}.HTTPError()
|
||||
|
||||
if actualStatus != expectStatus {
|
||||
t.Errorf("expected %d but got %d", expectStatus, actualStatus)
|
||||
}
|
||||
|
||||
if actualMessage != expectMessage {
|
||||
t.Errorf("expected '%s' but got '%s'", expectMessage, actualMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapError(t *testing.T) {
|
||||
errFoo := errors.New("foo")
|
||||
|
||||
expect := sentinelWrappedError{
|
||||
error: errFoo,
|
||||
sentinel: SentinelHTTPError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
},
|
||||
}
|
||||
|
||||
actual := WrapError(errFoo, SentinelHTTPError{
|
||||
status: http.StatusInternalServerError,
|
||||
message: "foo",
|
||||
})
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
441
pkg/modules/api/formdata.go
Normal file
441
pkg/modules/api/formdata.go
Normal file
@@ -0,0 +1,441 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
)
|
||||
|
||||
// FormData is a helper for validating and hydrating values from a
|
||||
// "multipart/form-data" request.
|
||||
//
|
||||
// form := ctx.FormData()
|
||||
type FormData struct {
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
errors error
|
||||
}
|
||||
|
||||
// Validate returns nil or an error related to the FormData values, with a
|
||||
// SentinelHTTPError (status code 400, errors' details as message) wrapped
|
||||
// inside.
|
||||
//
|
||||
// var foo string
|
||||
//
|
||||
// err := ctx.FormData().
|
||||
// MandatoryString("foo", &foo, "bar").
|
||||
// Validate()
|
||||
func (form FormData) Validate() error {
|
||||
if form.errors == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return WrapError(
|
||||
form.errors,
|
||||
NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %s", form.errors)),
|
||||
)
|
||||
}
|
||||
|
||||
// String binds a form data value to a string variable.
|
||||
//
|
||||
// var foo string
|
||||
//
|
||||
// ctx.FormData().String("foo", &foo, "bar")
|
||||
func (form *FormData) String(key string, target *string, defaultValue string) *FormData {
|
||||
return form.mustValue(key, target, defaultValue)
|
||||
}
|
||||
|
||||
// MandatoryString binds a form data value to a string variable. It populates
|
||||
// an error if the value is empty or the "key" does not exist.
|
||||
//
|
||||
// var foo string
|
||||
//
|
||||
// ctx.FormData().MandatoryString("foo", &foo)
|
||||
func (form *FormData) MandatoryString(key string, target *string) *FormData {
|
||||
return form.mustMandatoryValue(key, target)
|
||||
}
|
||||
|
||||
// Bool binds a form data value to a bool variable. It populates an error if
|
||||
// the value is not bool.
|
||||
//
|
||||
// var foo bool
|
||||
//
|
||||
// ctx.FormData().Bool("foo", &foo, true)
|
||||
func (form *FormData) Bool(key string, target *bool, defaultValue bool) *FormData {
|
||||
return form.mustValue(key, target, defaultValue)
|
||||
}
|
||||
|
||||
// MandatoryBool binds a form data value to a bool variable. It populates an
|
||||
// error if the value is not bool, is empty, or the "key" does not exist.
|
||||
//
|
||||
// var foo bool
|
||||
//
|
||||
// ctx.FormData().MandatoryBool("foo", &foo)
|
||||
func (form *FormData) MandatoryBool(key string, target *bool) *FormData {
|
||||
return form.mustMandatoryValue(key, target)
|
||||
}
|
||||
|
||||
// Int binds a form data value to an int variable. It populates an error if the
|
||||
// value is not int.
|
||||
//
|
||||
// var foo int
|
||||
//
|
||||
// ctx.FormData().Int("foo", &foo, 2)
|
||||
func (form *FormData) Int(key string, target *int, defaultValue int) *FormData {
|
||||
return form.mustValue(key, target, defaultValue)
|
||||
}
|
||||
|
||||
// MandatoryInt binds a form data value to an int variable. It populates an
|
||||
// error if the value is not int, is empty, or the "key" does not exist.
|
||||
//
|
||||
// var foo int
|
||||
//
|
||||
// ctx.FormData().MandatoryInt("foo", &foo)
|
||||
func (form *FormData) MandatoryInt(key string, target *int) *FormData {
|
||||
return form.mustMandatoryValue(key, target)
|
||||
}
|
||||
|
||||
// Float64 binds a form data value to a float64 variable. It populates an error
|
||||
// if the value is not float64.
|
||||
//
|
||||
// var foo float64
|
||||
//
|
||||
// ctx.FormData().Float64("foo", &foo, 2.0)
|
||||
func (form *FormData) Float64(key string, target *float64, defaultValue float64) *FormData {
|
||||
return form.mustValue(key, target, defaultValue)
|
||||
}
|
||||
|
||||
// MandatoryFloat64 binds a form data value to a float64 variable. It populates
|
||||
// an error if the is not float64, is empty, or the "key" does not exist.
|
||||
//
|
||||
// var foo float64
|
||||
//
|
||||
// ctx.FormData().MandatoryFloat64("foo", &foo)
|
||||
func (form *FormData) MandatoryFloat64(key string, target *float64) *FormData {
|
||||
return form.mustMandatoryValue(key, target)
|
||||
}
|
||||
|
||||
// Duration binds a form data value to a time.Duration variable. It populates
|
||||
// an error if the form data value is not time.Duration.
|
||||
//
|
||||
// var foo time.Duration
|
||||
//
|
||||
// ctx.FormData().Duration("foo", &foo, time.Duration(2) * time.Second)
|
||||
func (form *FormData) Duration(key string, target *time.Duration, defaultValue time.Duration) *FormData {
|
||||
return form.mustValue(key, target, defaultValue)
|
||||
}
|
||||
|
||||
// MandatoryDuration binds a form data value to a time.Duration variable. It
|
||||
// populates an error if the value is not time.Duration, is empty, or the "key"
|
||||
// does not exist.
|
||||
//
|
||||
// var foo time.Duration
|
||||
//
|
||||
// ctx.FormData().MandatoryDuration("foo", &foo)
|
||||
func (form *FormData) MandatoryDuration(key string, target *time.Duration) *FormData {
|
||||
return form.mustMandatoryValue(key, target)
|
||||
}
|
||||
|
||||
// Custom helps to define a custom binding function for a form data value.
|
||||
//
|
||||
// var foo map[string]string
|
||||
//
|
||||
// ctx.FormData().Custom("foo", func(value string) error {
|
||||
// if value == "" {
|
||||
// foo = "bar"
|
||||
//
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// err := json.Unmarshal([]byte(value), &foo)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("unmarshal foo: %w", err)
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
// })
|
||||
func (form *FormData) Custom(key string, assign func(value string) error) *FormData {
|
||||
var value string
|
||||
form.mustValue(key, &value, "")
|
||||
|
||||
err := assign(value)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// MandatoryCustom helps to define a custom binding function for a form data
|
||||
// value. It populates an error if the value is empty or the "key" does not
|
||||
// exist.
|
||||
//
|
||||
// var foo map[string]string
|
||||
//
|
||||
// ctx.FormData().MandatoryCustom("foo", func(value string) error {
|
||||
// err := json.Unmarshal([]byte(value), &foo)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("unmarshal foo: %w", err)
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
// })
|
||||
func (form *FormData) MandatoryCustom(key string, assign func(value string) error) *FormData {
|
||||
var value string
|
||||
form.mustMandatoryValue(key, &value)
|
||||
|
||||
if value == "" {
|
||||
return form
|
||||
}
|
||||
|
||||
err := assign(value)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Path binds the absolute path of a form data file to a string variable.
|
||||
//
|
||||
// var path string
|
||||
//
|
||||
// ctx.FormData().Path("foo.txt", &path)
|
||||
func (form *FormData) Path(filename string, target *string) *FormData {
|
||||
return form.path(filename, target)
|
||||
}
|
||||
|
||||
// MandatoryPath binds the absolute path ofa form data file to a string
|
||||
// variable. It populates an error if the file does not exist.
|
||||
//
|
||||
// var path string
|
||||
//
|
||||
// ctx.FormData().MandatoryPath("foo.txt", &path)
|
||||
func (form *FormData) MandatoryPath(filename string, target *string) *FormData {
|
||||
return form.mandatoryPath(filename, target)
|
||||
}
|
||||
|
||||
// Content binds the content of a form data file to a string variable.
|
||||
//
|
||||
// var content string
|
||||
//
|
||||
// ctx.FormData().Content("foo.txt", &content, "bar")
|
||||
func (form *FormData) Content(filename string, target *string, defaultValue string) *FormData {
|
||||
var path string
|
||||
form.path(filename, &path)
|
||||
|
||||
if path == "" {
|
||||
*target = defaultValue
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
return form.readFile(path, filename, target)
|
||||
}
|
||||
|
||||
// MandatoryContent binds the content of a form data file to a string variable.
|
||||
// It populates an error if the file does not exist.
|
||||
//
|
||||
// var content string
|
||||
//
|
||||
// ctx.FormData().MandatoryContent("foo.txt", &content)
|
||||
func (form *FormData) MandatoryContent(filename string, target *string) *FormData {
|
||||
var path string
|
||||
form.mandatoryPath(filename, &path)
|
||||
|
||||
if path == "" {
|
||||
return form
|
||||
}
|
||||
|
||||
return form.readFile(path, filename, target)
|
||||
}
|
||||
|
||||
// Paths binds the absolute paths of form data files, according to a list of
|
||||
// file extensions, to a string slice variable.
|
||||
//
|
||||
// var paths []string
|
||||
//
|
||||
// ctx.FormData().Paths([]string{".txt"}, &paths)
|
||||
func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
|
||||
return form.paths(extensions, target)
|
||||
}
|
||||
|
||||
// MandatoryPaths binds the absolute paths of form data files, according to a
|
||||
// list of file extensions, to a string slice variable. It populates an error
|
||||
// if there is no file for given file extensions.
|
||||
//
|
||||
// var paths []string
|
||||
//
|
||||
// ctx.FormData().MandatoryPaths([]string{".txt"}, &paths)
|
||||
func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *FormData {
|
||||
form.paths(extensions, target)
|
||||
|
||||
if len(*target) > 0 {
|
||||
return form
|
||||
}
|
||||
|
||||
form.append(
|
||||
fmt.Errorf("no form file found for extensions: %v", extensions),
|
||||
)
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// paths binds the absolute paths of form data files, according to a list of
|
||||
// file extensions, to a string slice variable.
|
||||
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
for filename, path := range form.files {
|
||||
for _, ext := range extensions {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/228.
|
||||
if strings.ToLower(filepath.Ext(filename)) == ext {
|
||||
*target = append(*target, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/139.
|
||||
sort.Strings(*target)
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// append adds an error to the list of errors.
|
||||
func (form *FormData) append(err error) {
|
||||
form.errors = multierr.Append(form.errors, err)
|
||||
}
|
||||
|
||||
// mustValue binds the target interface with a form data value. If the value is
|
||||
// empty or the "key" does not exist, it binds the default value. Currently,
|
||||
// only the string, bool, int, float64 and time.Duration types are bindable.
|
||||
func (form *FormData) mustValue(key string, target interface{}, defaultValue interface{}) *FormData {
|
||||
val, ok := form.values[key]
|
||||
|
||||
if !ok || val[0] == "" {
|
||||
switch t := (target).(type) {
|
||||
case *string:
|
||||
*t = defaultValue.(string)
|
||||
case *bool:
|
||||
*t = defaultValue.(bool)
|
||||
case *int:
|
||||
*t = defaultValue.(int)
|
||||
case *float64:
|
||||
*t = defaultValue.(float64)
|
||||
case *time.Duration:
|
||||
*t = defaultValue.(time.Duration)
|
||||
default:
|
||||
panic("target type not supported")
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
return form.mustAssign(key, val[0], target)
|
||||
}
|
||||
|
||||
// mustMandatoryValue binds the target interface with a form data value. It
|
||||
// populates an error if the value is empty or the "key" does not exist.
|
||||
// Currently, only the string, bool, int, float64 and time.Duration types are
|
||||
// bindable.
|
||||
func (form *FormData) mustMandatoryValue(key string, target interface{}) *FormData {
|
||||
val, ok := form.values[key]
|
||||
|
||||
if !ok || val[0] == "" {
|
||||
form.append(
|
||||
fmt.Errorf("form value '%s' is required", key),
|
||||
)
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
form.mustAssign(key, val[0], target)
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// mustAssign parses the string value and tries to convert it to the target
|
||||
// interface real type. Currently, only the string, bool, int, float64 and
|
||||
// time.Duration types are bindable.
|
||||
func (form *FormData) mustAssign(key, value string, target interface{}) *FormData {
|
||||
var err error
|
||||
|
||||
switch t := (target).(type) {
|
||||
case *string:
|
||||
*t = value
|
||||
case *bool:
|
||||
*t, err = strconv.ParseBool(value)
|
||||
case *int:
|
||||
*t, err = strconv.Atoi(value)
|
||||
case *float64:
|
||||
*t, err = strconv.ParseFloat(value, 64)
|
||||
case *time.Duration:
|
||||
*t, err = time.ParseDuration(value)
|
||||
default:
|
||||
panic("target type not supported")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// path binds the absolute path of a form data file to a string variable.
|
||||
func (form *FormData) path(filename string, target *string) *FormData {
|
||||
for name, path := range form.files {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/228.
|
||||
nameLowerExt := strings.TrimSuffix(name, filepath.Ext(name)) + strings.ToLower(filepath.Ext(name))
|
||||
if name == filename || nameLowerExt == filename {
|
||||
*target = path
|
||||
return form
|
||||
}
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// mandatoryPath binds the absolute path of a form data file to a string
|
||||
// variable. It populates an error if the file does not exist.
|
||||
func (form *FormData) mandatoryPath(filename string, target *string) *FormData {
|
||||
form.path(filename, target)
|
||||
|
||||
if *target != "" {
|
||||
return form
|
||||
}
|
||||
|
||||
form.append(
|
||||
fmt.Errorf("form file '%s' is required", filename),
|
||||
)
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// readFile binds the content of a file to a string variable. It populates an
|
||||
// error if it fails to read the file content.
|
||||
func (form *FormData) readFile(path, filename string, target *string) *FormData {
|
||||
b, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form file '%s' is invalid (%w)", filename, err),
|
||||
)
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
*target = string(b)
|
||||
|
||||
return form
|
||||
}
|
||||
1187
pkg/modules/api/formdata_test.go
Normal file
1187
pkg/modules/api/formdata_test.go
Normal file
File diff suppressed because it is too large
Load Diff
291
pkg/modules/api/middlewares.go
Normal file
291
pkg/modules/api/middlewares.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ErrAsyncProcess happens when a handler or middleware handles a request in an
|
||||
// asynchronous fashion.
|
||||
var ErrAsyncProcess = errors.New("async process")
|
||||
|
||||
// ParseError parses an error and returns the corresponding HTTP status and
|
||||
// HTTP message.
|
||||
func ParseError(err error) (int, string) {
|
||||
echoErr, ok := err.(*echo.HTTPError)
|
||||
if ok {
|
||||
return echoErr.Code, http.StatusText(echoErr.Code)
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
var httpErr HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr.HTTPError()
|
||||
}
|
||||
|
||||
// Default 500 status code.
|
||||
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
|
||||
// returns a response as "text/plain; charset=UTF-8".
|
||||
func httpErrorHandler() echo.HTTPErrorHandler {
|
||||
return func(err error, c echo.Context) {
|
||||
logger := c.Get("logger").(*zap.Logger)
|
||||
status, message := ParseError(err)
|
||||
|
||||
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
|
||||
|
||||
err = c.String(status, message)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("send error response: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// latencyMiddleware sets the start time in the echo.Context under "startTime".
|
||||
// Its value will be used later to calculate a request latency.
|
||||
//
|
||||
// startTime := c.Get("startTime").(time.Time)
|
||||
func latencyMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// First piece for calculating the latency.
|
||||
startTime := time.Now()
|
||||
c.Set("startTime", startTime)
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rootPathMiddleware sets the root path in the echo.Context under "rootPath".
|
||||
// Its value may be used to skip a middleware execution based on a request
|
||||
// URI.
|
||||
//
|
||||
// rootPath := c.Get("rootPath").(string)
|
||||
// healthURI := fmt.Sprintf("%s/health", rootPath)
|
||||
//
|
||||
// // Skip the middleware if health check URI.
|
||||
// if c.Request().RequestURI == healthURI {
|
||||
// // Call the next middleware in the chain.
|
||||
// return next(c)
|
||||
// }
|
||||
func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set("rootPath", rootPath)
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// traceMiddleware sets the request identifier in the echo.Context under
|
||||
// "trace". Its value is either retrieved from the trace header or generated if
|
||||
// the header is not present / its value is empty.
|
||||
//
|
||||
// trace := c.Get("trace").(string)
|
||||
// traceHeader := c.Get("traceHeader").(string).
|
||||
func traceMiddleware(header string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Get or create the request identifier.
|
||||
trace := c.Request().Header.Get(header)
|
||||
|
||||
if trace == "" {
|
||||
trace = uuid.New().String()
|
||||
}
|
||||
|
||||
c.Set("trace", trace)
|
||||
c.Set("traceHeader", header)
|
||||
c.Response().Header().Add(header, trace)
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loggerMiddleware sets the logger in the echo.Context under "logger" and logs
|
||||
// a synchronous request result.
|
||||
//
|
||||
// logger := c.Get("logger").(*zap.Logger)
|
||||
func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
startTime := c.Get("startTime").(time.Time)
|
||||
trace := c.Get("trace").(string)
|
||||
rootPath := c.Get("rootPath").(string)
|
||||
|
||||
// Create the request logger and add it to our locals.
|
||||
reqLogger := logger.With(zap.String("trace", trace))
|
||||
c.Set("logger", reqLogger.Named(func() string {
|
||||
return strings.ReplaceAll(
|
||||
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
|
||||
"/",
|
||||
"",
|
||||
)
|
||||
}()))
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
err := next(c)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
}
|
||||
|
||||
for _, path := range disableLoggingForPaths {
|
||||
URI := fmt.Sprintf("%s%s", rootPath, path)
|
||||
|
||||
if c.Request().RequestURI == URI {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Last piece for calculating the latency.
|
||||
finishTime := time.Now()
|
||||
|
||||
// Now, let's log!
|
||||
fields := make([]zap.Field, 12)
|
||||
fields[0] = zap.String("remote_ip", c.RealIP())
|
||||
fields[1] = zap.String("host", c.Request().Host)
|
||||
fields[2] = zap.String("uri", c.Request().RequestURI)
|
||||
fields[3] = zap.String("method", c.Request().Method)
|
||||
fields[4] = zap.String("path", func() string {
|
||||
path := c.Request().URL.Path
|
||||
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
return path
|
||||
}())
|
||||
fields[5] = zap.String("referer", c.Request().Referer())
|
||||
fields[6] = zap.String("user_agent", c.Request().UserAgent())
|
||||
fields[7] = zap.Int("status", c.Response().Status)
|
||||
fields[8] = zap.Int64("latency", int64(finishTime.Sub(startTime)))
|
||||
fields[9] = zap.String("latency_human", finishTime.Sub(startTime).String())
|
||||
fields[10] = zap.Int64("bytes_in", c.Request().ContentLength)
|
||||
fields[11] = zap.Int64("bytes_out", c.Response().Size)
|
||||
|
||||
if err != nil {
|
||||
reqLogger.Error(err.Error(), fields...)
|
||||
} else {
|
||||
reqLogger.Info("request handled", fields...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the
|
||||
// Context and related context.CancelFunc in the echo.Context under "context"
|
||||
// and "cancel". If the process is synchronous, it also handles the result of a
|
||||
// "multipart/form-data" request.
|
||||
//
|
||||
// ctx := c.Get("context").(*api.Context)
|
||||
// cancel := c.Get("cancel").(context.CancelFunc)
|
||||
func contextMiddleware(timeout time.Duration) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
logger := c.Get("logger").(*zap.Logger)
|
||||
|
||||
// We create a context with a timeout so that underlying processes are
|
||||
// able to stop early and handle correctly a timeout scenario.
|
||||
ctx, cancel, err := newContext(c, logger, timeout)
|
||||
if err != nil {
|
||||
cancel()
|
||||
|
||||
return fmt.Errorf("create request context: %w", err)
|
||||
}
|
||||
c.Set("context", ctx)
|
||||
c.Set("cancel", cancel)
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
err = next(c)
|
||||
|
||||
if errors.Is(err, ErrAsyncProcess) {
|
||||
// A middleware/handler tells us that it's handling the process
|
||||
// in an asynchronous fashion. Therefore, we must not cancel
|
||||
// the context nor send an output file.
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
defer cancel()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// No error, let's build the output file.
|
||||
outputPath, err := ctx.BuildOutputFile()
|
||||
if err != nil {
|
||||
return fmt.Errorf("build output file: %w", err)
|
||||
}
|
||||
|
||||
// Send the output file.
|
||||
err = c.Attachment(outputPath, ctx.OutputFilename(outputPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("send response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hardTimeoutMiddleware manages hard timeout scenarios, i.e., when a route
|
||||
// handler fails to timeout as expected.
|
||||
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
logger := c.Get("logger").(*zap.Logger)
|
||||
|
||||
// Define a hard timeout if the route handler fails to timeout as
|
||||
// expected.
|
||||
hardTimeoutCtx, hardTimeoutCancel := context.WithTimeout(
|
||||
context.Background(),
|
||||
hardTimeout,
|
||||
)
|
||||
defer hardTimeoutCancel()
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
// In case of hard timeout, a panic may occur.
|
||||
// This deferred function allows us to recover from such scenarios.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Debug(fmt.Sprintf("recovering from a panic (possible cause being a hard timeout): %s", r))
|
||||
}
|
||||
}()
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
errChan <- next(c)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errChan:
|
||||
return err
|
||||
case <-hardTimeoutCtx.Done():
|
||||
logger.Debug("hard timeout as the route handler did not timeout as expected")
|
||||
|
||||
return fmt.Errorf("hard timeout: %w", hardTimeoutCtx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
506
pkg/modules/api/middlewares_test.go
Normal file
506
pkg/modules/api/middlewares_test.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestParseError(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
err error
|
||||
expectStatus int
|
||||
expectMessage string
|
||||
}{
|
||||
{
|
||||
err: echo.ErrInternalServerError,
|
||||
expectStatus: http.StatusInternalServerError,
|
||||
expectMessage: http.StatusText(http.StatusInternalServerError),
|
||||
},
|
||||
{
|
||||
err: context.DeadlineExceeded,
|
||||
expectStatus: http.StatusServiceUnavailable,
|
||||
expectMessage: http.StatusText(http.StatusServiceUnavailable),
|
||||
},
|
||||
{
|
||||
err: WrapError(
|
||||
errors.New("foo"),
|
||||
NewSentinelHTTPError(http.StatusBadRequest, "foo"),
|
||||
),
|
||||
expectStatus: http.StatusBadRequest,
|
||||
expectMessage: "foo",
|
||||
},
|
||||
} {
|
||||
actualStatus, actualMessage := ParseError(tc.err)
|
||||
|
||||
if actualStatus != tc.expectStatus {
|
||||
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, actualStatus)
|
||||
}
|
||||
|
||||
if actualMessage != tc.expectMessage {
|
||||
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, actualMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHttpErrorHandler(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
err error
|
||||
expectStatus int
|
||||
expectMessage string
|
||||
}{
|
||||
{
|
||||
err: echo.ErrInternalServerError,
|
||||
expectStatus: http.StatusInternalServerError,
|
||||
expectMessage: http.StatusText(http.StatusInternalServerError),
|
||||
},
|
||||
{
|
||||
err: context.DeadlineExceeded,
|
||||
expectStatus: http.StatusServiceUnavailable,
|
||||
expectMessage: http.StatusText(http.StatusServiceUnavailable),
|
||||
},
|
||||
{
|
||||
err: WrapError(
|
||||
errors.New("foo"),
|
||||
NewSentinelHTTPError(http.StatusBadRequest, "foo"),
|
||||
),
|
||||
expectStatus: http.StatusBadRequest,
|
||||
expectMessage: "foo",
|
||||
},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
c.Set("logger", zap.NewNop())
|
||||
|
||||
handler := httpErrorHandler()
|
||||
handler(tc.err, c)
|
||||
|
||||
contentType := recorder.Header().Get(echo.HeaderContentType)
|
||||
if contentType != echo.MIMETextPlainCharsetUTF8 {
|
||||
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8, contentType)
|
||||
}
|
||||
|
||||
// Note: we cannot test the trace header in the response here, as it is set in the trace middleware.
|
||||
|
||||
if recorder.Code != tc.expectStatus {
|
||||
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
|
||||
}
|
||||
|
||||
if recorder.Body.String() != tc.expectMessage {
|
||||
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatencyMiddleware(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
|
||||
err := latencyMiddleware()(
|
||||
func(c echo.Context) error {
|
||||
return nil
|
||||
},
|
||||
)(c)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
startTime := c.Get("startTime").(time.Time)
|
||||
now := time.Now()
|
||||
|
||||
if now.Before(startTime) {
|
||||
t.Errorf("expected start time %s to be < %s", startTime, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootPathMiddleware(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
|
||||
err := rootPathMiddleware("foo")(
|
||||
func(c echo.Context) error {
|
||||
return nil
|
||||
},
|
||||
)(c)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
rootPath := c.Get("rootPath").(string)
|
||||
|
||||
if rootPath != "foo" {
|
||||
t.Errorf("expected '%s' but got '%s", "foo", rootPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceMiddleware(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
trace string
|
||||
}{
|
||||
{
|
||||
trace: "foo",
|
||||
},
|
||||
{
|
||||
trace: "",
|
||||
},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
|
||||
if tc.trace != "" {
|
||||
c.Request().Header.Set("Gotenberg-Trace", tc.trace)
|
||||
}
|
||||
|
||||
err := traceMiddleware("Gotenberg-Trace")(
|
||||
func(c echo.Context) error {
|
||||
return nil
|
||||
},
|
||||
)(c)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
trace := c.Get("trace").(string)
|
||||
|
||||
if trace == "" {
|
||||
t.Errorf("test %d: expected non empty trace in context", i)
|
||||
}
|
||||
|
||||
if tc.trace != "" && trace != tc.trace {
|
||||
t.Errorf("test %d: expected context trace '%s' but got '%s'", i, tc.trace, trace)
|
||||
}
|
||||
|
||||
if tc.trace == "" && trace == tc.trace {
|
||||
t.Errorf("test %d: expected context trace different from '%s' but got '%s'", i, tc.trace, trace)
|
||||
}
|
||||
|
||||
responseTrace := recorder.Header().Get("Gotenberg-Trace")
|
||||
|
||||
if tc.trace != "" && responseTrace != tc.trace {
|
||||
t.Errorf("test %d: expected header trace '%s' but got '%s'", i, tc.trace, responseTrace)
|
||||
}
|
||||
|
||||
if tc.trace == "" && responseTrace == tc.trace {
|
||||
t.Errorf("test %d: expected header trace different from '%s' but got '%s'", i, tc.trace, responseTrace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerMiddleware(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
request *http.Request
|
||||
next echo.HandlerFunc
|
||||
skipLogging bool
|
||||
}{
|
||||
{
|
||||
request: httptest.NewRequest(http.MethodGet, "/", nil),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
}(),
|
||||
},
|
||||
{
|
||||
request: httptest.NewRequest(http.MethodGet, "/health", nil),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
skipLogging: true,
|
||||
},
|
||||
{
|
||||
request: httptest.NewRequest(http.MethodGet, "/health", nil),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
c.Set("startTime", time.Now())
|
||||
c.Set("trace", "foo")
|
||||
c.Set("rootPath", "/")
|
||||
|
||||
var disableLoggingForPaths []string
|
||||
if tc.skipLogging {
|
||||
disableLoggingForPaths = append(disableLoggingForPaths, tc.request.RequestURI)
|
||||
}
|
||||
|
||||
err := loggerMiddleware(zap.NewNop(), disableLoggingForPaths)(tc.next)(c)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMiddleware(t *testing.T) {
|
||||
buildMultipartFormDataRequest := func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
err := writer.WriteField("foo", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
for i, tc := range []struct {
|
||||
request *http.Request
|
||||
next echo.HandlerFunc
|
||||
expectErr bool
|
||||
expectStatus int
|
||||
expectContentType string
|
||||
expectFilename string
|
||||
}{
|
||||
{
|
||||
request: httptest.NewRequest(http.MethodGet, "/", nil),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return ErrAsyncProcess
|
||||
}
|
||||
}(),
|
||||
expectStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
request: func() *http.Request {
|
||||
req := buildMultipartFormDataRequest()
|
||||
req.Header.Set("Gotenberg-Output-Filename", "foo")
|
||||
|
||||
return req
|
||||
}(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*Context)
|
||||
ctx.outputPaths = []string{
|
||||
"/tests/test/testdata/api/sample2.pdf",
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
expectStatus: http.StatusOK,
|
||||
expectContentType: "application/pdf",
|
||||
expectFilename: "foo.pdf",
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*Context)
|
||||
ctx.outputPaths = []string{
|
||||
"/tests/test/testdata/api/sample1.txt",
|
||||
"/tests/test/testdata/api/sample2.pdf",
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
expectStatus: http.StatusOK,
|
||||
expectContentType: "application/zip",
|
||||
},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
c.Set("logger", zap.NewNop())
|
||||
c.Set("trace", "foo")
|
||||
c.Set("startTime", time.Now())
|
||||
|
||||
err := contextMiddleware(time.Duration(10) * time.Second)(tc.next)(c)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if recorder.Code != tc.expectStatus {
|
||||
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
|
||||
}
|
||||
|
||||
if tc.expectStatus == http.StatusNoContent {
|
||||
continue
|
||||
}
|
||||
|
||||
contentType := recorder.Header().Get(echo.HeaderContentType)
|
||||
if contentType != tc.expectContentType {
|
||||
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, tc.expectContentType, contentType)
|
||||
}
|
||||
|
||||
contentDisposition := recorder.Header().Get(echo.HeaderContentDisposition)
|
||||
if !strings.Contains(contentDisposition, tc.expectFilename) {
|
||||
t.Errorf("test %d: expected %s '%s' to contain '%s'", i, echo.HeaderContentDisposition, contentDisposition, tc.expectFilename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardTimeoutMiddleware(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
next echo.HandlerFunc
|
||||
timeout time.Duration
|
||||
expectErr bool
|
||||
expectHardTimeout bool
|
||||
}{
|
||||
{
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
timeout: time.Duration(100) * time.Millisecond,
|
||||
},
|
||||
{
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
panic("foo")
|
||||
}
|
||||
}(),
|
||||
timeout: time.Duration(100) * time.Millisecond,
|
||||
expectErr: true,
|
||||
expectHardTimeout: true,
|
||||
},
|
||||
{
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return errors.New("foo")
|
||||
}
|
||||
}(),
|
||||
timeout: time.Duration(100) * time.Millisecond,
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
time.Sleep(time.Duration(200) * time.Millisecond)
|
||||
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
timeout: time.Duration(100) * time.Millisecond,
|
||||
expectErr: true,
|
||||
expectHardTimeout: true,
|
||||
},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
|
||||
c := srv.NewContext(request, recorder)
|
||||
c.Set("logger", zap.NewNop())
|
||||
|
||||
err := hardTimeoutMiddleware(tc.timeout)(tc.next)(c)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
var isHardTimeout bool
|
||||
if err != nil {
|
||||
isHardTimeout = strings.Contains(err.Error(), "hard timeout")
|
||||
}
|
||||
|
||||
if tc.expectHardTimeout && !isHardTimeout {
|
||||
t.Errorf("test %d: expected hard timeout error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectHardTimeout && isHardTimeout {
|
||||
t.Errorf("test %d: expected no hard timeout error but got one: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
pkg/modules/api/mocks.go
Normal file
74
pkg/modules/api/mocks.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ContextMock is a helper for tests.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
type ContextMock struct {
|
||||
*Context
|
||||
}
|
||||
|
||||
// SetDirPath sets the context's working directory path.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.SetDirPath("/foo")
|
||||
func (ctx *ContextMock) SetDirPath(path string) {
|
||||
ctx.dirPath = path
|
||||
}
|
||||
|
||||
// SetValues sets the values.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.SetValues(map[string][]string{
|
||||
// "url": {
|
||||
// "foo",
|
||||
// },
|
||||
// })
|
||||
func (ctx *ContextMock) SetValues(values map[string][]string) {
|
||||
ctx.values = values
|
||||
}
|
||||
|
||||
// SetFiles sets the files.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.SetFiles(map[string]string{
|
||||
// "foo": "/foo",
|
||||
// })
|
||||
func (ctx *ContextMock) SetFiles(files map[string]string) {
|
||||
ctx.files = files
|
||||
}
|
||||
|
||||
// SetCancelled sets if the context is cancelled or not.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.SetCancelled(true)
|
||||
func (ctx *ContextMock) SetCancelled(cancelled bool) {
|
||||
ctx.cancelled = cancelled
|
||||
}
|
||||
|
||||
// OutputPaths returns the registered output paths.
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// outputPaths := ctx.OutputPaths()
|
||||
func (ctx ContextMock) OutputPaths() []string {
|
||||
return ctx.outputPaths
|
||||
}
|
||||
|
||||
// SetLogger sets the logger.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.SetLogger(zap.NewNop())
|
||||
func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
|
||||
ctx.logger = logger
|
||||
}
|
||||
|
||||
// SetEchoContext sets the echo.Context.
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.setEchoContext(c)
|
||||
func (ctx *ContextMock) SetEchoContext(c echo.Context) {
|
||||
ctx.Context.echoCtx = c
|
||||
}
|
||||
105
pkg/modules/api/mocks_test.go
Normal file
105
pkg/modules/api/mocks_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestContextMock_SetDirPath(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetDirPath("/foo")
|
||||
|
||||
actual := mock.dirPath
|
||||
expect := "/foo"
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetValues(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetValues(map[string][]string{
|
||||
"foo": {"foo"},
|
||||
})
|
||||
|
||||
actual := mock.values
|
||||
expect := map[string][]string{
|
||||
"foo": {"foo"},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %+v but got: %+v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetFiles(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetFiles(map[string]string{
|
||||
"foo": "/foo",
|
||||
})
|
||||
|
||||
actual := mock.files
|
||||
expect := map[string]string{
|
||||
"foo": "/foo",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %+v but got: %+v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetCancelled(t *testing.T) {
|
||||
mock := &ContextMock{&Context{}}
|
||||
mock.SetCancelled(true)
|
||||
|
||||
actual := mock.cancelled
|
||||
|
||||
if !actual {
|
||||
t.Errorf("expected %t but got %t", true, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_OutputPaths(t *testing.T) {
|
||||
mock := ContextMock{
|
||||
&Context{
|
||||
outputPaths: []string{"/foo"},
|
||||
},
|
||||
}
|
||||
|
||||
actual := mock.OutputPaths()
|
||||
expect := []string{"/foo"}
|
||||
|
||||
if !reflect.DeepEqual(actual, expect) {
|
||||
t.Errorf("expected %+v but got: %+v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetLogger(t *testing.T) {
|
||||
mock := ContextMock{&Context{}}
|
||||
|
||||
expect := zap.NewNop()
|
||||
mock.SetLogger(expect)
|
||||
|
||||
actual := mock.logger
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetEchoContext(t *testing.T) {
|
||||
mock := ContextMock{&Context{}}
|
||||
|
||||
expect := echo.New().NewContext(nil, nil)
|
||||
mock.SetEchoContext(expect)
|
||||
|
||||
actual := mock.echoCtx
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
833
pkg/modules/chromium/chromium.go
Normal file
833
pkg/modules/chromium/chromium.go
Normal file
@@ -0,0 +1,833 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/emulation"
|
||||
"github.com/chromedp/cdproto/fetch"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(Chromium{})
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrURLNotAuthorized happens if a URL is not acceptable according to the
|
||||
// allowed/denied lists.
|
||||
ErrURLNotAuthorized = errors.New("URL not authorized")
|
||||
|
||||
// ErrInvalidEmulatedMediaType happens if the emulated media type is not
|
||||
// "screen" nor "print". Empty value are allowed though.
|
||||
ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type")
|
||||
|
||||
// ErrInvalidEvaluationExpression happens if an evaluation expression
|
||||
// returns an exception or undefined.
|
||||
ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression")
|
||||
|
||||
// ErrInvalidPrinterSettings happens if the Options have one or more
|
||||
// aberrant values.
|
||||
ErrInvalidPrinterSettings = errors.New("invalid printer settings")
|
||||
|
||||
// ErrPageRangesSyntaxError happens if the Options have an invalid page
|
||||
// ranges.
|
||||
ErrPageRangesSyntaxError = errors.New("page ranges syntax error")
|
||||
|
||||
// ErrRpccMessageTooLarge happens when the messages received by
|
||||
// ChromeDevTools are larger than 100 MB.
|
||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||
|
||||
// ErrConsoleExceptions happens when there are exceptions in the Chromium
|
||||
// console. It also happens only if the Options.FailOnConsoleExceptions is
|
||||
// set to true.
|
||||
ErrConsoleExceptions = errors.New("console exceptions")
|
||||
)
|
||||
|
||||
// Chromium is a module which provides both an API and routes for converting
|
||||
// HTML document to PDF.
|
||||
type Chromium struct {
|
||||
binPath string
|
||||
engine gotenberg.PDFEngine
|
||||
userAgent string
|
||||
incognito bool
|
||||
ignoreCertificateErrors bool
|
||||
disableWebSecurity bool
|
||||
allowFileAccessFromFiles bool
|
||||
proxyServer string
|
||||
allowList *regexp.Regexp
|
||||
denyList *regexp.Regexp
|
||||
disableJavaScript bool
|
||||
disableRoutes bool
|
||||
}
|
||||
|
||||
// LinkTag represents an HTML <link> element.
|
||||
type LinkTag struct {
|
||||
// Href is the "href" attribute of the HTML <link> element.
|
||||
// Required.
|
||||
Href string `json:"href"`
|
||||
}
|
||||
|
||||
// ScriptTag represents an HTML <script> element.
|
||||
type ScriptTag struct {
|
||||
// Src is the "src" attribute of the HTML <script> element.
|
||||
// Required.
|
||||
Src string `json:"src"`
|
||||
}
|
||||
|
||||
// Options are the available options for converting HTML document to PDF.
|
||||
type Options struct {
|
||||
// FailOnConsoleExceptions sets if the conversion should fail if there are
|
||||
// exceptions in the Chromium console.
|
||||
// Optional.
|
||||
FailOnConsoleExceptions bool
|
||||
|
||||
// WaitDelay is the duration to wait when loading an HTML document before
|
||||
// converting it to PDF.
|
||||
// Optional.
|
||||
WaitDelay time.Duration
|
||||
|
||||
// WaitWindowStatus is the window.status value to wait for before
|
||||
// converting an HTML document to PDF.
|
||||
// Optional.
|
||||
WaitWindowStatus string
|
||||
|
||||
// WaitForExpression is the custom JavaScript expression to wait before
|
||||
// converting an HTML document to PDF until it returns true
|
||||
// Optional.
|
||||
WaitForExpression string
|
||||
|
||||
// UserAgent overrides the default User-Agent header.
|
||||
// Optional.
|
||||
UserAgent string
|
||||
|
||||
// ExtraHTTPHeaders are the HTTP headers to send by Chromium while loading
|
||||
// the HTML document.
|
||||
// Optional.
|
||||
ExtraHTTPHeaders map[string]string
|
||||
|
||||
// ExtraLinkTags are HTML <link> attributes that are added on the fly.
|
||||
// Optional.
|
||||
ExtraLinkTags []LinkTag
|
||||
|
||||
// EmulatedMediaType is the media type to emulate, either "screen" or
|
||||
// "print".
|
||||
// Optional.
|
||||
EmulatedMediaType string
|
||||
|
||||
// ExtraScriptTags are HTML <script> attributes that are added on the fly.
|
||||
// Optional.
|
||||
ExtraScriptTags []ScriptTag
|
||||
|
||||
// Landscape sets the paper orientation.
|
||||
// Optional.
|
||||
Landscape bool
|
||||
|
||||
// PrintBackground prints the background graphics.
|
||||
// Optional.
|
||||
PrintBackground bool
|
||||
|
||||
// Scale is the scale of the page rendering.
|
||||
// Optional.
|
||||
Scale float64
|
||||
|
||||
// PaperWidth is the paper width, in inches.
|
||||
// Optional.
|
||||
PaperWidth float64
|
||||
|
||||
// PaperHeight is the paper height, in inches.
|
||||
// Optional.
|
||||
PaperHeight float64
|
||||
|
||||
// MarginTop is the top margin, in inches.
|
||||
// Optional.
|
||||
MarginTop float64
|
||||
|
||||
// MarginBottom is the bottom margin, in inches.
|
||||
// Optional.
|
||||
MarginBottom float64
|
||||
|
||||
// MarginLeft is the left margin, in inches.
|
||||
// Optional.
|
||||
MarginLeft float64
|
||||
|
||||
// MarginRight is the right margin, in inches.
|
||||
// Optional.
|
||||
MarginRight float64
|
||||
|
||||
// Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages.
|
||||
// Optional.
|
||||
PageRanges string
|
||||
|
||||
// HeaderTemplate is the HTML template of the header. It should be valid
|
||||
// HTML markup with following classes used to inject printing values into
|
||||
// them:
|
||||
// - date: formatted print date
|
||||
// - title: document title
|
||||
// - url: document location
|
||||
// - pageNumber: current page number
|
||||
// - totalPages: total pages in the document
|
||||
// For example, <span class=title></span> would generate span containing
|
||||
// the title.
|
||||
// Optional.
|
||||
HeaderTemplate string
|
||||
|
||||
// FooterTemplate is the HTML template of the footer. It should use the
|
||||
// same format as the HeaderTemplate.
|
||||
// Optional.
|
||||
FooterTemplate string
|
||||
|
||||
// PreferCSSPageSize defines whether to prefer page size as defined by CSS.
|
||||
// If false, the content will be scaled to fit the paper size.
|
||||
// Optional.
|
||||
PreferCSSPageSize bool
|
||||
}
|
||||
|
||||
// DefaultOptions returns the default values for Options.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
FailOnConsoleExceptions: false,
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
WaitForExpression: "",
|
||||
UserAgent: "",
|
||||
ExtraHTTPHeaders: nil,
|
||||
ExtraLinkTags: nil,
|
||||
EmulatedMediaType: "",
|
||||
ExtraScriptTags: nil,
|
||||
Landscape: false,
|
||||
PrintBackground: false,
|
||||
Scale: 1.0,
|
||||
PaperWidth: 8.5,
|
||||
PaperHeight: 11,
|
||||
MarginTop: 0.39,
|
||||
MarginBottom: 0.39,
|
||||
MarginLeft: 0.39,
|
||||
MarginRight: 0.39,
|
||||
PageRanges: "",
|
||||
HeaderTemplate: "<html><head></head><body></body></html>",
|
||||
FooterTemplate: "<html><head></head><body></body></html>",
|
||||
PreferCSSPageSize: false,
|
||||
}
|
||||
}
|
||||
|
||||
// API helps to interact with Chromium for converting HTML documents to PDF.
|
||||
type API interface {
|
||||
PDF(ctx context.Context, logger *zap.Logger, URL, outputPath string, options Options) error
|
||||
}
|
||||
|
||||
// Provider is a module interface which exposes a method for creating an API
|
||||
// for other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(chromium.Provider))
|
||||
// chromium, _ := provider.(chromium.Provider).Chromium()
|
||||
// }
|
||||
type Provider interface {
|
||||
Chromium() (API, error)
|
||||
}
|
||||
|
||||
// Descriptor returns a Chromium's module descriptor.
|
||||
func (mod Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "chromium",
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
|
||||
fs.String("chromium-user-agent", "", "Override the default User-Agent header")
|
||||
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
|
||||
fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors")
|
||||
fs.Bool("chromium-disable-web-security", false, "Don't enforce the same-origin policy")
|
||||
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
|
||||
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
||||
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
|
||||
fs.String("chromium-deny-list", "^file:///[^tmp].*", "Set the denied URLs for Chromium using a regular expression")
|
||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||
|
||||
err := fs.MarkDeprecated("chromium-user-agent", "use the userAgent form field instead")
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("create deprecated flags for chromium module: %v", err))
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(Chromium) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
mod.userAgent = flags.MustString("chromium-user-agent")
|
||||
mod.ignoreCertificateErrors = flags.MustBool("chromium-ignore-certificate-errors")
|
||||
mod.disableWebSecurity = flags.MustBool("chromium-disable-web-security")
|
||||
mod.allowFileAccessFromFiles = flags.MustBool("chromium-allow-file-access-from-files")
|
||||
mod.proxyServer = flags.MustString("chromium-proxy-server")
|
||||
mod.allowList = flags.MustRegexp("chromium-allow-list")
|
||||
mod.denyList = flags.MustRegexp("chromium-deny-list")
|
||||
mod.disableJavaScript = flags.MustBool("chromium-disable-javascript")
|
||||
mod.disableRoutes = flags.MustBool("chromium-disable-routes")
|
||||
|
||||
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("CHROMIUM_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
mod.binPath = binPath
|
||||
|
||||
provider, err := ctx.Module(new(gotenberg.PDFEngineProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get PDF engine provider: %w", err)
|
||||
}
|
||||
|
||||
engine, err := provider.(gotenberg.PDFEngineProvider).PDFEngine()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get PDF engine: %w", err)
|
||||
}
|
||||
|
||||
mod.engine = engine
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (mod Chromium) Validate() error {
|
||||
_, err := os.Stat(mod.binPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("chromium binary path does not exist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Metrics returns the metrics.
|
||||
func (mod Chromium) Metrics() ([]gotenberg.Metric, error) {
|
||||
return []gotenberg.Metric{
|
||||
{
|
||||
Name: "chromium_active_instances_count",
|
||||
Description: "Current number of active Chromium instances.",
|
||||
Read: func() float64 {
|
||||
activeInstancesCountMu.RLock()
|
||||
defer activeInstancesCountMu.RUnlock()
|
||||
|
||||
return activeInstancesCount
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Chromium returns an API for interacting with Chromium for converting HTML
|
||||
// documents to PDF.
|
||||
func (mod Chromium) Chromium() (API, error) {
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
// Routes returns the HTTP routes.
|
||||
func (mod Chromium) Routes() ([]api.Route, error) {
|
||||
if mod.disableRoutes {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []api.Route{
|
||||
convertURLRoute(mod, mod.engine),
|
||||
convertHTMLRoute(mod, mod.engine),
|
||||
convertMarkdownRoute(mod, mod.engine),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PDF converts a URL to PDF. It creates a dedicated Chromium instance.
|
||||
// Substantial calls to this method may increase CPU and memory usage
|
||||
// drastically. In such a scenario, the given context may also be done before
|
||||
// the end of the conversion.
|
||||
func (mod Chromium) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath string, options Options) error {
|
||||
debug := debugLogger{logger: logger.Named("browser")}
|
||||
userProfileDirPath := gotenberg.NewDirPath()
|
||||
|
||||
args := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.CombinedOutput(debug),
|
||||
chromedp.ExecPath(mod.binPath),
|
||||
chromedp.NoSandbox,
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/327
|
||||
// https://github.com/chromedp/chromedp/issues/904
|
||||
chromedp.DisableGPU,
|
||||
// See:
|
||||
// https://github.com/puppeteer/puppeteer/issues/661
|
||||
// https://github.com/puppeteer/puppeteer/issues/2410
|
||||
chromedp.Flag("font-render-hinting", "none"),
|
||||
chromedp.UserDataDir(userProfileDirPath),
|
||||
)
|
||||
|
||||
if mod.userAgent != "" && options.UserAgent == "" {
|
||||
// Deprecated.
|
||||
args = append(args, chromedp.UserAgent(mod.userAgent))
|
||||
}
|
||||
|
||||
if mod.incognito {
|
||||
args = append(args, chromedp.Flag("incognito", mod.incognito))
|
||||
}
|
||||
|
||||
if mod.ignoreCertificateErrors {
|
||||
args = append(args, chromedp.IgnoreCertErrors)
|
||||
}
|
||||
|
||||
if mod.disableWebSecurity {
|
||||
args = append(args, chromedp.Flag("disable-web-security", true))
|
||||
}
|
||||
|
||||
if mod.allowFileAccessFromFiles {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/356.
|
||||
args = append(args, chromedp.Flag("allow-file-access-from-files", true))
|
||||
}
|
||||
|
||||
if mod.proxyServer != "" {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/376.
|
||||
args = append(args, chromedp.ProxyServer(mod.proxyServer))
|
||||
}
|
||||
|
||||
if options.UserAgent != "" {
|
||||
args = append(args, chromedp.UserAgent(options.UserAgent))
|
||||
}
|
||||
|
||||
allocatorCtx, cancel := chromedp.NewExecAllocator(ctx, args...)
|
||||
defer cancel()
|
||||
|
||||
taskCtx, cancel := chromedp.NewContext(allocatorCtx,
|
||||
chromedp.WithDebugf(debug.Printf),
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
// We validate the "main" URL against our allow / deny lists.
|
||||
if !mod.allowList.MatchString(URL) {
|
||||
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", URL, ErrURLNotAuthorized)
|
||||
}
|
||||
|
||||
if mod.denyList.String() != "" && mod.denyList.MatchString(URL) {
|
||||
return fmt.Errorf("'%s' matches the expression from the denied list: %w", URL, ErrURLNotAuthorized)
|
||||
}
|
||||
|
||||
var (
|
||||
consoleExceptions error
|
||||
consoleExceptionsMu sync.RWMutex
|
||||
)
|
||||
|
||||
printToPDF := func(URL string, options Options, result *[]byte) chromedp.Tasks {
|
||||
// We validate the underlying requests against our allow / deny lists.
|
||||
// If a request does not pass the validation, we make it fail.
|
||||
listenForEventRequestPaused(taskCtx, logger, mod.allowList, mod.denyList)
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
if options.FailOnConsoleExceptions && !mod.disableJavaScript {
|
||||
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
|
||||
}
|
||||
|
||||
return chromedp.Tasks{
|
||||
network.Enable(),
|
||||
fetch.Enable(),
|
||||
runtime.Enable(),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||
if !mod.disableJavaScript {
|
||||
logger.Debug("JavaScript not disabled")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug("disable JavaScript")
|
||||
|
||||
err := emulation.SetScriptExecutionDisabled(true).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("disable JavaScript: %w", err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if len(options.ExtraHTTPHeaders) == 0 {
|
||||
logger.Debug("no extra HTTP headers")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.ExtraHTTPHeaders))
|
||||
|
||||
headers := make(network.Headers, len(options.ExtraHTTPHeaders))
|
||||
for key, value := range options.ExtraHTTPHeaders {
|
||||
headers[key] = value
|
||||
}
|
||||
|
||||
err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("set extra HTTP headers: %w", err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
logger.Debug(fmt.Sprintf("navigate to '%s'", URL))
|
||||
|
||||
_, _, _, err := page.Navigate(URL).Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("navigate to '%s': %w", URL, err)
|
||||
}
|
||||
|
||||
err = runBatch(
|
||||
ctx,
|
||||
waitForEventDomContentEventFired(ctx, logger),
|
||||
waitForEventLoadEventFired(ctx, logger),
|
||||
waitForEventNetworkIdle(ctx, logger),
|
||||
waitForEventLoadingFinished(ctx, logger),
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("wait for events: %w", err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/354
|
||||
// https://github.com/puppeteer/puppeteer/issues/2685
|
||||
// https://github.com/chromedp/chromedp/issues/520
|
||||
script := `
|
||||
(() => {
|
||||
const css = 'html { -webkit-print-color-adjust: exact !important; }';
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.type = 'text/css';
|
||||
style.appendChild(document.createTextNode(css));
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
`
|
||||
|
||||
evaluate := chromedp.Evaluate(script, nil)
|
||||
err := evaluate.Do(ctx)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("add CSS for exact colors: %w", err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if len(options.ExtraLinkTags) == 0 {
|
||||
logger.Debug("no extra link tags")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("extra link tags: %+v", options.ExtraLinkTags))
|
||||
|
||||
addLinkTag := func(i int, linkTag LinkTag) func() error {
|
||||
return func() error {
|
||||
script := `
|
||||
(() => {
|
||||
const link = document.createElement('link');
|
||||
link.href = '%s';
|
||||
link.rel = 'stylesheet'
|
||||
document.head.appendChild(link);
|
||||
})();
|
||||
`
|
||||
|
||||
evaluate := chromedp.Evaluate(fmt.Sprintf(script, linkTag.Href), nil)
|
||||
err := evaluate.Do(ctx)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("add extra link tag %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
|
||||
for i, linkTag := range options.ExtraLinkTags {
|
||||
eg.Go(addLinkTag(i, linkTag))
|
||||
}
|
||||
|
||||
err := eg.Wait()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("add extra link tags: %w", err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if options.EmulatedMediaType == "" {
|
||||
logger.Debug("no emulated media type")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if options.EmulatedMediaType != "screen" && options.EmulatedMediaType != "print" {
|
||||
return fmt.Errorf("validate emulated media type '%s': %w", options.EmulatedMediaType, ErrInvalidEmulatedMediaType)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("emulate media type '%s'", options.EmulatedMediaType))
|
||||
|
||||
emulatedMedia := emulation.SetEmulatedMedia()
|
||||
|
||||
err := emulatedMedia.WithMedia(options.EmulatedMediaType).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("emulate media type '%s': %w", options.EmulatedMediaType, err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if mod.disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping extra script tags")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(options.ExtraScriptTags) == 0 {
|
||||
logger.Debug("no extra script tags")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("extra script tags: %+v", options.ExtraScriptTags))
|
||||
|
||||
addScriptTag := func(i int, scriptTag ScriptTag) func() error {
|
||||
return func() error {
|
||||
script := `
|
||||
(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = '%s';
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
`
|
||||
|
||||
evaluate := chromedp.Evaluate(fmt.Sprintf(script, scriptTag.Src), nil)
|
||||
err := evaluate.Do(ctx)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("add extra script tag %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
|
||||
for i, scriptTag := range options.ExtraScriptTags {
|
||||
eg.Go(addScriptTag(i, scriptTag))
|
||||
}
|
||||
|
||||
err := eg.Wait()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("add extra script tags: %w", err)
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if mod.disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping wait delay")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if options.WaitDelay <= 0 {
|
||||
logger.Debug("no wait delay")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// We wait for a given amount of time so that JavaScript
|
||||
// scripts have a chance to finish before printing the page
|
||||
// to PDF.
|
||||
logger.Debug(fmt.Sprintf("wait '%s' before print", options.WaitDelay))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait delay: %w", ctx.Err())
|
||||
case <-time.After(options.WaitDelay):
|
||||
return nil
|
||||
}
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if mod.disableJavaScript {
|
||||
logger.Debug("JavaScript disabled, skipping wait for window status / wait for expression")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if options.WaitWindowStatus == "" && options.WaitForExpression == "" {
|
||||
logger.Debug("no wait for window status nor wait for expression")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
evaluate := func(expression string) error {
|
||||
// We wait until the evaluation of the expression is true or
|
||||
// until the context is done.
|
||||
logger.Debug(fmt.Sprintf("wait until '%s' is true before print", expression))
|
||||
|
||||
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
|
||||
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
|
||||
case <-ticker.C:
|
||||
var ok bool
|
||||
|
||||
evaluate := chromedp.Evaluate(expression, &ok)
|
||||
err := evaluate.Do(ctx)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression)
|
||||
}
|
||||
|
||||
if ok {
|
||||
ticker.Stop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if options.WaitWindowStatus != "" {
|
||||
logger.Warn("option 'WaitWindowStatus' is deprecated; prefer 'WaitForExpression' instead")
|
||||
|
||||
err := evaluate(fmt.Sprintf("window.status === '%s'", options.WaitWindowStatus))
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for window.status === '%s': %w", options.WaitWindowStatus, err)
|
||||
}
|
||||
}
|
||||
|
||||
if options.WaitForExpression != "" {
|
||||
err := evaluate(options.WaitForExpression)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for expression '%s': %w", options.WaitForExpression, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
printToPDF := page.PrintToPDF().
|
||||
WithLandscape(options.Landscape).
|
||||
WithPrintBackground(options.PrintBackground).
|
||||
WithScale(options.Scale).
|
||||
WithPaperWidth(options.PaperWidth).
|
||||
WithPaperHeight(options.PaperHeight).
|
||||
WithMarginTop(options.MarginTop).
|
||||
WithMarginBottom(options.MarginBottom).
|
||||
WithMarginLeft(options.MarginLeft).
|
||||
WithMarginRight(options.MarginRight).
|
||||
WithPageRanges(options.PageRanges).
|
||||
WithDisplayHeaderFooter(true).
|
||||
WithHeaderTemplate(options.HeaderTemplate).
|
||||
WithFooterTemplate(options.FooterTemplate).
|
||||
WithPreferCSSPageSize(options.PreferCSSPageSize)
|
||||
|
||||
logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPDF))
|
||||
|
||||
data, _, err := printToPDF.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("print to PDF: %w", err)
|
||||
}
|
||||
|
||||
*result = data
|
||||
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
activeInstancesCountMu.Lock()
|
||||
activeInstancesCount += 1
|
||||
activeInstancesCountMu.Unlock()
|
||||
|
||||
var buffer []byte
|
||||
err := chromedp.Run(taskCtx, printToPDF(URL, options, &buffer))
|
||||
|
||||
activeInstancesCountMu.Lock()
|
||||
activeInstancesCount -= 1
|
||||
activeInstancesCountMu.Unlock()
|
||||
|
||||
// Always remove the user profile directory created by Chromium.
|
||||
go func() {
|
||||
logger.Debug(fmt.Sprintf("remove user profile directory '%s'", userProfileDirPath))
|
||||
|
||||
err := os.RemoveAll(userProfileDirPath)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("remove user profile directory: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
errMessage := err.Error()
|
||||
|
||||
if strings.Contains(errMessage, "Show invalid printer settings error (-32000)") {
|
||||
return ErrInvalidPrinterSettings
|
||||
}
|
||||
|
||||
if strings.Contains(errMessage, "Page range syntax error") {
|
||||
return ErrPageRangesSyntaxError
|
||||
}
|
||||
|
||||
if strings.Contains(errMessage, "rpcc: message too large") {
|
||||
return ErrRpccMessageTooLarge
|
||||
}
|
||||
|
||||
return fmt.Errorf("chromium PDF: %w", err)
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
consoleExceptionsMu.RLock()
|
||||
defer consoleExceptionsMu.RUnlock()
|
||||
|
||||
if consoleExceptions != nil {
|
||||
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(outputPath, buffer, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write result to output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
activeInstancesCount float64
|
||||
activeInstancesCountMu sync.RWMutex
|
||||
)
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*Chromium)(nil)
|
||||
_ gotenberg.Provisioner = (*Chromium)(nil)
|
||||
_ gotenberg.Validator = (*Chromium)(nil)
|
||||
_ gotenberg.MetricsProvider = (*Chromium)(nil)
|
||||
_ api.Router = (*Chromium)(nil)
|
||||
_ API = (*Chromium)(nil)
|
||||
_ Provider = (*Chromium)(nil)
|
||||
)
|
||||
491
pkg/modules/chromium/chromium_test.go
Normal file
491
pkg/modules/chromium/chromium_test.go
Normal file
@@ -0,0 +1,491 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ProtoModule struct {
|
||||
descriptor func() gotenberg.ModuleDescriptor
|
||||
}
|
||||
|
||||
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return mod.descriptor()
|
||||
}
|
||||
|
||||
type ProtoAPI struct {
|
||||
pdf func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error
|
||||
}
|
||||
|
||||
func (mod ProtoAPI) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath string, options Options) error {
|
||||
return mod.pdf(ctx, logger, URL, outputPath, options)
|
||||
}
|
||||
|
||||
type ProtoPDFEngineProvider struct {
|
||||
ProtoModule
|
||||
pdfEngine func() (gotenberg.PDFEngine, error)
|
||||
}
|
||||
|
||||
func (mod ProtoPDFEngineProvider) PDFEngine() (gotenberg.PDFEngine, error) {
|
||||
return mod.pdfEngine()
|
||||
}
|
||||
|
||||
type ProtoPDFEngine struct {
|
||||
merge func(_ context.Context, _ *zap.Logger, _ []string, _ string) error
|
||||
convert func(_ context.Context, _ *zap.Logger, _, _, _ string) error
|
||||
}
|
||||
|
||||
func (mod ProtoPDFEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return mod.merge(ctx, logger, inputPaths, outputPath)
|
||||
}
|
||||
|
||||
func (mod ProtoPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
|
||||
return mod.convert(ctx, logger, format, inputPath, outputPath)
|
||||
}
|
||||
|
||||
func TestDefaultOptions(t *testing.T) {
|
||||
actual := DefaultOptions()
|
||||
notExpect := Options{}
|
||||
|
||||
if reflect.DeepEqual(actual, notExpect) {
|
||||
t.Errorf("expected %v and got identical %v", actual, notExpect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Descriptor(t *testing.T) {
|
||||
descriptor := Chromium{}.Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(Chromium))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Provision(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
ctx *gotenberg.Context
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Chromium).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoPDFEngineProvider }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.pdfEngine = func() (gotenberg.PDFEngine, error) {
|
||||
return nil, errors.New("foo")
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Chromium).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
ctx: func() *gotenberg.Context {
|
||||
mod := struct{ ProtoPDFEngineProvider }{}
|
||||
mod.descriptor = func() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
|
||||
}
|
||||
mod.pdfEngine = func() (gotenberg.PDFEngine, error) {
|
||||
return struct{ ProtoPDFEngine }{}, nil
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: new(Chromium).Descriptor().FlagSet,
|
||||
},
|
||||
[]gotenberg.ModuleDescriptor{
|
||||
mod.Descriptor(),
|
||||
},
|
||||
)
|
||||
}(),
|
||||
},
|
||||
} {
|
||||
mod := new(Chromium)
|
||||
err := mod.Provision(tc.ctx)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Validate(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
binPath string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
binPath: "/foo",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
},
|
||||
} {
|
||||
mod := new(Chromium)
|
||||
mod.binPath = tc.binPath
|
||||
err := mod.Validate()
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Metrics(t *testing.T) {
|
||||
metrics, err := new(Chromium).Metrics()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if len(metrics) != 1 {
|
||||
t.Fatalf("expected %d metrics, but got %d", 1, len(metrics))
|
||||
}
|
||||
|
||||
actual := metrics[0].Read()
|
||||
if actual != 0 {
|
||||
t.Errorf("expected %d Chromium instances, but got %f", 0, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Chromium(t *testing.T) {
|
||||
mod := new(Chromium)
|
||||
|
||||
_, err := mod.Chromium()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Routes(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
expectRoutes int
|
||||
disableRoutes bool
|
||||
}{
|
||||
{
|
||||
expectRoutes: 3,
|
||||
},
|
||||
{
|
||||
disableRoutes: true,
|
||||
},
|
||||
} {
|
||||
mod := new(Chromium)
|
||||
mod.disableRoutes = tc.disableRoutes
|
||||
|
||||
routes, err := mod.Routes()
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if tc.expectRoutes != len(routes) {
|
||||
t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_PDF(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
timeout time.Duration
|
||||
cancel context.CancelFunc
|
||||
URL string
|
||||
options Options
|
||||
userAgent string
|
||||
incognito bool
|
||||
ignoreCertificateErrors bool
|
||||
disableWebSecurity bool
|
||||
allowFileAccessFromFiles bool
|
||||
proxyServer string
|
||||
allowList *regexp.Regexp
|
||||
denyList *regexp.Regexp
|
||||
disableJavaScript bool
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
allowList: regexp.MustCompile("file:///tmp/*"),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
denyList: regexp.MustCompile("file:///tests/*"),
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
UserAgent: "foo",
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample10/index.html",
|
||||
options: Options{
|
||||
FailOnConsoleExceptions: true,
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample9/index.html",
|
||||
disableJavaScript: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
ExtraHTTPHeaders: map[string]string{
|
||||
"foo": "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
|
||||
options: Options{
|
||||
ExtraLinkTags: []LinkTag{
|
||||
{
|
||||
Href: "font.woff",
|
||||
},
|
||||
{
|
||||
Href: "style.css",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
|
||||
options: Options{
|
||||
EmulatedMediaType: "foo",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
|
||||
options: Options{
|
||||
EmulatedMediaType: "screen",
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
|
||||
options: Options{
|
||||
EmulatedMediaType: "print",
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
|
||||
options: Options{
|
||||
ExtraScriptTags: []ScriptTag{
|
||||
{
|
||||
Src: "script.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
WaitDelay: time.Duration(1) * time.Nanosecond,
|
||||
},
|
||||
},
|
||||
{
|
||||
timeout: time.Duration(3) * time.Second,
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
|
||||
options: Options{
|
||||
WaitWindowStatus: "foo",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
timeout: time.Duration(3) * time.Second,
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
|
||||
options: Options{
|
||||
WaitWindowStatus: "ready",
|
||||
},
|
||||
},
|
||||
{
|
||||
timeout: time.Duration(3) * time.Second,
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
|
||||
options: Options{
|
||||
WaitForExpression: "window.status === 'foo'",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
timeout: time.Duration(3) * time.Second,
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
|
||||
options: Options{
|
||||
WaitForExpression: "window.status === 'ready'",
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
WaitForExpression: "return undefined",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
MarginBottom: 100,
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
PageRanges: "foo",
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
userAgent: "foo",
|
||||
incognito: true,
|
||||
ignoreCertificateErrors: true,
|
||||
disableWebSecurity: true,
|
||||
allowFileAccessFromFiles: true,
|
||||
proxyServer: "foo",
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample1/index.html",
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample3/index.html",
|
||||
allowList: regexp.MustCompile("file:///tests/*"),
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample3/index.html",
|
||||
denyList: regexp.MustCompile("file:///etc/*"),
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
|
||||
options: Options{
|
||||
HeaderTemplate: func() string {
|
||||
b, err := ioutil.ReadFile("/tests/test/testdata/chromium/url/sample2/header.html")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}(),
|
||||
FooterTemplate: func() string {
|
||||
b, err := ioutil.ReadFile("/tests/test/testdata/chromium/url/sample2/footer.html")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}(),
|
||||
},
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample5/index.html",
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample6/index.html",
|
||||
allowFileAccessFromFiles: true,
|
||||
},
|
||||
{
|
||||
URL: "file:///tests/test/testdata/chromium/html/sample7/index.html",
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
mod := new(Chromium)
|
||||
mod.binPath = os.Getenv("CHROMIUM_BIN_PATH")
|
||||
mod.userAgent = tc.userAgent
|
||||
mod.incognito = tc.incognito
|
||||
mod.ignoreCertificateErrors = tc.ignoreCertificateErrors
|
||||
mod.disableWebSecurity = tc.disableWebSecurity
|
||||
mod.allowFileAccessFromFiles = tc.allowFileAccessFromFiles
|
||||
mod.proxyServer = tc.proxyServer
|
||||
|
||||
if tc.allowList == nil {
|
||||
tc.allowList = regexp.MustCompile("")
|
||||
}
|
||||
|
||||
if tc.denyList == nil {
|
||||
tc.denyList = regexp.MustCompile("")
|
||||
}
|
||||
|
||||
mod.allowList = tc.allowList
|
||||
mod.denyList = tc.denyList
|
||||
mod.disableJavaScript = tc.disableJavaScript
|
||||
|
||||
outputDir, err := gotenberg.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := os.RemoveAll(outputDir)
|
||||
if err != nil {
|
||||
t.Fatalf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}()
|
||||
|
||||
if tc.timeout == 0 {
|
||||
err = mod.PDF(context.Background(), zap.NewNop(), tc.URL, outputDir+"/foo.pdf", tc.options)
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tc.timeout)
|
||||
defer cancel()
|
||||
|
||||
err = mod.PDF(ctx, zap.NewNop(), tc.URL, outputDir+"/foo.pdf", tc.options)
|
||||
}
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.expectErr && err != nil {
|
||||
t.Errorf("test %d: expected no error but got: %v", i, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ProtoModule)(nil)
|
||||
_ API = (*ProtoAPI)(nil)
|
||||
_ gotenberg.PDFEngineProvider = (*ProtoPDFEngineProvider)(nil)
|
||||
_ gotenberg.Module = (*ProtoPDFEngineProvider)(nil)
|
||||
_ gotenberg.PDFEngine = (*ProtoPDFEngine)(nil)
|
||||
)
|
||||
31
pkg/modules/chromium/debug.go
Normal file
31
pkg/modules/chromium/debug.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// debugLogger is wrapper around a zap.Logger which is used for debugging
|
||||
// Chromium.
|
||||
type debugLogger struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Write logs the bytes in a debug message.
|
||||
func (debug debugLogger) Write(p []byte) (n int, err error) {
|
||||
debug.logger.Debug(string(p))
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Printf logs a debug message.
|
||||
func (debug debugLogger) Printf(format string, v ...interface{}) {
|
||||
debug.logger.Debug(fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ io.Writer = (*debugLogger)(nil)
|
||||
)
|
||||
24
pkg/modules/chromium/debug_test.go
Normal file
24
pkg/modules/chromium/debug_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestDebugLogger_Write(t *testing.T) {
|
||||
actual, err := debugLogger{logger: zap.NewNop()}.Write([]byte("foo"))
|
||||
expected := len([]byte("foo"))
|
||||
|
||||
if actual != expected {
|
||||
t.Errorf("expected %d but got %d", expected, actual)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("expected not error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugLogger_Printf(t *testing.T) {
|
||||
debugLogger{logger: zap.NewNop()}.Printf("%s", "foo")
|
||||
}
|
||||
4
pkg/modules/chromium/doc.go
Normal file
4
pkg/modules/chromium/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package chromium provides a module which adds routes for converting HTML
|
||||
// documents to PDF. Other modules may also retrieve the API provided by this
|
||||
// module.
|
||||
package chromium
|
||||
190
pkg/modules/chromium/events.go
Normal file
190
pkg/modules/chromium/events.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/chromedp/cdproto/cdp"
|
||||
"github.com/chromedp/cdproto/fetch"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// listenForEventRequestPaused listens for requests to check if they are
|
||||
// allowed or not.
|
||||
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp.Regexp, denyList *regexp.Regexp) {
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch e := ev.(type) {
|
||||
case *fetch.EventRequestPaused:
|
||||
go func() {
|
||||
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
||||
allow := true
|
||||
|
||||
if !allowList.MatchString(e.Request.URL) {
|
||||
logger.Warn(fmt.Sprintf("'%s' does not match the expression from the allowed list", e.Request.URL))
|
||||
allow = false
|
||||
}
|
||||
|
||||
if denyList.String() != "" && denyList.MatchString(e.Request.URL) {
|
||||
logger.Warn(fmt.Sprintf("'%s' matches the expression from the denied list", e.Request.URL))
|
||||
allow = false
|
||||
}
|
||||
|
||||
cctx := chromedp.FromContext(ctx)
|
||||
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
|
||||
|
||||
if allow {
|
||||
req := fetch.ContinueRequest(e.RequestID)
|
||||
err := req.Do(executorCtx)
|
||||
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("continue request: %s", err))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||
err := req.Do(executorCtx)
|
||||
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("fail request: %s", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// listenForEventExceptionThrown listens for exceptions in the console and
|
||||
// appends those exceptions to the given error pointer.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch ev := ev.(type) {
|
||||
case *runtime.EventExceptionThrown:
|
||||
logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
|
||||
|
||||
consoleExceptionsMu.Lock()
|
||||
defer consoleExceptionsMu.Unlock()
|
||||
|
||||
*consoleExceptions = multierr.Append(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// waitForEventDomContentEventFired waits until the event DomContentEventFired
|
||||
// is fired or the context timeout.
|
||||
func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) func() error {
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev interface{}) {
|
||||
switch ev.(type) {
|
||||
case *page.EventDomContentEventFired:
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.Debug("event DomContentEventFired fired")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for event DomContentEventFired: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventLoadEventFired waits until the event LoadEventFired is fired or
|
||||
// the context timeout.
|
||||
func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func() error {
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev interface{}) {
|
||||
switch ev.(type) {
|
||||
case *page.EventLoadEventFired:
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.Debug("event LoadEventFired fired")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for event LoadEventFired: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventNetworkIdle waits until the event networkIdle is fired or the
|
||||
// context timeout.
|
||||
func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() error {
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev interface{}) {
|
||||
switch e := ev.(type) {
|
||||
case *page.EventLifecycleEvent:
|
||||
if e.Name == "networkIdle" {
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.Debug("event networkIdle fired")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for event networkIdle: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForEventLoadingFinished waits until the event LoadingFinished is fired
|
||||
// or the context timeout.
|
||||
func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func() error {
|
||||
return func() error {
|
||||
ch := make(chan struct{})
|
||||
cctx, cancel := context.WithCancel(ctx)
|
||||
chromedp.ListenTarget(cctx, func(ev interface{}) {
|
||||
switch ev.(type) {
|
||||
case *network.EventLoadingFinished:
|
||||
cancel()
|
||||
close(ch)
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
logger.Debug("event LoadingFinished fired")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for event LoadingFinished: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runBatch runs all functions simultaneously and waits until all of them are
|
||||
// completed or an error is encountered.
|
||||
func runBatch(ctx context.Context, fn ...func() error) error {
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
|
||||
for _, f := range fn {
|
||||
eg.Go(f)
|
||||
}
|
||||
|
||||
return eg.Wait()
|
||||
}
|
||||
428
pkg/modules/chromium/routes.go
Normal file
428
pkg/modules/chromium/routes.go
Normal file
@@ -0,0 +1,428 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/russross/blackfriday/v2"
|
||||
"go.uber.org/multierr"
|
||||
)
|
||||
|
||||
// FormDataChromiumPDFOptions creates Options form the form data. Fallback to
|
||||
// default value if the considered key is not present.
|
||||
func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
defaultOptions := DefaultOptions()
|
||||
|
||||
var (
|
||||
failOnConsoleExceptions bool
|
||||
waitDelay time.Duration
|
||||
waitWindowStatus string
|
||||
waitForExpression string
|
||||
userAgent string
|
||||
extraHTTPHeaders map[string]string
|
||||
emulatedMediaType string
|
||||
landscape, printBackground bool
|
||||
scale, paperWidth, paperHeight float64
|
||||
marginTop, marginBottom, marginLeft, marginRight float64
|
||||
pageRanges string
|
||||
headerTemplate, footerTemplate string
|
||||
preferCSSPageSize bool
|
||||
)
|
||||
|
||||
form := ctx.FormData().
|
||||
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
|
||||
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
|
||||
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
|
||||
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
|
||||
String("userAgent", &userAgent, defaultOptions.UserAgent).
|
||||
Custom("extraHttpHeaders", func(value string) error {
|
||||
if value == "" {
|
||||
extraHTTPHeaders = defaultOptions.ExtraHTTPHeaders
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(value), &extraHTTPHeaders)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal extra HTTP headers: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}).
|
||||
Custom("emulatedMediaType", func(value string) error {
|
||||
if value == "" {
|
||||
emulatedMediaType = defaultOptions.EmulatedMediaType
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if value != "screen" && value != "print" {
|
||||
return fmt.Errorf("wrong value, expected either 'screen', 'print' or empty")
|
||||
}
|
||||
|
||||
emulatedMediaType = value
|
||||
|
||||
return nil
|
||||
}).
|
||||
Bool("landscape", &landscape, defaultOptions.Landscape).
|
||||
Bool("printBackground", &printBackground, defaultOptions.PrintBackground).
|
||||
Float64("scale", &scale, defaultOptions.Scale).
|
||||
Float64("paperWidth", &paperWidth, defaultOptions.PaperWidth).
|
||||
Float64("paperHeight", &paperHeight, defaultOptions.PaperHeight).
|
||||
Float64("marginTop", &marginTop, defaultOptions.MarginTop).
|
||||
Float64("marginBottom", &marginBottom, defaultOptions.MarginBottom).
|
||||
Float64("marginLeft", &marginLeft, defaultOptions.MarginLeft).
|
||||
Float64("marginRight", &marginRight, defaultOptions.MarginRight).
|
||||
String("nativePageRanges", &pageRanges, defaultOptions.PageRanges).
|
||||
Content("header.html", &headerTemplate, defaultOptions.HeaderTemplate).
|
||||
Content("footer.html", &footerTemplate, defaultOptions.FooterTemplate).
|
||||
Bool("preferCssPageSize", &preferCSSPageSize, defaultOptions.PreferCSSPageSize)
|
||||
|
||||
options := Options{
|
||||
FailOnConsoleExceptions: failOnConsoleExceptions,
|
||||
WaitDelay: waitDelay,
|
||||
WaitWindowStatus: waitWindowStatus,
|
||||
WaitForExpression: waitForExpression,
|
||||
UserAgent: userAgent,
|
||||
ExtraHTTPHeaders: extraHTTPHeaders,
|
||||
ExtraLinkTags: defaultOptions.ExtraLinkTags,
|
||||
EmulatedMediaType: emulatedMediaType,
|
||||
ExtraScriptTags: defaultOptions.ExtraScriptTags,
|
||||
Landscape: landscape,
|
||||
PrintBackground: printBackground,
|
||||
Scale: scale,
|
||||
PaperWidth: paperWidth,
|
||||
PaperHeight: paperHeight,
|
||||
MarginTop: marginTop,
|
||||
MarginBottom: marginBottom,
|
||||
MarginLeft: marginLeft,
|
||||
MarginRight: marginRight,
|
||||
PageRanges: pageRanges,
|
||||
HeaderTemplate: headerTemplate,
|
||||
FooterTemplate: footerTemplate,
|
||||
PreferCSSPageSize: preferCSSPageSize,
|
||||
}
|
||||
|
||||
return form, options
|
||||
}
|
||||
|
||||
// convertURLRoute returns an api.Route which can convert a URL to PDF.
|
||||
func convertURLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/convert/url",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPDFOptions(ctx)
|
||||
|
||||
var (
|
||||
URL string
|
||||
PDFformat string
|
||||
)
|
||||
|
||||
err := form.
|
||||
MandatoryString("url", &URL).
|
||||
String("pdfFormat", &PDFformat, "").
|
||||
Custom("extraLinkTags", func(value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(value), &options.ExtraLinkTags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal extra link tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}).
|
||||
Custom("extraScriptTags", func(value string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(value), &options.ExtraScriptTags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal extra script tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}).
|
||||
Validate()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = convertURL(ctx, chromium, engine, URL, PDFformat, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// convertHTMLRoute returns an api.Route which can convert an HTML file to PDF.
|
||||
func convertHTMLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/convert/html",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPDFOptions(ctx)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
PDFformat string
|
||||
)
|
||||
|
||||
err := form.
|
||||
MandatoryPath("index.html", &inputPath).
|
||||
String("pdfFormat", &PDFformat, "").
|
||||
Validate()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("file://%s", inputPath)
|
||||
|
||||
err = convertURL(ctx, chromium, engine, URL, PDFformat, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// convertMarkdownRoute returns an api.Route which can convert markdown files
|
||||
// to PDF.
|
||||
func convertMarkdownRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/convert/markdown",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPDFOptions(ctx)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
markdownPaths []string
|
||||
PDFformat string
|
||||
)
|
||||
|
||||
err := form.
|
||||
MandatoryPath("index.html", &inputPath).
|
||||
MandatoryPaths([]string{".md"}, &markdownPaths).
|
||||
String("pdfFormat", &PDFformat, "").
|
||||
Validate()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
// We have to convert each markdown file referenced in the HTML
|
||||
// file to... HTML. Thanks to the "html/template" package, we are
|
||||
// able to provide the "toHTML" function which the user may call
|
||||
// directly inside the HTML file.
|
||||
|
||||
var markdownFilesNotFoundErr error
|
||||
|
||||
tmpl, err := template.
|
||||
New(filepath.Base(inputPath)).
|
||||
Funcs(template.FuncMap{
|
||||
"toHTML": func(filename string) (template.HTML, error) {
|
||||
var path string
|
||||
|
||||
for _, markdownPath := range markdownPaths {
|
||||
markdownFilename := filepath.Base(markdownPath)
|
||||
|
||||
if filename == markdownFilename {
|
||||
path = markdownPath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
markdownFilesNotFoundErr = multierr.Append(
|
||||
markdownFilesNotFoundErr,
|
||||
fmt.Errorf("'%s'", filename),
|
||||
)
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
b, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
|
||||
}
|
||||
|
||||
unsafe := blackfriday.Run(b)
|
||||
sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
|
||||
|
||||
// #nosec
|
||||
return template.HTML(sanitized), nil
|
||||
},
|
||||
}).ParseFiles(inputPath)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse template file: %w", err)
|
||||
}
|
||||
|
||||
var buffer bytes.Buffer
|
||||
|
||||
err = tmpl.Execute(&buffer, &struct{}{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
|
||||
if markdownFilesNotFoundErr != nil {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
inputPath = ctx.GeneratePath(".html")
|
||||
|
||||
err = os.WriteFile(inputPath, buffer.Bytes(), 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write template result: %w", err)
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("file://%s", inputPath)
|
||||
|
||||
err = convertURL(ctx, chromium, engine, URL, PDFformat, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// convertURL is a stub which is called by the other methods of this file.
|
||||
func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL, PDFformat string, options Options) error {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err := chromium.PDF(ctx, ctx.Log(), URL, outputPath, options)
|
||||
if err != nil {
|
||||
|
||||
if errors.Is(err, ErrURLNotAuthorized) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusForbidden,
|
||||
fmt.Sprintf("'%s' does not match the authorized URLs", URL),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidEvaluationExpression) {
|
||||
if options.WaitForExpression == "" {
|
||||
// We do not expect the 'waitWindowStatus' form field to return
|
||||
// an ErrInvalidEvaluationExpression error. In such a scenario,
|
||||
// we return a 500.
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidPrinterSettings) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusBadRequest,
|
||||
"Chromium does not handle the provided settings; please check for aberrant form values",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrPageRangesSyntaxError) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("Chromium does not handle the page ranges '%s' (nativePageRanges)", options.PageRanges),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrConsoleExceptions) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusConflict,
|
||||
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
|
||||
// So far so good, the URL has been converted to PDF.
|
||||
// Now, let's check if the client want to convert this result PDF
|
||||
// to a specific PDF format.
|
||||
|
||||
if PDFformat != "" {
|
||||
convertInputPath := outputPath
|
||||
convertOutputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPath)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPDFFormatNotAvailable) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHTTPError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", PDFformat),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
}
|
||||
|
||||
// Important: the output path is now the converted file.
|
||||
outputPath = convertOutputPath
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user