├── .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 | 5 | -------------------------------------------------------------------------------- /.idea/git_toolbox_prj.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/go.imports.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/golinter.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 18 | -------------------------------------------------------------------------------- /.idea/jsonSchemas.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/swagger-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | 21 | 22 | 32 | 33 | 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 | --------------------------------------------------------------------------------