├── .deploystack
└── docker-compose.yml
├── .dockerignore
├── .envrc
├── .github
└── workflows
│ ├── release.yaml
│ └── test.yaml
├── .gitignore
├── .idea
├── .gitignore
├── codeStyles
│ └── codeStyleConfig.xml
├── git_toolbox_prj.xml
├── go.imports.xml
├── golinter.xml
├── jsonSchemas.xml
├── misc.xml
├── modules.xml
├── swagger-settings.xml
├── vcs.xml
├── webResources.xml
├── webarchive.iml
└── yamllint.xml
├── Dockerfile
├── LICENSE.txt
├── README.md
├── adapters
├── processors
│ ├── headers.go
│ ├── internal
│ │ └── mediainline.go
│ ├── pdf.go
│ ├── pdf_test.go
│ ├── processors.go
│ ├── processors_test.go
│ └── singlefile.go
└── repository
│ ├── badger.go
│ ├── badger
│ ├── marshal.go
│ ├── page.go
│ └── page_test.go
│ └── badgers3
│ ├── marshal.go
│ └── page.go
├── api
├── gen.go
├── openapi.yaml
└── openapi
│ ├── oas_cfg_gen.go
│ ├── oas_client_gen.go
│ ├── oas_handlers_gen.go
│ ├── oas_interfaces_gen.go
│ ├── oas_json_gen.go
│ ├── oas_middleware_gen.go
│ ├── oas_parameters_gen.go
│ ├── oas_request_decoders_gen.go
│ ├── oas_request_encoders_gen.go
│ ├── oas_response_decoders_gen.go
│ ├── oas_response_encoders_gen.go
│ ├── oas_router_gen.go
│ ├── oas_schemas_gen.go
│ ├── oas_server_gen.go
│ ├── oas_unimplemented_gen.go
│ └── oas_validators_gen.go
├── application
└── application.go
├── cmd
└── service
│ └── main.go
├── config
├── config.go
└── config_test.go
├── devenv.lock
├── devenv.nix
├── devenv.yaml
├── docker-compose.yaml
├── entity
├── cache.go
├── file.go
├── page.go
├── result.go
└── worker.go
├── go.mod
├── go.sum
├── ports
└── rest
│ ├── converter.go
│ ├── service.go
│ └── ui.go
└── ui
├── basic
├── index.html
├── lib.js
├── main.js
└── style.css
└── embed.go
/.deploystack/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | webarchive:
5 | image: ghcr.io/derfenix/webarchive:latest
6 | environment:
7 | LOGGING_DEBUG: "true"
8 | API_ADDRESS: "0.0.0.0:5001"
9 | PDF_DPI: "300"
10 | DB_PATH: "/db"
11 | volumes:
12 | - ./db:/db
13 | ports:
14 | - "0.0.0.0:5001:5001"
15 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .idea
2 | *~
3 | .fuse_hidden*
4 | .directory
5 | .Trash-*
6 | .nfs*
7 | *.exe
8 | *.exe~
9 | *.dll
10 | *.so
11 | *.dylib
12 | *.test
13 | *.out
14 | go.work
15 | db
16 |
--------------------------------------------------------------------------------
/.envrc:
--------------------------------------------------------------------------------
1 | export DIRENV_WARN_TIMEOUT=20s
2 |
3 | eval "$(devenv direnvrc)"
4 |
5 | use devenv
6 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: release
3 |
4 | "on":
5 | push:
6 | tags:
7 | - "v*"
8 |
9 | jobs:
10 | release:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Setup Go
15 | uses: actions/setup-go@v3
16 | with:
17 | go-version: 1.23.x
18 |
19 | - name: Checkout code
20 | uses: actions/checkout@v3
21 |
22 | - name: Setup Docker Buildx
23 | id: buildx
24 | uses: docker/setup-buildx-action@v2
25 |
26 | - name: Login to GitHub Container Registry
27 | uses: docker/login-action@v2
28 | with:
29 | registry: ghcr.io
30 | username: ${{ github.actor }}
31 | password: ${{ github.token }}
32 |
33 | - name: Docker meta
34 | id: meta
35 | uses: docker/metadata-action@v4
36 | with:
37 | images: ghcr.io/derfenix/webarchive
38 |
39 | - name: Build and push
40 | uses: docker/build-push-action@v4
41 | with:
42 | push: true
43 | file: ./Dockerfile
44 | platforms: linux/amd64,linux/arm64
45 | tags: |
46 | ghcr.io/derfenix/webarchive:latest
47 | ghcr.io/derfenix/webarchive:${{github.ref_name}}
48 | labels: ${{ steps.meta.outputs.labels }}
49 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: test
3 |
4 | "on":
5 | pull_request:
6 | push:
7 | branches:
8 | - master
9 |
10 | jobs:
11 | test:
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Setup Go
16 | uses: actions/setup-go@v3
17 | with:
18 | go-version: 1.23.x
19 |
20 | - name: Checkout code
21 | uses: actions/checkout@v3
22 |
23 | - name: go mod package cache
24 | uses: actions/cache@v3
25 | with:
26 | path: |
27 | ~/.cache/go-build
28 | ~/go/pkg/mod
29 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
30 | restore-keys: |
31 | ${{ runner.os }}-go-
32 |
33 | - name: Tests
34 | run: go test ./...
35 |
36 | - name: golangci-lint
37 | uses: golangci/golangci-lint-action@v3
38 | with:
39 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
40 | version: latest
41 |
42 | # Optional: working directory, useful for monorepos
43 | # working-directory: somedir
44 |
45 | # Optional: golangci-lint command line arguments.
46 | # args: --issues-exit-code=0
47 |
48 | # Optional: show only new issues if it's a pull request. The default value is `false`.
49 | # only-new-issues: true
50 |
51 | # Optional: if set to true then the all caching functionality will be complete disabled,
52 | # takes precedence over all other caching options.
53 | # skip-cache: true
54 |
55 | # Optional: if set to true then the action don't cache or restore ~/go/pkg.
56 | # skip-pkg-cache: true
57 |
58 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build.
59 | # skip-build-cache: true
60 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/**/workspace.xml
2 | .idea/**/tasks.xml
3 | .idea/**/usage.statistics.xml
4 | .idea/**/dictionaries
5 | .idea/**/shelf
6 | .idea/**/aws.xml
7 | .idea/**/contentModel.xml
8 | .idea/**/dataSources/
9 | .idea/**/dataSources.ids
10 | .idea/**/dataSources.local.xml
11 | .idea/**/sqlDataSources.xml
12 | .idea/**/dynamic.xml
13 | .idea/**/uiDesigner.xml
14 | .idea/**/dbnavigator.xml
15 | .idea/**/gradle.xml
16 | .idea/**/libraries
17 | cmake-build-*/
18 | .idea/**/mongoSettings.xml
19 | *.iws
20 | out/
21 | .idea_modules/
22 | atlassian-ide-plugin.xml
23 | .idea/replstate.xml
24 | .idea/sonarlint/
25 | com_crashlytics_export_strings.xml
26 | crashlytics.properties
27 | crashlytics-build.properties
28 | fabric.properties
29 | .idea/httpRequests
30 | .idea/caches/build_file_checksums.ser
31 | *~
32 | .fuse_hidden*
33 | .directory
34 | .Trash-*
35 | .nfs*
36 | *.exe
37 | *.exe~
38 | *.dll
39 | *.so
40 | *.dylib
41 | *.test
42 | *.out
43 | go.work
44 | test.http
45 | db
46 | http-client.env.json
47 | http-client.private.env.json
48 |
49 | # Devenv
50 | .devenv*
51 | devenv.local.nix
52 |
53 | # direnv
54 | .direnv
55 |
56 | # pre-commit
57 | .pre-commit-config.yaml
58 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/git_toolbox_prj.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.idea/go.imports.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/golinter.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/jsonSchemas.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/swagger-settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/webResources.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.idea/webarchive.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/yamllint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | true
5 | /usr/bin/yamllint
6 |
7 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.23-alpine AS builder
2 |
3 | WORKDIR /project
4 | ADD go.* ./
5 | RUN go mod download
6 | ADD . .
7 | RUN CGO_ENABLED=0 go build -o service ./cmd/service/main.go
8 |
9 | FROM surnet/alpine-wkhtmltopdf:3.17.0-0.12.6-full
10 |
11 | WORKDIR /project
12 | COPY --from=builder /project/service service
13 | ENTRYPOINT ["./service"]
14 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright (c) 2023, derfenix All rights reserved.
2 |
3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4 |
5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6 |
7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8 |
9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10 |
11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Own Webarchive
2 |
3 | Aimed to be a simple, fast and easy-to-use webarchive for personal or home-net usage.
4 |
5 | ## Supported store formats
6 |
7 | * **headers** — save all headers from response
8 | * **pdf** — save page in pdf
9 | * **single_file** — save html and all its resources (css,js,images) into one html file
10 |
11 | ## Requirements
12 |
13 | * Golang 1.19 or higher
14 | * wkhtmltopdf binary in $PATH (to save pages in pdf)
15 |
16 | ## Configuration
17 |
18 | The service can be configured via environment variables. There is a list of available
19 | variables:
20 |
21 | * **DB**
22 | * **DB_PATH** — path for the database files (default `./db`)
23 | * **LOGGING**
24 | * **LOGGING_DEBUG** — enable debug logs (default `false`)
25 | * **API**
26 | * **API_ADDRESS** — address the API server will listen (default `0.0.0.0:5001`)
27 | * **UI**
28 | * **UI_ENABLED** — Enable builtin web UI (default `true`)
29 | * **UI_PREFIX** — Prefix for the web UI (default `/`)
30 | * **UI_THEME** — UI theme name (default `basic`). No other values available yet
31 | * **PDF**
32 | * **PDF_LANDSCAPE** — use landscape page orientation instead of portrait (default `false`)
33 | * **PDF_GRAYSCALE** — use grayscale filter for the output pdf (default `false`)
34 | * **PDF_MEDIA_PRINT** — use media type `print` for the request (default `true`)
35 | * **PDF_ZOOM** — zoom page (default `1.0` i.e. no actual zoom)
36 | * **PDF_VIEWPORT** — use specified viewport value (default `1280x720`)
37 | * **PDF_DPI** — use specified DPI value for the output pdf (default `150`)
38 | * **PDF_FILENAME** — use specified name for output pdf file (default `page.pdf`)
39 |
40 |
41 | *Note*: Prefix **WEBARCHIVE_** can be used with the environment variable names
42 | in case of any conflicts.
43 |
44 | ## ⚡ One-Click Deploy
45 |
46 | | Cloud Provider | Deploy Button |
47 | |----------------|---------------|
48 | | AWS |
|
49 | | DigitalOcean |
|
50 | | Render |
|
51 |
52 | Generated by DeployStack.io
53 |
54 | ## Usage
55 |
56 | ### 1. Start the server
57 |
58 | #### Start without docker
59 | ```shell
60 | go run ./cmd/server/main.go
61 | ```
62 |
63 | #### Change API address
64 | ```shell
65 | API_ADDRESS=127.0.0.1:3001 go run ./cmd/server/main.go
66 | ```
67 |
68 | #### Start in docker
69 |
70 | ```shell
71 | docker compose up -d webarchive
72 | ```
73 |
74 | ### 2. Add a page
75 |
76 | ```shell
77 | curl -X POST --location "http://localhost:5001/api/v1/pages" \
78 | -H "Content-Type: application/json" \
79 | -d "{
80 | \"url\": \"https://github.com/wkhtmltopdf/wkhtmltopdf/issues/1937\",
81 | \"formats\": [
82 | \"pdf\",
83 | \"headers\"
84 | ]
85 | }" | jq .
86 | ```
87 |
88 | or
89 |
90 | ```shell
91 | curl -X POST --location \
92 | "http://localhost:5001/api/v1/pages?url=https%3A%2F%2Fgithub.com%2Fwkhtmltopdf%2Fwkhtmltopdf%2Fissues%2F1937&formats=pdf%2Cheaders&description=Foo+Bar"
93 | ```
94 |
95 | ### 3. Get the page's info
96 |
97 | ```shell
98 | curl -X GET --location "http://localhost:5001/api/v1/pages/$page_id" | jq .
99 | ```
100 | where `$page_id` — value of the `id` field from previous command response.
101 | If `status` field in response is `success` (or `with_errors`) - the `results` field
102 | will contain all processed formats with ids of the stored files.
103 |
104 | ### 4. Open file in browser
105 |
106 | ```shell
107 | xdg-open "http://localhost:5001/api/v1/pages/$page_id/file/$file_id"
108 | ```
109 | Where `$page_id` — value of the `id` field from previous command response, and
110 | `$file_id` — the id of interesting file.
111 |
112 | ### 5. List all stored pages
113 |
114 | ```shell
115 | curl -X GET --location "http://localhost:5001/api/v1/pages" | jq .
116 | ```
117 |
118 | ## Roadmap
119 |
120 | - [x] Save page to pdf
121 | - [x] Save URL headers
122 | - [x] Save page to the single-page html
123 | - [ ] Save page to html with separate resource files (?)
124 | - [ ] Basic web UI
125 | - [ ] Optional authentication
126 | - [ ] Multi-user access
127 | - [ ] Support SQL database with or without separate files storage
128 | - [ ] Tags/Categories
129 | - [ ] Save page to markdown
130 |
--------------------------------------------------------------------------------
/adapters/processors/headers.go:
--------------------------------------------------------------------------------
1 | package processors
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "net/http"
8 |
9 | "github.com/derfenix/webarchive/entity"
10 | )
11 |
12 | func NewHeaders(client *http.Client) *Headers {
13 | return &Headers{client: client}
14 | }
15 |
16 | type Headers struct {
17 | client *http.Client
18 | }
19 |
20 | func (h *Headers) Process(ctx context.Context, url string, _ *entity.Cache) ([]entity.File, error) {
21 | var (
22 | headersFile entity.File
23 | err error
24 | )
25 |
26 | req, reqErr := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
27 | if reqErr != nil {
28 | return nil, fmt.Errorf("create request: %w", reqErr)
29 | }
30 |
31 | resp, doErr := h.client.Do(req)
32 | if doErr != nil {
33 | return nil, fmt.Errorf("call url: %w", doErr)
34 | }
35 |
36 | if resp.Body != nil {
37 | _ = resp.Body.Close()
38 | }
39 |
40 | headersFile, err = h.newFile(resp.Header)
41 |
42 | if err != nil {
43 | return nil, fmt.Errorf("new file from headers: %w", err)
44 | }
45 |
46 | return []entity.File{headersFile}, nil
47 | }
48 |
49 | func (h *Headers) newFile(headers http.Header) (entity.File, error) {
50 | buf := bytes.NewBuffer(nil)
51 |
52 | if err := headers.Write(buf); err != nil {
53 | return entity.File{}, fmt.Errorf("write headers: %w", err)
54 | }
55 |
56 | return entity.NewFile("headers", buf.Bytes()), nil
57 | }
58 |
--------------------------------------------------------------------------------
/adapters/processors/internal/mediainline.go:
--------------------------------------------------------------------------------
1 | package internal
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/base64"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | "net/url"
11 | "strings"
12 |
13 | "github.com/disintegration/imaging"
14 | "github.com/gabriel-vasile/mimetype"
15 | "go.uber.org/zap"
16 | "golang.org/x/net/html"
17 | )
18 |
19 | type MediaInline struct {
20 | log *zap.Logger
21 | getter func(context.Context, string) (*http.Response, error)
22 | }
23 |
24 | func NewMediaInline(log *zap.Logger, getter func(context.Context, string) (*http.Response, error)) *MediaInline {
25 | return &MediaInline{log: log, getter: getter}
26 | }
27 |
28 | func (m *MediaInline) Inline(ctx context.Context, reader io.Reader, pageURL string) (*html.Node, error) {
29 | htmlNode, err := html.Parse(reader)
30 | if err != nil {
31 | return nil, fmt.Errorf("parse response body: %w", err)
32 | }
33 |
34 | baseURL, err := url.Parse(pageURL)
35 | if err != nil {
36 | return nil, fmt.Errorf("parse page url: %w", err)
37 | }
38 |
39 | m.visit(ctx, htmlNode, m.processorFunc, baseURL)
40 |
41 | return htmlNode, nil
42 | }
43 |
44 | func (m *MediaInline) processorFunc(ctx context.Context, node *html.Node, baseURL *url.URL) error {
45 | switch node.Data {
46 | case "link":
47 | if err := m.processHref(ctx, node.Attr, baseURL); err != nil {
48 | return fmt.Errorf("process link %s: %w", node.Attr, err)
49 | }
50 |
51 | case "script", "img":
52 | if err := m.processSrc(ctx, node.Attr, baseURL); err != nil {
53 | return fmt.Errorf("process script %s: %w", node.Attr, err)
54 | }
55 |
56 | case "a":
57 | if err := m.processAHref(node.Attr, baseURL); err != nil {
58 | return fmt.Errorf("process a href %s: %w", node.Attr, err)
59 | }
60 | }
61 |
62 | return nil
63 | }
64 |
65 | func (m *MediaInline) processAHref(attrs []html.Attribute, baseURL *url.URL) error {
66 | for idx, attr := range attrs {
67 | switch attr.Key {
68 | case "href":
69 | attrs[idx].Val = normalizeURL(attr.Val, baseURL)
70 | }
71 | }
72 |
73 | return nil
74 | }
75 |
76 | func (m *MediaInline) processHref(ctx context.Context, attrs []html.Attribute, baseURL *url.URL) error {
77 | var shouldProcess bool
78 | var value string
79 | var valueIdx int
80 |
81 | for idx, attr := range attrs {
82 | switch attr.Key {
83 | case "rel":
84 | switch attr.Val {
85 | case "stylesheet", "icon", "alternate icon", "shortcut icon", "manifest":
86 | shouldProcess = true
87 | }
88 |
89 | case "href":
90 | value = attr.Val
91 | valueIdx = idx
92 | }
93 | }
94 |
95 | if !shouldProcess {
96 | return nil
97 | }
98 |
99 | encodedValue, err := m.loadAndEncode(ctx, baseURL, value)
100 | if err != nil {
101 | return err
102 | }
103 |
104 | attrs[valueIdx].Val = encodedValue
105 |
106 | return nil
107 | }
108 |
109 | func (m *MediaInline) processSrc(ctx context.Context, attrs []html.Attribute, baseURL *url.URL) error {
110 | var shouldProcess bool
111 | var value string
112 | var valueIdx int
113 |
114 | for idx, attr := range attrs {
115 | switch attr.Key {
116 | case "src":
117 | value = attr.Val
118 | valueIdx = idx
119 | shouldProcess = true
120 | case "data-src":
121 | value = attr.Val
122 | }
123 | }
124 |
125 | if !shouldProcess {
126 | return nil
127 | }
128 |
129 | encodedValue, err := m.loadAndEncode(ctx, baseURL, value)
130 | if err != nil {
131 | return err
132 | }
133 |
134 | attrs[valueIdx].Val = encodedValue
135 |
136 | return nil
137 | }
138 |
139 | func (m *MediaInline) loadAndEncode(ctx context.Context, baseURL *url.URL, value string) (string, error) {
140 | mime := "text/plain"
141 |
142 | if value == "" {
143 | return "", nil
144 | }
145 |
146 | normalizedURL := normalizeURL(value, baseURL)
147 | if normalizedURL == "" {
148 | return value, nil
149 | }
150 |
151 | response, err := m.getter(ctx, normalizedURL)
152 | if err != nil {
153 | m.log.Sugar().With(zap.Error(err)).Errorf("load %s", normalizedURL)
154 | return value, nil
155 | }
156 |
157 | defer func() {
158 | _ = response.Body.Close()
159 | }()
160 |
161 | cleanMime := func(s string) string {
162 | s, _, _ = strings.Cut(s, "+")
163 | return s
164 | }
165 |
166 | if ct := response.Header.Get("Content-Type"); ct != "" {
167 | mime = ct
168 | }
169 |
170 | encodedVal, err := m.encodeResource(response.Body, &mime)
171 | if err != nil {
172 | return value, fmt.Errorf("encode resource: %w", err)
173 | }
174 |
175 | return fmt.Sprintf("data:%s;base64, %s", cleanMime(mime), encodedVal), nil
176 | }
177 |
178 | func (m *MediaInline) visit(ctx context.Context, n *html.Node, proc func(context.Context, *html.Node, *url.URL) error, baseURL *url.URL) {
179 | if err := proc(ctx, n, baseURL); err != nil {
180 | m.log.Error("process error", zap.Error(err))
181 | }
182 |
183 | if n.FirstChild != nil {
184 | m.visit(ctx, n.FirstChild, proc, baseURL)
185 | }
186 |
187 | if n.NextSibling != nil {
188 | m.visit(ctx, n.NextSibling, proc, baseURL)
189 | }
190 | }
191 |
192 | func normalizeURL(resourceURL string, base *url.URL) string {
193 | if strings.HasPrefix(resourceURL, "//") {
194 | return "https:" + resourceURL
195 | }
196 |
197 | if strings.HasPrefix(resourceURL, "about:") {
198 | return ""
199 | }
200 |
201 | parsedResourceURL, err := url.Parse(resourceURL)
202 | if err != nil {
203 | return resourceURL
204 | }
205 |
206 | reference := base.ResolveReference(parsedResourceURL)
207 |
208 | return reference.String()
209 | }
210 |
211 | func (m *MediaInline) encodeResource(r io.Reader, mime *string) (string, error) {
212 | all, err := io.ReadAll(r)
213 | if err != nil {
214 | return "", fmt.Errorf("read data: %w", err)
215 | }
216 |
217 | all, err = m.preprocessResource(all, mime)
218 | if err != nil {
219 | return "", fmt.Errorf("preprocess resource: %w", err)
220 | }
221 |
222 | return base64.StdEncoding.EncodeToString(all), nil
223 | }
224 |
225 | func (m *MediaInline) preprocessResource(data []byte, mime *string) ([]byte, error) {
226 | detectedMime := mimetype.Detect(data)
227 |
228 | switch {
229 | case strings.HasPrefix(detectedMime.String(), "image"):
230 | decodedImage, err := imaging.Decode(bytes.NewBuffer(data))
231 | if err != nil {
232 | m.log.Error("failed to decode image", zap.Error(err))
233 |
234 | return data, nil
235 | }
236 |
237 | if size := decodedImage.Bounds().Size(); size.X > 1024 || size.Y > 1024 {
238 | thumbnail := imaging.Thumbnail(decodedImage, 1024, 1024, imaging.Lanczos)
239 | buf := bytes.NewBuffer(nil)
240 |
241 | if err := imaging.Encode(buf, thumbnail, imaging.JPEG, imaging.JPEGQuality(90)); err != nil {
242 | m.log.Error("failed to create resized image", zap.Error(err))
243 |
244 | return data, nil
245 | }
246 |
247 | *mime = "image/jpeg"
248 | m.log.Info("Resized")
249 |
250 | return buf.Bytes(), nil
251 | }
252 | }
253 |
254 | return data, nil
255 | }
256 |
--------------------------------------------------------------------------------
/adapters/processors/pdf.go:
--------------------------------------------------------------------------------
1 | package processors
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "time"
7 |
8 | "github.com/SebastiaanKlippert/go-wkhtmltopdf"
9 |
10 | "github.com/derfenix/webarchive/config"
11 | "github.com/derfenix/webarchive/entity"
12 | )
13 |
14 | func NewPDF(cfg config.PDF) *PDF {
15 | return &PDF{cfg: cfg}
16 | }
17 |
18 | type PDF struct {
19 | cfg config.PDF
20 | }
21 |
22 | func (p *PDF) Process(_ context.Context, url string, cache *entity.Cache) ([]entity.File, error) {
23 | gen, err := wkhtmltopdf.NewPDFGenerator()
24 | if err != nil {
25 | return nil, fmt.Errorf("new pdf generator: %w", err)
26 | }
27 |
28 | gen.Dpi.Set(p.cfg.DPI)
29 | gen.PageSize.Set(wkhtmltopdf.PageSizeA4)
30 |
31 | if p.cfg.Landscape {
32 | gen.Orientation.Set(wkhtmltopdf.OrientationLandscape)
33 | } else {
34 | gen.Orientation.Set(wkhtmltopdf.OrientationPortrait)
35 | }
36 |
37 | gen.Grayscale.Set(p.cfg.Grayscale)
38 | gen.Title.Set(url)
39 |
40 | opts := wkhtmltopdf.NewPageOptions()
41 | opts.PrintMediaType.Set(p.cfg.MediaPrint)
42 | opts.JavascriptDelay.Set(200)
43 | opts.DisableJavascript.Set(false)
44 | opts.LoadErrorHandling.Set("ignore")
45 | opts.LoadMediaErrorHandling.Set("skip")
46 | opts.FooterRight.Set("[opts]")
47 | opts.HeaderLeft.Set(url)
48 | opts.HeaderRight.Set(time.Now().Format(time.DateOnly))
49 | opts.FooterFontSize.Set(10)
50 | opts.Zoom.Set(p.cfg.Zoom)
51 | opts.ViewportSize.Set(p.cfg.Viewport)
52 | opts.NoBackground.Set(true)
53 | opts.DisableLocalFileAccess.Set(false)
54 | opts.DisableExternalLinks.Set(false)
55 | opts.DisableInternalLinks.Set(false)
56 |
57 | var page wkhtmltopdf.PageProvider
58 | if len(cache.Get()) > 0 {
59 | page = &wkhtmltopdf.PageReader{Input: cache.Reader(), PageOptions: opts}
60 | } else {
61 | page = &wkhtmltopdf.Page{Input: url, PageOptions: opts}
62 | }
63 |
64 | gen.AddPage(page)
65 |
66 | err = gen.Create()
67 | if err != nil {
68 | return nil, fmt.Errorf("create pdf: %w", err)
69 | }
70 |
71 | file := entity.NewFile(p.cfg.Filename, gen.Bytes())
72 |
73 | return []entity.File{file}, nil
74 | }
75 |
--------------------------------------------------------------------------------
/adapters/processors/pdf_test.go:
--------------------------------------------------------------------------------
1 | //go:build integration
2 |
3 | package processors
4 |
5 | import (
6 | "context"
7 | "fmt"
8 | "testing"
9 | "time"
10 |
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func TestPDF_Process(t *testing.T) {
15 | t.Parallel()
16 |
17 | if testing.Short() {
18 | t.Skip("skip test with external resource")
19 | }
20 |
21 | files, err := (&PDF{}).Process(context.Background(), "https://github.com/SebastiaanKlippert/go-wkhtmltopdf")
22 | require.NoError(t, err)
23 | require.Len(t, files, 1)
24 |
25 | f := files[0]
26 | fmt.Println("ID ", f.ID)
27 | fmt.Println("Name ", f.Name)
28 | fmt.Println("MimeType ", f.MimeType)
29 | fmt.Println("Size ", f.Size)
30 | fmt.Println("Created ", f.Created.Format(time.RFC3339))
31 | }
32 |
--------------------------------------------------------------------------------
/adapters/processors/processors.go:
--------------------------------------------------------------------------------
1 | package processors
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "net"
8 | "net/http"
9 | "net/http/cookiejar"
10 | "strings"
11 | "time"
12 |
13 | "go.uber.org/zap"
14 | "golang.org/x/net/html"
15 |
16 | "github.com/derfenix/webarchive/config"
17 | "github.com/derfenix/webarchive/entity"
18 | )
19 |
20 | const defaultEncoding = "utf-8"
21 |
22 | type processor interface {
23 | Process(ctx context.Context, url string, cache *entity.Cache) ([]entity.File, error)
24 | }
25 |
26 | func NewProcessors(cfg config.Config, log *zap.Logger) (*Processors, error) {
27 | jar, err := cookiejar.New(&cookiejar.Options{
28 | PublicSuffixList: nil,
29 | })
30 | if err != nil {
31 | return nil, fmt.Errorf("create cookie jar: %w", err)
32 | }
33 |
34 | httpClient := &http.Client{
35 | Transport: &http.Transport{
36 | DialContext: (&net.Dialer{
37 | Timeout: time.Second * 10,
38 | KeepAlive: time.Second * 10,
39 | }).DialContext,
40 | MaxIdleConns: 20,
41 | MaxIdleConnsPerHost: 5,
42 | MaxConnsPerHost: 10,
43 | IdleConnTimeout: time.Second * 60,
44 | ResponseHeaderTimeout: time.Second * 20,
45 | MaxResponseHeaderBytes: 1024 * 1024 * 50,
46 | WriteBufferSize: 256,
47 | ReadBufferSize: 1024 * 64,
48 | ForceAttemptHTTP2: true,
49 | },
50 | CheckRedirect: func(req *http.Request, via []*http.Request) error {
51 | if len(via) > 3 {
52 | return fmt.Errorf("too many redirects")
53 | }
54 |
55 | return nil
56 | },
57 | Jar: jar,
58 | Timeout: time.Second * 30,
59 | }
60 |
61 | procs := Processors{
62 | client: httpClient,
63 | processors: map[entity.Format]processor{
64 | entity.FormatHeaders: NewHeaders(httpClient),
65 | entity.FormatPDF: NewPDF(cfg.PDF),
66 | entity.FormatSingleFile: NewSingleFile(httpClient, log),
67 | },
68 | }
69 |
70 | return &procs, nil
71 | }
72 |
73 | type Processors struct {
74 | processors map[entity.Format]processor
75 | client *http.Client
76 | }
77 |
78 | func (p *Processors) Process(ctx context.Context, format entity.Format, url string, cache *entity.Cache) entity.Result {
79 | result := entity.Result{Format: format}
80 |
81 | proc, ok := p.processors[format]
82 | if !ok {
83 | result.Err = fmt.Errorf("no processor registered")
84 |
85 | return result
86 | }
87 |
88 | files, err := proc.Process(ctx, url, cache)
89 | if err != nil {
90 | result.Err = fmt.Errorf("process: %w", err)
91 |
92 | return result
93 | }
94 |
95 | result.Files = files
96 |
97 | return result
98 | }
99 |
100 | func (p *Processors) OverrideProcessor(format entity.Format, proc processor) error {
101 | p.processors[format] = proc
102 |
103 | return nil
104 | }
105 |
106 | func (p *Processors) GetMeta(ctx context.Context, url string, cache *entity.Cache) (entity.Meta, error) {
107 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
108 | if err != nil {
109 | return entity.Meta{}, fmt.Errorf("new request: %w", err)
110 | }
111 |
112 | response, err := p.client.Do(req)
113 | if err != nil {
114 | return entity.Meta{}, fmt.Errorf("do request: %w", err)
115 | }
116 |
117 | if response.StatusCode != http.StatusOK {
118 | return entity.Meta{}, fmt.Errorf("want status 200, got %d", response.StatusCode)
119 | }
120 |
121 | if response.Body == nil {
122 | return entity.Meta{}, fmt.Errorf("empty response body")
123 | }
124 |
125 | defer func() {
126 | _ = response.Body.Close()
127 | }()
128 |
129 | tee := io.TeeReader(response.Body, cache)
130 |
131 | htmlNode, err := html.Parse(tee)
132 | if err != nil {
133 | return entity.Meta{}, fmt.Errorf("parse response body: %w", err)
134 | }
135 |
136 | var fc *html.Node
137 | for fc = htmlNode.FirstChild; fc != nil && fc.Data != "html"; fc = fc.NextSibling {
138 | }
139 |
140 | if fc == nil {
141 | return entity.Meta{}, fmt.Errorf("failed to find html tag")
142 | }
143 |
144 | fc = fc.NextSibling
145 | if fc == nil {
146 | return entity.Meta{}, fmt.Errorf("failed to find html tag")
147 | }
148 |
149 | for fc = fc.FirstChild; fc != nil && fc.Data != "head"; fc = fc.NextSibling {
150 | fmt.Println(fc.Data)
151 | }
152 |
153 | if fc == nil {
154 | return entity.Meta{}, fmt.Errorf("failed to find html tag")
155 | }
156 |
157 | meta := entity.Meta{}
158 | getMetaData(fc, &meta)
159 | meta.Encoding = encodingFromHeader(response.Header)
160 |
161 | return meta, nil
162 | }
163 |
164 | func getMetaData(n *html.Node, meta *entity.Meta) {
165 | if n == nil {
166 | return
167 | }
168 |
169 | for c := n.FirstChild; c != nil; c = c.NextSibling {
170 | if c.Type == html.ElementNode && c.Data == "title" {
171 | meta.Title = c.FirstChild.Data
172 | }
173 | if c.Type == html.ElementNode && c.Data == "meta" {
174 | attrs := make(map[string]string)
175 | for _, attr := range c.Attr {
176 | attrs[attr.Key] = attr.Val
177 | }
178 |
179 | name, ok := attrs["name"]
180 | if ok && name == "description" {
181 | meta.Description = attrs["content"]
182 | }
183 | }
184 |
185 | getMetaData(c, meta)
186 | }
187 | }
188 |
189 | func encodingFromHeader(headers http.Header) string {
190 | var foundEncoding bool
191 | var encoding string
192 |
193 | _, encoding, foundEncoding = strings.Cut(headers.Get("Content-Type"), "; ")
194 | if foundEncoding {
195 | _, encoding, foundEncoding = strings.Cut(encoding, "=")
196 | }
197 |
198 | if !foundEncoding {
199 | encoding = defaultEncoding
200 | }
201 |
202 | return encoding
203 | }
204 |
--------------------------------------------------------------------------------
/adapters/processors/processors_test.go:
--------------------------------------------------------------------------------
1 | package processors
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | "github.com/stretchr/testify/require"
9 | "go.uber.org/zap/zaptest"
10 |
11 | "github.com/derfenix/webarchive/config"
12 | "github.com/derfenix/webarchive/entity"
13 | )
14 |
15 | func TestProcessors_GetMeta(t *testing.T) {
16 | t.Parallel()
17 |
18 | ctx := context.Background()
19 | cfg, err := config.NewConfig(ctx)
20 | require.NoError(t, err)
21 |
22 | procs, err := NewProcessors(cfg, zaptest.NewLogger(t))
23 | require.NoError(t, err)
24 |
25 | cache := entity.NewCache()
26 |
27 | meta, err := procs.GetMeta(ctx, "https://habr.com/ru/companies/wirenboard/articles/722718/", cache)
28 | require.NoError(t, err)
29 | assert.Equal(t, "Сколько стоит умный дом? Рассказываю, как строил свой и что получилось за 1000 руб./м² / Хабр", meta.Title)
30 | }
31 |
--------------------------------------------------------------------------------
/adapters/processors/singlefile.go:
--------------------------------------------------------------------------------
1 | package processors
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "net/http"
8 |
9 | "go.uber.org/zap"
10 | "golang.org/x/net/html"
11 |
12 | "github.com/derfenix/webarchive/adapters/processors/internal"
13 | "github.com/derfenix/webarchive/entity"
14 | )
15 |
16 | func NewSingleFile(client *http.Client, log *zap.Logger) *SingleFile {
17 | return &SingleFile{client: client, log: log}
18 | }
19 |
20 | type SingleFile struct {
21 | client *http.Client
22 | log *zap.Logger
23 | }
24 |
25 | func (s *SingleFile) Process(ctx context.Context, pageURL string, cache *entity.Cache) ([]entity.File, error) {
26 | reader := cache.Reader()
27 |
28 | if reader == nil {
29 | response, err := s.get(ctx, pageURL)
30 | if err != nil {
31 | return nil, err
32 | }
33 |
34 | defer func() {
35 | _ = response.Body.Close()
36 | }()
37 |
38 | reader = response.Body
39 | }
40 |
41 | inlinedHTML, err := internal.NewMediaInline(s.log, s.get).Inline(ctx, reader, pageURL)
42 | if err != nil {
43 | return nil, fmt.Errorf("inline media: %w", err)
44 | }
45 |
46 | buf := bytes.NewBuffer(nil)
47 | if err := html.Render(buf, inlinedHTML); err != nil {
48 | return nil, fmt.Errorf("render result html: %w", err)
49 | }
50 |
51 | htmlFile := entity.NewFile("page.html", buf.Bytes())
52 |
53 | return []entity.File{htmlFile}, nil
54 | }
55 |
56 | func (s *SingleFile) get(ctx context.Context, url string) (*http.Response, error) {
57 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
58 | if err != nil {
59 | return nil, fmt.Errorf("new request: %w", err)
60 | }
61 |
62 | response, err := s.client.Do(req)
63 | if err != nil {
64 | return nil, fmt.Errorf("do request: %w", err)
65 | }
66 |
67 | if response.StatusCode != http.StatusOK {
68 | return nil, fmt.Errorf("want status 200, got %d", response.StatusCode)
69 | }
70 |
71 | if response.Body == nil {
72 | return nil, fmt.Errorf("empty response body")
73 | }
74 |
75 | return response, nil
76 | }
77 |
--------------------------------------------------------------------------------
/adapters/repository/badger.go:
--------------------------------------------------------------------------------
1 | package repository
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os"
7 | "path"
8 |
9 | "github.com/dgraph-io/badger/v4"
10 | "github.com/dgraph-io/badger/v4/options"
11 | "go.uber.org/zap"
12 | )
13 |
14 | const (
15 | backupStartPath = "backup_start.db"
16 | backupStopPath = "backup_stop.db"
17 | )
18 |
19 | type BackupType uint8
20 |
21 | const (
22 | BackupStart BackupType = iota
23 | BackupStop
24 | )
25 |
26 | var ErrDBClosed = fmt.Errorf("database is closed")
27 |
28 | type logger struct {
29 | *zap.SugaredLogger
30 | }
31 |
32 | func (l *logger) Warningf(s string, i ...interface{}) {
33 | l.SugaredLogger.Warnf(s, i...)
34 | }
35 |
36 | func NewBadger(dir string, log *zap.Logger) (*badger.DB, error) {
37 | opts := badger.DefaultOptions(dir)
38 | opts.Logger = &logger{SugaredLogger: log.Sugar()}
39 | opts.Compression = options.ZSTD
40 | opts.ZSTDCompressionLevel = 6
41 |
42 | db, err := badger.Open(opts)
43 | if err != nil {
44 | return nil, fmt.Errorf("open database: %w", err)
45 | }
46 |
47 | if err := Backup(db, BackupStart); err != nil {
48 | log.Error("backup on start failed", zap.Error(err))
49 | }
50 |
51 | return db, nil
52 | }
53 |
54 | func Backup(db *badger.DB, bt BackupType) error {
55 | dir := db.Opts().Dir
56 | var backupPath string
57 |
58 | switch bt {
59 | case BackupStart:
60 | backupPath = path.Join(dir, backupStartPath)
61 | case BackupStop:
62 | backupPath = path.Join(dir, backupStopPath)
63 | }
64 |
65 | file, err := os.OpenFile(backupPath, os.O_CREATE|os.O_WRONLY, os.FileMode(0600))
66 | if err != nil {
67 | return fmt.Errorf("open backup file %s: %w", backupPath, err)
68 | }
69 | defer func() {
70 | _ = file.Close()
71 | }()
72 |
73 | _, err = db.Backup(file, 0)
74 | if err != nil {
75 | return fmt.Errorf("backup: %w", err)
76 | }
77 |
78 | return nil
79 | }
80 |
81 | func Restore(db *badger.DB) error {
82 | dir := db.Opts().Dir
83 |
84 | backupPathStart := path.Join(dir, backupStartPath)
85 | backupPathStop := path.Join(dir, backupStopPath)
86 |
87 | startStat, err := os.Stat(backupPathStart)
88 | if err != nil && !errors.Is(err, os.ErrNotExist) {
89 | return fmt.Errorf("stat file %s: %w", backupPathStart, err)
90 | }
91 |
92 | stopStat, err := os.Stat(backupPathStop)
93 | if err != nil && !errors.Is(err, os.ErrNotExist) {
94 | return fmt.Errorf("stat file %s: %w", backupPathStop, err)
95 | }
96 |
97 | var backupFile string
98 |
99 | switch {
100 | case stopStat != nil && startStat != nil:
101 | if stopStat.ModTime().After(startStat.ModTime()) {
102 | backupFile = backupPathStop
103 | } else {
104 | backupFile = backupPathStart
105 | }
106 |
107 | case stopStat != nil:
108 | backupFile = backupPathStart
109 |
110 | case startStat != nil:
111 | backupFile = backupPathStop
112 | }
113 |
114 | file, err := os.OpenFile(backupFile, os.O_RDONLY, os.FileMode(0600))
115 | if err != nil {
116 | return fmt.Errorf("open backup file %s: %w", backupFile, err)
117 | }
118 |
119 | defer func() {
120 | _ = file.Close()
121 | }()
122 |
123 | if err := db.Load(file, 20); err != nil {
124 | return fmt.Errorf("load backup: %w", err)
125 | }
126 |
127 | return nil
128 | }
129 |
--------------------------------------------------------------------------------
/adapters/repository/badger/marshal.go:
--------------------------------------------------------------------------------
1 | package badger
2 |
3 | import (
4 | "github.com/vmihailenco/msgpack/v5"
5 | )
6 |
7 | func marshal(v interface{}) ([]byte, error) {
8 | return msgpack.Marshal(v)
9 | }
10 |
11 | func unmarshal(b []byte, v interface{}) error {
12 | return msgpack.Unmarshal(b, v)
13 | }
14 |
--------------------------------------------------------------------------------
/adapters/repository/badger/page.go:
--------------------------------------------------------------------------------
1 | package badger
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "sort"
7 |
8 | "github.com/dgraph-io/badger/v4"
9 | "github.com/google/uuid"
10 |
11 | "github.com/derfenix/webarchive/adapters/repository"
12 |
13 | "github.com/derfenix/webarchive/entity"
14 | )
15 |
16 | func NewPage(db *badger.DB) (*Page, error) {
17 | return &Page{
18 | db: db,
19 | prefix: []byte("page:"),
20 | }, nil
21 | }
22 |
23 | type Page struct {
24 | db *badger.DB
25 | prefix []byte
26 | }
27 |
28 | func (p *Page) GetFile(_ context.Context, pageID, fileID uuid.UUID) (*entity.File, error) {
29 | page := entity.Page{}
30 | page.ID = pageID
31 |
32 | var file *entity.File
33 |
34 | err := p.db.View(func(txn *badger.Txn) error {
35 | data, err := txn.Get(p.key(&page))
36 | if err != nil {
37 | return fmt.Errorf("get data: %w", err)
38 | }
39 |
40 | err = data.Value(func(val []byte) error {
41 | if err := unmarshal(val, &page); err != nil {
42 | return fmt.Errorf("unmarshal data: %w", err)
43 | }
44 |
45 | return nil
46 | })
47 | if err != nil {
48 | return fmt.Errorf("get value: %w", err)
49 | }
50 |
51 | for i := range page.Results {
52 | for j := range page.Results[i].Files {
53 | ff := &page.Results[i].Files[j]
54 |
55 | if ff.ID == fileID {
56 | file = ff
57 | }
58 | }
59 |
60 | }
61 |
62 | return nil
63 | })
64 | if err != nil {
65 | return nil, fmt.Errorf("view: %w", err)
66 | }
67 |
68 | return file, nil
69 | }
70 |
71 | func (p *Page) Save(_ context.Context, page *entity.Page) error {
72 | if p.db.IsClosed() {
73 | return repository.ErrDBClosed
74 | }
75 |
76 | marshaled, err := marshal(page)
77 | if err != nil {
78 | return fmt.Errorf("marshal data: %w", err)
79 | }
80 |
81 | if err := p.db.Update(func(txn *badger.Txn) error {
82 | if err := txn.Set(p.key(page), marshaled); err != nil {
83 | return fmt.Errorf("put data: %w", err)
84 | }
85 |
86 | return nil
87 | }); err != nil {
88 | return fmt.Errorf("update db: %w", err)
89 | }
90 |
91 | return nil
92 | }
93 |
94 | func (p *Page) Get(_ context.Context, id uuid.UUID) (*entity.Page, error) {
95 | page := entity.Page{}
96 | page.ID = id
97 |
98 | err := p.db.View(func(txn *badger.Txn) error {
99 | data, err := txn.Get(p.key(&page))
100 | if err != nil {
101 | return fmt.Errorf("get data: %w", err)
102 | }
103 |
104 | err = data.Value(func(val []byte) error {
105 | if err := unmarshal(val, &page); err != nil {
106 | return fmt.Errorf("unmarshal data: %w", err)
107 | }
108 |
109 | return nil
110 | })
111 | if err != nil {
112 | return fmt.Errorf("get value: %w", err)
113 | }
114 |
115 | return nil
116 | })
117 | if err != nil {
118 | return nil, fmt.Errorf("view: %w", err)
119 | }
120 |
121 | return &page, nil
122 | }
123 |
124 | func (p *Page) ListAll(ctx context.Context) ([]*entity.Page, error) {
125 | pages := make([]*entity.Page, 0, 100)
126 |
127 | err := p.db.View(func(txn *badger.Txn) error {
128 | iterator := txn.NewIterator(badger.DefaultIteratorOptions)
129 |
130 | defer iterator.Close()
131 |
132 | for iterator.Seek(p.prefix); iterator.ValidForPrefix(p.prefix); iterator.Next() {
133 | if err := ctx.Err(); err != nil {
134 | return fmt.Errorf("context canceled: %w", err)
135 | }
136 |
137 | var page entity.Page
138 |
139 | err := iterator.Item().Value(func(val []byte) error {
140 | if err := unmarshal(val, &page); err != nil {
141 | return fmt.Errorf("unmarshal: %w", err)
142 | }
143 |
144 | return nil
145 | })
146 |
147 | if err != nil {
148 | return fmt.Errorf("get item: %w", err)
149 | }
150 |
151 | pages = append(pages, &page)
152 | }
153 |
154 | return nil
155 | })
156 |
157 | if err != nil {
158 | return nil, fmt.Errorf("view: %w", err)
159 | }
160 |
161 | sort.Slice(pages, func(i, j int) bool {
162 | return pages[i].Created.After(pages[j].Created)
163 | })
164 |
165 | return pages, nil
166 | }
167 |
168 | func (p *Page) ListUnprocessed(ctx context.Context) ([]entity.Page, error) {
169 | pages := make([]entity.Page, 0, 100)
170 |
171 | err := p.db.View(func(txn *badger.Txn) error {
172 | iterator := txn.NewIterator(badger.DefaultIteratorOptions)
173 |
174 | defer iterator.Close()
175 |
176 | for iterator.Seek(p.prefix); iterator.ValidForPrefix(p.prefix); iterator.Next() {
177 | if err := ctx.Err(); err != nil {
178 | return fmt.Errorf("context canceled: %w", err)
179 | }
180 |
181 | var page entity.Page
182 |
183 | err := iterator.Item().Value(func(val []byte) error {
184 | if err := unmarshal(val, &page); err != nil {
185 | return fmt.Errorf("unmarshal: %w", err)
186 | }
187 |
188 | return nil
189 | })
190 |
191 | if err != nil {
192 | return fmt.Errorf("get item: %w", err)
193 | }
194 |
195 | if page.Status == entity.StatusNew || page.Status == entity.StatusProcessing {
196 | //goland:noinspection GoVetCopyLock
197 | pages = append(pages, page) //nolint:govet // didn't touch the lock here
198 | }
199 |
200 | }
201 |
202 | return nil
203 | })
204 |
205 | if err != nil {
206 | return nil, fmt.Errorf("view: %w", err)
207 | }
208 |
209 | sort.Slice(pages, func(i, j int) bool {
210 | return pages[i].Created.After(pages[j].Created)
211 | })
212 |
213 | return pages, nil
214 | }
215 |
216 | func (p *Page) key(site *entity.Page) []byte {
217 | return append(p.prefix, []byte(site.ID.String())...)
218 | }
219 |
--------------------------------------------------------------------------------
/adapters/repository/badger/page_test.go:
--------------------------------------------------------------------------------
1 | package badger
2 |
3 | import (
4 | "context"
5 | "os"
6 | "testing"
7 | "time"
8 |
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | "go.uber.org/zap/zaptest"
12 |
13 | "github.com/derfenix/webarchive/adapters/repository"
14 |
15 | "github.com/derfenix/webarchive/entity"
16 | )
17 |
18 | func TestSite(t *testing.T) {
19 | t.Parallel()
20 |
21 | if testing.Short() {
22 | t.Skip("skip db test")
23 | }
24 |
25 | ctx := context.Background()
26 |
27 | tempDir, err := os.MkdirTemp(os.TempDir(), "badger_test")
28 | require.NoError(t, err)
29 |
30 | t.Cleanup(func() {
31 | assert.NoError(t, os.RemoveAll(tempDir))
32 | })
33 |
34 | log := zaptest.NewLogger(t)
35 |
36 | db, err := repository.NewBadger(tempDir, log.Named("db"))
37 | require.NoError(t, err)
38 |
39 | siteRepo, err := NewPage(db)
40 | require.NoError(t, err)
41 |
42 | t.Run("base path", func(t *testing.T) {
43 | t.Parallel()
44 |
45 | site := entity.NewPage("https://google.com", "Save all google", entity.FormatPDF, entity.FormatSingleFile)
46 | site.Created = site.Created.Truncate(time.Microsecond)
47 |
48 | err := siteRepo.Save(ctx, site)
49 | require.NoError(t, err)
50 |
51 | storedSite, err := siteRepo.Get(ctx, site.ID)
52 | require.NoError(t, err)
53 |
54 | assert.Equal(t, site.ID, storedSite.ID)
55 | assert.Equal(t, site.URL, storedSite.URL)
56 | assert.Equal(t, site.Status, storedSite.Status)
57 |
58 | all, err := siteRepo.ListAll(ctx)
59 | require.NoError(t, err)
60 | require.Len(t, all, 1)
61 |
62 | assert.Equal(t, site.ID, all[0].ID)
63 | assert.Equal(t, site.URL, all[0].URL)
64 | assert.Equal(t, site.Status, all[0].Status)
65 | })
66 | }
67 |
--------------------------------------------------------------------------------
/adapters/repository/badgers3/marshal.go:
--------------------------------------------------------------------------------
1 | package badgers3
2 |
3 | import (
4 | "github.com/vmihailenco/msgpack/v5"
5 | )
6 |
7 | func marshal(v interface{}) ([]byte, error) {
8 | return msgpack.Marshal(v)
9 | }
10 |
11 | func unmarshal(b []byte, v interface{}) error { //nolint:unused // will use later
12 | return msgpack.Unmarshal(b, v)
13 | }
14 |
--------------------------------------------------------------------------------
/adapters/repository/badgers3/page.go:
--------------------------------------------------------------------------------
1 | package badgers3
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "errors"
7 | "fmt"
8 | "time"
9 |
10 | "github.com/derfenix/webarchive/adapters/repository"
11 | "github.com/derfenix/webarchive/entity"
12 | "github.com/dgraph-io/badger/v4"
13 | "github.com/google/uuid"
14 | "github.com/minio/minio-go/v7"
15 | )
16 |
17 | func NewPage(db *badger.DB, s3 *minio.Client, bucketName string) (*Page, error) {
18 | return &Page{
19 | db: db,
20 | s3: s3,
21 | prefix: []byte("pages3:"),
22 | bucketName: bucketName,
23 | }, nil
24 | }
25 |
26 | type Page struct {
27 | db *badger.DB
28 | s3 *minio.Client
29 | prefix []byte
30 | bucketName string
31 | }
32 |
33 | func (p *Page) ListAll(ctx context.Context) ([]*entity.PageBase, error) {
34 | // TODO implement me
35 | panic("implement me")
36 | }
37 |
38 | func (p *Page) Get(ctx context.Context, id uuid.UUID) (*entity.Page, error) {
39 | // TODO implement me
40 | panic("implement me")
41 | }
42 |
43 | func (p *Page) GetFile(ctx context.Context, pageID, fileID uuid.UUID) (*entity.File, error) {
44 | // TODO implement me
45 | panic("implement me")
46 | }
47 |
48 | func (p *Page) Save(ctx context.Context, page *entity.Page) error {
49 | if p.db.IsClosed() {
50 | return repository.ErrDBClosed
51 | }
52 |
53 | marshaled, err := marshal(page.PageBase)
54 | if err != nil {
55 | return fmt.Errorf("marshal data: %w", err)
56 | }
57 |
58 | if err := p.db.Update(func(txn *badger.Txn) error {
59 | if err := txn.Set(p.key(page), marshaled); err != nil {
60 | return fmt.Errorf("put data: %w", err)
61 | }
62 |
63 | return nil
64 | }); err != nil {
65 | return fmt.Errorf("update db: %w", err)
66 | }
67 |
68 | snowball := make(chan minio.SnowballObject, 1)
69 |
70 | go func() {
71 | defer close(snowball)
72 |
73 | for _, result := range page.Results {
74 | for _, file := range result.Files {
75 | for {
76 | if ctx.Err() != nil {
77 | return
78 | }
79 |
80 | if len(snowball) < cap(snowball) {
81 | break
82 | }
83 | }
84 |
85 | snowball <- minio.SnowballObject{
86 | Key: file.ID.String(),
87 | Size: int64(len(file.Data)),
88 | ModTime: time.Now(),
89 | Content: bytes.NewReader(file.Data),
90 | }
91 | }
92 | }
93 | }()
94 |
95 | if err = p.s3.PutObjectsSnowball(ctx, p.bucketName, minio.SnowballOptions{Compress: true}, snowball); err != nil {
96 | if dErr := p.db.Update(func(txn *badger.Txn) error {
97 | if err := txn.Delete(p.key(page)); err != nil {
98 | return fmt.Errorf("put data: %w", err)
99 | }
100 |
101 | return nil
102 | }); dErr != nil {
103 | err = errors.Join(err, dErr)
104 | }
105 |
106 | return fmt.Errorf("store files to s3: %w", err)
107 | }
108 |
109 | return nil
110 | }
111 |
112 | func (p *Page) ListUnprocessed(ctx context.Context) ([]entity.Page, error) {
113 | // TODO implement me
114 | panic("implement me")
115 | }
116 |
117 | func (p *Page) key(site *entity.Page) []byte {
118 | return append(p.prefix, []byte(site.ID.String())...)
119 | }
120 |
--------------------------------------------------------------------------------
/api/gen.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | //go:generate go run github.com/ogen-go/ogen/cmd/ogen@v0.77.0 --target ./openapi -package openapi --clean openapi.yaml
4 |
--------------------------------------------------------------------------------
/api/openapi.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | openapi: 3.0.3
3 | info:
4 | title: Sample API
5 | description: API description in Markdown.
6 | version: 1.0.0
7 | servers:
8 | - url: 'https://api.example.com/api/v1'
9 | paths:
10 | /pages:
11 | get:
12 | operationId: getPages
13 | summary: Get all pages
14 | responses:
15 | 200:
16 | description: All pages data
17 | content:
18 | application/json:
19 | schema:
20 | $ref: '#/components/schemas/pages'
21 | default:
22 | $ref: '#/components/responses/undefinedError'
23 | post:
24 | operationId: addPage
25 | summary: Add new page
26 | parameters:
27 | - in: query
28 | name: url
29 | schema:
30 | type: string
31 | - in: query
32 | name: description
33 | schema:
34 | type: string
35 | - in: query
36 | name: formats
37 | style: form
38 | explode: false
39 | schema:
40 | type: array
41 | items:
42 | $ref: '#/components/schemas/format'
43 | requestBody:
44 | content:
45 | application/json:
46 | schema:
47 | type: object
48 | properties:
49 | url:
50 | type: string
51 | description:
52 | type: string
53 | formats:
54 | type: array
55 | items:
56 | $ref: '#/components/schemas/format'
57 | required:
58 | - url
59 | responses:
60 | 201:
61 | description: Page added
62 | content:
63 | application/json:
64 | schema:
65 | $ref: '#/components/schemas/page'
66 | 400:
67 | description: Bad request
68 | content:
69 | application/json:
70 | schema:
71 | type: object
72 | properties:
73 | field:
74 | type: string
75 | nullable: false
76 | error:
77 | type: string
78 | nullable: false
79 | required:
80 | - error
81 | - field
82 | default:
83 | $ref: '#/components/responses/undefinedError'
84 |
85 | /pages/{id}:
86 | parameters:
87 | - in: path
88 | name: id
89 | required: true
90 | schema:
91 | type: string
92 | format: uuid
93 | get:
94 | operationId: getPage
95 | description: Get page details
96 | responses:
97 | 200:
98 | description: Page data
99 | content:
100 | application/json:
101 | schema:
102 | $ref: '#/components/schemas/pageWithResults'
103 | 404:
104 | description: Page not found
105 | default:
106 | $ref: '#/components/responses/undefinedError'
107 |
108 | /pages/{id}/file/{file_id}:
109 | parameters:
110 | - in: path
111 | name: id
112 | required: true
113 | schema:
114 | type: string
115 | format: uuid
116 | - in: path
117 | name: file_id
118 | required: true
119 | schema:
120 | type: string
121 | format: uuid
122 | get:
123 | operationId: getFile
124 | description: Get file content
125 | responses:
126 | 200:
127 | description: File content
128 | content:
129 | application/pdf: {}
130 | text/plain:
131 | schema:
132 | type: string
133 | text/html:
134 | schema:
135 | type: string
136 | 404:
137 | description: Page of file not found
138 | default:
139 | $ref: '#/components/responses/undefinedError'
140 |
141 | components:
142 | responses:
143 | undefinedError:
144 | description: Undefined Error
145 | content:
146 | application/json:
147 | schema:
148 | $ref: '#/components/schemas/error'
149 | schemas:
150 | format:
151 | type: string
152 | enum:
153 | - all
154 | - pdf
155 | - single_file
156 | - headers
157 | error:
158 | type: object
159 | properties:
160 | message:
161 | type: string
162 | localized:
163 | type: string
164 | required:
165 | - message
166 | pages:
167 | type: array
168 | items:
169 | $ref: '#/components/schemas/page'
170 | page:
171 | type: object
172 | properties:
173 | id:
174 | type: string
175 | format: uuid
176 | url:
177 | type: string
178 | created:
179 | type: string
180 | format: date-time
181 | formats:
182 | type: array
183 | items:
184 | $ref: '#/components/schemas/format'
185 | status:
186 | $ref: '#/components/schemas/status'
187 | meta:
188 | type: object
189 | properties:
190 | title:
191 | type: string
192 | description:
193 | type: string
194 | error:
195 | type: string
196 | required:
197 | - title
198 | - description
199 | required:
200 | - id
201 | - url
202 | - formats
203 | - status
204 | - created
205 | - meta
206 | result:
207 | type: object
208 | properties:
209 | format:
210 | $ref: '#/components/schemas/format'
211 | error:
212 | type: string
213 | files:
214 | type: array
215 | items:
216 | type: object
217 | properties:
218 | id:
219 | type: string
220 | format: uuid
221 | name:
222 | type: string
223 | mimetype:
224 | type: string
225 | size:
226 | type: integer
227 | format: int64
228 | required:
229 | - id
230 | - name
231 | - mimetype
232 | - size
233 | required:
234 | - format
235 | - files
236 | pageWithResults:
237 | allOf:
238 | - $ref: '#/components/schemas/page'
239 | - type: object
240 | properties:
241 | results:
242 | type: array
243 | items:
244 | $ref: '#/components/schemas/result'
245 | required:
246 | - results
247 | status:
248 | type: string
249 | enum:
250 | - new
251 | - processing
252 | - done
253 | - failed
254 | - with_errors
255 |
--------------------------------------------------------------------------------
/api/openapi/oas_cfg_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "net/http"
7 |
8 | "go.opentelemetry.io/otel"
9 | "go.opentelemetry.io/otel/metric"
10 | "go.opentelemetry.io/otel/trace"
11 |
12 | ht "github.com/ogen-go/ogen/http"
13 | "github.com/ogen-go/ogen/middleware"
14 | "github.com/ogen-go/ogen/ogenerrors"
15 | "github.com/ogen-go/ogen/otelogen"
16 | )
17 |
18 | var (
19 | // Allocate option closure once.
20 | clientSpanKind = trace.WithSpanKind(trace.SpanKindClient)
21 | // Allocate option closure once.
22 | serverSpanKind = trace.WithSpanKind(trace.SpanKindServer)
23 | )
24 |
25 | type (
26 | optionFunc[C any] func(*C)
27 | otelOptionFunc func(*otelConfig)
28 | )
29 |
30 | type otelConfig struct {
31 | TracerProvider trace.TracerProvider
32 | Tracer trace.Tracer
33 | MeterProvider metric.MeterProvider
34 | Meter metric.Meter
35 | }
36 |
37 | func (cfg *otelConfig) initOTEL() {
38 | if cfg.TracerProvider == nil {
39 | cfg.TracerProvider = otel.GetTracerProvider()
40 | }
41 | if cfg.MeterProvider == nil {
42 | cfg.MeterProvider = otel.GetMeterProvider()
43 | }
44 | cfg.Tracer = cfg.TracerProvider.Tracer(otelogen.Name,
45 | trace.WithInstrumentationVersion(otelogen.SemVersion()),
46 | )
47 | cfg.Meter = cfg.MeterProvider.Meter(otelogen.Name)
48 | }
49 |
50 | // ErrorHandler is error handler.
51 | type ErrorHandler = ogenerrors.ErrorHandler
52 |
53 | type serverConfig struct {
54 | otelConfig
55 | NotFound http.HandlerFunc
56 | MethodNotAllowed func(w http.ResponseWriter, r *http.Request, allowed string)
57 | ErrorHandler ErrorHandler
58 | Prefix string
59 | Middleware Middleware
60 | MaxMultipartMemory int64
61 | }
62 |
63 | // ServerOption is server config option.
64 | type ServerOption interface {
65 | applyServer(*serverConfig)
66 | }
67 |
68 | var _ = []ServerOption{
69 | (optionFunc[serverConfig])(nil),
70 | (otelOptionFunc)(nil),
71 | }
72 |
73 | func (o optionFunc[C]) applyServer(c *C) {
74 | o(c)
75 | }
76 |
77 | func (o otelOptionFunc) applyServer(c *serverConfig) {
78 | o(&c.otelConfig)
79 | }
80 |
81 | func newServerConfig(opts ...ServerOption) serverConfig {
82 | cfg := serverConfig{
83 | NotFound: http.NotFound,
84 | MethodNotAllowed: func(w http.ResponseWriter, r *http.Request, allowed string) {
85 | w.Header().Set("Allow", allowed)
86 | w.WriteHeader(http.StatusMethodNotAllowed)
87 | },
88 | ErrorHandler: ogenerrors.DefaultErrorHandler,
89 | Middleware: nil,
90 | MaxMultipartMemory: 32 << 20, // 32 MB
91 | }
92 | for _, opt := range opts {
93 | opt.applyServer(&cfg)
94 | }
95 | cfg.initOTEL()
96 | return cfg
97 | }
98 |
99 | type baseServer struct {
100 | cfg serverConfig
101 | requests metric.Int64Counter
102 | errors metric.Int64Counter
103 | duration metric.Float64Histogram
104 | }
105 |
106 | func (s baseServer) notFound(w http.ResponseWriter, r *http.Request) {
107 | s.cfg.NotFound(w, r)
108 | }
109 |
110 | func (s baseServer) notAllowed(w http.ResponseWriter, r *http.Request, allowed string) {
111 | s.cfg.MethodNotAllowed(w, r, allowed)
112 | }
113 |
114 | func (cfg serverConfig) baseServer() (s baseServer, err error) {
115 | s = baseServer{cfg: cfg}
116 | if s.requests, err = s.cfg.Meter.Int64Counter(otelogen.ServerRequestCount); err != nil {
117 | return s, err
118 | }
119 | if s.errors, err = s.cfg.Meter.Int64Counter(otelogen.ServerErrorsCount); err != nil {
120 | return s, err
121 | }
122 | if s.duration, err = s.cfg.Meter.Float64Histogram(otelogen.ServerDuration); err != nil {
123 | return s, err
124 | }
125 | return s, nil
126 | }
127 |
128 | type clientConfig struct {
129 | otelConfig
130 | Client ht.Client
131 | }
132 |
133 | // ClientOption is client config option.
134 | type ClientOption interface {
135 | applyClient(*clientConfig)
136 | }
137 |
138 | var _ = []ClientOption{
139 | (optionFunc[clientConfig])(nil),
140 | (otelOptionFunc)(nil),
141 | }
142 |
143 | func (o optionFunc[C]) applyClient(c *C) {
144 | o(c)
145 | }
146 |
147 | func (o otelOptionFunc) applyClient(c *clientConfig) {
148 | o(&c.otelConfig)
149 | }
150 |
151 | func newClientConfig(opts ...ClientOption) clientConfig {
152 | cfg := clientConfig{
153 | Client: http.DefaultClient,
154 | }
155 | for _, opt := range opts {
156 | opt.applyClient(&cfg)
157 | }
158 | cfg.initOTEL()
159 | return cfg
160 | }
161 |
162 | type baseClient struct {
163 | cfg clientConfig
164 | requests metric.Int64Counter
165 | errors metric.Int64Counter
166 | duration metric.Float64Histogram
167 | }
168 |
169 | func (cfg clientConfig) baseClient() (c baseClient, err error) {
170 | c = baseClient{cfg: cfg}
171 | if c.requests, err = c.cfg.Meter.Int64Counter(otelogen.ClientRequestCount); err != nil {
172 | return c, err
173 | }
174 | if c.errors, err = c.cfg.Meter.Int64Counter(otelogen.ClientErrorsCount); err != nil {
175 | return c, err
176 | }
177 | if c.duration, err = c.cfg.Meter.Float64Histogram(otelogen.ClientDuration); err != nil {
178 | return c, err
179 | }
180 | return c, nil
181 | }
182 |
183 | // Option is config option.
184 | type Option interface {
185 | ServerOption
186 | ClientOption
187 | }
188 |
189 | // WithTracerProvider specifies a tracer provider to use for creating a tracer.
190 | //
191 | // If none is specified, the global provider is used.
192 | func WithTracerProvider(provider trace.TracerProvider) Option {
193 | return otelOptionFunc(func(cfg *otelConfig) {
194 | if provider != nil {
195 | cfg.TracerProvider = provider
196 | }
197 | })
198 | }
199 |
200 | // WithMeterProvider specifies a meter provider to use for creating a meter.
201 | //
202 | // If none is specified, the otel.GetMeterProvider() is used.
203 | func WithMeterProvider(provider metric.MeterProvider) Option {
204 | return otelOptionFunc(func(cfg *otelConfig) {
205 | if provider != nil {
206 | cfg.MeterProvider = provider
207 | }
208 | })
209 | }
210 |
211 | // WithClient specifies http client to use.
212 | func WithClient(client ht.Client) ClientOption {
213 | return optionFunc[clientConfig](func(cfg *clientConfig) {
214 | if client != nil {
215 | cfg.Client = client
216 | }
217 | })
218 | }
219 |
220 | // WithNotFound specifies Not Found handler to use.
221 | func WithNotFound(notFound http.HandlerFunc) ServerOption {
222 | return optionFunc[serverConfig](func(cfg *serverConfig) {
223 | if notFound != nil {
224 | cfg.NotFound = notFound
225 | }
226 | })
227 | }
228 |
229 | // WithMethodNotAllowed specifies Method Not Allowed handler to use.
230 | func WithMethodNotAllowed(methodNotAllowed func(w http.ResponseWriter, r *http.Request, allowed string)) ServerOption {
231 | return optionFunc[serverConfig](func(cfg *serverConfig) {
232 | if methodNotAllowed != nil {
233 | cfg.MethodNotAllowed = methodNotAllowed
234 | }
235 | })
236 | }
237 |
238 | // WithErrorHandler specifies error handler to use.
239 | func WithErrorHandler(h ErrorHandler) ServerOption {
240 | return optionFunc[serverConfig](func(cfg *serverConfig) {
241 | if h != nil {
242 | cfg.ErrorHandler = h
243 | }
244 | })
245 | }
246 |
247 | // WithPathPrefix specifies server path prefix.
248 | func WithPathPrefix(prefix string) ServerOption {
249 | return optionFunc[serverConfig](func(cfg *serverConfig) {
250 | cfg.Prefix = prefix
251 | })
252 | }
253 |
254 | // WithMiddleware specifies middlewares to use.
255 | func WithMiddleware(m ...Middleware) ServerOption {
256 | return optionFunc[serverConfig](func(cfg *serverConfig) {
257 | switch len(m) {
258 | case 0:
259 | cfg.Middleware = nil
260 | case 1:
261 | cfg.Middleware = m[0]
262 | default:
263 | cfg.Middleware = middleware.ChainMiddlewares(m...)
264 | }
265 | })
266 | }
267 |
268 | // WithMaxMultipartMemory specifies limit of memory for storing file parts.
269 | // File parts which can't be stored in memory will be stored on disk in temporary files.
270 | func WithMaxMultipartMemory(max int64) ServerOption {
271 | return optionFunc[serverConfig](func(cfg *serverConfig) {
272 | if max > 0 {
273 | cfg.MaxMultipartMemory = max
274 | }
275 | })
276 | }
277 |
--------------------------------------------------------------------------------
/api/openapi/oas_client_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "context"
7 | "net/url"
8 | "strings"
9 | "time"
10 |
11 | "github.com/go-faster/errors"
12 | "go.opentelemetry.io/otel/attribute"
13 | "go.opentelemetry.io/otel/codes"
14 | "go.opentelemetry.io/otel/metric"
15 | semconv "go.opentelemetry.io/otel/semconv/v1.19.0"
16 | "go.opentelemetry.io/otel/trace"
17 |
18 | "github.com/ogen-go/ogen/conv"
19 | ht "github.com/ogen-go/ogen/http"
20 | "github.com/ogen-go/ogen/otelogen"
21 | "github.com/ogen-go/ogen/uri"
22 | )
23 |
24 | // Invoker invokes operations described by OpenAPI v3 specification.
25 | type Invoker interface {
26 | // AddPage invokes addPage operation.
27 | //
28 | // Add new page.
29 | //
30 | // POST /pages
31 | AddPage(ctx context.Context, request OptAddPageReq, params AddPageParams) (AddPageRes, error)
32 | // GetFile invokes getFile operation.
33 | //
34 | // Get file content.
35 | //
36 | // GET /pages/{id}/file/{file_id}
37 | GetFile(ctx context.Context, params GetFileParams) (GetFileRes, error)
38 | // GetPage invokes getPage operation.
39 | //
40 | // Get page details.
41 | //
42 | // GET /pages/{id}
43 | GetPage(ctx context.Context, params GetPageParams) (GetPageRes, error)
44 | // GetPages invokes getPages operation.
45 | //
46 | // Get all pages.
47 | //
48 | // GET /pages
49 | GetPages(ctx context.Context) (Pages, error)
50 | }
51 |
52 | // Client implements OAS client.
53 | type Client struct {
54 | serverURL *url.URL
55 | baseClient
56 | }
57 | type errorHandler interface {
58 | NewError(ctx context.Context, err error) *ErrorStatusCode
59 | }
60 |
61 | var _ Handler = struct {
62 | errorHandler
63 | *Client
64 | }{}
65 |
66 | func trimTrailingSlashes(u *url.URL) {
67 | u.Path = strings.TrimRight(u.Path, "/")
68 | u.RawPath = strings.TrimRight(u.RawPath, "/")
69 | }
70 |
71 | // NewClient initializes new Client defined by OAS.
72 | func NewClient(serverURL string, opts ...ClientOption) (*Client, error) {
73 | u, err := url.Parse(serverURL)
74 | if err != nil {
75 | return nil, err
76 | }
77 | trimTrailingSlashes(u)
78 |
79 | c, err := newClientConfig(opts...).baseClient()
80 | if err != nil {
81 | return nil, err
82 | }
83 | return &Client{
84 | serverURL: u,
85 | baseClient: c,
86 | }, nil
87 | }
88 |
89 | type serverURLKey struct{}
90 |
91 | // WithServerURL sets context key to override server URL.
92 | func WithServerURL(ctx context.Context, u *url.URL) context.Context {
93 | return context.WithValue(ctx, serverURLKey{}, u)
94 | }
95 |
96 | func (c *Client) requestURL(ctx context.Context) *url.URL {
97 | u, ok := ctx.Value(serverURLKey{}).(*url.URL)
98 | if !ok {
99 | return c.serverURL
100 | }
101 | return u
102 | }
103 |
104 | // AddPage invokes addPage operation.
105 | //
106 | // Add new page.
107 | //
108 | // POST /pages
109 | func (c *Client) AddPage(ctx context.Context, request OptAddPageReq, params AddPageParams) (AddPageRes, error) {
110 | res, err := c.sendAddPage(ctx, request, params)
111 | return res, err
112 | }
113 |
114 | func (c *Client) sendAddPage(ctx context.Context, request OptAddPageReq, params AddPageParams) (res AddPageRes, err error) {
115 | otelAttrs := []attribute.KeyValue{
116 | otelogen.OperationID("addPage"),
117 | semconv.HTTPMethodKey.String("POST"),
118 | semconv.HTTPRouteKey.String("/pages"),
119 | }
120 | // Validate request before sending.
121 | if err := func() error {
122 | if value, ok := request.Get(); ok {
123 | if err := func() error {
124 | if err := value.Validate(); err != nil {
125 | return err
126 | }
127 | return nil
128 | }(); err != nil {
129 | return err
130 | }
131 | }
132 | return nil
133 | }(); err != nil {
134 | return res, errors.Wrap(err, "validate")
135 | }
136 |
137 | // Run stopwatch.
138 | startTime := time.Now()
139 | defer func() {
140 | // Use floating point division here for higher precision (instead of Millisecond method).
141 | elapsedDuration := time.Since(startTime)
142 | c.duration.Record(ctx, float64(float64(elapsedDuration)/float64(time.Millisecond)), metric.WithAttributes(otelAttrs...))
143 | }()
144 |
145 | // Increment request counter.
146 | c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
147 |
148 | // Start a span for this request.
149 | ctx, span := c.cfg.Tracer.Start(ctx, "AddPage",
150 | trace.WithAttributes(otelAttrs...),
151 | clientSpanKind,
152 | )
153 | // Track stage for error reporting.
154 | var stage string
155 | defer func() {
156 | if err != nil {
157 | span.RecordError(err)
158 | span.SetStatus(codes.Error, stage)
159 | c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
160 | }
161 | span.End()
162 | }()
163 |
164 | stage = "BuildURL"
165 | u := uri.Clone(c.requestURL(ctx))
166 | var pathParts [1]string
167 | pathParts[0] = "/pages"
168 | uri.AddPathParts(u, pathParts[:]...)
169 |
170 | stage = "EncodeQueryParams"
171 | q := uri.NewQueryEncoder()
172 | {
173 | // Encode "url" parameter.
174 | cfg := uri.QueryParameterEncodingConfig{
175 | Name: "url",
176 | Style: uri.QueryStyleForm,
177 | Explode: true,
178 | }
179 |
180 | if err := q.EncodeParam(cfg, func(e uri.Encoder) error {
181 | if val, ok := params.URL.Get(); ok {
182 | return e.EncodeValue(conv.StringToString(val))
183 | }
184 | return nil
185 | }); err != nil {
186 | return res, errors.Wrap(err, "encode query")
187 | }
188 | }
189 | {
190 | // Encode "description" parameter.
191 | cfg := uri.QueryParameterEncodingConfig{
192 | Name: "description",
193 | Style: uri.QueryStyleForm,
194 | Explode: true,
195 | }
196 |
197 | if err := q.EncodeParam(cfg, func(e uri.Encoder) error {
198 | if val, ok := params.Description.Get(); ok {
199 | return e.EncodeValue(conv.StringToString(val))
200 | }
201 | return nil
202 | }); err != nil {
203 | return res, errors.Wrap(err, "encode query")
204 | }
205 | }
206 | {
207 | // Encode "formats" parameter.
208 | cfg := uri.QueryParameterEncodingConfig{
209 | Name: "formats",
210 | Style: uri.QueryStyleForm,
211 | Explode: false,
212 | }
213 |
214 | if err := q.EncodeParam(cfg, func(e uri.Encoder) error {
215 | return e.EncodeArray(func(e uri.Encoder) error {
216 | for i, item := range params.Formats {
217 | if err := func() error {
218 | return e.EncodeValue(conv.StringToString(string(item)))
219 | }(); err != nil {
220 | return errors.Wrapf(err, "[%d]", i)
221 | }
222 | }
223 | return nil
224 | })
225 | }); err != nil {
226 | return res, errors.Wrap(err, "encode query")
227 | }
228 | }
229 | u.RawQuery = q.Values().Encode()
230 |
231 | stage = "EncodeRequest"
232 | r, err := ht.NewRequest(ctx, "POST", u)
233 | if err != nil {
234 | return res, errors.Wrap(err, "create request")
235 | }
236 | if err := encodeAddPageRequest(request, r); err != nil {
237 | return res, errors.Wrap(err, "encode request")
238 | }
239 |
240 | stage = "SendRequest"
241 | resp, err := c.cfg.Client.Do(r)
242 | if err != nil {
243 | return res, errors.Wrap(err, "do request")
244 | }
245 | defer resp.Body.Close()
246 |
247 | stage = "DecodeResponse"
248 | result, err := decodeAddPageResponse(resp)
249 | if err != nil {
250 | return res, errors.Wrap(err, "decode response")
251 | }
252 |
253 | return result, nil
254 | }
255 |
256 | // GetFile invokes getFile operation.
257 | //
258 | // Get file content.
259 | //
260 | // GET /pages/{id}/file/{file_id}
261 | func (c *Client) GetFile(ctx context.Context, params GetFileParams) (GetFileRes, error) {
262 | res, err := c.sendGetFile(ctx, params)
263 | return res, err
264 | }
265 |
266 | func (c *Client) sendGetFile(ctx context.Context, params GetFileParams) (res GetFileRes, err error) {
267 | otelAttrs := []attribute.KeyValue{
268 | otelogen.OperationID("getFile"),
269 | semconv.HTTPMethodKey.String("GET"),
270 | semconv.HTTPRouteKey.String("/pages/{id}/file/{file_id}"),
271 | }
272 |
273 | // Run stopwatch.
274 | startTime := time.Now()
275 | defer func() {
276 | // Use floating point division here for higher precision (instead of Millisecond method).
277 | elapsedDuration := time.Since(startTime)
278 | c.duration.Record(ctx, float64(float64(elapsedDuration)/float64(time.Millisecond)), metric.WithAttributes(otelAttrs...))
279 | }()
280 |
281 | // Increment request counter.
282 | c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
283 |
284 | // Start a span for this request.
285 | ctx, span := c.cfg.Tracer.Start(ctx, "GetFile",
286 | trace.WithAttributes(otelAttrs...),
287 | clientSpanKind,
288 | )
289 | // Track stage for error reporting.
290 | var stage string
291 | defer func() {
292 | if err != nil {
293 | span.RecordError(err)
294 | span.SetStatus(codes.Error, stage)
295 | c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
296 | }
297 | span.End()
298 | }()
299 |
300 | stage = "BuildURL"
301 | u := uri.Clone(c.requestURL(ctx))
302 | var pathParts [4]string
303 | pathParts[0] = "/pages/"
304 | {
305 | // Encode "id" parameter.
306 | e := uri.NewPathEncoder(uri.PathEncoderConfig{
307 | Param: "id",
308 | Style: uri.PathStyleSimple,
309 | Explode: false,
310 | })
311 | if err := func() error {
312 | return e.EncodeValue(conv.UUIDToString(params.ID))
313 | }(); err != nil {
314 | return res, errors.Wrap(err, "encode path")
315 | }
316 | encoded, err := e.Result()
317 | if err != nil {
318 | return res, errors.Wrap(err, "encode path")
319 | }
320 | pathParts[1] = encoded
321 | }
322 | pathParts[2] = "/file/"
323 | {
324 | // Encode "file_id" parameter.
325 | e := uri.NewPathEncoder(uri.PathEncoderConfig{
326 | Param: "file_id",
327 | Style: uri.PathStyleSimple,
328 | Explode: false,
329 | })
330 | if err := func() error {
331 | return e.EncodeValue(conv.UUIDToString(params.FileID))
332 | }(); err != nil {
333 | return res, errors.Wrap(err, "encode path")
334 | }
335 | encoded, err := e.Result()
336 | if err != nil {
337 | return res, errors.Wrap(err, "encode path")
338 | }
339 | pathParts[3] = encoded
340 | }
341 | uri.AddPathParts(u, pathParts[:]...)
342 |
343 | stage = "EncodeRequest"
344 | r, err := ht.NewRequest(ctx, "GET", u)
345 | if err != nil {
346 | return res, errors.Wrap(err, "create request")
347 | }
348 |
349 | stage = "SendRequest"
350 | resp, err := c.cfg.Client.Do(r)
351 | if err != nil {
352 | return res, errors.Wrap(err, "do request")
353 | }
354 | defer resp.Body.Close()
355 |
356 | stage = "DecodeResponse"
357 | result, err := decodeGetFileResponse(resp)
358 | if err != nil {
359 | return res, errors.Wrap(err, "decode response")
360 | }
361 |
362 | return result, nil
363 | }
364 |
365 | // GetPage invokes getPage operation.
366 | //
367 | // Get page details.
368 | //
369 | // GET /pages/{id}
370 | func (c *Client) GetPage(ctx context.Context, params GetPageParams) (GetPageRes, error) {
371 | res, err := c.sendGetPage(ctx, params)
372 | return res, err
373 | }
374 |
375 | func (c *Client) sendGetPage(ctx context.Context, params GetPageParams) (res GetPageRes, err error) {
376 | otelAttrs := []attribute.KeyValue{
377 | otelogen.OperationID("getPage"),
378 | semconv.HTTPMethodKey.String("GET"),
379 | semconv.HTTPRouteKey.String("/pages/{id}"),
380 | }
381 |
382 | // Run stopwatch.
383 | startTime := time.Now()
384 | defer func() {
385 | // Use floating point division here for higher precision (instead of Millisecond method).
386 | elapsedDuration := time.Since(startTime)
387 | c.duration.Record(ctx, float64(float64(elapsedDuration)/float64(time.Millisecond)), metric.WithAttributes(otelAttrs...))
388 | }()
389 |
390 | // Increment request counter.
391 | c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
392 |
393 | // Start a span for this request.
394 | ctx, span := c.cfg.Tracer.Start(ctx, "GetPage",
395 | trace.WithAttributes(otelAttrs...),
396 | clientSpanKind,
397 | )
398 | // Track stage for error reporting.
399 | var stage string
400 | defer func() {
401 | if err != nil {
402 | span.RecordError(err)
403 | span.SetStatus(codes.Error, stage)
404 | c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
405 | }
406 | span.End()
407 | }()
408 |
409 | stage = "BuildURL"
410 | u := uri.Clone(c.requestURL(ctx))
411 | var pathParts [2]string
412 | pathParts[0] = "/pages/"
413 | {
414 | // Encode "id" parameter.
415 | e := uri.NewPathEncoder(uri.PathEncoderConfig{
416 | Param: "id",
417 | Style: uri.PathStyleSimple,
418 | Explode: false,
419 | })
420 | if err := func() error {
421 | return e.EncodeValue(conv.UUIDToString(params.ID))
422 | }(); err != nil {
423 | return res, errors.Wrap(err, "encode path")
424 | }
425 | encoded, err := e.Result()
426 | if err != nil {
427 | return res, errors.Wrap(err, "encode path")
428 | }
429 | pathParts[1] = encoded
430 | }
431 | uri.AddPathParts(u, pathParts[:]...)
432 |
433 | stage = "EncodeRequest"
434 | r, err := ht.NewRequest(ctx, "GET", u)
435 | if err != nil {
436 | return res, errors.Wrap(err, "create request")
437 | }
438 |
439 | stage = "SendRequest"
440 | resp, err := c.cfg.Client.Do(r)
441 | if err != nil {
442 | return res, errors.Wrap(err, "do request")
443 | }
444 | defer resp.Body.Close()
445 |
446 | stage = "DecodeResponse"
447 | result, err := decodeGetPageResponse(resp)
448 | if err != nil {
449 | return res, errors.Wrap(err, "decode response")
450 | }
451 |
452 | return result, nil
453 | }
454 |
455 | // GetPages invokes getPages operation.
456 | //
457 | // Get all pages.
458 | //
459 | // GET /pages
460 | func (c *Client) GetPages(ctx context.Context) (Pages, error) {
461 | res, err := c.sendGetPages(ctx)
462 | return res, err
463 | }
464 |
465 | func (c *Client) sendGetPages(ctx context.Context) (res Pages, err error) {
466 | otelAttrs := []attribute.KeyValue{
467 | otelogen.OperationID("getPages"),
468 | semconv.HTTPMethodKey.String("GET"),
469 | semconv.HTTPRouteKey.String("/pages"),
470 | }
471 |
472 | // Run stopwatch.
473 | startTime := time.Now()
474 | defer func() {
475 | // Use floating point division here for higher precision (instead of Millisecond method).
476 | elapsedDuration := time.Since(startTime)
477 | c.duration.Record(ctx, float64(float64(elapsedDuration)/float64(time.Millisecond)), metric.WithAttributes(otelAttrs...))
478 | }()
479 |
480 | // Increment request counter.
481 | c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
482 |
483 | // Start a span for this request.
484 | ctx, span := c.cfg.Tracer.Start(ctx, "GetPages",
485 | trace.WithAttributes(otelAttrs...),
486 | clientSpanKind,
487 | )
488 | // Track stage for error reporting.
489 | var stage string
490 | defer func() {
491 | if err != nil {
492 | span.RecordError(err)
493 | span.SetStatus(codes.Error, stage)
494 | c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
495 | }
496 | span.End()
497 | }()
498 |
499 | stage = "BuildURL"
500 | u := uri.Clone(c.requestURL(ctx))
501 | var pathParts [1]string
502 | pathParts[0] = "/pages"
503 | uri.AddPathParts(u, pathParts[:]...)
504 |
505 | stage = "EncodeRequest"
506 | r, err := ht.NewRequest(ctx, "GET", u)
507 | if err != nil {
508 | return res, errors.Wrap(err, "create request")
509 | }
510 |
511 | stage = "SendRequest"
512 | resp, err := c.cfg.Client.Do(r)
513 | if err != nil {
514 | return res, errors.Wrap(err, "do request")
515 | }
516 | defer resp.Body.Close()
517 |
518 | stage = "DecodeResponse"
519 | result, err := decodeGetPagesResponse(resp)
520 | if err != nil {
521 | return res, errors.Wrap(err, "decode response")
522 | }
523 |
524 | return result, nil
525 | }
526 |
--------------------------------------------------------------------------------
/api/openapi/oas_handlers_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "context"
7 | "net/http"
8 | "time"
9 |
10 | "github.com/go-faster/errors"
11 | "go.opentelemetry.io/otel/attribute"
12 | "go.opentelemetry.io/otel/codes"
13 | "go.opentelemetry.io/otel/metric"
14 | semconv "go.opentelemetry.io/otel/semconv/v1.19.0"
15 | "go.opentelemetry.io/otel/trace"
16 |
17 | ht "github.com/ogen-go/ogen/http"
18 | "github.com/ogen-go/ogen/middleware"
19 | "github.com/ogen-go/ogen/ogenerrors"
20 | "github.com/ogen-go/ogen/otelogen"
21 | )
22 |
23 | // handleAddPageRequest handles addPage operation.
24 | //
25 | // Add new page.
26 | //
27 | // POST /pages
28 | func (s *Server) handleAddPageRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) {
29 | otelAttrs := []attribute.KeyValue{
30 | otelogen.OperationID("addPage"),
31 | semconv.HTTPMethodKey.String("POST"),
32 | semconv.HTTPRouteKey.String("/pages"),
33 | }
34 |
35 | // Start a span for this request.
36 | ctx, span := s.cfg.Tracer.Start(r.Context(), "AddPage",
37 | trace.WithAttributes(otelAttrs...),
38 | serverSpanKind,
39 | )
40 | defer span.End()
41 |
42 | // Run stopwatch.
43 | startTime := time.Now()
44 | defer func() {
45 | elapsedDuration := time.Since(startTime)
46 | // Use floating point division here for higher precision (instead of Millisecond method).
47 | s.duration.Record(ctx, float64(float64(elapsedDuration)/float64(time.Millisecond)), metric.WithAttributes(otelAttrs...))
48 | }()
49 |
50 | // Increment request counter.
51 | s.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
52 |
53 | var (
54 | recordError = func(stage string, err error) {
55 | span.RecordError(err)
56 | span.SetStatus(codes.Error, stage)
57 | s.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
58 | }
59 | err error
60 | opErrContext = ogenerrors.OperationContext{
61 | Name: "AddPage",
62 | ID: "addPage",
63 | }
64 | )
65 | params, err := decodeAddPageParams(args, argsEscaped, r)
66 | if err != nil {
67 | err = &ogenerrors.DecodeParamsError{
68 | OperationContext: opErrContext,
69 | Err: err,
70 | }
71 | recordError("DecodeParams", err)
72 | s.cfg.ErrorHandler(ctx, w, r, err)
73 | return
74 | }
75 | request, close, err := s.decodeAddPageRequest(r)
76 | if err != nil {
77 | err = &ogenerrors.DecodeRequestError{
78 | OperationContext: opErrContext,
79 | Err: err,
80 | }
81 | recordError("DecodeRequest", err)
82 | s.cfg.ErrorHandler(ctx, w, r, err)
83 | return
84 | }
85 | defer func() {
86 | if err := close(); err != nil {
87 | recordError("CloseRequest", err)
88 | }
89 | }()
90 |
91 | var response AddPageRes
92 | if m := s.cfg.Middleware; m != nil {
93 | mreq := middleware.Request{
94 | Context: ctx,
95 | OperationName: "AddPage",
96 | OperationSummary: "Add new page",
97 | OperationID: "addPage",
98 | Body: request,
99 | Params: middleware.Parameters{
100 | {
101 | Name: "url",
102 | In: "query",
103 | }: params.URL,
104 | {
105 | Name: "description",
106 | In: "query",
107 | }: params.Description,
108 | {
109 | Name: "formats",
110 | In: "query",
111 | }: params.Formats,
112 | },
113 | Raw: r,
114 | }
115 |
116 | type (
117 | Request = OptAddPageReq
118 | Params = AddPageParams
119 | Response = AddPageRes
120 | )
121 | response, err = middleware.HookMiddleware[
122 | Request,
123 | Params,
124 | Response,
125 | ](
126 | m,
127 | mreq,
128 | unpackAddPageParams,
129 | func(ctx context.Context, request Request, params Params) (response Response, err error) {
130 | response, err = s.h.AddPage(ctx, request, params)
131 | return response, err
132 | },
133 | )
134 | } else {
135 | response, err = s.h.AddPage(ctx, request, params)
136 | }
137 | if err != nil {
138 | if errRes, ok := errors.Into[*ErrorStatusCode](err); ok {
139 | if err := encodeErrorResponse(errRes, w, span); err != nil {
140 | recordError("Internal", err)
141 | }
142 | return
143 | }
144 | if errors.Is(err, ht.ErrNotImplemented) {
145 | s.cfg.ErrorHandler(ctx, w, r, err)
146 | return
147 | }
148 | if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil {
149 | recordError("Internal", err)
150 | }
151 | return
152 | }
153 |
154 | if err := encodeAddPageResponse(response, w, span); err != nil {
155 | recordError("EncodeResponse", err)
156 | if !errors.Is(err, ht.ErrInternalServerErrorResponse) {
157 | s.cfg.ErrorHandler(ctx, w, r, err)
158 | }
159 | return
160 | }
161 | }
162 |
163 | // handleGetFileRequest handles getFile operation.
164 | //
165 | // Get file content.
166 | //
167 | // GET /pages/{id}/file/{file_id}
168 | func (s *Server) handleGetFileRequest(args [2]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) {
169 | otelAttrs := []attribute.KeyValue{
170 | otelogen.OperationID("getFile"),
171 | semconv.HTTPMethodKey.String("GET"),
172 | semconv.HTTPRouteKey.String("/pages/{id}/file/{file_id}"),
173 | }
174 |
175 | // Start a span for this request.
176 | ctx, span := s.cfg.Tracer.Start(r.Context(), "GetFile",
177 | trace.WithAttributes(otelAttrs...),
178 | serverSpanKind,
179 | )
180 | defer span.End()
181 |
182 | // Run stopwatch.
183 | startTime := time.Now()
184 | defer func() {
185 | elapsedDuration := time.Since(startTime)
186 | // Use floating point division here for higher precision (instead of Millisecond method).
187 | s.duration.Record(ctx, float64(float64(elapsedDuration)/float64(time.Millisecond)), metric.WithAttributes(otelAttrs...))
188 | }()
189 |
190 | // Increment request counter.
191 | s.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
192 |
193 | var (
194 | recordError = func(stage string, err error) {
195 | span.RecordError(err)
196 | span.SetStatus(codes.Error, stage)
197 | s.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
198 | }
199 | err error
200 | opErrContext = ogenerrors.OperationContext{
201 | Name: "GetFile",
202 | ID: "getFile",
203 | }
204 | )
205 | params, err := decodeGetFileParams(args, argsEscaped, r)
206 | if err != nil {
207 | err = &ogenerrors.DecodeParamsError{
208 | OperationContext: opErrContext,
209 | Err: err,
210 | }
211 | recordError("DecodeParams", err)
212 | s.cfg.ErrorHandler(ctx, w, r, err)
213 | return
214 | }
215 |
216 | var response GetFileRes
217 | if m := s.cfg.Middleware; m != nil {
218 | mreq := middleware.Request{
219 | Context: ctx,
220 | OperationName: "GetFile",
221 | OperationSummary: "",
222 | OperationID: "getFile",
223 | Body: nil,
224 | Params: middleware.Parameters{
225 | {
226 | Name: "id",
227 | In: "path",
228 | }: params.ID,
229 | {
230 | Name: "file_id",
231 | In: "path",
232 | }: params.FileID,
233 | },
234 | Raw: r,
235 | }
236 |
237 | type (
238 | Request = struct{}
239 | Params = GetFileParams
240 | Response = GetFileRes
241 | )
242 | response, err = middleware.HookMiddleware[
243 | Request,
244 | Params,
245 | Response,
246 | ](
247 | m,
248 | mreq,
249 | unpackGetFileParams,
250 | func(ctx context.Context, request Request, params Params) (response Response, err error) {
251 | response, err = s.h.GetFile(ctx, params)
252 | return response, err
253 | },
254 | )
255 | } else {
256 | response, err = s.h.GetFile(ctx, params)
257 | }
258 | if err != nil {
259 | if errRes, ok := errors.Into[*ErrorStatusCode](err); ok {
260 | if err := encodeErrorResponse(errRes, w, span); err != nil {
261 | recordError("Internal", err)
262 | }
263 | return
264 | }
265 | if errors.Is(err, ht.ErrNotImplemented) {
266 | s.cfg.ErrorHandler(ctx, w, r, err)
267 | return
268 | }
269 | if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil {
270 | recordError("Internal", err)
271 | }
272 | return
273 | }
274 |
275 | if err := encodeGetFileResponse(response, w, span); err != nil {
276 | recordError("EncodeResponse", err)
277 | if !errors.Is(err, ht.ErrInternalServerErrorResponse) {
278 | s.cfg.ErrorHandler(ctx, w, r, err)
279 | }
280 | return
281 | }
282 | }
283 |
284 | // handleGetPageRequest handles getPage operation.
285 | //
286 | // Get page details.
287 | //
288 | // GET /pages/{id}
289 | func (s *Server) handleGetPageRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) {
290 | otelAttrs := []attribute.KeyValue{
291 | otelogen.OperationID("getPage"),
292 | semconv.HTTPMethodKey.String("GET"),
293 | semconv.HTTPRouteKey.String("/pages/{id}"),
294 | }
295 |
296 | // Start a span for this request.
297 | ctx, span := s.cfg.Tracer.Start(r.Context(), "GetPage",
298 | trace.WithAttributes(otelAttrs...),
299 | serverSpanKind,
300 | )
301 | defer span.End()
302 |
303 | // Run stopwatch.
304 | startTime := time.Now()
305 | defer func() {
306 | elapsedDuration := time.Since(startTime)
307 | // Use floating point division here for higher precision (instead of Millisecond method).
308 | s.duration.Record(ctx, float64(float64(elapsedDuration)/float64(time.Millisecond)), metric.WithAttributes(otelAttrs...))
309 | }()
310 |
311 | // Increment request counter.
312 | s.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
313 |
314 | var (
315 | recordError = func(stage string, err error) {
316 | span.RecordError(err)
317 | span.SetStatus(codes.Error, stage)
318 | s.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
319 | }
320 | err error
321 | opErrContext = ogenerrors.OperationContext{
322 | Name: "GetPage",
323 | ID: "getPage",
324 | }
325 | )
326 | params, err := decodeGetPageParams(args, argsEscaped, r)
327 | if err != nil {
328 | err = &ogenerrors.DecodeParamsError{
329 | OperationContext: opErrContext,
330 | Err: err,
331 | }
332 | recordError("DecodeParams", err)
333 | s.cfg.ErrorHandler(ctx, w, r, err)
334 | return
335 | }
336 |
337 | var response GetPageRes
338 | if m := s.cfg.Middleware; m != nil {
339 | mreq := middleware.Request{
340 | Context: ctx,
341 | OperationName: "GetPage",
342 | OperationSummary: "",
343 | OperationID: "getPage",
344 | Body: nil,
345 | Params: middleware.Parameters{
346 | {
347 | Name: "id",
348 | In: "path",
349 | }: params.ID,
350 | },
351 | Raw: r,
352 | }
353 |
354 | type (
355 | Request = struct{}
356 | Params = GetPageParams
357 | Response = GetPageRes
358 | )
359 | response, err = middleware.HookMiddleware[
360 | Request,
361 | Params,
362 | Response,
363 | ](
364 | m,
365 | mreq,
366 | unpackGetPageParams,
367 | func(ctx context.Context, request Request, params Params) (response Response, err error) {
368 | response, err = s.h.GetPage(ctx, params)
369 | return response, err
370 | },
371 | )
372 | } else {
373 | response, err = s.h.GetPage(ctx, params)
374 | }
375 | if err != nil {
376 | if errRes, ok := errors.Into[*ErrorStatusCode](err); ok {
377 | if err := encodeErrorResponse(errRes, w, span); err != nil {
378 | recordError("Internal", err)
379 | }
380 | return
381 | }
382 | if errors.Is(err, ht.ErrNotImplemented) {
383 | s.cfg.ErrorHandler(ctx, w, r, err)
384 | return
385 | }
386 | if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil {
387 | recordError("Internal", err)
388 | }
389 | return
390 | }
391 |
392 | if err := encodeGetPageResponse(response, w, span); err != nil {
393 | recordError("EncodeResponse", err)
394 | if !errors.Is(err, ht.ErrInternalServerErrorResponse) {
395 | s.cfg.ErrorHandler(ctx, w, r, err)
396 | }
397 | return
398 | }
399 | }
400 |
401 | // handleGetPagesRequest handles getPages operation.
402 | //
403 | // Get all pages.
404 | //
405 | // GET /pages
406 | func (s *Server) handleGetPagesRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) {
407 | otelAttrs := []attribute.KeyValue{
408 | otelogen.OperationID("getPages"),
409 | semconv.HTTPMethodKey.String("GET"),
410 | semconv.HTTPRouteKey.String("/pages"),
411 | }
412 |
413 | // Start a span for this request.
414 | ctx, span := s.cfg.Tracer.Start(r.Context(), "GetPages",
415 | trace.WithAttributes(otelAttrs...),
416 | serverSpanKind,
417 | )
418 | defer span.End()
419 |
420 | // Run stopwatch.
421 | startTime := time.Now()
422 | defer func() {
423 | elapsedDuration := time.Since(startTime)
424 | // Use floating point division here for higher precision (instead of Millisecond method).
425 | s.duration.Record(ctx, float64(float64(elapsedDuration)/float64(time.Millisecond)), metric.WithAttributes(otelAttrs...))
426 | }()
427 |
428 | // Increment request counter.
429 | s.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
430 |
431 | var (
432 | recordError = func(stage string, err error) {
433 | span.RecordError(err)
434 | span.SetStatus(codes.Error, stage)
435 | s.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...))
436 | }
437 | err error
438 | )
439 |
440 | var response Pages
441 | if m := s.cfg.Middleware; m != nil {
442 | mreq := middleware.Request{
443 | Context: ctx,
444 | OperationName: "GetPages",
445 | OperationSummary: "Get all pages",
446 | OperationID: "getPages",
447 | Body: nil,
448 | Params: middleware.Parameters{},
449 | Raw: r,
450 | }
451 |
452 | type (
453 | Request = struct{}
454 | Params = struct{}
455 | Response = Pages
456 | )
457 | response, err = middleware.HookMiddleware[
458 | Request,
459 | Params,
460 | Response,
461 | ](
462 | m,
463 | mreq,
464 | nil,
465 | func(ctx context.Context, request Request, params Params) (response Response, err error) {
466 | response, err = s.h.GetPages(ctx)
467 | return response, err
468 | },
469 | )
470 | } else {
471 | response, err = s.h.GetPages(ctx)
472 | }
473 | if err != nil {
474 | if errRes, ok := errors.Into[*ErrorStatusCode](err); ok {
475 | if err := encodeErrorResponse(errRes, w, span); err != nil {
476 | recordError("Internal", err)
477 | }
478 | return
479 | }
480 | if errors.Is(err, ht.ErrNotImplemented) {
481 | s.cfg.ErrorHandler(ctx, w, r, err)
482 | return
483 | }
484 | if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil {
485 | recordError("Internal", err)
486 | }
487 | return
488 | }
489 |
490 | if err := encodeGetPagesResponse(response, w, span); err != nil {
491 | recordError("EncodeResponse", err)
492 | if !errors.Is(err, ht.ErrInternalServerErrorResponse) {
493 | s.cfg.ErrorHandler(ctx, w, r, err)
494 | }
495 | return
496 | }
497 | }
498 |
--------------------------------------------------------------------------------
/api/openapi/oas_interfaces_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 | package openapi
3 |
4 | type AddPageRes interface {
5 | addPageRes()
6 | }
7 |
8 | type GetFileRes interface {
9 | getFileRes()
10 | }
11 |
12 | type GetPageRes interface {
13 | getPageRes()
14 | }
15 |
--------------------------------------------------------------------------------
/api/openapi/oas_middleware_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "github.com/ogen-go/ogen/middleware"
7 | )
8 |
9 | // Middleware is middleware type.
10 | type Middleware = middleware.Middleware
11 |
--------------------------------------------------------------------------------
/api/openapi/oas_parameters_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "fmt"
7 | "net/http"
8 | "net/url"
9 |
10 | "github.com/go-faster/errors"
11 | "github.com/google/uuid"
12 |
13 | "github.com/ogen-go/ogen/conv"
14 | "github.com/ogen-go/ogen/middleware"
15 | "github.com/ogen-go/ogen/ogenerrors"
16 | "github.com/ogen-go/ogen/uri"
17 | "github.com/ogen-go/ogen/validate"
18 | )
19 |
20 | // AddPageParams is parameters of addPage operation.
21 | type AddPageParams struct {
22 | URL OptString
23 | Description OptString
24 | Formats []Format
25 | }
26 |
27 | func unpackAddPageParams(packed middleware.Parameters) (params AddPageParams) {
28 | {
29 | key := middleware.ParameterKey{
30 | Name: "url",
31 | In: "query",
32 | }
33 | if v, ok := packed[key]; ok {
34 | params.URL = v.(OptString)
35 | }
36 | }
37 | {
38 | key := middleware.ParameterKey{
39 | Name: "description",
40 | In: "query",
41 | }
42 | if v, ok := packed[key]; ok {
43 | params.Description = v.(OptString)
44 | }
45 | }
46 | {
47 | key := middleware.ParameterKey{
48 | Name: "formats",
49 | In: "query",
50 | }
51 | if v, ok := packed[key]; ok {
52 | params.Formats = v.([]Format)
53 | }
54 | }
55 | return params
56 | }
57 |
58 | func decodeAddPageParams(args [0]string, argsEscaped bool, r *http.Request) (params AddPageParams, _ error) {
59 | q := uri.NewQueryDecoder(r.URL.Query())
60 | // Decode query: url.
61 | if err := func() error {
62 | cfg := uri.QueryParameterDecodingConfig{
63 | Name: "url",
64 | Style: uri.QueryStyleForm,
65 | Explode: true,
66 | }
67 |
68 | if err := q.HasParam(cfg); err == nil {
69 | if err := q.DecodeParam(cfg, func(d uri.Decoder) error {
70 | var paramsDotURLVal string
71 | if err := func() error {
72 | val, err := d.DecodeValue()
73 | if err != nil {
74 | return err
75 | }
76 |
77 | c, err := conv.ToString(val)
78 | if err != nil {
79 | return err
80 | }
81 |
82 | paramsDotURLVal = c
83 | return nil
84 | }(); err != nil {
85 | return err
86 | }
87 | params.URL.SetTo(paramsDotURLVal)
88 | return nil
89 | }); err != nil {
90 | return err
91 | }
92 | }
93 | return nil
94 | }(); err != nil {
95 | return params, &ogenerrors.DecodeParamError{
96 | Name: "url",
97 | In: "query",
98 | Err: err,
99 | }
100 | }
101 | // Decode query: description.
102 | if err := func() error {
103 | cfg := uri.QueryParameterDecodingConfig{
104 | Name: "description",
105 | Style: uri.QueryStyleForm,
106 | Explode: true,
107 | }
108 |
109 | if err := q.HasParam(cfg); err == nil {
110 | if err := q.DecodeParam(cfg, func(d uri.Decoder) error {
111 | var paramsDotDescriptionVal string
112 | if err := func() error {
113 | val, err := d.DecodeValue()
114 | if err != nil {
115 | return err
116 | }
117 |
118 | c, err := conv.ToString(val)
119 | if err != nil {
120 | return err
121 | }
122 |
123 | paramsDotDescriptionVal = c
124 | return nil
125 | }(); err != nil {
126 | return err
127 | }
128 | params.Description.SetTo(paramsDotDescriptionVal)
129 | return nil
130 | }); err != nil {
131 | return err
132 | }
133 | }
134 | return nil
135 | }(); err != nil {
136 | return params, &ogenerrors.DecodeParamError{
137 | Name: "description",
138 | In: "query",
139 | Err: err,
140 | }
141 | }
142 | // Decode query: formats.
143 | if err := func() error {
144 | cfg := uri.QueryParameterDecodingConfig{
145 | Name: "formats",
146 | Style: uri.QueryStyleForm,
147 | Explode: false,
148 | }
149 |
150 | if err := q.HasParam(cfg); err == nil {
151 | if err := q.DecodeParam(cfg, func(d uri.Decoder) error {
152 | return d.DecodeArray(func(d uri.Decoder) error {
153 | var paramsDotFormatsVal Format
154 | if err := func() error {
155 | val, err := d.DecodeValue()
156 | if err != nil {
157 | return err
158 | }
159 |
160 | c, err := conv.ToString(val)
161 | if err != nil {
162 | return err
163 | }
164 |
165 | paramsDotFormatsVal = Format(c)
166 | return nil
167 | }(); err != nil {
168 | return err
169 | }
170 | params.Formats = append(params.Formats, paramsDotFormatsVal)
171 | return nil
172 | })
173 | }); err != nil {
174 | return err
175 | }
176 | if err := func() error {
177 | var failures []validate.FieldError
178 | for i, elem := range params.Formats {
179 | if err := func() error {
180 | if err := elem.Validate(); err != nil {
181 | return err
182 | }
183 | return nil
184 | }(); err != nil {
185 | failures = append(failures, validate.FieldError{
186 | Name: fmt.Sprintf("[%d]", i),
187 | Error: err,
188 | })
189 | }
190 | }
191 | if len(failures) > 0 {
192 | return &validate.Error{Fields: failures}
193 | }
194 | return nil
195 | }(); err != nil {
196 | return err
197 | }
198 | }
199 | return nil
200 | }(); err != nil {
201 | return params, &ogenerrors.DecodeParamError{
202 | Name: "formats",
203 | In: "query",
204 | Err: err,
205 | }
206 | }
207 | return params, nil
208 | }
209 |
210 | // GetFileParams is parameters of getFile operation.
211 | type GetFileParams struct {
212 | ID uuid.UUID
213 | FileID uuid.UUID
214 | }
215 |
216 | func unpackGetFileParams(packed middleware.Parameters) (params GetFileParams) {
217 | {
218 | key := middleware.ParameterKey{
219 | Name: "id",
220 | In: "path",
221 | }
222 | params.ID = packed[key].(uuid.UUID)
223 | }
224 | {
225 | key := middleware.ParameterKey{
226 | Name: "file_id",
227 | In: "path",
228 | }
229 | params.FileID = packed[key].(uuid.UUID)
230 | }
231 | return params
232 | }
233 |
234 | func decodeGetFileParams(args [2]string, argsEscaped bool, r *http.Request) (params GetFileParams, _ error) {
235 | // Decode path: id.
236 | if err := func() error {
237 | param := args[0]
238 | if argsEscaped {
239 | unescaped, err := url.PathUnescape(args[0])
240 | if err != nil {
241 | return errors.Wrap(err, "unescape path")
242 | }
243 | param = unescaped
244 | }
245 | if len(param) > 0 {
246 | d := uri.NewPathDecoder(uri.PathDecoderConfig{
247 | Param: "id",
248 | Value: param,
249 | Style: uri.PathStyleSimple,
250 | Explode: false,
251 | })
252 |
253 | if err := func() error {
254 | val, err := d.DecodeValue()
255 | if err != nil {
256 | return err
257 | }
258 |
259 | c, err := conv.ToUUID(val)
260 | if err != nil {
261 | return err
262 | }
263 |
264 | params.ID = c
265 | return nil
266 | }(); err != nil {
267 | return err
268 | }
269 | } else {
270 | return validate.ErrFieldRequired
271 | }
272 | return nil
273 | }(); err != nil {
274 | return params, &ogenerrors.DecodeParamError{
275 | Name: "id",
276 | In: "path",
277 | Err: err,
278 | }
279 | }
280 | // Decode path: file_id.
281 | if err := func() error {
282 | param := args[1]
283 | if argsEscaped {
284 | unescaped, err := url.PathUnescape(args[1])
285 | if err != nil {
286 | return errors.Wrap(err, "unescape path")
287 | }
288 | param = unescaped
289 | }
290 | if len(param) > 0 {
291 | d := uri.NewPathDecoder(uri.PathDecoderConfig{
292 | Param: "file_id",
293 | Value: param,
294 | Style: uri.PathStyleSimple,
295 | Explode: false,
296 | })
297 |
298 | if err := func() error {
299 | val, err := d.DecodeValue()
300 | if err != nil {
301 | return err
302 | }
303 |
304 | c, err := conv.ToUUID(val)
305 | if err != nil {
306 | return err
307 | }
308 |
309 | params.FileID = c
310 | return nil
311 | }(); err != nil {
312 | return err
313 | }
314 | } else {
315 | return validate.ErrFieldRequired
316 | }
317 | return nil
318 | }(); err != nil {
319 | return params, &ogenerrors.DecodeParamError{
320 | Name: "file_id",
321 | In: "path",
322 | Err: err,
323 | }
324 | }
325 | return params, nil
326 | }
327 |
328 | // GetPageParams is parameters of getPage operation.
329 | type GetPageParams struct {
330 | ID uuid.UUID
331 | }
332 |
333 | func unpackGetPageParams(packed middleware.Parameters) (params GetPageParams) {
334 | {
335 | key := middleware.ParameterKey{
336 | Name: "id",
337 | In: "path",
338 | }
339 | params.ID = packed[key].(uuid.UUID)
340 | }
341 | return params
342 | }
343 |
344 | func decodeGetPageParams(args [1]string, argsEscaped bool, r *http.Request) (params GetPageParams, _ error) {
345 | // Decode path: id.
346 | if err := func() error {
347 | param := args[0]
348 | if argsEscaped {
349 | unescaped, err := url.PathUnescape(args[0])
350 | if err != nil {
351 | return errors.Wrap(err, "unescape path")
352 | }
353 | param = unescaped
354 | }
355 | if len(param) > 0 {
356 | d := uri.NewPathDecoder(uri.PathDecoderConfig{
357 | Param: "id",
358 | Value: param,
359 | Style: uri.PathStyleSimple,
360 | Explode: false,
361 | })
362 |
363 | if err := func() error {
364 | val, err := d.DecodeValue()
365 | if err != nil {
366 | return err
367 | }
368 |
369 | c, err := conv.ToUUID(val)
370 | if err != nil {
371 | return err
372 | }
373 |
374 | params.ID = c
375 | return nil
376 | }(); err != nil {
377 | return err
378 | }
379 | } else {
380 | return validate.ErrFieldRequired
381 | }
382 | return nil
383 | }(); err != nil {
384 | return params, &ogenerrors.DecodeParamError{
385 | Name: "id",
386 | In: "path",
387 | Err: err,
388 | }
389 | }
390 | return params, nil
391 | }
392 |
--------------------------------------------------------------------------------
/api/openapi/oas_request_decoders_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "io"
7 | "mime"
8 | "net/http"
9 |
10 | "github.com/go-faster/errors"
11 | "github.com/go-faster/jx"
12 | "go.uber.org/multierr"
13 |
14 | "github.com/ogen-go/ogen/ogenerrors"
15 | "github.com/ogen-go/ogen/validate"
16 | )
17 |
18 | func (s *Server) decodeAddPageRequest(r *http.Request) (
19 | req OptAddPageReq,
20 | close func() error,
21 | rerr error,
22 | ) {
23 | var closers []func() error
24 | close = func() error {
25 | var merr error
26 | // Close in reverse order, to match defer behavior.
27 | for i := len(closers) - 1; i >= 0; i-- {
28 | c := closers[i]
29 | merr = multierr.Append(merr, c())
30 | }
31 | return merr
32 | }
33 | defer func() {
34 | if rerr != nil {
35 | rerr = multierr.Append(rerr, close())
36 | }
37 | }()
38 | if _, ok := r.Header["Content-Type"]; !ok && r.ContentLength == 0 {
39 | return req, close, nil
40 | }
41 | ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
42 | if err != nil {
43 | return req, close, errors.Wrap(err, "parse media type")
44 | }
45 | switch {
46 | case ct == "application/json":
47 | if r.ContentLength == 0 {
48 | return req, close, nil
49 | }
50 | buf, err := io.ReadAll(r.Body)
51 | if err != nil {
52 | return req, close, err
53 | }
54 |
55 | if len(buf) == 0 {
56 | return req, close, nil
57 | }
58 |
59 | d := jx.DecodeBytes(buf)
60 |
61 | var request OptAddPageReq
62 | if err := func() error {
63 | request.Reset()
64 | if err := request.Decode(d); err != nil {
65 | return err
66 | }
67 | if err := d.Skip(); err != io.EOF {
68 | return errors.New("unexpected trailing data")
69 | }
70 | return nil
71 | }(); err != nil {
72 | err = &ogenerrors.DecodeBodyError{
73 | ContentType: ct,
74 | Body: buf,
75 | Err: err,
76 | }
77 | return req, close, err
78 | }
79 | if err := func() error {
80 | if value, ok := request.Get(); ok {
81 | if err := func() error {
82 | if err := value.Validate(); err != nil {
83 | return err
84 | }
85 | return nil
86 | }(); err != nil {
87 | return err
88 | }
89 | }
90 | return nil
91 | }(); err != nil {
92 | return req, close, errors.Wrap(err, "validate")
93 | }
94 | return request, close, nil
95 | default:
96 | return req, close, validate.InvalidContentType(ct)
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/api/openapi/oas_request_encoders_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "bytes"
7 | "net/http"
8 |
9 | "github.com/go-faster/jx"
10 |
11 | ht "github.com/ogen-go/ogen/http"
12 | )
13 |
14 | func encodeAddPageRequest(
15 | req OptAddPageReq,
16 | r *http.Request,
17 | ) error {
18 | const contentType = "application/json"
19 | if !req.Set {
20 | // Keep request with empty body if value is not set.
21 | return nil
22 | }
23 | e := new(jx.Encoder)
24 | {
25 | if req.Set {
26 | req.Encode(e)
27 | }
28 | }
29 | encoded := e.Bytes()
30 | ht.SetBody(r, bytes.NewReader(encoded), contentType)
31 | return nil
32 | }
33 |
--------------------------------------------------------------------------------
/api/openapi/oas_response_decoders_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "bytes"
7 | "io"
8 | "mime"
9 | "net/http"
10 |
11 | "github.com/go-faster/errors"
12 | "github.com/go-faster/jx"
13 |
14 | "github.com/ogen-go/ogen/ogenerrors"
15 | "github.com/ogen-go/ogen/validate"
16 | )
17 |
18 | func decodeAddPageResponse(resp *http.Response) (res AddPageRes, _ error) {
19 | switch resp.StatusCode {
20 | case 201:
21 | // Code 201.
22 | ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
23 | if err != nil {
24 | return res, errors.Wrap(err, "parse media type")
25 | }
26 | switch {
27 | case ct == "application/json":
28 | buf, err := io.ReadAll(resp.Body)
29 | if err != nil {
30 | return res, err
31 | }
32 | d := jx.DecodeBytes(buf)
33 |
34 | var response Page
35 | if err := func() error {
36 | if err := response.Decode(d); err != nil {
37 | return err
38 | }
39 | if err := d.Skip(); err != io.EOF {
40 | return errors.New("unexpected trailing data")
41 | }
42 | return nil
43 | }(); err != nil {
44 | err = &ogenerrors.DecodeBodyError{
45 | ContentType: ct,
46 | Body: buf,
47 | Err: err,
48 | }
49 | return res, err
50 | }
51 | return &response, nil
52 | default:
53 | return res, validate.InvalidContentType(ct)
54 | }
55 | case 400:
56 | // Code 400.
57 | ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
58 | if err != nil {
59 | return res, errors.Wrap(err, "parse media type")
60 | }
61 | switch {
62 | case ct == "application/json":
63 | buf, err := io.ReadAll(resp.Body)
64 | if err != nil {
65 | return res, err
66 | }
67 | d := jx.DecodeBytes(buf)
68 |
69 | var response AddPageBadRequest
70 | if err := func() error {
71 | if err := response.Decode(d); err != nil {
72 | return err
73 | }
74 | if err := d.Skip(); err != io.EOF {
75 | return errors.New("unexpected trailing data")
76 | }
77 | return nil
78 | }(); err != nil {
79 | err = &ogenerrors.DecodeBodyError{
80 | ContentType: ct,
81 | Body: buf,
82 | Err: err,
83 | }
84 | return res, err
85 | }
86 | return &response, nil
87 | default:
88 | return res, validate.InvalidContentType(ct)
89 | }
90 | }
91 | // Convenient error response.
92 | defRes, err := func() (res *ErrorStatusCode, err error) {
93 | ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
94 | if err != nil {
95 | return res, errors.Wrap(err, "parse media type")
96 | }
97 | switch {
98 | case ct == "application/json":
99 | buf, err := io.ReadAll(resp.Body)
100 | if err != nil {
101 | return res, err
102 | }
103 | d := jx.DecodeBytes(buf)
104 |
105 | var response Error
106 | if err := func() error {
107 | if err := response.Decode(d); err != nil {
108 | return err
109 | }
110 | if err := d.Skip(); err != io.EOF {
111 | return errors.New("unexpected trailing data")
112 | }
113 | return nil
114 | }(); err != nil {
115 | err = &ogenerrors.DecodeBodyError{
116 | ContentType: ct,
117 | Body: buf,
118 | Err: err,
119 | }
120 | return res, err
121 | }
122 | return &ErrorStatusCode{
123 | StatusCode: resp.StatusCode,
124 | Response: response,
125 | }, nil
126 | default:
127 | return res, validate.InvalidContentType(ct)
128 | }
129 | }()
130 | if err != nil {
131 | return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode)
132 | }
133 | return res, errors.Wrap(defRes, "error")
134 | }
135 |
136 | func decodeGetFileResponse(resp *http.Response) (res GetFileRes, _ error) {
137 | switch resp.StatusCode {
138 | case 200:
139 | // Code 200.
140 | ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
141 | if err != nil {
142 | return res, errors.Wrap(err, "parse media type")
143 | }
144 | switch {
145 | case ct == "application/pdf":
146 | reader := resp.Body
147 | b, err := io.ReadAll(reader)
148 | if err != nil {
149 | return res, err
150 | }
151 |
152 | response := GetFileOKApplicationPdf{Data: bytes.NewReader(b)}
153 | return &response, nil
154 | case ct == "text/html":
155 | reader := resp.Body
156 | b, err := io.ReadAll(reader)
157 | if err != nil {
158 | return res, err
159 | }
160 |
161 | response := GetFileOKTextHTML{Data: bytes.NewReader(b)}
162 | return &response, nil
163 | case ct == "text/plain":
164 | reader := resp.Body
165 | b, err := io.ReadAll(reader)
166 | if err != nil {
167 | return res, err
168 | }
169 |
170 | response := GetFileOKTextPlain{Data: bytes.NewReader(b)}
171 | return &response, nil
172 | default:
173 | return res, validate.InvalidContentType(ct)
174 | }
175 | case 404:
176 | // Code 404.
177 | return &GetFileNotFound{}, nil
178 | }
179 | // Convenient error response.
180 | defRes, err := func() (res *ErrorStatusCode, err error) {
181 | ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
182 | if err != nil {
183 | return res, errors.Wrap(err, "parse media type")
184 | }
185 | switch {
186 | case ct == "application/json":
187 | buf, err := io.ReadAll(resp.Body)
188 | if err != nil {
189 | return res, err
190 | }
191 | d := jx.DecodeBytes(buf)
192 |
193 | var response Error
194 | if err := func() error {
195 | if err := response.Decode(d); err != nil {
196 | return err
197 | }
198 | if err := d.Skip(); err != io.EOF {
199 | return errors.New("unexpected trailing data")
200 | }
201 | return nil
202 | }(); err != nil {
203 | err = &ogenerrors.DecodeBodyError{
204 | ContentType: ct,
205 | Body: buf,
206 | Err: err,
207 | }
208 | return res, err
209 | }
210 | return &ErrorStatusCode{
211 | StatusCode: resp.StatusCode,
212 | Response: response,
213 | }, nil
214 | default:
215 | return res, validate.InvalidContentType(ct)
216 | }
217 | }()
218 | if err != nil {
219 | return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode)
220 | }
221 | return res, errors.Wrap(defRes, "error")
222 | }
223 |
224 | func decodeGetPageResponse(resp *http.Response) (res GetPageRes, _ error) {
225 | switch resp.StatusCode {
226 | case 200:
227 | // Code 200.
228 | ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
229 | if err != nil {
230 | return res, errors.Wrap(err, "parse media type")
231 | }
232 | switch {
233 | case ct == "application/json":
234 | buf, err := io.ReadAll(resp.Body)
235 | if err != nil {
236 | return res, err
237 | }
238 | d := jx.DecodeBytes(buf)
239 |
240 | var response PageWithResults
241 | if err := func() error {
242 | if err := response.Decode(d); err != nil {
243 | return err
244 | }
245 | if err := d.Skip(); err != io.EOF {
246 | return errors.New("unexpected trailing data")
247 | }
248 | return nil
249 | }(); err != nil {
250 | err = &ogenerrors.DecodeBodyError{
251 | ContentType: ct,
252 | Body: buf,
253 | Err: err,
254 | }
255 | return res, err
256 | }
257 | return &response, nil
258 | default:
259 | return res, validate.InvalidContentType(ct)
260 | }
261 | case 404:
262 | // Code 404.
263 | return &GetPageNotFound{}, nil
264 | }
265 | // Convenient error response.
266 | defRes, err := func() (res *ErrorStatusCode, err error) {
267 | ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
268 | if err != nil {
269 | return res, errors.Wrap(err, "parse media type")
270 | }
271 | switch {
272 | case ct == "application/json":
273 | buf, err := io.ReadAll(resp.Body)
274 | if err != nil {
275 | return res, err
276 | }
277 | d := jx.DecodeBytes(buf)
278 |
279 | var response Error
280 | if err := func() error {
281 | if err := response.Decode(d); err != nil {
282 | return err
283 | }
284 | if err := d.Skip(); err != io.EOF {
285 | return errors.New("unexpected trailing data")
286 | }
287 | return nil
288 | }(); err != nil {
289 | err = &ogenerrors.DecodeBodyError{
290 | ContentType: ct,
291 | Body: buf,
292 | Err: err,
293 | }
294 | return res, err
295 | }
296 | return &ErrorStatusCode{
297 | StatusCode: resp.StatusCode,
298 | Response: response,
299 | }, nil
300 | default:
301 | return res, validate.InvalidContentType(ct)
302 | }
303 | }()
304 | if err != nil {
305 | return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode)
306 | }
307 | return res, errors.Wrap(defRes, "error")
308 | }
309 |
310 | func decodeGetPagesResponse(resp *http.Response) (res Pages, _ error) {
311 | switch resp.StatusCode {
312 | case 200:
313 | // Code 200.
314 | ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
315 | if err != nil {
316 | return res, errors.Wrap(err, "parse media type")
317 | }
318 | switch {
319 | case ct == "application/json":
320 | buf, err := io.ReadAll(resp.Body)
321 | if err != nil {
322 | return res, err
323 | }
324 | d := jx.DecodeBytes(buf)
325 |
326 | var response Pages
327 | if err := func() error {
328 | if err := response.Decode(d); err != nil {
329 | return err
330 | }
331 | if err := d.Skip(); err != io.EOF {
332 | return errors.New("unexpected trailing data")
333 | }
334 | return nil
335 | }(); err != nil {
336 | err = &ogenerrors.DecodeBodyError{
337 | ContentType: ct,
338 | Body: buf,
339 | Err: err,
340 | }
341 | return res, err
342 | }
343 | return response, nil
344 | default:
345 | return res, validate.InvalidContentType(ct)
346 | }
347 | }
348 | // Convenient error response.
349 | defRes, err := func() (res *ErrorStatusCode, err error) {
350 | ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
351 | if err != nil {
352 | return res, errors.Wrap(err, "parse media type")
353 | }
354 | switch {
355 | case ct == "application/json":
356 | buf, err := io.ReadAll(resp.Body)
357 | if err != nil {
358 | return res, err
359 | }
360 | d := jx.DecodeBytes(buf)
361 |
362 | var response Error
363 | if err := func() error {
364 | if err := response.Decode(d); err != nil {
365 | return err
366 | }
367 | if err := d.Skip(); err != io.EOF {
368 | return errors.New("unexpected trailing data")
369 | }
370 | return nil
371 | }(); err != nil {
372 | err = &ogenerrors.DecodeBodyError{
373 | ContentType: ct,
374 | Body: buf,
375 | Err: err,
376 | }
377 | return res, err
378 | }
379 | return &ErrorStatusCode{
380 | StatusCode: resp.StatusCode,
381 | Response: response,
382 | }, nil
383 | default:
384 | return res, validate.InvalidContentType(ct)
385 | }
386 | }()
387 | if err != nil {
388 | return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode)
389 | }
390 | return res, errors.Wrap(defRes, "error")
391 | }
392 |
--------------------------------------------------------------------------------
/api/openapi/oas_response_encoders_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "io"
7 | "net/http"
8 |
9 | "github.com/go-faster/errors"
10 | "github.com/go-faster/jx"
11 | "go.opentelemetry.io/otel/codes"
12 | "go.opentelemetry.io/otel/trace"
13 |
14 | ht "github.com/ogen-go/ogen/http"
15 | )
16 |
17 | func encodeAddPageResponse(response AddPageRes, w http.ResponseWriter, span trace.Span) error {
18 | switch response := response.(type) {
19 | case *Page:
20 | w.Header().Set("Content-Type", "application/json")
21 | w.WriteHeader(201)
22 | span.SetStatus(codes.Ok, http.StatusText(201))
23 |
24 | e := new(jx.Encoder)
25 | response.Encode(e)
26 | if _, err := e.WriteTo(w); err != nil {
27 | return errors.Wrap(err, "write")
28 | }
29 |
30 | return nil
31 |
32 | case *AddPageBadRequest:
33 | w.Header().Set("Content-Type", "application/json")
34 | w.WriteHeader(400)
35 | span.SetStatus(codes.Error, http.StatusText(400))
36 |
37 | e := new(jx.Encoder)
38 | response.Encode(e)
39 | if _, err := e.WriteTo(w); err != nil {
40 | return errors.Wrap(err, "write")
41 | }
42 |
43 | return nil
44 |
45 | default:
46 | return errors.Errorf("unexpected response type: %T", response)
47 | }
48 | }
49 |
50 | func encodeGetFileResponse(response GetFileRes, w http.ResponseWriter, span trace.Span) error {
51 | switch response := response.(type) {
52 | case *GetFileOKApplicationPdf:
53 | w.Header().Set("Content-Type", "application/pdf")
54 | w.WriteHeader(200)
55 | span.SetStatus(codes.Ok, http.StatusText(200))
56 |
57 | writer := w
58 | if _, err := io.Copy(writer, response); err != nil {
59 | return errors.Wrap(err, "write")
60 | }
61 |
62 | return nil
63 |
64 | case *GetFileOKTextHTML:
65 | w.Header().Set("Content-Type", "text/html")
66 | w.WriteHeader(200)
67 | span.SetStatus(codes.Ok, http.StatusText(200))
68 |
69 | writer := w
70 | if _, err := io.Copy(writer, response); err != nil {
71 | return errors.Wrap(err, "write")
72 | }
73 |
74 | return nil
75 |
76 | case *GetFileOKTextPlain:
77 | w.Header().Set("Content-Type", "text/plain")
78 | w.WriteHeader(200)
79 | span.SetStatus(codes.Ok, http.StatusText(200))
80 |
81 | writer := w
82 | if _, err := io.Copy(writer, response); err != nil {
83 | return errors.Wrap(err, "write")
84 | }
85 |
86 | return nil
87 |
88 | case *GetFileNotFound:
89 | w.WriteHeader(404)
90 | span.SetStatus(codes.Error, http.StatusText(404))
91 |
92 | return nil
93 |
94 | default:
95 | return errors.Errorf("unexpected response type: %T", response)
96 | }
97 | }
98 |
99 | func encodeGetPageResponse(response GetPageRes, w http.ResponseWriter, span trace.Span) error {
100 | switch response := response.(type) {
101 | case *PageWithResults:
102 | w.Header().Set("Content-Type", "application/json")
103 | w.WriteHeader(200)
104 | span.SetStatus(codes.Ok, http.StatusText(200))
105 |
106 | e := new(jx.Encoder)
107 | response.Encode(e)
108 | if _, err := e.WriteTo(w); err != nil {
109 | return errors.Wrap(err, "write")
110 | }
111 |
112 | return nil
113 |
114 | case *GetPageNotFound:
115 | w.WriteHeader(404)
116 | span.SetStatus(codes.Error, http.StatusText(404))
117 |
118 | return nil
119 |
120 | default:
121 | return errors.Errorf("unexpected response type: %T", response)
122 | }
123 | }
124 |
125 | func encodeGetPagesResponse(response Pages, w http.ResponseWriter, span trace.Span) error {
126 | w.Header().Set("Content-Type", "application/json")
127 | w.WriteHeader(200)
128 | span.SetStatus(codes.Ok, http.StatusText(200))
129 |
130 | e := new(jx.Encoder)
131 | response.Encode(e)
132 | if _, err := e.WriteTo(w); err != nil {
133 | return errors.Wrap(err, "write")
134 | }
135 |
136 | return nil
137 | }
138 |
139 | func encodeErrorResponse(response *ErrorStatusCode, w http.ResponseWriter, span trace.Span) error {
140 | w.Header().Set("Content-Type", "application/json")
141 | code := response.StatusCode
142 | if code == 0 {
143 | // Set default status code.
144 | code = http.StatusOK
145 | }
146 | w.WriteHeader(code)
147 | st := http.StatusText(code)
148 | if code >= http.StatusBadRequest {
149 | span.SetStatus(codes.Error, st)
150 | } else {
151 | span.SetStatus(codes.Ok, st)
152 | }
153 |
154 | e := new(jx.Encoder)
155 | response.Response.Encode(e)
156 | if _, err := e.WriteTo(w); err != nil {
157 | return errors.Wrap(err, "write")
158 | }
159 |
160 | if code >= http.StatusInternalServerError {
161 | return errors.Wrapf(ht.ErrInternalServerErrorResponse, "code: %d, message: %s", code, http.StatusText(code))
162 | }
163 | return nil
164 |
165 | }
166 |
--------------------------------------------------------------------------------
/api/openapi/oas_router_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "net/http"
7 | "net/url"
8 | "strings"
9 |
10 | "github.com/ogen-go/ogen/uri"
11 | )
12 |
13 | func (s *Server) cutPrefix(path string) (string, bool) {
14 | prefix := s.cfg.Prefix
15 | if prefix == "" {
16 | return path, true
17 | }
18 | if !strings.HasPrefix(path, prefix) {
19 | // Prefix doesn't match.
20 | return "", false
21 | }
22 | // Cut prefix from the path.
23 | return strings.TrimPrefix(path, prefix), true
24 | }
25 |
26 | // ServeHTTP serves http request as defined by OpenAPI v3 specification,
27 | // calling handler that matches the path or returning not found error.
28 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
29 | elem := r.URL.Path
30 | elemIsEscaped := false
31 | if rawPath := r.URL.RawPath; rawPath != "" {
32 | if normalized, ok := uri.NormalizeEscapedPath(rawPath); ok {
33 | elem = normalized
34 | elemIsEscaped = strings.ContainsRune(elem, '%')
35 | }
36 | }
37 |
38 | elem, ok := s.cutPrefix(elem)
39 | if !ok || len(elem) == 0 {
40 | s.notFound(w, r)
41 | return
42 | }
43 | args := [2]string{}
44 |
45 | // Static code generated router with unwrapped path search.
46 | switch {
47 | default:
48 | if len(elem) == 0 {
49 | break
50 | }
51 | switch elem[0] {
52 | case '/': // Prefix: "/pages"
53 | if l := len("/pages"); len(elem) >= l && elem[0:l] == "/pages" {
54 | elem = elem[l:]
55 | } else {
56 | break
57 | }
58 |
59 | if len(elem) == 0 {
60 | switch r.Method {
61 | case "GET":
62 | s.handleGetPagesRequest([0]string{}, elemIsEscaped, w, r)
63 | case "POST":
64 | s.handleAddPageRequest([0]string{}, elemIsEscaped, w, r)
65 | default:
66 | s.notAllowed(w, r, "GET,POST")
67 | }
68 |
69 | return
70 | }
71 | switch elem[0] {
72 | case '/': // Prefix: "/"
73 | if l := len("/"); len(elem) >= l && elem[0:l] == "/" {
74 | elem = elem[l:]
75 | } else {
76 | break
77 | }
78 |
79 | // Param: "id"
80 | // Match until "/"
81 | idx := strings.IndexByte(elem, '/')
82 | if idx < 0 {
83 | idx = len(elem)
84 | }
85 | args[0] = elem[:idx]
86 | elem = elem[idx:]
87 |
88 | if len(elem) == 0 {
89 | switch r.Method {
90 | case "GET":
91 | s.handleGetPageRequest([1]string{
92 | args[0],
93 | }, elemIsEscaped, w, r)
94 | default:
95 | s.notAllowed(w, r, "GET")
96 | }
97 |
98 | return
99 | }
100 | switch elem[0] {
101 | case '/': // Prefix: "/file/"
102 | if l := len("/file/"); len(elem) >= l && elem[0:l] == "/file/" {
103 | elem = elem[l:]
104 | } else {
105 | break
106 | }
107 |
108 | // Param: "file_id"
109 | // Leaf parameter
110 | args[1] = elem
111 | elem = ""
112 |
113 | if len(elem) == 0 {
114 | // Leaf node.
115 | switch r.Method {
116 | case "GET":
117 | s.handleGetFileRequest([2]string{
118 | args[0],
119 | args[1],
120 | }, elemIsEscaped, w, r)
121 | default:
122 | s.notAllowed(w, r, "GET")
123 | }
124 |
125 | return
126 | }
127 | }
128 | }
129 | }
130 | }
131 | s.notFound(w, r)
132 | }
133 |
134 | // Route is route object.
135 | type Route struct {
136 | name string
137 | summary string
138 | operationID string
139 | pathPattern string
140 | count int
141 | args [2]string
142 | }
143 |
144 | // Name returns ogen operation name.
145 | //
146 | // It is guaranteed to be unique and not empty.
147 | func (r Route) Name() string {
148 | return r.name
149 | }
150 |
151 | // Summary returns OpenAPI summary.
152 | func (r Route) Summary() string {
153 | return r.summary
154 | }
155 |
156 | // OperationID returns OpenAPI operationId.
157 | func (r Route) OperationID() string {
158 | return r.operationID
159 | }
160 |
161 | // PathPattern returns OpenAPI path.
162 | func (r Route) PathPattern() string {
163 | return r.pathPattern
164 | }
165 |
166 | // Args returns parsed arguments.
167 | func (r Route) Args() []string {
168 | return r.args[:r.count]
169 | }
170 |
171 | // FindRoute finds Route for given method and path.
172 | //
173 | // Note: this method does not unescape path or handle reserved characters in path properly. Use FindPath instead.
174 | func (s *Server) FindRoute(method, path string) (Route, bool) {
175 | return s.FindPath(method, &url.URL{Path: path})
176 | }
177 |
178 | // FindPath finds Route for given method and URL.
179 | func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) {
180 | var (
181 | elem = u.Path
182 | args = r.args
183 | )
184 | if rawPath := u.RawPath; rawPath != "" {
185 | if normalized, ok := uri.NormalizeEscapedPath(rawPath); ok {
186 | elem = normalized
187 | }
188 | defer func() {
189 | for i, arg := range r.args[:r.count] {
190 | if unescaped, err := url.PathUnescape(arg); err == nil {
191 | r.args[i] = unescaped
192 | }
193 | }
194 | }()
195 | }
196 |
197 | elem, ok := s.cutPrefix(elem)
198 | if !ok {
199 | return r, false
200 | }
201 |
202 | // Static code generated router with unwrapped path search.
203 | switch {
204 | default:
205 | if len(elem) == 0 {
206 | break
207 | }
208 | switch elem[0] {
209 | case '/': // Prefix: "/pages"
210 | if l := len("/pages"); len(elem) >= l && elem[0:l] == "/pages" {
211 | elem = elem[l:]
212 | } else {
213 | break
214 | }
215 |
216 | if len(elem) == 0 {
217 | switch method {
218 | case "GET":
219 | r.name = "GetPages"
220 | r.summary = "Get all pages"
221 | r.operationID = "getPages"
222 | r.pathPattern = "/pages"
223 | r.args = args
224 | r.count = 0
225 | return r, true
226 | case "POST":
227 | r.name = "AddPage"
228 | r.summary = "Add new page"
229 | r.operationID = "addPage"
230 | r.pathPattern = "/pages"
231 | r.args = args
232 | r.count = 0
233 | return r, true
234 | default:
235 | return
236 | }
237 | }
238 | switch elem[0] {
239 | case '/': // Prefix: "/"
240 | if l := len("/"); len(elem) >= l && elem[0:l] == "/" {
241 | elem = elem[l:]
242 | } else {
243 | break
244 | }
245 |
246 | // Param: "id"
247 | // Match until "/"
248 | idx := strings.IndexByte(elem, '/')
249 | if idx < 0 {
250 | idx = len(elem)
251 | }
252 | args[0] = elem[:idx]
253 | elem = elem[idx:]
254 |
255 | if len(elem) == 0 {
256 | switch method {
257 | case "GET":
258 | r.name = "GetPage"
259 | r.summary = ""
260 | r.operationID = "getPage"
261 | r.pathPattern = "/pages/{id}"
262 | r.args = args
263 | r.count = 1
264 | return r, true
265 | default:
266 | return
267 | }
268 | }
269 | switch elem[0] {
270 | case '/': // Prefix: "/file/"
271 | if l := len("/file/"); len(elem) >= l && elem[0:l] == "/file/" {
272 | elem = elem[l:]
273 | } else {
274 | break
275 | }
276 |
277 | // Param: "file_id"
278 | // Leaf parameter
279 | args[1] = elem
280 | elem = ""
281 |
282 | if len(elem) == 0 {
283 | switch method {
284 | case "GET":
285 | // Leaf: GetFile
286 | r.name = "GetFile"
287 | r.summary = ""
288 | r.operationID = "getFile"
289 | r.pathPattern = "/pages/{id}/file/{file_id}"
290 | r.args = args
291 | r.count = 2
292 | return r, true
293 | default:
294 | return
295 | }
296 | }
297 | }
298 | }
299 | }
300 | }
301 | return r, false
302 | }
303 |
--------------------------------------------------------------------------------
/api/openapi/oas_schemas_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "fmt"
7 | "io"
8 | "time"
9 |
10 | "github.com/go-faster/errors"
11 | "github.com/google/uuid"
12 | )
13 |
14 | func (s *ErrorStatusCode) Error() string {
15 | return fmt.Sprintf("code %d: %+v", s.StatusCode, s.Response)
16 | }
17 |
18 | type AddPageBadRequest struct {
19 | Field string `json:"field"`
20 | Error string `json:"error"`
21 | }
22 |
23 | // GetField returns the value of Field.
24 | func (s *AddPageBadRequest) GetField() string {
25 | return s.Field
26 | }
27 |
28 | // GetError returns the value of Error.
29 | func (s *AddPageBadRequest) GetError() string {
30 | return s.Error
31 | }
32 |
33 | // SetField sets the value of Field.
34 | func (s *AddPageBadRequest) SetField(val string) {
35 | s.Field = val
36 | }
37 |
38 | // SetError sets the value of Error.
39 | func (s *AddPageBadRequest) SetError(val string) {
40 | s.Error = val
41 | }
42 |
43 | func (*AddPageBadRequest) addPageRes() {}
44 |
45 | type AddPageReq struct {
46 | URL string `json:"url"`
47 | Description OptString `json:"description"`
48 | Formats []Format `json:"formats"`
49 | }
50 |
51 | // GetURL returns the value of URL.
52 | func (s *AddPageReq) GetURL() string {
53 | return s.URL
54 | }
55 |
56 | // GetDescription returns the value of Description.
57 | func (s *AddPageReq) GetDescription() OptString {
58 | return s.Description
59 | }
60 |
61 | // GetFormats returns the value of Formats.
62 | func (s *AddPageReq) GetFormats() []Format {
63 | return s.Formats
64 | }
65 |
66 | // SetURL sets the value of URL.
67 | func (s *AddPageReq) SetURL(val string) {
68 | s.URL = val
69 | }
70 |
71 | // SetDescription sets the value of Description.
72 | func (s *AddPageReq) SetDescription(val OptString) {
73 | s.Description = val
74 | }
75 |
76 | // SetFormats sets the value of Formats.
77 | func (s *AddPageReq) SetFormats(val []Format) {
78 | s.Formats = val
79 | }
80 |
81 | // Ref: #/components/schemas/error
82 | type Error struct {
83 | Message string `json:"message"`
84 | Localized OptString `json:"localized"`
85 | }
86 |
87 | // GetMessage returns the value of Message.
88 | func (s *Error) GetMessage() string {
89 | return s.Message
90 | }
91 |
92 | // GetLocalized returns the value of Localized.
93 | func (s *Error) GetLocalized() OptString {
94 | return s.Localized
95 | }
96 |
97 | // SetMessage sets the value of Message.
98 | func (s *Error) SetMessage(val string) {
99 | s.Message = val
100 | }
101 |
102 | // SetLocalized sets the value of Localized.
103 | func (s *Error) SetLocalized(val OptString) {
104 | s.Localized = val
105 | }
106 |
107 | // ErrorStatusCode wraps Error with StatusCode.
108 | type ErrorStatusCode struct {
109 | StatusCode int
110 | Response Error
111 | }
112 |
113 | // GetStatusCode returns the value of StatusCode.
114 | func (s *ErrorStatusCode) GetStatusCode() int {
115 | return s.StatusCode
116 | }
117 |
118 | // GetResponse returns the value of Response.
119 | func (s *ErrorStatusCode) GetResponse() Error {
120 | return s.Response
121 | }
122 |
123 | // SetStatusCode sets the value of StatusCode.
124 | func (s *ErrorStatusCode) SetStatusCode(val int) {
125 | s.StatusCode = val
126 | }
127 |
128 | // SetResponse sets the value of Response.
129 | func (s *ErrorStatusCode) SetResponse(val Error) {
130 | s.Response = val
131 | }
132 |
133 | // Ref: #/components/schemas/format
134 | type Format string
135 |
136 | const (
137 | FormatAll Format = "all"
138 | FormatPdf Format = "pdf"
139 | FormatSingleFile Format = "single_file"
140 | FormatHeaders Format = "headers"
141 | )
142 |
143 | // AllValues returns all Format values.
144 | func (Format) AllValues() []Format {
145 | return []Format{
146 | FormatAll,
147 | FormatPdf,
148 | FormatSingleFile,
149 | FormatHeaders,
150 | }
151 | }
152 |
153 | // MarshalText implements encoding.TextMarshaler.
154 | func (s Format) MarshalText() ([]byte, error) {
155 | switch s {
156 | case FormatAll:
157 | return []byte(s), nil
158 | case FormatPdf:
159 | return []byte(s), nil
160 | case FormatSingleFile:
161 | return []byte(s), nil
162 | case FormatHeaders:
163 | return []byte(s), nil
164 | default:
165 | return nil, errors.Errorf("invalid value: %q", s)
166 | }
167 | }
168 |
169 | // UnmarshalText implements encoding.TextUnmarshaler.
170 | func (s *Format) UnmarshalText(data []byte) error {
171 | switch Format(data) {
172 | case FormatAll:
173 | *s = FormatAll
174 | return nil
175 | case FormatPdf:
176 | *s = FormatPdf
177 | return nil
178 | case FormatSingleFile:
179 | *s = FormatSingleFile
180 | return nil
181 | case FormatHeaders:
182 | *s = FormatHeaders
183 | return nil
184 | default:
185 | return errors.Errorf("invalid value: %q", data)
186 | }
187 | }
188 |
189 | // GetFileNotFound is response for GetFile operation.
190 | type GetFileNotFound struct{}
191 |
192 | func (*GetFileNotFound) getFileRes() {}
193 |
194 | type GetFileOKApplicationPdf struct {
195 | Data io.Reader
196 | }
197 |
198 | // Read reads data from the Data reader.
199 | //
200 | // Kept to satisfy the io.Reader interface.
201 | func (s GetFileOKApplicationPdf) Read(p []byte) (n int, err error) {
202 | if s.Data == nil {
203 | return 0, io.EOF
204 | }
205 | return s.Data.Read(p)
206 | }
207 |
208 | func (*GetFileOKApplicationPdf) getFileRes() {}
209 |
210 | type GetFileOKTextHTML struct {
211 | Data io.Reader
212 | }
213 |
214 | // Read reads data from the Data reader.
215 | //
216 | // Kept to satisfy the io.Reader interface.
217 | func (s GetFileOKTextHTML) Read(p []byte) (n int, err error) {
218 | if s.Data == nil {
219 | return 0, io.EOF
220 | }
221 | return s.Data.Read(p)
222 | }
223 |
224 | func (*GetFileOKTextHTML) getFileRes() {}
225 |
226 | type GetFileOKTextPlain struct {
227 | Data io.Reader
228 | }
229 |
230 | // Read reads data from the Data reader.
231 | //
232 | // Kept to satisfy the io.Reader interface.
233 | func (s GetFileOKTextPlain) Read(p []byte) (n int, err error) {
234 | if s.Data == nil {
235 | return 0, io.EOF
236 | }
237 | return s.Data.Read(p)
238 | }
239 |
240 | func (*GetFileOKTextPlain) getFileRes() {}
241 |
242 | // GetPageNotFound is response for GetPage operation.
243 | type GetPageNotFound struct{}
244 |
245 | func (*GetPageNotFound) getPageRes() {}
246 |
247 | // NewOptAddPageReq returns new OptAddPageReq with value set to v.
248 | func NewOptAddPageReq(v AddPageReq) OptAddPageReq {
249 | return OptAddPageReq{
250 | Value: v,
251 | Set: true,
252 | }
253 | }
254 |
255 | // OptAddPageReq is optional AddPageReq.
256 | type OptAddPageReq struct {
257 | Value AddPageReq
258 | Set bool
259 | }
260 |
261 | // IsSet returns true if OptAddPageReq was set.
262 | func (o OptAddPageReq) IsSet() bool { return o.Set }
263 |
264 | // Reset unsets value.
265 | func (o *OptAddPageReq) Reset() {
266 | var v AddPageReq
267 | o.Value = v
268 | o.Set = false
269 | }
270 |
271 | // SetTo sets value to v.
272 | func (o *OptAddPageReq) SetTo(v AddPageReq) {
273 | o.Set = true
274 | o.Value = v
275 | }
276 |
277 | // Get returns value and boolean that denotes whether value was set.
278 | func (o OptAddPageReq) Get() (v AddPageReq, ok bool) {
279 | if !o.Set {
280 | return v, false
281 | }
282 | return o.Value, true
283 | }
284 |
285 | // Or returns value if set, or given parameter if does not.
286 | func (o OptAddPageReq) Or(d AddPageReq) AddPageReq {
287 | if v, ok := o.Get(); ok {
288 | return v
289 | }
290 | return d
291 | }
292 |
293 | // NewOptString returns new OptString with value set to v.
294 | func NewOptString(v string) OptString {
295 | return OptString{
296 | Value: v,
297 | Set: true,
298 | }
299 | }
300 |
301 | // OptString is optional string.
302 | type OptString struct {
303 | Value string
304 | Set bool
305 | }
306 |
307 | // IsSet returns true if OptString was set.
308 | func (o OptString) IsSet() bool { return o.Set }
309 |
310 | // Reset unsets value.
311 | func (o *OptString) Reset() {
312 | var v string
313 | o.Value = v
314 | o.Set = false
315 | }
316 |
317 | // SetTo sets value to v.
318 | func (o *OptString) SetTo(v string) {
319 | o.Set = true
320 | o.Value = v
321 | }
322 |
323 | // Get returns value and boolean that denotes whether value was set.
324 | func (o OptString) Get() (v string, ok bool) {
325 | if !o.Set {
326 | return v, false
327 | }
328 | return o.Value, true
329 | }
330 |
331 | // Or returns value if set, or given parameter if does not.
332 | func (o OptString) Or(d string) string {
333 | if v, ok := o.Get(); ok {
334 | return v
335 | }
336 | return d
337 | }
338 |
339 | // Ref: #/components/schemas/page
340 | type Page struct {
341 | ID uuid.UUID `json:"id"`
342 | URL string `json:"url"`
343 | Created time.Time `json:"created"`
344 | Formats []Format `json:"formats"`
345 | Status Status `json:"status"`
346 | Meta PageMeta `json:"meta"`
347 | }
348 |
349 | // GetID returns the value of ID.
350 | func (s *Page) GetID() uuid.UUID {
351 | return s.ID
352 | }
353 |
354 | // GetURL returns the value of URL.
355 | func (s *Page) GetURL() string {
356 | return s.URL
357 | }
358 |
359 | // GetCreated returns the value of Created.
360 | func (s *Page) GetCreated() time.Time {
361 | return s.Created
362 | }
363 |
364 | // GetFormats returns the value of Formats.
365 | func (s *Page) GetFormats() []Format {
366 | return s.Formats
367 | }
368 |
369 | // GetStatus returns the value of Status.
370 | func (s *Page) GetStatus() Status {
371 | return s.Status
372 | }
373 |
374 | // GetMeta returns the value of Meta.
375 | func (s *Page) GetMeta() PageMeta {
376 | return s.Meta
377 | }
378 |
379 | // SetID sets the value of ID.
380 | func (s *Page) SetID(val uuid.UUID) {
381 | s.ID = val
382 | }
383 |
384 | // SetURL sets the value of URL.
385 | func (s *Page) SetURL(val string) {
386 | s.URL = val
387 | }
388 |
389 | // SetCreated sets the value of Created.
390 | func (s *Page) SetCreated(val time.Time) {
391 | s.Created = val
392 | }
393 |
394 | // SetFormats sets the value of Formats.
395 | func (s *Page) SetFormats(val []Format) {
396 | s.Formats = val
397 | }
398 |
399 | // SetStatus sets the value of Status.
400 | func (s *Page) SetStatus(val Status) {
401 | s.Status = val
402 | }
403 |
404 | // SetMeta sets the value of Meta.
405 | func (s *Page) SetMeta(val PageMeta) {
406 | s.Meta = val
407 | }
408 |
409 | func (*Page) addPageRes() {}
410 |
411 | type PageMeta struct {
412 | Title string `json:"title"`
413 | Description string `json:"description"`
414 | Error OptString `json:"error"`
415 | }
416 |
417 | // GetTitle returns the value of Title.
418 | func (s *PageMeta) GetTitle() string {
419 | return s.Title
420 | }
421 |
422 | // GetDescription returns the value of Description.
423 | func (s *PageMeta) GetDescription() string {
424 | return s.Description
425 | }
426 |
427 | // GetError returns the value of Error.
428 | func (s *PageMeta) GetError() OptString {
429 | return s.Error
430 | }
431 |
432 | // SetTitle sets the value of Title.
433 | func (s *PageMeta) SetTitle(val string) {
434 | s.Title = val
435 | }
436 |
437 | // SetDescription sets the value of Description.
438 | func (s *PageMeta) SetDescription(val string) {
439 | s.Description = val
440 | }
441 |
442 | // SetError sets the value of Error.
443 | func (s *PageMeta) SetError(val OptString) {
444 | s.Error = val
445 | }
446 |
447 | // Merged schema.
448 | // Ref: #/components/schemas/pageWithResults
449 | type PageWithResults struct {
450 | ID uuid.UUID `json:"id"`
451 | URL string `json:"url"`
452 | Created time.Time `json:"created"`
453 | Formats []Format `json:"formats"`
454 | Status Status `json:"status"`
455 | Meta PageWithResultsMeta `json:"meta"`
456 | Results []Result `json:"results"`
457 | }
458 |
459 | // GetID returns the value of ID.
460 | func (s *PageWithResults) GetID() uuid.UUID {
461 | return s.ID
462 | }
463 |
464 | // GetURL returns the value of URL.
465 | func (s *PageWithResults) GetURL() string {
466 | return s.URL
467 | }
468 |
469 | // GetCreated returns the value of Created.
470 | func (s *PageWithResults) GetCreated() time.Time {
471 | return s.Created
472 | }
473 |
474 | // GetFormats returns the value of Formats.
475 | func (s *PageWithResults) GetFormats() []Format {
476 | return s.Formats
477 | }
478 |
479 | // GetStatus returns the value of Status.
480 | func (s *PageWithResults) GetStatus() Status {
481 | return s.Status
482 | }
483 |
484 | // GetMeta returns the value of Meta.
485 | func (s *PageWithResults) GetMeta() PageWithResultsMeta {
486 | return s.Meta
487 | }
488 |
489 | // GetResults returns the value of Results.
490 | func (s *PageWithResults) GetResults() []Result {
491 | return s.Results
492 | }
493 |
494 | // SetID sets the value of ID.
495 | func (s *PageWithResults) SetID(val uuid.UUID) {
496 | s.ID = val
497 | }
498 |
499 | // SetURL sets the value of URL.
500 | func (s *PageWithResults) SetURL(val string) {
501 | s.URL = val
502 | }
503 |
504 | // SetCreated sets the value of Created.
505 | func (s *PageWithResults) SetCreated(val time.Time) {
506 | s.Created = val
507 | }
508 |
509 | // SetFormats sets the value of Formats.
510 | func (s *PageWithResults) SetFormats(val []Format) {
511 | s.Formats = val
512 | }
513 |
514 | // SetStatus sets the value of Status.
515 | func (s *PageWithResults) SetStatus(val Status) {
516 | s.Status = val
517 | }
518 |
519 | // SetMeta sets the value of Meta.
520 | func (s *PageWithResults) SetMeta(val PageWithResultsMeta) {
521 | s.Meta = val
522 | }
523 |
524 | // SetResults sets the value of Results.
525 | func (s *PageWithResults) SetResults(val []Result) {
526 | s.Results = val
527 | }
528 |
529 | func (*PageWithResults) getPageRes() {}
530 |
531 | type PageWithResultsMeta struct {
532 | Title string `json:"title"`
533 | Description string `json:"description"`
534 | Error OptString `json:"error"`
535 | }
536 |
537 | // GetTitle returns the value of Title.
538 | func (s *PageWithResultsMeta) GetTitle() string {
539 | return s.Title
540 | }
541 |
542 | // GetDescription returns the value of Description.
543 | func (s *PageWithResultsMeta) GetDescription() string {
544 | return s.Description
545 | }
546 |
547 | // GetError returns the value of Error.
548 | func (s *PageWithResultsMeta) GetError() OptString {
549 | return s.Error
550 | }
551 |
552 | // SetTitle sets the value of Title.
553 | func (s *PageWithResultsMeta) SetTitle(val string) {
554 | s.Title = val
555 | }
556 |
557 | // SetDescription sets the value of Description.
558 | func (s *PageWithResultsMeta) SetDescription(val string) {
559 | s.Description = val
560 | }
561 |
562 | // SetError sets the value of Error.
563 | func (s *PageWithResultsMeta) SetError(val OptString) {
564 | s.Error = val
565 | }
566 |
567 | type Pages []Page
568 |
569 | // Ref: #/components/schemas/result
570 | type Result struct {
571 | Format Format `json:"format"`
572 | Error OptString `json:"error"`
573 | Files []ResultFilesItem `json:"files"`
574 | }
575 |
576 | // GetFormat returns the value of Format.
577 | func (s *Result) GetFormat() Format {
578 | return s.Format
579 | }
580 |
581 | // GetError returns the value of Error.
582 | func (s *Result) GetError() OptString {
583 | return s.Error
584 | }
585 |
586 | // GetFiles returns the value of Files.
587 | func (s *Result) GetFiles() []ResultFilesItem {
588 | return s.Files
589 | }
590 |
591 | // SetFormat sets the value of Format.
592 | func (s *Result) SetFormat(val Format) {
593 | s.Format = val
594 | }
595 |
596 | // SetError sets the value of Error.
597 | func (s *Result) SetError(val OptString) {
598 | s.Error = val
599 | }
600 |
601 | // SetFiles sets the value of Files.
602 | func (s *Result) SetFiles(val []ResultFilesItem) {
603 | s.Files = val
604 | }
605 |
606 | type ResultFilesItem struct {
607 | ID uuid.UUID `json:"id"`
608 | Name string `json:"name"`
609 | Mimetype string `json:"mimetype"`
610 | Size int64 `json:"size"`
611 | }
612 |
613 | // GetID returns the value of ID.
614 | func (s *ResultFilesItem) GetID() uuid.UUID {
615 | return s.ID
616 | }
617 |
618 | // GetName returns the value of Name.
619 | func (s *ResultFilesItem) GetName() string {
620 | return s.Name
621 | }
622 |
623 | // GetMimetype returns the value of Mimetype.
624 | func (s *ResultFilesItem) GetMimetype() string {
625 | return s.Mimetype
626 | }
627 |
628 | // GetSize returns the value of Size.
629 | func (s *ResultFilesItem) GetSize() int64 {
630 | return s.Size
631 | }
632 |
633 | // SetID sets the value of ID.
634 | func (s *ResultFilesItem) SetID(val uuid.UUID) {
635 | s.ID = val
636 | }
637 |
638 | // SetName sets the value of Name.
639 | func (s *ResultFilesItem) SetName(val string) {
640 | s.Name = val
641 | }
642 |
643 | // SetMimetype sets the value of Mimetype.
644 | func (s *ResultFilesItem) SetMimetype(val string) {
645 | s.Mimetype = val
646 | }
647 |
648 | // SetSize sets the value of Size.
649 | func (s *ResultFilesItem) SetSize(val int64) {
650 | s.Size = val
651 | }
652 |
653 | // Ref: #/components/schemas/status
654 | type Status string
655 |
656 | const (
657 | StatusNew Status = "new"
658 | StatusProcessing Status = "processing"
659 | StatusDone Status = "done"
660 | StatusFailed Status = "failed"
661 | StatusWithErrors Status = "with_errors"
662 | )
663 |
664 | // AllValues returns all Status values.
665 | func (Status) AllValues() []Status {
666 | return []Status{
667 | StatusNew,
668 | StatusProcessing,
669 | StatusDone,
670 | StatusFailed,
671 | StatusWithErrors,
672 | }
673 | }
674 |
675 | // MarshalText implements encoding.TextMarshaler.
676 | func (s Status) MarshalText() ([]byte, error) {
677 | switch s {
678 | case StatusNew:
679 | return []byte(s), nil
680 | case StatusProcessing:
681 | return []byte(s), nil
682 | case StatusDone:
683 | return []byte(s), nil
684 | case StatusFailed:
685 | return []byte(s), nil
686 | case StatusWithErrors:
687 | return []byte(s), nil
688 | default:
689 | return nil, errors.Errorf("invalid value: %q", s)
690 | }
691 | }
692 |
693 | // UnmarshalText implements encoding.TextUnmarshaler.
694 | func (s *Status) UnmarshalText(data []byte) error {
695 | switch Status(data) {
696 | case StatusNew:
697 | *s = StatusNew
698 | return nil
699 | case StatusProcessing:
700 | *s = StatusProcessing
701 | return nil
702 | case StatusDone:
703 | *s = StatusDone
704 | return nil
705 | case StatusFailed:
706 | *s = StatusFailed
707 | return nil
708 | case StatusWithErrors:
709 | *s = StatusWithErrors
710 | return nil
711 | default:
712 | return errors.Errorf("invalid value: %q", data)
713 | }
714 | }
715 |
--------------------------------------------------------------------------------
/api/openapi/oas_server_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "context"
7 | )
8 |
9 | // Handler handles operations described by OpenAPI v3 specification.
10 | type Handler interface {
11 | // AddPage implements addPage operation.
12 | //
13 | // Add new page.
14 | //
15 | // POST /pages
16 | AddPage(ctx context.Context, req OptAddPageReq, params AddPageParams) (AddPageRes, error)
17 | // GetFile implements getFile operation.
18 | //
19 | // Get file content.
20 | //
21 | // GET /pages/{id}/file/{file_id}
22 | GetFile(ctx context.Context, params GetFileParams) (GetFileRes, error)
23 | // GetPage implements getPage operation.
24 | //
25 | // Get page details.
26 | //
27 | // GET /pages/{id}
28 | GetPage(ctx context.Context, params GetPageParams) (GetPageRes, error)
29 | // GetPages implements getPages operation.
30 | //
31 | // Get all pages.
32 | //
33 | // GET /pages
34 | GetPages(ctx context.Context) (Pages, error)
35 | // NewError creates *ErrorStatusCode from error returned by handler.
36 | //
37 | // Used for common default response.
38 | NewError(ctx context.Context, err error) *ErrorStatusCode
39 | }
40 |
41 | // Server implements http server based on OpenAPI v3 specification and
42 | // calls Handler to handle requests.
43 | type Server struct {
44 | h Handler
45 | baseServer
46 | }
47 |
48 | // NewServer creates new Server.
49 | func NewServer(h Handler, opts ...ServerOption) (*Server, error) {
50 | s, err := newServerConfig(opts...).baseServer()
51 | if err != nil {
52 | return nil, err
53 | }
54 | return &Server{
55 | h: h,
56 | baseServer: s,
57 | }, nil
58 | }
59 |
--------------------------------------------------------------------------------
/api/openapi/oas_unimplemented_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "context"
7 |
8 | ht "github.com/ogen-go/ogen/http"
9 | )
10 |
11 | // UnimplementedHandler is no-op Handler which returns http.ErrNotImplemented.
12 | type UnimplementedHandler struct{}
13 |
14 | var _ Handler = UnimplementedHandler{}
15 |
16 | // AddPage implements addPage operation.
17 | //
18 | // Add new page.
19 | //
20 | // POST /pages
21 | func (UnimplementedHandler) AddPage(ctx context.Context, req OptAddPageReq, params AddPageParams) (r AddPageRes, _ error) {
22 | return r, ht.ErrNotImplemented
23 | }
24 |
25 | // GetFile implements getFile operation.
26 | //
27 | // Get file content.
28 | //
29 | // GET /pages/{id}/file/{file_id}
30 | func (UnimplementedHandler) GetFile(ctx context.Context, params GetFileParams) (r GetFileRes, _ error) {
31 | return r, ht.ErrNotImplemented
32 | }
33 |
34 | // GetPage implements getPage operation.
35 | //
36 | // Get page details.
37 | //
38 | // GET /pages/{id}
39 | func (UnimplementedHandler) GetPage(ctx context.Context, params GetPageParams) (r GetPageRes, _ error) {
40 | return r, ht.ErrNotImplemented
41 | }
42 |
43 | // GetPages implements getPages operation.
44 | //
45 | // Get all pages.
46 | //
47 | // GET /pages
48 | func (UnimplementedHandler) GetPages(ctx context.Context) (r Pages, _ error) {
49 | return r, ht.ErrNotImplemented
50 | }
51 |
52 | // NewError creates *ErrorStatusCode from error returned by handler.
53 | //
54 | // Used for common default response.
55 | func (UnimplementedHandler) NewError(ctx context.Context, err error) (r *ErrorStatusCode) {
56 | r = new(ErrorStatusCode)
57 | return r
58 | }
59 |
--------------------------------------------------------------------------------
/api/openapi/oas_validators_gen.go:
--------------------------------------------------------------------------------
1 | // Code generated by ogen, DO NOT EDIT.
2 |
3 | package openapi
4 |
5 | import (
6 | "fmt"
7 |
8 | "github.com/go-faster/errors"
9 |
10 | "github.com/ogen-go/ogen/validate"
11 | )
12 |
13 | func (s *AddPageReq) Validate() error {
14 | var failures []validate.FieldError
15 | if err := func() error {
16 | var failures []validate.FieldError
17 | for i, elem := range s.Formats {
18 | if err := func() error {
19 | if err := elem.Validate(); err != nil {
20 | return err
21 | }
22 | return nil
23 | }(); err != nil {
24 | failures = append(failures, validate.FieldError{
25 | Name: fmt.Sprintf("[%d]", i),
26 | Error: err,
27 | })
28 | }
29 | }
30 | if len(failures) > 0 {
31 | return &validate.Error{Fields: failures}
32 | }
33 | return nil
34 | }(); err != nil {
35 | failures = append(failures, validate.FieldError{
36 | Name: "formats",
37 | Error: err,
38 | })
39 | }
40 | if len(failures) > 0 {
41 | return &validate.Error{Fields: failures}
42 | }
43 | return nil
44 | }
45 |
46 | func (s Format) Validate() error {
47 | switch s {
48 | case "all":
49 | return nil
50 | case "pdf":
51 | return nil
52 | case "single_file":
53 | return nil
54 | case "headers":
55 | return nil
56 | default:
57 | return errors.Errorf("invalid value: %v", s)
58 | }
59 | }
60 |
61 | func (s *Page) Validate() error {
62 | var failures []validate.FieldError
63 | if err := func() error {
64 | if s.Formats == nil {
65 | return errors.New("nil is invalid value")
66 | }
67 | var failures []validate.FieldError
68 | for i, elem := range s.Formats {
69 | if err := func() error {
70 | if err := elem.Validate(); err != nil {
71 | return err
72 | }
73 | return nil
74 | }(); err != nil {
75 | failures = append(failures, validate.FieldError{
76 | Name: fmt.Sprintf("[%d]", i),
77 | Error: err,
78 | })
79 | }
80 | }
81 | if len(failures) > 0 {
82 | return &validate.Error{Fields: failures}
83 | }
84 | return nil
85 | }(); err != nil {
86 | failures = append(failures, validate.FieldError{
87 | Name: "formats",
88 | Error: err,
89 | })
90 | }
91 | if err := func() error {
92 | if err := s.Status.Validate(); err != nil {
93 | return err
94 | }
95 | return nil
96 | }(); err != nil {
97 | failures = append(failures, validate.FieldError{
98 | Name: "status",
99 | Error: err,
100 | })
101 | }
102 | if len(failures) > 0 {
103 | return &validate.Error{Fields: failures}
104 | }
105 | return nil
106 | }
107 |
108 | func (s *PageWithResults) Validate() error {
109 | var failures []validate.FieldError
110 | if err := func() error {
111 | if s.Formats == nil {
112 | return errors.New("nil is invalid value")
113 | }
114 | var failures []validate.FieldError
115 | for i, elem := range s.Formats {
116 | if err := func() error {
117 | if err := elem.Validate(); err != nil {
118 | return err
119 | }
120 | return nil
121 | }(); err != nil {
122 | failures = append(failures, validate.FieldError{
123 | Name: fmt.Sprintf("[%d]", i),
124 | Error: err,
125 | })
126 | }
127 | }
128 | if len(failures) > 0 {
129 | return &validate.Error{Fields: failures}
130 | }
131 | return nil
132 | }(); err != nil {
133 | failures = append(failures, validate.FieldError{
134 | Name: "formats",
135 | Error: err,
136 | })
137 | }
138 | if err := func() error {
139 | if err := s.Status.Validate(); err != nil {
140 | return err
141 | }
142 | return nil
143 | }(); err != nil {
144 | failures = append(failures, validate.FieldError{
145 | Name: "status",
146 | Error: err,
147 | })
148 | }
149 | if err := func() error {
150 | if s.Results == nil {
151 | return errors.New("nil is invalid value")
152 | }
153 | var failures []validate.FieldError
154 | for i, elem := range s.Results {
155 | if err := func() error {
156 | if err := elem.Validate(); err != nil {
157 | return err
158 | }
159 | return nil
160 | }(); err != nil {
161 | failures = append(failures, validate.FieldError{
162 | Name: fmt.Sprintf("[%d]", i),
163 | Error: err,
164 | })
165 | }
166 | }
167 | if len(failures) > 0 {
168 | return &validate.Error{Fields: failures}
169 | }
170 | return nil
171 | }(); err != nil {
172 | failures = append(failures, validate.FieldError{
173 | Name: "results",
174 | Error: err,
175 | })
176 | }
177 | if len(failures) > 0 {
178 | return &validate.Error{Fields: failures}
179 | }
180 | return nil
181 | }
182 |
183 | func (s Pages) Validate() error {
184 | alias := ([]Page)(s)
185 | if alias == nil {
186 | return errors.New("nil is invalid value")
187 | }
188 | var failures []validate.FieldError
189 | for i, elem := range alias {
190 | if err := func() error {
191 | if err := elem.Validate(); err != nil {
192 | return err
193 | }
194 | return nil
195 | }(); err != nil {
196 | failures = append(failures, validate.FieldError{
197 | Name: fmt.Sprintf("[%d]", i),
198 | Error: err,
199 | })
200 | }
201 | }
202 | if len(failures) > 0 {
203 | return &validate.Error{Fields: failures}
204 | }
205 | return nil
206 | }
207 |
208 | func (s *Result) Validate() error {
209 | var failures []validate.FieldError
210 | if err := func() error {
211 | if err := s.Format.Validate(); err != nil {
212 | return err
213 | }
214 | return nil
215 | }(); err != nil {
216 | failures = append(failures, validate.FieldError{
217 | Name: "format",
218 | Error: err,
219 | })
220 | }
221 | if err := func() error {
222 | if s.Files == nil {
223 | return errors.New("nil is invalid value")
224 | }
225 | return nil
226 | }(); err != nil {
227 | failures = append(failures, validate.FieldError{
228 | Name: "files",
229 | Error: err,
230 | })
231 | }
232 | if len(failures) > 0 {
233 | return &validate.Error{Fields: failures}
234 | }
235 | return nil
236 | }
237 |
238 | func (s Status) Validate() error {
239 | switch s {
240 | case "new":
241 | return nil
242 | case "processing":
243 | return nil
244 | case "done":
245 | return nil
246 | case "failed":
247 | return nil
248 | case "with_errors":
249 | return nil
250 | default:
251 | return errors.Errorf("invalid value: %v", s)
252 | }
253 | }
254 |
--------------------------------------------------------------------------------
/application/application.go:
--------------------------------------------------------------------------------
1 | package application
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net"
8 | "net/http"
9 | "strings"
10 | "sync"
11 | "time"
12 |
13 | "github.com/dgraph-io/badger/v4"
14 | "github.com/ogen-go/ogen/middleware"
15 | "go.uber.org/zap"
16 | "go.uber.org/zap/zapcore"
17 |
18 | "github.com/derfenix/webarchive/adapters/processors"
19 | "github.com/derfenix/webarchive/adapters/repository"
20 | badgerRepo "github.com/derfenix/webarchive/adapters/repository/badger"
21 | "github.com/derfenix/webarchive/api/openapi"
22 | "github.com/derfenix/webarchive/config"
23 | "github.com/derfenix/webarchive/entity"
24 | "github.com/derfenix/webarchive/ports/rest"
25 | )
26 |
27 | func NewApplication(cfg config.Config) (Application, error) {
28 | log, err := newLogger(cfg.Logging)
29 | if err != nil {
30 | return Application{}, fmt.Errorf("new logger: %w", err)
31 | }
32 |
33 | db, err := repository.NewBadger(cfg.DB.Path, log.Named("db"))
34 | if err != nil {
35 | return Application{}, fmt.Errorf("new badger: %w", err)
36 | }
37 |
38 | pageRepo, err := badgerRepo.NewPage(db)
39 | if err != nil {
40 | return Application{}, fmt.Errorf("new page repo: %w", err)
41 | }
42 |
43 | processor, err := processors.NewProcessors(cfg, log.Named("processor"))
44 | if err != nil {
45 | return Application{}, fmt.Errorf("new processors: %w", err)
46 | }
47 |
48 | workerCh := make(chan *entity.Page)
49 | worker := entity.NewWorker(workerCh, pageRepo, processor, log.Named("worker"))
50 |
51 | server, err := openapi.NewServer(
52 | rest.NewService(pageRepo, workerCh, processor),
53 | openapi.WithPathPrefix("/api/v1"),
54 | openapi.WithMiddleware(
55 | func(r middleware.Request, next middleware.Next) (middleware.Response, error) {
56 | start := time.Now()
57 |
58 | log := log.With(
59 | zap.String("operation_id", r.OperationID),
60 | zap.String("uri", r.Raw.RequestURI),
61 | )
62 |
63 | var response middleware.Response
64 | var reqErr error
65 |
66 | response, reqErr = next(r)
67 |
68 | log.Debug("request completed", zap.Duration("duration", time.Since(start)), zap.Error(err))
69 |
70 | return response, reqErr
71 | },
72 | ),
73 | )
74 | if err != nil {
75 | return Application{}, fmt.Errorf("new rest server: %w", err)
76 | }
77 |
78 | var httpHandler http.Handler = server
79 |
80 | if cfg.UI.Enabled {
81 | ui := rest.NewUI(cfg.UI)
82 |
83 | httpHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84 | if strings.HasPrefix(r.URL.Path, "/api/") {
85 | server.ServeHTTP(w, r)
86 |
87 | return
88 | }
89 |
90 | ui.ServeHTTP(w, r)
91 | })
92 | }
93 |
94 | httpServer := http.Server{
95 | Addr: cfg.API.Address,
96 | Handler: httpHandler,
97 | ReadTimeout: time.Second * 15,
98 | ReadHeaderTimeout: time.Second * 5,
99 | IdleTimeout: time.Second * 30,
100 | MaxHeaderBytes: 1024 * 2,
101 | }
102 |
103 | return Application{
104 | cfg: cfg,
105 | log: log,
106 | db: db,
107 | processor: processor,
108 | httpServer: &httpServer,
109 | worker: worker,
110 |
111 | pageRepo: pageRepo,
112 | }, nil
113 | }
114 |
115 | type Application struct {
116 | cfg config.Config
117 | log *zap.Logger
118 | db *badger.DB
119 | processor entity.Processor
120 | httpServer *http.Server
121 | worker *entity.Worker
122 |
123 | pageRepo *badgerRepo.Page
124 | }
125 |
126 | func (a *Application) Log() *zap.Logger {
127 | return a.log
128 | }
129 |
130 | func (a *Application) Start(ctx context.Context, wg *sync.WaitGroup) error {
131 | wg.Add(3)
132 |
133 | a.httpServer.BaseContext = func(net.Listener) context.Context {
134 | return ctx
135 | }
136 |
137 | go a.worker.Start(ctx, wg)
138 |
139 | go func() {
140 | defer wg.Done()
141 |
142 | <-ctx.Done()
143 |
144 | shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
145 | defer cancel()
146 |
147 | if err := a.httpServer.Shutdown(shutdownCtx); err != nil {
148 | a.log.Warn("http graceful shutdown failed", zap.Error(err))
149 | }
150 | }()
151 |
152 | go func() {
153 | defer wg.Done()
154 |
155 | a.log.Info("starting http server", zap.String("address", a.httpServer.Addr))
156 |
157 | if err := a.httpServer.ListenAndServe(); err != nil {
158 | if !errors.Is(err, http.ErrServerClosed) {
159 | a.log.Error("http serve error", zap.Error(err))
160 | }
161 |
162 | a.log.Info("http server stopped")
163 | }
164 | }()
165 |
166 | return nil
167 | }
168 |
169 | func (a *Application) Stop() error {
170 | var errs error
171 |
172 | if err := a.db.Sync(); err != nil {
173 | errs = errors.Join(errs, fmt.Errorf("sync db: %w", err))
174 | }
175 |
176 | if err := repository.Backup(a.db, repository.BackupStop); err != nil {
177 | errs = errors.Join(errs, fmt.Errorf("backup on stop: %w", err))
178 | }
179 |
180 | if err := a.db.Close(); err != nil {
181 | errs = errors.Join(errs, fmt.Errorf("close db: %w", err))
182 | }
183 |
184 | return errs
185 | }
186 |
187 | func newLogger(cfg config.Logging) (*zap.Logger, error) {
188 | logCfg := zap.NewProductionConfig()
189 | logCfg.EncoderConfig.EncodeTime = zapcore.RFC3339TimeEncoder
190 | logCfg.EncoderConfig.EncodeDuration = zapcore.NanosDurationEncoder
191 | logCfg.DisableCaller = true
192 | logCfg.DisableStacktrace = true
193 |
194 | logCfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
195 | if cfg.Debug {
196 | logCfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel)
197 | }
198 |
199 | log, err := logCfg.Build()
200 | if err != nil {
201 | return nil, fmt.Errorf("build logger: %w", err)
202 | }
203 |
204 | return log, nil
205 | }
206 |
--------------------------------------------------------------------------------
/cmd/service/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "os/signal"
8 | "sync"
9 | "syscall"
10 |
11 | "go.uber.org/zap"
12 |
13 | "github.com/derfenix/webarchive/application"
14 | "github.com/derfenix/webarchive/config"
15 | )
16 |
17 | func main() {
18 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
19 | defer cancel()
20 |
21 | cfg, err := config.NewConfig(ctx)
22 | if err != nil {
23 | fmt.Printf("failed to init config: %s", err.Error())
24 | os.Exit(2)
25 | }
26 |
27 | app, err := application.NewApplication(cfg)
28 | if err != nil {
29 | fmt.Printf("failed to init application: %s", err.Error())
30 | os.Exit(2)
31 | }
32 |
33 | wg := sync.WaitGroup{}
34 |
35 | if err := app.Start(ctx, &wg); err != nil {
36 | app.Log().Fatal("failed to start application", zap.Error(err))
37 | }
38 |
39 | wg.Wait()
40 |
41 | if err := app.Stop(); err != nil {
42 | app.Log().Fatal("failed to graceful stop", zap.Error(err))
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/sethvargo/go-envconfig"
8 | )
9 |
10 | const envPrefix = "WEBARCHIVE_"
11 |
12 | func NewConfig(ctx context.Context) (Config, error) {
13 | cfg := Config{}
14 |
15 | lookuper := envconfig.MultiLookuper(
16 | envconfig.PrefixLookuper(envPrefix, envconfig.OsLookuper()),
17 | envconfig.OsLookuper(),
18 | )
19 |
20 | if err := envconfig.ProcessWith(ctx, &envconfig.Config{
21 | Target: &cfg,
22 | Lookuper: lookuper,
23 | }); err != nil {
24 | return Config{}, fmt.Errorf("process env: %w", err)
25 | }
26 |
27 | return cfg, nil
28 | }
29 |
30 | type Config struct {
31 | DB DB `env:",prefix=DB_"`
32 | Logging Logging `env:",prefix=LOGGING_"`
33 | API API `env:",prefix=API_"`
34 | UI UI `env:",prefix=UI_"`
35 | PDF PDF `env:",prefix=PDF_"`
36 | }
37 |
38 | type PDF struct {
39 | Landscape bool `env:"LANDSCAPE,default=false"`
40 | Grayscale bool `env:"GRAYSCALE,default=false"`
41 | MediaPrint bool `env:"MEDIA_PRINT,default=true"`
42 | Zoom float64 `env:"ZOOM,default=1"`
43 | Viewport string `env:"VIEWPORT,default=1280x720"`
44 | DPI uint `env:"DPI,default=150"`
45 | Filename string `env:"FILENAME,default=page.pdf"`
46 | }
47 |
48 | type API struct {
49 | Address string `env:"ADDRESS,default=0.0.0.0:5001"`
50 | }
51 |
52 | type UI struct {
53 | Enabled bool `env:"ENABLED,default=true"`
54 | Prefix string `env:"PREFIX,default=/"`
55 | Theme string `env:"THEME,default=basic"`
56 | }
57 |
58 | type DB struct {
59 | Path string `env:"PATH,default=./db"`
60 | }
61 |
62 | type Logging struct {
63 | Debug bool `env:"DEBUG"`
64 | }
65 |
--------------------------------------------------------------------------------
/config/config_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "context"
5 | "os"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestNewConfig(t *testing.T) {
13 | t.Parallel()
14 |
15 | ctx := context.Background()
16 |
17 | t.Run("no envs", func(t *testing.T) {
18 |
19 | config, err := NewConfig(ctx)
20 | require.NoError(t, err)
21 |
22 | assert.Equal(t, "./db", config.DB.Path)
23 | })
24 |
25 | t.Run("env without prefix", func(t *testing.T) {
26 | require.NoError(t, os.Setenv("DB_PATH", "./old_db"))
27 |
28 | config, err := NewConfig(ctx)
29 | require.NoError(t, err)
30 |
31 | assert.Equal(t, "./old_db", config.DB.Path)
32 | })
33 |
34 | t.Run("prefix env override", func(t *testing.T) {
35 | require.NoError(t, os.Setenv("WEBARCHIVE_DB_PATH", "./new_db"))
36 |
37 | config, err := NewConfig(ctx)
38 | require.NoError(t, err)
39 |
40 | assert.Equal(t, "./new_db", config.DB.Path)
41 | })
42 | }
43 |
--------------------------------------------------------------------------------
/devenv.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "devenv": {
4 | "locked": {
5 | "dir": "src/modules",
6 | "lastModified": 1741670053,
7 | "owner": "cachix",
8 | "repo": "devenv",
9 | "rev": "47abb5dfd5b7824a0979ef86f3986aea48847312",
10 | "type": "github"
11 | },
12 | "original": {
13 | "dir": "src/modules",
14 | "owner": "cachix",
15 | "repo": "devenv",
16 | "type": "github"
17 | }
18 | },
19 | "flake-compat": {
20 | "flake": false,
21 | "locked": {
22 | "lastModified": 1733328505,
23 | "owner": "edolstra",
24 | "repo": "flake-compat",
25 | "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
26 | "type": "github"
27 | },
28 | "original": {
29 | "owner": "edolstra",
30 | "repo": "flake-compat",
31 | "type": "github"
32 | }
33 | },
34 | "git-hooks": {
35 | "inputs": {
36 | "flake-compat": "flake-compat",
37 | "gitignore": "gitignore",
38 | "nixpkgs": [
39 | "nixpkgs"
40 | ]
41 | },
42 | "locked": {
43 | "lastModified": 1741379162,
44 | "owner": "cachix",
45 | "repo": "git-hooks.nix",
46 | "rev": "b5a62751225b2f62ff3147d0a334055ebadcd5cc",
47 | "type": "github"
48 | },
49 | "original": {
50 | "owner": "cachix",
51 | "repo": "git-hooks.nix",
52 | "type": "github"
53 | }
54 | },
55 | "gitignore": {
56 | "inputs": {
57 | "nixpkgs": [
58 | "git-hooks",
59 | "nixpkgs"
60 | ]
61 | },
62 | "locked": {
63 | "lastModified": 1709087332,
64 | "owner": "hercules-ci",
65 | "repo": "gitignore.nix",
66 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
67 | "type": "github"
68 | },
69 | "original": {
70 | "owner": "hercules-ci",
71 | "repo": "gitignore.nix",
72 | "type": "github"
73 | }
74 | },
75 | "nixpkgs": {
76 | "locked": {
77 | "lastModified": 1733477122,
78 | "owner": "cachix",
79 | "repo": "devenv-nixpkgs",
80 | "rev": "7bd9e84d0452f6d2e63b6e6da29fe73fac951857",
81 | "type": "github"
82 | },
83 | "original": {
84 | "owner": "cachix",
85 | "ref": "rolling",
86 | "repo": "devenv-nixpkgs",
87 | "type": "github"
88 | }
89 | },
90 | "nixpkgs_stable": {
91 | "locked": {
92 | "lastModified": 1741886019,
93 | "owner": "NixOS",
94 | "repo": "nixpkgs",
95 | "rev": "ed218927a5be8252112adf70905f2a282e2f6692",
96 | "type": "github"
97 | },
98 | "original": {
99 | "owner": "NixOS",
100 | "ref": "release-24.11",
101 | "repo": "nixpkgs",
102 | "type": "github"
103 | }
104 | },
105 | "root": {
106 | "inputs": {
107 | "devenv": "devenv",
108 | "git-hooks": "git-hooks",
109 | "nixpkgs": "nixpkgs",
110 | "nixpkgs_stable": "nixpkgs_stable",
111 | "pre-commit-hooks": [
112 | "git-hooks"
113 | ]
114 | }
115 | }
116 | },
117 | "root": "root",
118 | "version": 7
119 | }
120 |
--------------------------------------------------------------------------------
/devenv.nix:
--------------------------------------------------------------------------------
1 | {
2 | pkgs,
3 | ...
4 | }:
5 |
6 | {
7 |
8 | packages = [
9 | pkgs.git
10 | pkgs.go_1_23
11 | ];
12 |
13 | enterShell = ''
14 | git --version
15 | go version
16 | '';
17 |
18 | enterTest = ''
19 | echo "Running tests"
20 | go test -race ./...
21 | '';
22 | }
23 |
--------------------------------------------------------------------------------
/devenv.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
2 | inputs:
3 | nixpkgs:
4 | url: github:cachix/devenv-nixpkgs/rolling
5 | nixpkgs_stable:
6 | url: "github:NixOS/nixpkgs/release-24.11"
7 | # If you're using non-OSS software, you can set allowUnfree to true.
8 | # allowUnfree: true
9 |
10 | # If you're willing to use a package that's vulnerable
11 | # permittedInsecurePackages:
12 | # - "openssl-1.1.1w"
13 |
14 | # If you have more than one devenv you can merge them
15 | #imports:
16 | # - ./backend
17 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | webarchive:
5 | image: ghcr.io/derfenix/webarchive:latest
6 | # build:
7 | # dockerfile: ./Dockerfile
8 | # context: .
9 | environment:
10 | LOGGING_DEBUG: "true"
11 | API_ADDRESS: "0.0.0.0:5001"
12 | PDF_DPI: "300"
13 | DB_PATH: "/db"
14 | volumes:
15 | - ./db:/db
16 | ports:
17 | - "0.0.0.0:5001:5001"
18 |
--------------------------------------------------------------------------------
/entity/cache.go:
--------------------------------------------------------------------------------
1 | package entity
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "sync"
7 | )
8 |
9 | func NewCache() *Cache {
10 | return &Cache{data: make([]byte, 0, 1024*512)}
11 | }
12 |
13 | type Cache struct {
14 | mu sync.RWMutex
15 | data []byte
16 | }
17 |
18 | func (c *Cache) Write(p []byte) (n int, err error) {
19 | c.mu.Lock()
20 | c.data = append(c.data, p...)
21 | c.mu.Unlock()
22 |
23 | return len(p), nil
24 | }
25 |
26 | func (c *Cache) Get() []byte {
27 | c.mu.RLock()
28 | defer c.mu.RUnlock()
29 |
30 | return c.data
31 | }
32 |
33 | func (c *Cache) Reader() io.Reader {
34 | c.mu.RLock()
35 | defer c.mu.RUnlock()
36 |
37 | if len(c.data) == 0 {
38 | return nil
39 | }
40 |
41 | return bytes.NewBuffer(c.data)
42 | }
43 |
--------------------------------------------------------------------------------
/entity/file.go:
--------------------------------------------------------------------------------
1 | package entity
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/gabriel-vasile/mimetype"
7 | "github.com/google/uuid"
8 | )
9 |
10 | func NewFile(name string, data []byte) File {
11 | detected := mimetype.Detect(data)
12 |
13 | return File{
14 | ID: uuid.New(),
15 | Name: name,
16 | MimeType: detected.String(),
17 | Size: int64(len(data)),
18 | Data: data,
19 | Created: time.Now(),
20 | }
21 | }
22 |
23 | type File struct {
24 | ID uuid.UUID
25 | Name string
26 | MimeType string
27 | Size int64
28 | Data []byte
29 | Created time.Time
30 | }
31 |
--------------------------------------------------------------------------------
/entity/page.go:
--------------------------------------------------------------------------------
1 | package entity
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "runtime/debug"
7 | "sync"
8 | "time"
9 |
10 | "github.com/google/uuid"
11 | )
12 |
13 | type Processor interface {
14 | Process(ctx context.Context, format Format, url string, cache *Cache) Result
15 | GetMeta(ctx context.Context, url string, cache *Cache) (Meta, error)
16 | }
17 |
18 | type Format uint8
19 |
20 | const (
21 | FormatHeaders Format = iota
22 | FormatSingleFile
23 | FormatPDF
24 | )
25 |
26 | var AllFormats = []Format{
27 | FormatHeaders,
28 | FormatPDF,
29 | FormatSingleFile,
30 | }
31 |
32 | type Status uint8
33 |
34 | const (
35 | StatusNew Status = iota
36 | StatusProcessing
37 | StatusDone
38 | StatusFailed
39 | StatusWithErrors
40 | )
41 |
42 | type Meta struct {
43 | Title string
44 | Description string
45 | Encoding string
46 | Error string
47 | }
48 |
49 | type PageBase struct {
50 | ID uuid.UUID
51 | URL string
52 | Description string
53 | Created time.Time
54 | Formats []Format
55 | Version uint16
56 | Status Status
57 | Meta Meta
58 | }
59 |
60 | func NewPage(url string, description string, formats ...Format) *Page {
61 | return &Page{
62 | PageBase: PageBase{
63 | ID: uuid.New(),
64 | URL: url,
65 | Description: description,
66 | Formats: formats,
67 | Created: time.Now(),
68 | Version: 1,
69 | },
70 | cache: NewCache(),
71 | }
72 | }
73 |
74 | type Page struct {
75 | PageBase
76 | Results ResultsRO
77 | cache *Cache
78 | }
79 |
80 | func (p *Page) SetProcessing() {
81 | p.Status = StatusProcessing
82 | }
83 |
84 | func (p *Page) Prepare(ctx context.Context, processor Processor) {
85 | meta, err := processor.GetMeta(ctx, p.URL, p.cache)
86 | if err != nil {
87 | p.Meta.Error = err.Error()
88 | } else {
89 | p.Meta = meta
90 | }
91 | }
92 |
93 | func (p *Page) Process(ctx context.Context, processor Processor) {
94 | innerWG := sync.WaitGroup{}
95 | innerWG.Add(len(p.Formats))
96 |
97 | results := Results{}
98 |
99 | for _, format := range p.Formats {
100 | go func(format Format) {
101 | defer innerWG.Done()
102 |
103 | defer func() {
104 | if err := recover(); err != nil {
105 | results.Add(Result{Format: format, Err: fmt.Errorf("recovered from panic: %v (%s)", err, string(debug.Stack()))})
106 | }
107 | }()
108 |
109 | result := processor.Process(ctx, format, p.URL, p.cache)
110 | results.Add(result)
111 | }(format)
112 | }
113 |
114 | innerWG.Wait()
115 |
116 | var hasResultWithOutErrors bool
117 | for _, result := range results.Results() {
118 | if result.Err != nil {
119 | p.Status = StatusWithErrors
120 | } else {
121 | hasResultWithOutErrors = true
122 | }
123 | }
124 |
125 | if !hasResultWithOutErrors {
126 | p.Status = StatusFailed
127 | }
128 |
129 | if p.Status == StatusProcessing {
130 | p.Status = StatusDone
131 | }
132 |
133 | p.Results = results.RO()
134 | }
135 |
--------------------------------------------------------------------------------
/entity/result.go:
--------------------------------------------------------------------------------
1 | package entity
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/vmihailenco/msgpack/v5"
7 | )
8 |
9 | type ResultsRO []Result
10 |
11 | type Result struct {
12 | Format Format
13 | Err error
14 | Files []File
15 | }
16 |
17 | type Results struct {
18 | mu sync.RWMutex
19 | results []Result
20 | }
21 |
22 | func (r *Results) RO() ResultsRO {
23 | r.mu.Lock()
24 | defer r.mu.Unlock()
25 |
26 | return r.results
27 | }
28 |
29 | func (r *Results) MarshalMsgpack() ([]byte, error) {
30 | return msgpack.Marshal(r.results)
31 | }
32 |
33 | func (r *Results) UnmarshalMsgpack(b []byte) error {
34 | return msgpack.Unmarshal(b, &r.results)
35 | }
36 |
37 | func (r *Results) Add(result Result) {
38 | r.mu.Lock()
39 | r.results = append(r.results, result)
40 | r.mu.Unlock()
41 | }
42 |
43 | func (r *Results) Results() []Result {
44 | r.mu.RLock()
45 | defer r.mu.RUnlock()
46 |
47 | return r.results
48 | }
49 |
--------------------------------------------------------------------------------
/entity/worker.go:
--------------------------------------------------------------------------------
1 | package entity
2 |
3 | import (
4 | "context"
5 | "sync"
6 |
7 | "go.uber.org/zap"
8 | )
9 |
10 | type Pages interface {
11 | Save(ctx context.Context, page *Page) error
12 | ListUnprocessed(ctx context.Context) ([]Page, error)
13 | }
14 |
15 | func NewWorker(ch chan *Page, pages Pages, processor Processor, log *zap.Logger) *Worker {
16 | return &Worker{pages: pages, processor: processor, log: log, ch: ch}
17 | }
18 |
19 | type Worker struct {
20 | ch chan *Page
21 | pages Pages
22 | processor Processor
23 | log *zap.Logger
24 | }
25 |
26 | func (w *Worker) Start(ctx context.Context, wg *sync.WaitGroup) {
27 | defer wg.Done()
28 |
29 | w.log.Info("starting")
30 |
31 | wg.Add(1)
32 | go func() {
33 | defer wg.Done()
34 |
35 | unprocessed, err := w.pages.ListUnprocessed(ctx)
36 | if err != nil {
37 | w.log.Error("failed to get unprocessed pages", zap.Error(err))
38 | } else {
39 | for i := range unprocessed {
40 | w.ch <- &unprocessed[i]
41 | }
42 | }
43 | }()
44 |
45 | for {
46 | select {
47 | case <-ctx.Done():
48 | return
49 |
50 | case page, open := <-w.ch:
51 | if !open {
52 | w.log.Warn("channel closed")
53 | return
54 | }
55 |
56 | log := w.log.With(zap.Stringer("page_id", page.ID), zap.String("page_url", page.URL))
57 |
58 | log.Info("got new page")
59 |
60 | wg.Add(1)
61 | go w.do(ctx, wg, page, log)
62 | }
63 | }
64 | }
65 |
66 | func (w *Worker) do(ctx context.Context, wg *sync.WaitGroup, page *Page, log *zap.Logger) {
67 | defer wg.Done()
68 |
69 | page.SetProcessing()
70 | if err := w.pages.Save(ctx, page); err != nil {
71 | w.log.Error(
72 | "failed to save processing page",
73 | zap.String("page_id", page.ID.String()),
74 | zap.String("page_url", page.URL),
75 | zap.Error(err),
76 | )
77 | }
78 |
79 | page.Process(ctx, w.processor)
80 |
81 | log.Debug("page processed")
82 |
83 | if err := w.pages.Save(ctx, page); err != nil {
84 | w.log.Error(
85 | "failed to save processed page",
86 | zap.String("page_id", page.ID.String()),
87 | zap.String("page_url", page.URL),
88 | zap.Error(err),
89 | )
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/derfenix/webarchive
2 |
3 | go 1.23.0
4 |
5 | toolchain go1.23.6
6 |
7 | require (
8 | github.com/SebastiaanKlippert/go-wkhtmltopdf v1.9.3
9 | github.com/dgraph-io/badger/v4 v4.6.0
10 | github.com/disintegration/imaging v1.6.2
11 | github.com/gabriel-vasile/mimetype v1.4.8
12 | github.com/go-faster/errors v0.7.1
13 | github.com/go-faster/jx v1.1.0
14 | github.com/google/uuid v1.6.0
15 | github.com/minio/minio-go/v7 v7.0.88
16 | github.com/ogen-go/ogen v1.10.1
17 | github.com/sethvargo/go-envconfig v1.1.1
18 | github.com/stretchr/testify v1.10.0
19 | github.com/vmihailenco/msgpack/v5 v5.4.1
20 | go.opentelemetry.io/otel v1.35.0
21 | go.opentelemetry.io/otel/metric v1.35.0
22 | go.opentelemetry.io/otel/trace v1.35.0
23 | go.uber.org/multierr v1.11.0
24 | go.uber.org/zap v1.27.0
25 | golang.org/x/net v0.37.0
26 | )
27 |
28 | require (
29 | github.com/cespare/xxhash v1.1.0 // indirect
30 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
31 | github.com/davecgh/go-spew v1.1.1 // indirect
32 | github.com/dgraph-io/ristretto v0.2.0 // indirect
33 | github.com/dgraph-io/ristretto/v2 v2.1.0 // indirect
34 | github.com/dlclark/regexp2 v1.11.5 // indirect
35 | github.com/dustin/go-humanize v1.0.1 // indirect
36 | github.com/fatih/color v1.18.0 // indirect
37 | github.com/ghodss/yaml v1.0.0 // indirect
38 | github.com/go-faster/yaml v0.4.6 // indirect
39 | github.com/go-ini/ini v1.67.0 // indirect
40 | github.com/go-logr/logr v1.4.2 // indirect
41 | github.com/go-logr/stdr v1.2.2 // indirect
42 | github.com/goccy/go-json v0.10.5 // indirect
43 | github.com/gogo/protobuf v1.3.2 // indirect
44 | github.com/golang/glog v1.2.4 // indirect
45 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
46 | github.com/golang/protobuf v1.5.4 // indirect
47 | github.com/golang/snappy v1.0.0 // indirect
48 | github.com/google/flatbuffers v25.2.10+incompatible // indirect
49 | github.com/json-iterator/go v1.1.12 // indirect
50 | github.com/klauspost/compress v1.18.0 // indirect
51 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect
52 | github.com/mattn/go-colorable v0.1.14 // indirect
53 | github.com/mattn/go-isatty v0.0.20 // indirect
54 | github.com/minio/crc64nvme v1.0.1 // indirect
55 | github.com/minio/md5-simd v1.1.2 // indirect
56 | github.com/minio/sha256-simd v1.0.1 // indirect
57 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
58 | github.com/modern-go/reflect2 v1.0.2 // indirect
59 | github.com/pkg/errors v0.9.1 // indirect
60 | github.com/pmezard/go-difflib v1.0.0 // indirect
61 | github.com/rs/xid v1.6.0 // indirect
62 | github.com/segmentio/asm v1.2.0 // indirect
63 | github.com/sirupsen/logrus v1.9.3 // indirect
64 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
65 | go.opencensus.io v0.24.0 // indirect
66 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect
67 | golang.org/x/crypto v0.36.0 // indirect
68 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
69 | golang.org/x/image v0.25.0 // indirect
70 | golang.org/x/sync v0.12.0 // indirect
71 | golang.org/x/sys v0.31.0 // indirect
72 | golang.org/x/text v0.23.0 // indirect
73 | google.golang.org/protobuf v1.36.5 // indirect
74 | gopkg.in/ini.v1 v1.67.0 // indirect
75 | gopkg.in/yaml.v2 v2.4.0 // indirect
76 | gopkg.in/yaml.v3 v3.0.1 // indirect
77 | )
78 |
--------------------------------------------------------------------------------
/ports/rest/converter.go:
--------------------------------------------------------------------------------
1 | package rest
2 |
3 | import (
4 | "fmt"
5 | "html"
6 |
7 | "github.com/derfenix/webarchive/api/openapi"
8 | "github.com/derfenix/webarchive/entity"
9 | )
10 |
11 | func PageToRestWithResults(page *entity.Page) openapi.PageWithResults {
12 | return openapi.PageWithResults{
13 | ID: page.ID,
14 | URL: page.URL,
15 | Created: page.Created,
16 | Formats: func() []openapi.Format {
17 | res := make([]openapi.Format, len(page.Formats))
18 |
19 | for i, format := range page.Formats {
20 | res[i] = FormatToRest(format)
21 | }
22 |
23 | return res
24 | }(),
25 | Status: StatusToRest(page.Status),
26 | Meta: openapi.PageWithResultsMeta{
27 | Title: html.EscapeString(page.Meta.Title),
28 | Description: html.EscapeString(page.Meta.Description),
29 | Error: openapi.NewOptString(page.Meta.Error),
30 | },
31 | Results: func() []openapi.Result {
32 | results := make([]openapi.Result, len(page.Results))
33 |
34 | for i := range results {
35 | result := &page.Results[i]
36 |
37 | errText := openapi.OptString{}
38 | if result.Err != nil {
39 | errText = openapi.NewOptString(result.Err.Error())
40 | }
41 |
42 | results[i] = openapi.Result{
43 | Format: FormatToRest(result.Format),
44 | Error: errText,
45 | Files: func() []openapi.ResultFilesItem {
46 | files := make([]openapi.ResultFilesItem, len(result.Files))
47 |
48 | for j := range files {
49 | file := &result.Files[j]
50 |
51 | files[j] = openapi.ResultFilesItem{
52 | ID: file.ID,
53 | Name: file.Name,
54 | Mimetype: file.MimeType,
55 | Size: file.Size,
56 | }
57 | }
58 |
59 | return files
60 | }(),
61 | }
62 | }
63 |
64 | return results
65 | }(),
66 | }
67 | }
68 |
69 | func BasePageToRest(page *entity.PageBase) openapi.Page {
70 | return openapi.Page{
71 | ID: page.ID,
72 | URL: page.URL,
73 | Created: page.Created,
74 | Meta: openapi.PageMeta{
75 | Title: html.EscapeString(page.Meta.Title),
76 | Description: html.EscapeString(page.Meta.Description),
77 | Error: openapi.NewOptString(page.Meta.Error),
78 | },
79 | Formats: func() []openapi.Format {
80 | res := make([]openapi.Format, len(page.Formats))
81 |
82 | for i, format := range page.Formats {
83 | res[i] = FormatToRest(format)
84 | }
85 |
86 | return res
87 | }(),
88 | Status: StatusToRest(page.Status),
89 | }
90 | }
91 |
92 | func PageToRest(page *entity.Page) openapi.Page {
93 | return openapi.Page{
94 | ID: page.ID,
95 | URL: page.URL,
96 | Created: page.Created,
97 | Meta: openapi.PageMeta{
98 | Title: html.EscapeString(page.Meta.Title),
99 | Description: html.EscapeString(page.Meta.Description),
100 | Error: openapi.NewOptString(page.Meta.Error),
101 | },
102 | Formats: func() []openapi.Format {
103 | res := make([]openapi.Format, len(page.Formats))
104 |
105 | for i, format := range page.Formats {
106 | res[i] = FormatToRest(format)
107 | }
108 |
109 | return res
110 | }(),
111 | Status: StatusToRest(page.Status),
112 | }
113 | }
114 |
115 | func StatusToRest(s entity.Status) openapi.Status {
116 | switch s {
117 | case entity.StatusNew:
118 | return openapi.StatusNew
119 | case entity.StatusProcessing:
120 | return openapi.StatusProcessing
121 | case entity.StatusDone:
122 | return openapi.StatusDone
123 | case entity.StatusFailed:
124 | return openapi.StatusFailed
125 | case entity.StatusWithErrors:
126 | return openapi.StatusWithErrors
127 | default:
128 | return ""
129 | }
130 | }
131 |
132 | func FormatFromRest(format []openapi.Format) ([]entity.Format, error) {
133 | var formats []entity.Format
134 |
135 | switch {
136 | case len(format) == 0 || (len(format) == 1 && format[0] == openapi.FormatAll):
137 | formats = entity.AllFormats
138 |
139 | default:
140 | formats = make([]entity.Format, len(format))
141 | for i, format := range format {
142 | switch format {
143 | case openapi.FormatPdf:
144 | formats[i] = entity.FormatPDF
145 |
146 | case openapi.FormatHeaders:
147 | formats[i] = entity.FormatHeaders
148 |
149 | case openapi.FormatSingleFile:
150 | formats[i] = entity.FormatSingleFile
151 |
152 | default:
153 | return nil, fmt.Errorf("invalid format value %s", format)
154 | }
155 | }
156 | }
157 |
158 | return formats, nil
159 | }
160 |
161 | func FormatToRest(format entity.Format) openapi.Format {
162 | switch format {
163 | case entity.FormatPDF:
164 | return openapi.FormatPdf
165 | case entity.FormatSingleFile:
166 | return openapi.FormatSingleFile
167 | case entity.FormatHeaders:
168 | return openapi.FormatHeaders
169 | default:
170 | return ""
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/ports/rest/service.go:
--------------------------------------------------------------------------------
1 | package rest
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "net/http"
8 | "strings"
9 |
10 | "github.com/google/uuid"
11 |
12 | "github.com/derfenix/webarchive/api/openapi"
13 | "github.com/derfenix/webarchive/entity"
14 | )
15 |
16 | type Pages interface {
17 | ListAll(ctx context.Context) ([]*entity.Page, error)
18 | Save(ctx context.Context, site *entity.Page) error
19 | Get(ctx context.Context, id uuid.UUID) (*entity.Page, error)
20 | GetFile(ctx context.Context, pageID, fileID uuid.UUID) (*entity.File, error)
21 | }
22 |
23 | func NewService(pages Pages, ch chan *entity.Page, processor entity.Processor) *Service {
24 | return &Service{
25 | pages: pages,
26 | ch: ch,
27 | processor: processor,
28 | }
29 | }
30 |
31 | type Service struct {
32 | openapi.UnimplementedHandler
33 | processor entity.Processor
34 | pages Pages
35 | ch chan *entity.Page
36 | }
37 |
38 | func (s *Service) GetPage(ctx context.Context, params openapi.GetPageParams) (openapi.GetPageRes, error) {
39 | page, err := s.pages.Get(ctx, params.ID)
40 | if err != nil {
41 | return &openapi.GetPageNotFound{}, nil
42 | }
43 |
44 | restPage := PageToRestWithResults(page)
45 |
46 | return &restPage, nil
47 | }
48 |
49 | func (s *Service) AddPage(ctx context.Context, req openapi.OptAddPageReq, params openapi.AddPageParams) (openapi.AddPageRes, error) {
50 | url := params.URL.Or(req.Value.URL)
51 | description := params.Description.Or(req.Value.Description.Value)
52 |
53 | formats := req.Value.Formats
54 | if len(formats) == 0 {
55 | formats = params.Formats
56 | }
57 | if len(formats) == 0 {
58 | formats = []openapi.Format{"all"}
59 | }
60 |
61 | switch {
62 | case req.Value.URL != "":
63 | url = req.Value.URL
64 | case params.URL.IsSet():
65 | url = params.URL.Value
66 | }
67 |
68 | if url == "" {
69 | return &openapi.AddPageBadRequest{
70 | Field: "url",
71 | Error: "Value is required",
72 | }, nil
73 | }
74 |
75 | domainFormats, err := FormatFromRest(formats)
76 | if err != nil {
77 | return &openapi.AddPageBadRequest{
78 | Field: "formats",
79 | Error: err.Error(),
80 | }, nil
81 | }
82 |
83 | page := entity.NewPage(url, description, domainFormats...)
84 | page.Status = entity.StatusNew
85 | page.Prepare(ctx, s.processor)
86 |
87 | if err := s.pages.Save(ctx, page); err != nil {
88 | return nil, fmt.Errorf("save page: %w", err)
89 | }
90 |
91 | res := BasePageToRest(&page.PageBase)
92 |
93 | s.ch <- page
94 |
95 | return &res, nil
96 | }
97 |
98 | func (s *Service) GetPages(ctx context.Context) (openapi.Pages, error) {
99 | sites, err := s.pages.ListAll(ctx)
100 | if err != nil {
101 | return nil, fmt.Errorf("list all: %w", err)
102 | }
103 |
104 | res := make(openapi.Pages, len(sites))
105 | for i := range res {
106 | res[i] = PageToRest(sites[i])
107 | }
108 |
109 | return res, nil
110 | }
111 |
112 | func (s *Service) GetFile(ctx context.Context, params openapi.GetFileParams) (openapi.GetFileRes, error) {
113 | file, err := s.pages.GetFile(ctx, params.ID, params.FileID)
114 | if err != nil {
115 | return &openapi.GetFileNotFound{}, nil
116 | }
117 |
118 | switch {
119 | case file.MimeType == "application/pdf":
120 | return &openapi.GetFileOKApplicationPdf{Data: bytes.NewReader(file.Data)}, nil
121 |
122 | case strings.HasPrefix(file.MimeType, "text/plain"):
123 | return &openapi.GetFileOKTextPlain{Data: bytes.NewReader(file.Data)}, nil
124 |
125 | case strings.HasPrefix(file.MimeType, "text/html"):
126 | return &openapi.GetFileOKTextHTML{Data: bytes.NewReader(file.Data)}, nil
127 |
128 | default:
129 | return nil, fmt.Errorf("unsupported mimetype: %s", file.MimeType)
130 | }
131 | }
132 |
133 | func (s *Service) NewError(_ context.Context, err error) *openapi.ErrorStatusCode {
134 | return &openapi.ErrorStatusCode{
135 | StatusCode: http.StatusInternalServerError,
136 | Response: openapi.Error{
137 | Message: err.Error(),
138 | Localized: openapi.OptString{},
139 | },
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/ports/rest/ui.go:
--------------------------------------------------------------------------------
1 | package rest
2 |
3 | import (
4 | "io/fs"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/derfenix/webarchive/config"
9 | "github.com/derfenix/webarchive/ui"
10 | )
11 |
12 | func NewUI(cfg config.UI) *UI {
13 | return &UI{
14 | prefix: cfg.Prefix,
15 | theme: cfg.Theme,
16 | }
17 | }
18 |
19 | type UI struct {
20 | prefix string
21 | theme string
22 | }
23 |
24 | func (u *UI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
25 | serveRoot, err := fs.Sub(ui.StaticFiles, u.theme)
26 | if err != nil {
27 | w.WriteHeader(http.StatusInternalServerError)
28 | return
29 | }
30 |
31 | if strings.HasPrefix(r.URL.Path, u.prefix) {
32 | r.URL.Path = "/" + strings.TrimPrefix(r.URL.Path, u.prefix)
33 | }
34 | if !strings.HasPrefix(r.URL.Path, "/static") {
35 | r.URL.Path = "/"
36 | }
37 |
38 | r.URL.Path = strings.TrimPrefix(r.URL.Path, "/static")
39 |
40 | http.FileServer(http.FS(serveRoot)).ServeHTTP(w, r)
41 | }
42 |
--------------------------------------------------------------------------------
/ui/basic/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | WebArchive
8 |
9 |
10 |
11 |
12 |
13 |
14 |
20 |
21 |
22 |
23 | Back
24 |
25 |
26 |
27 |
28 |
Results
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | None
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/ui/basic/main.js:
--------------------------------------------------------------------------------
1 | function index() {
2 | $.ajax({
3 | url: "/api/v1/pages", success: function (data, status, xhr) {
4 | if (status !== "success") {
5 | gotError(status);
6 |
7 | return;
8 | }
9 |
10 | let elem = document.getElementById("data");
11 | elem.innerHTML = "";
12 | // elem.attachShadow({mode: 'open'});
13 |
14 | data.forEach(function (v) {
15 | let page_elem = pages_tmpl.content.cloneNode(true);
16 | $(page_elem).find(".url").attr("onclick", "goToPage('" + v.id + "');");
17 | $(page_elem).find(".status").addClass(v.status);
18 | $(page_elem).find(".status").attr("title", v.status);
19 | $(page_elem).find(".created").html(v.created);
20 | $(page_elem).find(".title").html(v.meta.title);
21 | $(page_elem).find(".description").html(v.meta.description);
22 | elem.append(page_elem); // (*)
23 | })
24 | }
25 | })
26 | }
27 |
28 | function goToPage(id) {
29 | history.pushState({"page": id}, null, id);
30 | page(id);
31 | }
32 |
33 | function page(id) {
34 | $.ajax({
35 | url: "/api/v1/pages/" + id, success: function (data, status, xhr) {
36 | if (status !== "success") {
37 | gotError(status);
38 |
39 | return;
40 | }
41 |
42 | let elem = document.getElementById("data");
43 | elem.innerHTML = "";
44 | let page_elem = page_tmpl.content.cloneNode(true);
45 | $(page_elem).find("#page_title").html(data.meta.title);
46 | $(page_elem).find("#page_description").html(data.meta.description);
47 | $(page_elem).find("#page_url").html(data.url);
48 |
49 | data.results.forEach(function (result) {
50 | let result_elem = result_tmpl.content.cloneNode(true);
51 | $(result_elem).find(".format").html(result.format);
52 | if (result.error !== "" && result.error !== undefined) {
53 | $(result_elem).find(".format").addClass("error");
54 | $(result_elem).find(".result_link").html("⚠");
55 | $(result_elem).find(".result_link").attr("title", result.error);
56 | } else {
57 | result.files.forEach(function (file) {
58 | $(result_elem).find(".result_link").attr("onclick", "window.open('/api/v1/pages/" + data.id + "/file/" + file.id + "', '_blank');");
59 | $(result_elem).find(".result_link").html(file.name);
60 | })
61 | }
62 |
63 | $(page_elem).find("#results").append(result_elem);
64 | })
65 |
66 | elem.append(page_elem); // (*)
67 | }
68 | })
69 | }
70 |
71 | function gotError(err) {
72 | console.log(err);
73 | }
74 |
75 | document.addEventListener("DOMContentLoaded", function () {
76 | $("#site_title").html("WebArchive " + window.location.hostname);
77 | document.title = "WebArchive " + window.location.hostname;
78 | if (window.location.pathname.endsWith("/")) {
79 | index();
80 | } else {
81 | page(window.location.pathname.slice(1));
82 | }
83 | });
84 | window.addEventListener('popstate', function (event) {
85 | if (event.state === null) {
86 | index();
87 | } else {
88 | page(event.state.page);
89 | }
90 | });
91 |
--------------------------------------------------------------------------------
/ui/basic/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | width: 85%;
3 | padding: 10px;
4 | margin: 0 auto;
5 | }
6 |
7 | h1 {
8 | background-color: azure;
9 | }
10 |
11 | .status {
12 | display: inline-block;
13 | padding: 8px;
14 | margin-left: 5px;
15 | }
16 |
17 | .created {
18 | font-size: 70%;
19 | }
20 |
21 | .title {
22 | font-weight: bold;
23 | }
24 |
25 | .link:hover {
26 | text-decoration: underline;
27 | cursor: pointer;
28 | }
29 |
30 |
31 | .result_item::before {
32 | content: "→";
33 | }
34 |
35 | .result_item::after {
36 | content: "";
37 | }
38 |
39 | .result_item {
40 | width: 300px;
41 | border-bottom: 1px black solid;
42 | }
43 |
44 | .format {
45 | font-weight: bold;
46 | width: 100px;
47 | display: inline-block;
48 | }
49 |
50 | .done {
51 | background-image: url("data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA4WlDQ1BzUkdCAAAYlWNgYDzNAARMDgwMuXklRUHuTgoRkVEKDEggMbm4gAE3YGRg+HYNRDIwXNYNLGHlx6MWG+AsAloIpD8AsUg6mM3IAmInQdgSIHZ5SUEJkK0DYicXFIHYQBcz8BSFBDkD2T5AtkI6EjsJiZ2SWpwMZOcA2fEIv+XPZ2Cw+MLAwDwRIZY0jYFhezsDg8QdhJjKQgYG/lYGhm2XEWKf/cH+ZRQ7VJJaUQIS8dN3ZChILEoESzODAjQtjYHh03IGBt5IBgbhCwwMXNEQd4ABazEwoEkMJ0IAAHLYNoSjH0ezAAAACXBIWXMAAdhxAAHYcQFzn84mAAAAGHRFWHRUaHVtYjo6RG9jdW1lbnQ6OlBhZ2VzADGn/7svAAAAF3RFWHRUaHVtYjo6SW1hZ2U6OldpZHRoADUxMhx8A9wAAAAYdEVYdFRodW1iOjpJbWFnZTo6aGVpZ2h0ADUxMsDQUFEAAAAXdEVYdFRodW1iOjpNVGltZQAxNTI4MDE5MjE1YNV9JgAAABl0RVh0VGh1bWI6Ok1pbWV0eXBlAGltYWdlL3BuZz+yVk4AAAATdEVYdFRodW1iOjpTaXplADE5LjFLQkIN3vwUAAAATXpUWHRUaHVtYjo6VVJJAAAImUvLzEm10tfX0y8tyMlPTCnWNzXT9ypLSSk289Q3NDUw00/NTcpJzU1JTUsszSmJNzQwNjc10yvISwcAFNoSWr6CThUAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTgtMDYtMDNUMTE6NDY6NTUrMDI6MDA6oRRQAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE4LTA2LTAzVDExOjQ2OjU1KzAyOjAwS/ys7AAAAEh6VFh0c29mdHdhcmUAAAiZ88xNTE/1TUzPTM5WMNMz17PQtVQwMjA00zUw0zU0UwgE4oySkgIrff3y8nK9TJDqXLBqvfyidAAb5hKPkj0UiQAAAfJJREFUOI19kj1MU3EUxX/nX94bsJoIxsEYdHLQwcS4uCiTEFO6GKMDkQSQWhK/mFwcHF0wDpi2QeMno4mlkeDk5IiJCYMxJpYYNtSAH/ge/V+HUmmB5x3vvefcc+89kBSGsnfY2T1BGkNJbVsKZyY5kPJhHrNeOfbjweALTjM1RYVXw1QTCbLFYMDEuFDH9qLsK3B9eiR+uoUgWwwGkEpAmLwVJiw2GG6QuIZsE+P/AwOfMPoxViTuZop0/SNI+TCfJLs+2n4If3E6F02ZuIGpUwpH6wSGMOtNBuMRYy9H1t72TNIhdBNAZqcxpO4J0rvags9InfWj2CODmqFBgTArlRfj/Lkj6M/38BlwYV3V0vJafLBt07z5qD2+9vsjv9L7wkVhp1aX4zFu41dLwRXg/Oa/C0N9peCdpKNg3/AaLl+OXmAo+5B0eYiVTKHthHN6DUpvbGZz05fi4w5hOM2s8+3G2VSmGFwFKA+xkimyxzn3uBlcn6xZhAka7gvmmj5RA7vnLb4lwicSZ1t125L38bFKjoUNIxXCfpw9ADV7Yd7gsFoca5FHg5WR6DlAqpH+UKm9P9SXqkqcBLWvp/e2gGVLHuUb4PoqmyJTpEsuzMtbD44uPJioCs16iwqVHAutt0gKQ9332QHwZpSfCNuu7S+VhMHJAHzsJQAAAABJRU5ErkJggg==");
52 | }
53 | .with_errors {
54 | background-image: url("data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAw3pUWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYAAHjabVBRDsMgCP3nFDsCAioex65dshvs+MOCS21GwhN45IHA8Xm/4DGMkoDkqqWVgmbSpFG3QNGtn5hQTowEZ7DU4UeQldhe9lRL9M96wkUpdYvyRUifQWwr0ST09SYUg3hsRBbsIdRCiMmJFALdv4Wlab1+YTtwNXWHAXXPJz1F7rlUu96ebQ4THZwYDZnFF+DhDNwtEMfRaG3dPBtmptjEDvLvTtPgC0JYWeC95VRKAAABhWlDQ1BJQ0MgcHJvZmlsZQAAeJx9kT1Iw0AYht+mlYpUBe0g4pChOtnBH8SxVqEIFUKt0KqDyaV/0KQhSXFxFFwLDv4sVh1cnHV1cBUEwR8QVxcnRRcp8buk0CLWO457eO97X+6+A4R6mWlWIAZoum2mEnExk10Vg68I0BxAHyZkZhlzkpREx/F1Dx/f76I8q3Pdn6NXzVkM8InEMWaYNvEG8cymbXDeJw6zoqwSnxOPm3RB4keuKx6/cS64LPDMsJlOzROHicVCGyttzIqmRjxNHFE1nfKFjMcq5y3OWrnKmvfkLwzl9JVlrtMaQQKLWIIEEQqqKKEMG1HadVIspOg83sE/7PolcinkKoGRYwEVaJBdP/gf/O6tlZ+a9JJCcaDrxXE+RoHgLtCoOc73seM0TgD/M3Clt/yVOjD7SXqtpUWOgP5t4OK6pSl7wOUOMPRkyKbsSn5aQj4PvJ/RN2WBwVugZ83rW/Mcpw9AmnqVvAEODoGxAmWvd3h3d3vf/q1p9u8HeTVyqV/sbxoAAA14aVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA0LjQuMC1FeGl2MiI+CiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIKICAgIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiCiAgICB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iCiAgICB4bWxuczpHSU1QPSJodHRwOi8vd3d3LmdpbXAub3JnL3htcC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgeG1wTU06RG9jdW1lbnRJRD0iZ2ltcDpkb2NpZDpnaW1wOjcwMmRkMzU0LWFiNGYtNDgwZi04MGY3LTUzNzliNjQ3NzNmMiIKICAgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDplM2VmYjE0ZS02MDk4LTRjZWMtYmRmNS1kMzk5MDg2ODcyNjMiCiAgIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDo1ODU3NGY5NC1jMDY5LTQ4NWMtOWU3Yy0yMGJlNTIyNWEzNDQiCiAgIGRjOkZvcm1hdD0iaW1hZ2UvcG5nIgogICBHSU1QOkFQST0iMi4wIgogICBHSU1QOlBsYXRmb3JtPSJMaW51eCIKICAgR0lNUDpUaW1lU3RhbXA9IjE2ODA2MzIxNTUwNzU2MjMiCiAgIEdJTVA6VmVyc2lvbj0iMi4xMC4zNCIKICAgdGlmZjpPcmllbnRhdGlvbj0iMSIKICAgeG1wOkNyZWF0b3JUb29sPSJHSU1QIDIuMTAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjM6MDQ6MDRUMjE6MTU6NTIrMDM6MDAiCiAgIHhtcDpNb2RpZnlEYXRlPSIyMDIzOjA0OjA0VDIxOjE1OjUyKzAzOjAwIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiCiAgICAgIHN0RXZ0OmNoYW5nZWQ9Ii8iCiAgICAgIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6YmZkN2Y4YmYtZDUzNS00YTI4LWE3MjctNmVhNzQ2MTdhNTcyIgogICAgICBzdEV2dDpzb2Z0d2FyZUFnZW50PSJHaW1wIDIuMTAgKExpbnV4KSIKICAgICAgc3RFdnQ6d2hlbj0iMjAyMy0wNC0wNFQyMToxNTo1NSswMzowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz5yHzZJAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAdhxAAHYcQFzn84mAAAAB3RJTUUH5wQEEg83nfB+0wAAAdNJREFUOMt9kzFok1EUhb/7gtXaLLZOIrGLi3MWF8GmaCGOihmqheBgOlhbpH91cZGagqSLQyhWFLU4CC5KKfa34OCUSegotNJJjA7Nr6D2HQf/lOZvkrfdxzvnnvvuOUaHoxr2epF0Tx/KF4gsi9q9s+TF2m1OeFGSGEk5jnsPgi3nWDaonr3PZkeCMGAMo2LQ31YVfAdu5so820cQBoyZsQD0dBwLZPBHcK1J4pqyMSrdwMBniVGJbWfMhwGZXQIvSp1kA0g0gKvDcyxhTEoMYIwDONUwiZEuYG/G1FCZj+/v0G8wE89zTjXM3twi3Ztiw4yBGPME2BEUDUxi4VOd0tAgVm/wHCjExPVfOwy6RMP13kNMbHzlusQ9iQ8/IqYmH+G/NRgXXE4qdPkCkWArro9FPxkuPuZv7hJ3D6S4cPEh0btpThvM2t61G5v5ApGzLHKO5fj6iHMsrQbcADgzy3YYcDTleAqkEwZasSxycVGNTQJw0BmVtVc8CAMOC6rAyRawUZeothhpdZpR51hMeGFdcMpaHftbUMyVebHfyjNccca8tLsRkp29mGiC24YpDMiYoyTPeefIeP//wwxWJKq5Ob50TePeOL99SR9Atzj/A8NJrxH3md/lAAAAAElFTkSuQmCC");
55 | }
56 | .failed {
57 | background-image: url("data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAwnpUWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYAAHjabVBbDgMhCPznFD0CAr6O49Zt0hv0+EXBZt2UBBwYMghwft4veAyjICAxl1RTQjWpUqkpKGjWZgwoM3qCC2x1+BGkJdaXLS3J+1c94KYUmqJ4ESpPJ46dqOL65Sbkg3j8iBR0F6ouxGREcIFma2GqJV9XOE7crZjDCLnHSS+Rey5Zr9ejzmGikwOjRmaxD/BwBm4KxOJo1LamnmdlraoH+XenZfAFQlpZ4LlBJXQAAAGFaUNDUElDQyBwcm9maWxlAAB4nH2RPUjDQBiG36aVilQF7SDikKE62cEfxLFWoQgVQq3QqoPJpX/QpCFJcXEUXAsO/ixWHVycdXVwFQTBHxBXFydFFynxu6TQItY7jnt473tf7r4DhHqZaVYgBmi6baYScTGTXRWDrwjQHEAfJmRmGXOSlETH8XUPH9/vojyrc92fo1fNWQzwicQxZpg28QbxzKZtcN4nDrOirBKfE4+bdEHiR64rHr9xLrgs8MywmU7NE4eJxUIbK23MiqZGPE0cUTWd8oWMxyrnLc5aucqa9+QvDOX0lWWu0xpBAotYggQRCqoooQwbUdp1Uiyk6DzewT/s+iVyKeQqgZFjARVokF0/+B/87q2Vn5r0kkJxoOvFcT5GgeAu0Kg5zvex4zROAP8zcKW3/JU6MPtJeq2lRY6A/m3g4rqlKXvA5Q4w9GTIpuxKflpCPg+8n9E3ZYHBW6Bnzetb8xynD0CaepW8AQ4OgbECZa93eHd3e9/+rWn27wd5NXKpX+xvGgAADXhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDQuNC4wLUV4aXYyIj4KIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIKICAgIHhtbG5zOkdJTVA9Imh0dHA6Ly93d3cuZ2ltcC5vcmcveG1wLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICB4bXBNTTpEb2N1bWVudElEPSJnaW1wOmRvY2lkOmdpbXA6ZjA1MTk2NDQtZjcwZi00ZDZlLTlmZmYtYTBmN2MxNzU0YmJkIgogICB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjllOWMxM2NmLWE3YzItNGYyYS1iOWI2LTBjZThlYjE3YzI2ZiIKICAgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOmNmMjBmNWNmLTI2ODMtNGFkMC04YjEwLTdjM2JmMTg3NzE3YyIKICAgZGM6Rm9ybWF0PSJpbWFnZS9wbmciCiAgIEdJTVA6QVBJPSIyLjAiCiAgIEdJTVA6UGxhdGZvcm09IkxpbnV4IgogICBHSU1QOlRpbWVTdGFtcD0iMTY4MDYzMjIyNjM5OTkzNCIKICAgR0lNUDpWZXJzaW9uPSIyLjEwLjM0IgogICB0aWZmOk9yaWVudGF0aW9uPSIxIgogICB4bXA6Q3JlYXRvclRvb2w9IkdJTVAgMi4xMCIKICAgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMzowNDowNFQyMToxNzowNSswMzowMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjM6MDQ6MDRUMjE6MTc6MDUrMDM6MDAiPgogICA8eG1wTU06SGlzdG9yeT4KICAgIDxyZGY6U2VxPgogICAgIDxyZGY6bGkKICAgICAgc3RFdnQ6YWN0aW9uPSJzYXZlZCIKICAgICAgc3RFdnQ6Y2hhbmdlZD0iLyIKICAgICAgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDoyNjFmNmNjZS1iZjZlLTQ1YzgtOTgwNS00MTQ3MjVhYTQ1NGUiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkdpbXAgMi4xMCAoTGludXgpIgogICAgICBzdEV2dDp3aGVuPSIyMDIzLTA0LTA0VDIxOjE3OjA2KzAzOjAwIi8+CiAgICA8L3JkZjpTZXE+CiAgIDwveG1wTU06SGlzdG9yeT4KICA8L3JkZjpEZXNjcmlwdGlvbj4KIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAKPD94cGFja2V0IGVuZD0idyI/PjDhlzgAAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAB2HEAAdhxAXOfziYAAAAHdElNRQfnBAQSEQYYb0E2AAAB/ElEQVQ4y32TMWiTURSFv/t+24Kpgq2L2qRTMnSsEDBz0Qr5Owg2DlVBREwHq3VxM6A4STsJQQwoYu2Pk2YopZNVMgQKgnYwQWjSdBFbrDVUq33XIYnGNMnd3uGd887jniO0GE3kZOntm+6DnY4Gw5GyJELa7J40AmsjL/qt2rhFh/eJ07erFkVLjpg5QZLHXp0ttBQout5FgSmQnha+NoDr/nTs6R6BClkeAp20HFWQX6CXayKmZrvycjsynyw6puiWETNddL3AXwGrNt7aNij6HbjQnz43I3DDqvYKjAMYTeTEosNtyFaQSX96NLM24vWA3KrgnNRETkw+m/EJ0lfHeQykKv8F4NGHzfepn6MLjlV5AAxU8UA+m/GZhgeXD3R0TXz8tnrVoncVXfzyY3Py9OId+3l7Yxw01ujQBMORsqKl6vno153toaHXN38Hjg/e7jAd0cGFK+WV6PMTIPdApG59hWA4UjaSCKkjZq6KH3LEzBTc2WsAR16e2Sq63mFHnCdAd0MG5yURUlNRk2Q1JABdBjNVWnp3v+h6+xVNAsH/bIusV/F/QVqJzo45YlINWVgGHai3DuyAXvKnY8/2RHnV9c4bMdNWtbfZSo3IulU7USM3LVPR9QJGJG5VTzliArtqESiAzCuaDKRjxbZtrK9zPpvxAbSr8x9XL9B6+Wy/tgAAAABJRU5ErkJggg==");
58 | }
59 | .processing {
60 | background-image: url("data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAwnpUWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYAAHjabVBRDsMgCP3nFDsCAioex65dshvs+MOCS21GwhN45CHA8Xm/4DGMkoDkqqWVgmbSpFG3QNGtn5hQTowEZ7DU4UeQldhe9lRL9M96wkUpdYvyRUifQWwr0ST09SYUg3j8iCzYQ6iFEJMTKQS6r4Wlab2usB24mrrDgLrnk54i91yqXW/PNoeJDk6MhsziH+DhDNwtEMfRaG3dXA2F56p2kH93mgZfQzBZ5e8uSo4AAAGFaUNDUElDQyBwcm9maWxlAAB4nH2RPUjDQBiG36aVilQF7SDikKE62cEfxLFWoQgVQq3QqoPJpX/QpCFJcXEUXAsO/ixWHVycdXVwFQTBHxBXFydFFynxu6TQItY7jnt473tf7r4DhHqZaVYgBmi6baYScTGTXRWDrwjQHEAfJmRmGXOSlETH8XUPH9/vojyrc92fo1fNWQzwicQxZpg28QbxzKZtcN4nDrOirBKfE4+bdEHiR64rHr9xLrgs8MywmU7NE4eJxUIbK23MiqZGPE0cUTWd8oWMxyrnLc5aucqa9+QvDOX0lWWu0xpBAotYggQRCqoooQwbUdp1Uiyk6DzewT/s+iVyKeQqgZFjARVokF0/+B/87q2Vn5r0kkJxoOvFcT5GgeAu0Kg5zvex4zROAP8zcKW3/JU6MPtJeq2lRY6A/m3g4rqlKXvA5Q4w9GTIpuxKflpCPg+8n9E3ZYHBW6Bnzetb8xynD0CaepW8AQ4OgbECZa93eHd3e9/+rWn27wd5NXKpX+xvGgAADXhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDQuNC4wLUV4aXYyIj4KIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIKICAgIHhtbG5zOkdJTVA9Imh0dHA6Ly93d3cuZ2ltcC5vcmcveG1wLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICB4bXBNTTpEb2N1bWVudElEPSJnaW1wOmRvY2lkOmdpbXA6YWM4YTFhNzgtYjlkMC00ZGU5LWI5NGItZjgzNTM2ODRhZmI1IgogICB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOmUyZjBjNDlmLWNiYmUtNDlmMS1hOWIwLTA3Mzg5MWM5NDVlMiIKICAgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjZmMmExZTE2LTZhN2QtNDlkNy05ZDZiLWYxZjZiNWJiZWYyMyIKICAgZGM6Rm9ybWF0PSJpbWFnZS9wbmciCiAgIEdJTVA6QVBJPSIyLjAiCiAgIEdJTVA6UGxhdGZvcm09IkxpbnV4IgogICBHSU1QOlRpbWVTdGFtcD0iMTY4MDYzMjMyNTY3NzIxMCIKICAgR0lNUDpWZXJzaW9uPSIyLjEwLjM0IgogICB0aWZmOk9yaWVudGF0aW9uPSIxIgogICB4bXA6Q3JlYXRvclRvb2w9IkdJTVAgMi4xMCIKICAgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMzowNDowNFQyMToxODo0NSswMzowMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjM6MDQ6MDRUMjE6MTg6NDUrMDM6MDAiPgogICA8eG1wTU06SGlzdG9yeT4KICAgIDxyZGY6U2VxPgogICAgIDxyZGY6bGkKICAgICAgc3RFdnQ6YWN0aW9uPSJzYXZlZCIKICAgICAgc3RFdnQ6Y2hhbmdlZD0iLyIKICAgICAgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo4Njg0ODI2Mi0xZTEwLTQ5YmQtOTE2MS0xMjNjODhmMDJkZTEiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkdpbXAgMi4xMCAoTGludXgpIgogICAgICBzdEV2dDp3aGVuPSIyMDIzLTA0LTA0VDIxOjE4OjQ1KzAzOjAwIi8+CiAgICA8L3JkZjpTZXE+CiAgIDwveG1wTU06SGlzdG9yeT4KICA8L3JkZjpEZXNjcmlwdGlvbj4KIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAKPD94cGFja2V0IGVuZD0idyI/PmGQEksAAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAB2HEAAdhxAXOfziYAAAAHdElNRQfnBAQSEi2f/uu1AAABzUlEQVQ4y32Tv2tTURTHP+c8UWoqaBUECXGSQCF71NZJtIPgKEj6AuJgClrtJC46iJOkU+EhBtJX7T/gUDq2T0twjRUiWKy8SWik1Cz+uMfhpZqkSc52D/f7vd9zz/crDKpaQ3j/YZSR40Yu3SKftX7X5EDn1buzOCthbgrPS+McmMWot4IQULi4PZggjIogZbCx/rKkCdzHn1g6SBBGReAFcHjgWIiB/QK5vU+i/2Qj5eFgPmOuAOyhMk8YZf4TOCsNlg3AD8CneGkZ5AHOnQSZSQhqDcHc1BCwA+bwJzdY2hgDe5i07Qq1hij1OIVIugNQBSrJvAC8ZOtLhTcfPezPAjDe7meoxynteW2TkWOzxFt3MPcUWGe3OceTacf35gzIjV55Si7dwixun8/Q2r3Mo+Jvsqcfc8i7xt3rLapr58GegXWsXbbJpVtKPmuot9LunkB1mcX1ewDcvLBHGJ1CdREY7X7bVslnTdtuCNomATiCSJlP354TRkcxC4Bz3bp1Bwi6jVRdK6Ba6fHCJsh4t3R+gtzCn3jdx8pvp1GZT/bcp1R3cDa7D+4fpjDKIFrC3FVUMziXfBi2CgT4k1+Hp7EzzvU4BTAszn8B8Tiikg6yGxcAAAAASUVORK5CYII=");
61 | }
62 |
--------------------------------------------------------------------------------
/ui/embed.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "embed"
5 | )
6 |
7 | //go:embed */*.html */*.css */*.js
8 | var StaticFiles embed.FS
9 |
--------------------------------------------------------------------------------