├── .devcontainer ├── compose.yaml └── devcontainer.json ├── .env.dev ├── .env.example ├── .github └── workflows │ ├── lint-test-build.yaml │ ├── publish-docker-image.yaml │ └── sync-docker-hub-readme.yaml ├── .gitignore ├── .golangci.yaml ├── .vscode └── project.code-snippets ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── screenshot.png ├── sponsors │ └── FetchGoat.png └── support-project-v1.json ├── cmd ├── app │ ├── init_schedule.go │ └── main.go ├── changepw │ └── main.go └── resetdb │ └── main.go ├── deno.json ├── deno.lock ├── docker ├── Dockerfile ├── Dockerfile.dev └── README.md ├── go.mod ├── go.sum ├── internal ├── config │ ├── env.go │ ├── env_validate.go │ └── version.go ├── cron │ └── cron.go ├── database │ ├── .gitignore │ ├── connect.go │ └── migrations │ │ ├── 20240720055349_enable_extensions.sql │ │ ├── 20240720055620_add_change_updated_at_function.sql │ │ ├── 20240720055717_add_users_table.sql │ │ ├── 20240720055720_add_sessions_table.sql │ │ ├── 20240720055723_add_databases_table.sql │ │ ├── 20240720055730_add_destinations_table.sql │ │ ├── 20240720060503_add_backups_table.sql │ │ ├── 20240720060508_add_executions_table.sql │ │ ├── 20240803171113_add_is_local_to_backups_table.sql │ │ ├── 20240805000451_add_restorations_table.sql │ │ ├── 20240811205655_add_file_size_to_executions_table.sql │ │ ├── 20240816011651_add_test_related_columns.sql │ │ ├── 20240818185840_add_webhooks_tables.sql │ │ ├── 20240908064127_add_hstore_extension.sql │ │ └── 20241014050530_add_v17_to_pg_version_check_in_databases.sql ├── integration │ ├── integration.go │ ├── postgres │ │ └── postgres.go │ └── storage │ │ ├── local.go │ │ ├── s3.go │ │ └── storage.go ├── logger │ ├── REAME.md │ ├── kv.go │ ├── kv_test.go │ ├── logger.go │ └── writer.go ├── service │ ├── auth │ │ ├── auth.go │ │ ├── cookies.go │ │ ├── delete_all_user_sessions.go │ │ ├── delete_all_user_sessions.sql │ │ ├── delete_old_sessions.go │ │ ├── delete_old_sessions.sql │ │ ├── delete_session.go │ │ ├── delete_session.sql │ │ ├── get_user_by_token.go │ │ ├── get_user_by_token.sql │ │ ├── get_user_sessions.go │ │ ├── get_user_sessions.sql │ │ ├── login.go │ │ └── login.sql │ ├── backups │ │ ├── backups.go │ │ ├── create_backup.go │ │ ├── create_backup.sql │ │ ├── delete_backup.go │ │ ├── delete_backup.sql │ │ ├── duplicate_backup.go │ │ ├── duplicate_backup.sql │ │ ├── get_all_backups.go │ │ ├── get_all_backups.sql │ │ ├── get_backup.go │ │ ├── get_backup.sql │ │ ├── get_backups_qty.go │ │ ├── get_backups_qty.sql │ │ ├── job_remove.go │ │ ├── job_upsert.go │ │ ├── paginate_backups.go │ │ ├── paginate_backups.sql │ │ ├── schedule_all.go │ │ ├── schedule_all.sql │ │ ├── toggle_is_active.go │ │ ├── toggle_is_active.sql │ │ ├── update_backup.go │ │ └── update_backup.sql │ ├── databases │ │ ├── create_database.go │ │ ├── create_database.sql │ │ ├── databases.go │ │ ├── delete_database.go │ │ ├── delete_database.sql │ │ ├── get_all_databases.go │ │ ├── get_all_databases.sql │ │ ├── get_database.go │ │ ├── get_database.sql │ │ ├── get_databases_qty.go │ │ ├── get_databases_qty.sql │ │ ├── paginate_databases.go │ │ ├── paginate_databases.sql │ │ ├── test_all_databases.go │ │ ├── test_database.go │ │ ├── test_database.sql │ │ ├── update_database.go │ │ └── update_database.sql │ ├── destinations │ │ ├── create_destination.go │ │ ├── create_destination.sql │ │ ├── delete_destination.go │ │ ├── delete_destination.sql │ │ ├── destinations.go │ │ ├── get_all_destinations.go │ │ ├── get_all_destinations.sql │ │ ├── get_destination.go │ │ ├── get_destination.sql │ │ ├── get_destinations_qty.go │ │ ├── get_destinations_qty.sql │ │ ├── paginate_destinations.go │ │ ├── paginate_destinations.sql │ │ ├── test_all_destinations.go │ │ ├── test_destination.go │ │ ├── test_destination.sql │ │ ├── update_destination.go │ │ └── update_destination.sql │ ├── executions │ │ ├── create_execution.go │ │ ├── create_execution.sql │ │ ├── executions.go │ │ ├── get_execution.go │ │ ├── get_execution.sql │ │ ├── get_execution_download_link_or_path.go │ │ ├── get_execution_download_link_or_path.sql │ │ ├── get_executions_qty.go │ │ ├── get_executions_qty.sql │ │ ├── list_backup_executions.go │ │ ├── list_backup_executions.sql │ │ ├── paginate_executions.go │ │ ├── paginate_executions.sql │ │ ├── run_execution.go │ │ ├── run_execution.sql │ │ ├── soft_delete_execution.go │ │ ├── soft_delete_execution.sql │ │ ├── soft_delete_expired_executions.go │ │ ├── soft_delete_expired_executions.sql │ │ ├── update_execution.go │ │ └── update_execution.sql │ ├── restorations │ │ ├── create_restoration.go │ │ ├── create_restoration.sql │ │ ├── get_restorations_qty.go │ │ ├── get_restorations_qty.sql │ │ ├── paginate_restorations.go │ │ ├── paginate_restorations.sql │ │ ├── restorations.go │ │ ├── run_restoration.go │ │ ├── update_restoration.go │ │ └── update_restoration.sql │ ├── service.go │ ├── users │ │ ├── change_password.go │ │ ├── change_password.sql │ │ ├── create_user.go │ │ ├── create_user.sql │ │ ├── get_user_by_email.go │ │ ├── get_user_by_email.sql │ │ ├── get_users_qty.go │ │ ├── get_users_qty.sql │ │ ├── update_user.go │ │ ├── update_user.sql │ │ └── users.go │ └── webhooks │ │ ├── create_webhook.go │ │ ├── create_webhook.sql │ │ ├── delete_webhook.go │ │ ├── delete_webhook.sql │ │ ├── duplicate_webhook.go │ │ ├── duplicate_webhook.sql │ │ ├── get_webhook.go │ │ ├── get_webhook.sql │ │ ├── paginate_webhook_executions.go │ │ ├── paginate_webhook_executions.sql │ │ ├── paginate_webhooks.go │ │ ├── paginate_webhooks.sql │ │ ├── run_webhook.go │ │ ├── run_webhook.sql │ │ ├── send_webhook_request.go │ │ ├── update_webhook.go │ │ ├── update_webhook.sql │ │ └── webhooks.go ├── staticdata │ ├── timezones.go │ └── timezones_test.go ├── util │ ├── cryptoutil │ │ ├── bcrypt.go │ │ ├── bcrypt_test.go │ │ ├── get_sha256_from_fs.go │ │ ├── get_sha256_from_fs_test.go │ │ └── get_sha256_from_fs_test_data │ │ │ ├── file1.txt │ │ │ ├── file2.txt │ │ │ └── subfolder │ │ │ └── subfolder │ │ │ └── file3.txt │ ├── echoutil │ │ ├── render_nodx.go │ │ └── render_nodx_test.go │ ├── maputil │ │ ├── get_sorted_string_keys.go │ │ └── get_sorted_string_keys_test.go │ ├── numutil │ │ ├── int_with_commas.go │ │ └── int_with_commas_test.go │ ├── paginateutil │ │ ├── README.md │ │ ├── create_offset_from_params.go │ │ ├── create_offset_from_params_test.go │ │ ├── create_paginate_response.go │ │ ├── create_paginate_response_test.go │ │ └── paginate_params.go │ ├── strutil │ │ ├── add_query_param_to_url.go │ │ ├── add_query_param_to_url_test.go │ │ ├── create_path.go │ │ ├── create_path_test.go │ │ ├── format_file_size.go │ │ ├── format_file_size_test.go │ │ ├── get_content_type_from_file_name.go │ │ ├── get_content_type_from_file_name_test.go │ │ ├── remove_leading_slash.go │ │ ├── remove_leading_slash_test.go │ │ ├── remove_trailing_slash.go │ │ └── remove_trailing_slash_test.go │ └── timeutil │ │ ├── layouts.go │ │ └── layouts_test.go ├── validate │ ├── README.md │ ├── cron_expression.go │ ├── cron_expression_test.go │ ├── email.go │ ├── email_test.go │ ├── json.go │ ├── json_test.go │ ├── listen_host.go │ ├── listen_host_test.go │ ├── port.go │ ├── port_test.go │ ├── struct.go │ └── struct_test.go └── view │ ├── api │ ├── health.go │ └── router.go │ ├── middleware │ ├── browser_cache.go │ ├── inject_reqctx.go │ ├── middleware.go │ ├── rate_limit.go │ ├── require_auth.go │ └── require_no_auth.go │ ├── reqctx │ ├── README.md │ ├── ctx.go │ └── ctx_test.go │ ├── router.go │ ├── static │ ├── .gitignore │ ├── css │ │ ├── partials │ │ │ ├── alpine.css │ │ │ ├── general.css │ │ │ ├── htmx.css │ │ │ ├── nodx-lucide.css │ │ │ ├── notyf.css │ │ │ ├── scrollbar.css │ │ │ ├── slim-select.css │ │ │ ├── sweetalert2.css │ │ │ └── tailwind.css │ │ └── style.css │ ├── favicon.ico │ ├── images │ │ ├── logo-black.svg │ │ ├── logo-elephant.png │ │ ├── logo-white.svg │ │ ├── logo.png │ │ ├── plus-circle.png │ │ ├── plus.png │ │ └── third-party │ │ │ ├── digital-ocean.png │ │ │ ├── hapi.png │ │ │ └── vultr.webp │ ├── js │ │ ├── app.js │ │ ├── init-helpers.js │ │ ├── init-htmx.js │ │ ├── init-notyf.js │ │ ├── init-sweetalert2.js │ │ └── init-theme-helper.js │ ├── libs │ │ ├── alpinejs │ │ │ └── alpinejs-3.14.1.min.js │ │ ├── chartjs │ │ │ └── chartjs-4.4.3.umd.min.js │ │ ├── htmx │ │ │ └── htmx-2.0.1.min.js │ │ ├── notyf │ │ │ ├── notyf-3.10.0.min.css │ │ │ └── notyf-3.10.0.min.js │ │ ├── slim-select │ │ │ ├── slimselect-2.8.2.css │ │ │ └── slimselect-2.8.2.min.js │ │ └── sweetalert2 │ │ │ └── sweetalert2-11.13.1.min.js │ ├── robots.txt │ └── static_fs.go │ └── web │ ├── auth │ ├── create_first_user.go │ ├── login.go │ ├── logout.go │ └── router.go │ ├── component │ ├── card_box.go │ ├── change_theme_button.go │ ├── change_theme_button.inc.js │ ├── copy_button.go │ ├── empty_results.go │ ├── enums.go │ ├── help_button_modal.go │ ├── hx_loading.go │ ├── input_control.go │ ├── logotype.go │ ├── modal.go │ ├── options_dropdown.go │ ├── options_dropdown.inc.js │ ├── pg_version_select_options.go │ ├── ping.go │ ├── pretty_destination_name.go │ ├── pretty_file_size.go │ ├── renderable_group.go │ ├── renderable_group_test.go │ ├── select_control.go │ ├── skeleton.go │ ├── spinner.go │ ├── star_on_github.go │ ├── star_on_github.inc.js │ ├── status_badge.go │ ├── support_project.go │ ├── support_project.inc.js │ ├── support_project_sponsors.go │ ├── textarea_control.go │ └── typography.go │ ├── dashboard │ ├── about │ │ ├── index.go │ │ └── router.go │ ├── backups │ │ ├── common.go │ │ ├── create_backup.go │ │ ├── delete_backup.go │ │ ├── duplicate_backup.go │ │ ├── edit_backup.go │ │ ├── index.go │ │ ├── list_backups.go │ │ ├── manual_run.go │ │ └── router.go │ ├── databases │ │ ├── create_database.go │ │ ├── delete_database.go │ │ ├── edit_database.go │ │ ├── index.go │ │ ├── list_databases.go │ │ ├── router.go │ │ └── test_database.go │ ├── destinations │ │ ├── create_destination.go │ │ ├── delete_destination.go │ │ ├── edit_destination.go │ │ ├── index.go │ │ ├── list_destinations.go │ │ ├── router.go │ │ └── test_destination.go │ ├── executions │ │ ├── index.go │ │ ├── list_executions.go │ │ ├── restore_execution.go │ │ ├── router.go │ │ ├── show_execution.go │ │ └── soft_delete_execution.go │ ├── health_button.go │ ├── profile │ │ ├── close_all_sessions.go │ │ ├── index.go │ │ ├── router.go │ │ └── update_user.go │ ├── restorations │ │ ├── index.go │ │ ├── list_restorations.go │ │ ├── router.go │ │ └── show_restoration.go │ ├── router.go │ ├── summary │ │ ├── index.go │ │ ├── index_how_to.go │ │ ├── index_how_to.inc.js │ │ └── router.go │ └── webhooks │ │ ├── common.go │ │ ├── create_webhook.go │ │ ├── delete_webhook.go │ │ ├── duplicate_webhook.go │ │ ├── edit_webhook.go │ │ ├── index.go │ │ ├── list_webhooks.go │ │ ├── router.go │ │ ├── run_webhook.go │ │ └── webhook_executions.go │ ├── layout │ ├── auth.go │ ├── common.go │ ├── dashboard.go │ ├── dashboard_aside.go │ ├── dashboard_aside.inc.js │ ├── dashboard_header.go │ ├── dashboard_header_updates.go │ └── dashboard_header_updates.inc.js │ ├── respondhtmx │ └── respond.go │ └── router.go ├── scripts ├── build-js.ts ├── check_deps.sh ├── sqlc-prebuild.ts └── startup.sh ├── sqlc.yaml ├── tailwind.config.ts └── taskfile.yaml /.devcontainer/compose.yaml: -------------------------------------------------------------------------------- 1 | name: pgbackweb-dev 2 | 3 | services: 4 | devcontainer: 5 | container_name: pbw_devcontainer 6 | build: 7 | context: ../ 8 | dockerfile: ./docker/Dockerfile.dev 9 | ports: 10 | - "8085:8085" 11 | volumes: 12 | - ../..:/workspaces:cached 13 | networks: 14 | - pbw_network 15 | depends_on: 16 | - postgres 17 | - adminer 18 | - minio 19 | 20 | postgres: 21 | container_name: pbw_postgres 22 | image: postgres:16 23 | environment: 24 | POSTGRES_USER: postgres 25 | POSTGRES_DB: pgbackweb 26 | POSTGRES_PASSWORD: password 27 | volumes: 28 | - pbw_vol_postgres:/var/lib/postgresql/data 29 | ports: 30 | - "5432:5432" 31 | networks: 32 | - pbw_network 33 | 34 | adminer: 35 | container_name: pbw_adminer 36 | image: adminer:4.8.1 37 | ports: 38 | - "8080:8080" 39 | environment: 40 | ADMINER_DEFAULT_SERVER: pbw_postgres 41 | ADMINER_DESIGN: "lucas-sandery" 42 | networks: 43 | - pbw_network 44 | 45 | minio: 46 | container_name: pbw_minio 47 | image: minio/minio:RELEASE.2024-02-12T21-02-27Z 48 | ports: 49 | - "9000:9000" 50 | - "9090:9090" 51 | environment: 52 | MINIO_ROOT_USER: "root" 53 | MINIO_ROOT_PASSWORD: "password" 54 | volumes: 55 | - pbw_vol_minio:/data 56 | command: minio server /data/minio --console-address ":9090" 57 | networks: 58 | - pbw_network 59 | 60 | volumes: 61 | pbw_vol_postgres: 62 | pbw_vol_minio: 63 | 64 | networks: 65 | pbw_network: 66 | driver: bridge 67 | -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # ⚠️ Do not use this file in production. This is only for development purposes. 3 | ############################################################################### 4 | 5 | # This values are configured by default in the compose.yaml file, so you can 6 | # copy this file to .env and use it as is (only for development). 7 | 8 | # Encryption key is used to encrypt and decrypt the sensitive data stored 9 | # in the database such as database credentials, secret keys, etc. 10 | PBW_ENCRYPTION_KEY="encryption-key" 11 | 12 | # Database connection string for a PostgreSQL database where the pgbackweb 13 | # will store its data. 14 | PBW_POSTGRES_CONN_STRING="postgresql://postgres:password@pbw_postgres:5432/pgbackweb?sslmode=disable" 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Encryption key is used to encrypt and decrypt the sensitive data stored 2 | # in the database such as database credentials, secret keys, etc. 3 | PBW_ENCRYPTION_KEY="" 4 | 5 | # Database connection string for a PostgreSQL database where the pgbackweb 6 | # will store its data. 7 | PBW_POSTGRES_CONN_STRING="" 8 | 9 | # The host on which the pgbackweb will listen for incoming HTTP requests. 10 | PBW_LISTEN_HOST="" 11 | 12 | # The port on which the pgbackweb will listen for incoming HTTP requests. 13 | PBW_LISTEN_PORT="" 14 | 15 | # Your timezone, this impacts logging, backup filenames and default timezone 16 | # in the web interface. 17 | TZ="" 18 | -------------------------------------------------------------------------------- /.github/workflows/lint-test-build.yaml: -------------------------------------------------------------------------------- 1 | name: Lint, test, and build 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | pull_request: 9 | branches: 10 | - main 11 | - develop 12 | 13 | jobs: 14 | lint-test-build: 15 | strategy: 16 | matrix: 17 | vars: [ 18 | { os: ubuntu-24.04, platform: linux/amd64 }, 19 | { os: ubuntu-24.04-arm, platform: linux/arm64 }, 20 | ] 21 | 22 | name: Lint, test, and build the code 23 | runs-on: ${{ matrix.vars.os }} 24 | timeout-minutes: 20 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Setup Docker buildx for multi-architecture builds 31 | uses: docker/setup-buildx-action@v3 32 | with: 33 | use: true 34 | 35 | - name: Build the Docker image 36 | run: > 37 | docker buildx build 38 | --load 39 | --platform ${{ matrix.vars.platform }} 40 | --tag pgbackweb:latest 41 | --file docker/Dockerfile.dev . 42 | 43 | - name: Run lint, test, and build 44 | run: > 45 | docker run --rm -v $PWD:/app pgbackweb:latest /bin/bash -c " 46 | cd /app && 47 | deno install && 48 | go mod download && 49 | task fixperms && 50 | task check-deps && 51 | task lint-only && 52 | task test-only && 53 | task build 54 | " 55 | -------------------------------------------------------------------------------- /.github/workflows/sync-docker-hub-readme.yaml: -------------------------------------------------------------------------------- 1 | name: Sync Docker Hub README 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - README.md 9 | workflow_dispatch: 10 | 11 | jobs: 12 | run_sync: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Sync Docker Hub README 19 | uses: ms-jpq/sync-dockerhub-readme@v1 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USER }} 22 | password: ${{ secrets.DOCKERHUB_PASS }} 23 | repository: eduardolat/pgbackweb 24 | readme: "./README.md" 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment 2 | .env 3 | .env.* 4 | !.env.dev 5 | !.env.example 6 | 7 | # NodeJS 8 | node_modules/ 9 | 10 | # Distribution folder 11 | dist/ 12 | .idea/ 13 | 14 | # Temp files/folders 15 | tmp/ 16 | temp/ 17 | .task/ 18 | 19 | # Binaries for programs and plugins 20 | *.exe 21 | *.exe~ 22 | *.dll 23 | *.so 24 | *.dylib 25 | 26 | # Test binary, built with `go test -c` 27 | *.test 28 | 29 | # Output of the go coverage tool, specifically when used with LiteIDE 30 | *.out 31 | 32 | # Go workspace file 33 | go.work 34 | go.work.sum -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: "5m" 3 | 4 | linters: 5 | disable-all: true 6 | enable: 7 | - errcheck 8 | - gosimple 9 | - govet 10 | - ineffassign 11 | - staticcheck 12 | - unused 13 | -------------------------------------------------------------------------------- /.vscode/project.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "If error return error": { 3 | "prefix": "rerr", 4 | "body": [ 5 | "if err != nil {", 6 | "\treturn err", 7 | "}", 8 | "" 9 | ], 10 | "description": "Inserts code to check for error and return it" 11 | }, 12 | "Get request context": { 13 | "prefix": "grcr", // Get Request Context Request 14 | "body": [ 15 | "ctx := c.Request().Context()" 16 | ], 17 | "description": "Get's the request from echo context" 18 | }, 19 | "Get request echo context": { 20 | "prefix": "grce", // Get Request Context Echo 21 | "body": [ 22 | "reqCtx := reqctx.GetCtx(c)" 23 | ], 24 | "description": "Inserts code to get echo context from a request" 25 | }, 26 | "Get both request and echo context": { 27 | "prefix": "grcb", // Get Request Context Both 28 | "body": [ 29 | "ctx := c.Request().Context()", 30 | "reqCtx := reqctx.GetCtx(c)" 31 | ], 32 | "description": "Inserts code to get both request and echo context" 33 | }, 34 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Luis Eduardo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eduardolat/pgbackweb/43d8aff1677610d8adfd0fe9c0e1ab24e2d77c00/assets/screenshot.png -------------------------------------------------------------------------------- /assets/sponsors/FetchGoat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eduardolat/pgbackweb/43d8aff1677610d8adfd0fe9c0e1ab24e2d77c00/assets/sponsors/FetchGoat.png -------------------------------------------------------------------------------- /cmd/resetdb/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/eduardolat/pgbackweb/internal/config" 9 | _ "github.com/lib/pq" 10 | ) 11 | 12 | func main() { 13 | log.Println("Are you sure you want to reset the database? (yes/no)") 14 | var answer string 15 | 16 | for answer != "yes" && answer != "no" { 17 | if _, err := fmt.Scanln(&answer); err != nil { 18 | panic(err) 19 | } 20 | if answer == "no" { 21 | log.Println("Exiting...") 22 | return 23 | } 24 | if answer == "yes" { 25 | log.Println("Resetting database...") 26 | break 27 | } 28 | log.Println("Please enter 'yes' or 'no'") 29 | } 30 | 31 | env, err := config.GetEnv() 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | db := connectDB(env) 37 | 38 | _, err = db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public;") 39 | if err != nil { 40 | panic(fmt.Errorf("❌ Could not reset DB: %w", err)) 41 | } 42 | 43 | log.Println("✅ Database reset") 44 | } 45 | 46 | func connectDB(env config.Env) *sql.DB { 47 | db, err := sql.Open("postgres", env.PBW_POSTGRES_CONN_STRING) 48 | if err != nil { 49 | panic(fmt.Errorf("❌ Could not connect to DB: %w", err)) 50 | } 51 | 52 | err = db.Ping() 53 | if err != nil { 54 | panic(fmt.Errorf("❌ Could not ping DB: %w", err)) 55 | } 56 | 57 | db.SetMaxOpenConns(1) 58 | log.Println("✅ Connected to DB") 59 | 60 | return db 61 | } 62 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodeModulesDir": "auto", 3 | "tasks": { 4 | "tailwindcss": "tailwindcss", 5 | "standard": "standard", 6 | "esbuild": "esbuild" 7 | }, 8 | "imports": { 9 | "tailwindcss": "npm:tailwindcss@3.4.6", 10 | "daisyui": "npm:daisyui@4.12.10", 11 | "esbuild": "npm:esbuild@0.23.1", 12 | "fs-extra": "npm:fs-extra@11.2.0", 13 | "glob": "npm:glob@11.0.0" 14 | }, 15 | "fmt": { 16 | "exclude": [ 17 | "./internal/view/static/libs/**/*" 18 | ] 19 | }, 20 | "lint": { 21 | "exclude": [ 22 | "./internal/view/static/libs/**/*" 23 | ], 24 | "rules": { 25 | "exclude": [ 26 | "no-window", 27 | "no-window-prefix" 28 | ] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | This folder stores all Dockerfiles required for the project. It is imperative 4 | that both images have the same dependencies and versions for consistency and 5 | reproducibility across environments. 6 | 7 | ## Dockerfile 8 | 9 | The Dockerfile is used for building the production image that will be published 10 | for production environments. It should only contain what is strictly required to 11 | run the application. 12 | 13 | ## Dockerfile.dev 14 | 15 | The Dockerfile.dev is used for building the development (e.g., devcontainers) 16 | and CI environment image. It includes all dependencies included in Dockerfile 17 | and others needed for development and testing. 18 | -------------------------------------------------------------------------------- /internal/config/env.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/caarlos0/env/v11" 7 | "github.com/joho/godotenv" 8 | ) 9 | 10 | type Env struct { 11 | PBW_ENCRYPTION_KEY string `env:"PBW_ENCRYPTION_KEY,required"` 12 | PBW_POSTGRES_CONN_STRING string `env:"PBW_POSTGRES_CONN_STRING,required"` 13 | PBW_LISTEN_HOST string `env:"PBW_LISTEN_HOST" envDefault:"0.0.0.0"` 14 | PBW_LISTEN_PORT string `env:"PBW_LISTEN_PORT" envDefault:"8085"` 15 | } 16 | 17 | var ( 18 | getEnvRes Env 19 | getEnvErr error 20 | getEnvOnce sync.Once 21 | ) 22 | 23 | // GetEnv returns the environment variables. 24 | // 25 | // If there is an error, it will log it and exit the program. 26 | func GetEnv(disableLogs ...bool) (Env, error) { 27 | getEnvOnce.Do(func() { 28 | _ = godotenv.Load() 29 | 30 | parsedEnv, err := env.ParseAs[Env]() 31 | if err != nil { 32 | getEnvErr = err 33 | return 34 | } 35 | 36 | if err := validateEnv(parsedEnv); err != nil { 37 | getEnvErr = err 38 | return 39 | } 40 | 41 | getEnvRes = parsedEnv 42 | }) 43 | 44 | return getEnvRes, getEnvErr 45 | } 46 | -------------------------------------------------------------------------------- /internal/config/env_validate.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/validate" 7 | ) 8 | 9 | // validateEnv runs additional validations on the environment variables. 10 | func validateEnv(env Env) error { 11 | if !validate.ListenHost(env.PBW_LISTEN_HOST) { 12 | return fmt.Errorf("invalid listen address %s", env.PBW_LISTEN_HOST) 13 | } 14 | 15 | if !validate.Port(env.PBW_LISTEN_PORT) { 16 | return fmt.Errorf("invalid listen port %s, valid values are 1-65535", env.PBW_LISTEN_PORT) 17 | } 18 | 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /internal/config/version.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const Version = "v0.4.2" 4 | -------------------------------------------------------------------------------- /internal/database/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore the generated files to avoid git conflicts 2 | dbgen/ -------------------------------------------------------------------------------- /internal/database/connect.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/config" 7 | "github.com/eduardolat/pgbackweb/internal/logger" 8 | _ "github.com/lib/pq" 9 | ) 10 | 11 | func Connect(env config.Env) *sql.DB { 12 | db, err := sql.Open("postgres", env.PBW_POSTGRES_CONN_STRING) 13 | if err != nil { 14 | logger.FatalError( 15 | "could not connect to DB", 16 | logger.KV{ 17 | "error": err, 18 | }, 19 | ) 20 | } 21 | 22 | err = db.Ping() 23 | if err != nil { 24 | logger.FatalError( 25 | "could not ping DB", 26 | logger.KV{ 27 | "error": err, 28 | }, 29 | ) 30 | } 31 | 32 | db.SetMaxOpenConns(10) 33 | logger.Info("connected to DB") 34 | 35 | return db 36 | } 37 | -------------------------------------------------------------------------------- /internal/database/migrations/20240720055349_enable_extensions.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 4 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 5 | -- +goose StatementEnd 6 | 7 | -- +goose Down 8 | -- +goose StatementBegin 9 | DROP EXTENSION IF EXISTS "uuid-ossp"; 10 | DROP EXTENSION IF EXISTS pgcrypto; 11 | -- +goose StatementEnd 12 | -------------------------------------------------------------------------------- /internal/database/migrations/20240720055620_add_change_updated_at_function.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE OR REPLACE FUNCTION change_updated_at() RETURNS TRIGGER AS $$ 4 | BEGIN 5 | IF row(NEW.*) IS DISTINCT FROM row(OLD.*) THEN 6 | NEW.updated_at = now(); 7 | RETURN NEW; 8 | ELSE 9 | RETURN OLD; 10 | END IF; 11 | END; 12 | $$ language 'plpgsql'; 13 | -- +goose StatementEnd 14 | 15 | -- +goose Down 16 | -- +goose StatementBegin 17 | DROP FUNCTION IF EXISTS change_updated_at(); 18 | -- +goose StatementEnd 19 | -------------------------------------------------------------------------------- /internal/database/migrations/20240720055717_add_users_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE IF NOT EXISTS users ( 4 | id UUID NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY, 5 | 6 | name TEXT NOT NULL, 7 | email TEXT NOT NULL UNIQUE CHECK (email = lower(email)), 8 | password TEXT NOT NULL, 9 | 10 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 11 | updated_at TIMESTAMPTZ 12 | ); 13 | 14 | CREATE TRIGGER users_change_updated_at 15 | BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION change_updated_at(); 16 | -- +goose StatementEnd 17 | 18 | -- +goose Down 19 | -- +goose StatementBegin 20 | DROP TABLE IF EXISTS users; 21 | -- +goose StatementEnd 22 | -------------------------------------------------------------------------------- /internal/database/migrations/20240720055720_add_sessions_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE IF NOT EXISTS sessions ( 4 | id UUID NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY, 5 | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 6 | 7 | token BYTEA NOT NULL, 8 | 9 | ip TEXT NOT NULL, 10 | user_agent TEXT NOT NULL, 11 | 12 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 13 | ); 14 | 15 | CREATE INDEX IF NOT EXISTS 16 | idx_sessions_user_id ON sessions(user_id); 17 | -- +goose StatementEnd 18 | 19 | -- +goose Down 20 | -- +goose StatementBegin 21 | DROP TABLE IF EXISTS sessions; 22 | -- +goose StatementEnd 23 | -------------------------------------------------------------------------------- /internal/database/migrations/20240720055723_add_databases_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE IF NOT EXISTS databases ( 4 | id UUID NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY, 5 | 6 | name TEXT NOT NULL UNIQUE, 7 | connection_string BYTEA NOT NULL, 8 | pg_version TEXT NOT NULL CHECK (pg_version in ('13', '14', '15', '16')), 9 | 10 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 11 | updated_at TIMESTAMPTZ 12 | ); 13 | 14 | CREATE TRIGGER databases_change_updated_at 15 | BEFORE UPDATE ON databases FOR EACH ROW EXECUTE FUNCTION change_updated_at(); 16 | -- +goose StatementEnd 17 | 18 | -- +goose Down 19 | -- +goose StatementBegin 20 | DROP TABLE IF EXISTS databases; 21 | -- +goose StatementEnd 22 | -------------------------------------------------------------------------------- /internal/database/migrations/20240720055730_add_destinations_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE IF NOT EXISTS destinations ( 4 | id UUID NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY, 5 | 6 | name TEXT NOT NULL UNIQUE, 7 | bucket_name TEXT NOT NULL, 8 | access_key BYTEA NOT NULL, 9 | secret_key BYTEA NOT NULL, 10 | region TEXT NOT NULL, 11 | endpoint TEXT NOT NULL, 12 | 13 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 14 | updated_at TIMESTAMPTZ 15 | ); 16 | 17 | CREATE TRIGGER destinations_change_updated_at 18 | BEFORE UPDATE ON destinations FOR EACH ROW EXECUTE FUNCTION change_updated_at(); 19 | -- +goose StatementEnd 20 | 21 | -- +goose Down 22 | -- +goose StatementBegin 23 | DROP TABLE IF EXISTS destinations; 24 | -- +goose StatementEnd 25 | -------------------------------------------------------------------------------- /internal/database/migrations/20240720060503_add_backups_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE IF NOT EXISTS backups ( 4 | id UUID NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY, 5 | database_id UUID NOT NULL REFERENCES databases(id) ON DELETE CASCADE, 6 | destination_id UUID NOT NULL REFERENCES destinations(id) ON DELETE CASCADE, 7 | 8 | name TEXT NOT NULL, 9 | cron_expression TEXT NOT NULL, 10 | time_zone TEXT NOT NULL, 11 | is_active BOOLEAN NOT NULL DEFAULT FALSE, 12 | dest_dir TEXT NOT NULL, 13 | retention_days SMALLINT NOT NULL DEFAULT 0, 14 | 15 | opt_data_only BOOLEAN NOT NULL DEFAULT FALSE, 16 | opt_schema_only BOOLEAN NOT NULL DEFAULT FALSE, 17 | opt_clean BOOLEAN NOT NULL DEFAULT FALSE, 18 | opt_if_exists BOOLEAN NOT NULL DEFAULT FALSE, 19 | opt_create BOOLEAN NOT NULL DEFAULT FALSE, 20 | opt_no_comments BOOLEAN NOT NULL DEFAULT FALSE, 21 | 22 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 23 | updated_at TIMESTAMPTZ 24 | ); 25 | 26 | CREATE TRIGGER backups_change_updated_at 27 | BEFORE UPDATE ON backups FOR EACH ROW EXECUTE FUNCTION change_updated_at(); 28 | 29 | CREATE INDEX IF NOT EXISTS 30 | idx_backups_database_id ON backups(database_id); 31 | 32 | CREATE INDEX IF NOT EXISTS 33 | idx_backups_destination_id ON backups(destination_id); 34 | -- +goose StatementEnd 35 | 36 | -- +goose Down 37 | -- +goose StatementBegin 38 | DROP TABLE IF EXISTS backups; 39 | -- +goose StatementEnd 40 | -------------------------------------------------------------------------------- /internal/database/migrations/20240720060508_add_executions_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE IF NOT EXISTS executions ( 4 | id UUID NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY, 5 | backup_id UUID NOT NULL REFERENCES backups(id) ON DELETE CASCADE, 6 | 7 | status TEXT NOT NULL CHECK ( 8 | status IN ('running', 'success', 'failed', 'deleted') 9 | ) DEFAULT 'running', 10 | message TEXT, 11 | path TEXT, 12 | 13 | started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 14 | updated_at TIMESTAMPTZ, 15 | finished_at TIMESTAMPTZ, 16 | deleted_at TIMESTAMPTZ 17 | ); 18 | 19 | CREATE TRIGGER executions_change_updated_at 20 | BEFORE UPDATE ON executions FOR EACH ROW EXECUTE FUNCTION change_updated_at(); 21 | 22 | CREATE INDEX IF NOT EXISTS 23 | idx_executions_backup_id ON executions(backup_id); 24 | -- +goose StatementEnd 25 | 26 | -- +goose Down 27 | -- +goose StatementBegin 28 | DROP TABLE IF EXISTS executions; 29 | -- +goose StatementEnd 30 | -------------------------------------------------------------------------------- /internal/database/migrations/20240803171113_add_is_local_to_backups_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE backups ALTER COLUMN destination_id DROP NOT NULL; 4 | ALTER TABLE backups ADD COLUMN is_local BOOLEAN NOT NULL DEFAULT FALSE; 5 | ALTER TABLE backups ADD CONSTRAINT backups_destination_check CHECK ( 6 | (is_local = TRUE AND destination_id IS NULL) OR 7 | (is_local = FALSE AND destination_id IS NOT NULL) 8 | ); 9 | -- +goose StatementEnd 10 | 11 | -- +goose Down 12 | -- +goose StatementBegin 13 | ALTER TABLE backups DROP CONSTRAINT backups_destination_check; 14 | ALTER TABLE backups DROP COLUMN is_local; 15 | ALTER TABLE backups ALTER COLUMN destination_id SET NOT NULL; 16 | -- +goose StatementEnd 17 | -------------------------------------------------------------------------------- /internal/database/migrations/20240805000451_add_restorations_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE IF NOT EXISTS restorations ( 4 | id UUID NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY, 5 | execution_id UUID NOT NULL REFERENCES executions(id) ON DELETE CASCADE, 6 | database_id UUID REFERENCES databases(id) ON DELETE CASCADE, 7 | 8 | status TEXT NOT NULL CHECK ( 9 | status IN ('running', 'success', 'failed') 10 | ) DEFAULT 'running', 11 | message TEXT, 12 | 13 | started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 14 | updated_at TIMESTAMPTZ, 15 | finished_at TIMESTAMPTZ 16 | ); 17 | 18 | CREATE TRIGGER restorations_change_updated_at 19 | BEFORE UPDATE ON restorations FOR EACH ROW EXECUTE FUNCTION change_updated_at(); 20 | 21 | CREATE INDEX IF NOT EXISTS 22 | idx_restorations_execution_id ON restorations(execution_id); 23 | -- +goose StatementEnd 24 | 25 | -- +goose Down 26 | -- +goose StatementBegin 27 | DROP TABLE IF EXISTS restorations; 28 | -- +goose StatementEnd 29 | -------------------------------------------------------------------------------- /internal/database/migrations/20240811205655_add_file_size_to_executions_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE executions ADD COLUMN file_size BIGINT NULL DEFAULT NULL; 4 | -- +goose StatementEnd 5 | 6 | -- +goose Down 7 | -- +goose StatementBegin 8 | ALTER TABLE executions DROP COLUMN file_size; 9 | -- +goose StatementEnd 10 | -------------------------------------------------------------------------------- /internal/database/migrations/20240816011651_add_test_related_columns.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE databases 4 | ADD COLUMN IF NOT EXISTS test_ok BOOLEAN, 5 | ADD COLUMN IF NOT EXISTS test_error TEXT, 6 | ADD COLUMN IF NOT EXISTS last_test_at TIMESTAMPTZ; 7 | 8 | ALTER TABLE destinations 9 | ADD COLUMN IF NOT EXISTS test_ok BOOLEAN, 10 | ADD COLUMN IF NOT EXISTS test_error TEXT, 11 | ADD COLUMN IF NOT EXISTS last_test_at TIMESTAMPTZ; 12 | -- +goose StatementEnd 13 | 14 | -- +goose Down 15 | -- +goose StatementBegin 16 | ALTER TABLE databases 17 | DROP COLUMN IF EXISTS test_ok, 18 | DROP COLUMN IF EXISTS test_error, 19 | DROP COLUMN IF EXISTS last_test_at; 20 | 21 | ALTER TABLE destinations 22 | DROP COLUMN IF EXISTS test_ok, 23 | DROP COLUMN IF EXISTS test_error, 24 | DROP COLUMN IF EXISTS last_test_at; 25 | -- +goose StatementEnd 26 | -------------------------------------------------------------------------------- /internal/database/migrations/20240908064127_add_hstore_extension.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE EXTENSION IF NOT EXISTS hstore; 4 | -- +goose StatementEnd 5 | 6 | -- +goose Down 7 | -- +goose StatementBegin 8 | DROP EXTENSION IF EXISTS hstore; 9 | -- +goose StatementEnd 10 | -------------------------------------------------------------------------------- /internal/database/migrations/20241014050530_add_v17_to_pg_version_check_in_databases.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE databases 4 | DROP CONSTRAINT IF EXISTS databases_pg_version_check, 5 | ADD CONSTRAINT databases_pg_version_check 6 | CHECK (pg_version IN ('13', '14', '15', '16', '17')); 7 | -- +goose StatementEnd 8 | 9 | -- +goose Down 10 | -- +goose StatementBegin 11 | ALTER TABLE databases 12 | DROP CONSTRAINT IF EXISTS databases_pg_version_check, 13 | ADD CONSTRAINT databases_pg_version_check 14 | CHECK (pg_version IN ('13', '14', '15', '16')); 15 | -- +goose StatementEnd 16 | -------------------------------------------------------------------------------- /internal/integration/integration.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/integration/postgres" 5 | "github.com/eduardolat/pgbackweb/internal/integration/storage" 6 | ) 7 | 8 | type Integration struct { 9 | PGClient *postgres.Client 10 | StorageClient *storage.Client 11 | } 12 | 13 | func New() *Integration { 14 | pgClient := postgres.New() 15 | storageClient := storage.New() 16 | 17 | return &Integration{ 18 | PGClient: pgClient, 19 | StorageClient: storageClient, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /internal/integration/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | type Client struct{} 4 | 5 | func New() *Client { 6 | return &Client{} 7 | } 8 | -------------------------------------------------------------------------------- /internal/logger/REAME.md: -------------------------------------------------------------------------------- 1 | # Logger 2 | 3 | This package includes a custom logger for the project. 4 | 5 | Functions are exposed for logging messages easily; it is imperative that these 6 | functions are used throughout the project to maintain a standard in log 7 | messages. 8 | 9 | If necessary, the `logWriter` can be edited so that in addition to writing to 10 | stdout, it can also write to a file or send the logs to a server like Better 11 | Stack or New Relic. 12 | -------------------------------------------------------------------------------- /internal/logger/kv.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import "github.com/eduardolat/pgbackweb/internal/util/maputil" 4 | 5 | // KV is a record of key-value pair to be logged 6 | type KV map[string]any 7 | 8 | // kvToArgs converts a slice of KV to a slice of any 9 | func kvToArgs(kv ...KV) []any { 10 | pickedKv := KV{} 11 | if len(kv) > 0 { 12 | pickedKv = kv[0] 13 | } 14 | 15 | sortedKeys := maputil.GetSortedStringKeys(pickedKv) 16 | args := make([]any, 0, len(sortedKeys)*2) 17 | 18 | for _, k := range sortedKeys { 19 | args = append(args, k, pickedKv[k]) 20 | } 21 | return args 22 | } 23 | 24 | // kvToArgsNs converts a slice of KV to a slice of any 25 | // and adds a namespace to the resulting slice 26 | func kvToArgsNs(ns string, kv ...KV) []any { 27 | pickedKv := KV{} 28 | if len(kv) > 0 { 29 | pickedKv = kv[0] 30 | } 31 | 32 | sortedKeys := maputil.GetSortedStringKeys(pickedKv) 33 | args := make([]any, 0, len(sortedKeys)*2+2) 34 | args = append(args, "ns", ns) 35 | for _, k := range sortedKeys { 36 | args = append(args, k, pickedKv[k]) 37 | } 38 | return args 39 | } 40 | -------------------------------------------------------------------------------- /internal/logger/writer.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import "os" 4 | 5 | // logWriter is a simple io.Writer that can redirect logs to os.Stdout 6 | // or any other required place 7 | type logWriter struct{} 8 | 9 | // Write writes the log message to os.Stdout or any other required place 10 | func (w *logWriter) Write(p []byte) (n int, err error) { 11 | return os.Stdout.Write(p) 12 | } 13 | -------------------------------------------------------------------------------- /internal/service/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/config" 7 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 8 | ) 9 | 10 | const ( 11 | maxSessionAge = time.Hour * 12 12 | ) 13 | 14 | type Service struct { 15 | env config.Env 16 | dbgen *dbgen.Queries 17 | } 18 | 19 | func New(env config.Env, dbgen *dbgen.Queries) *Service { 20 | return &Service{ 21 | env: env, 22 | dbgen: dbgen, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/service/auth/cookies.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | const ( 12 | sessionCookieName = "pbw_session" 13 | ) 14 | 15 | func (s *Service) SetSessionCookie(c echo.Context, token string) { 16 | cookie := http.Cookie{ 17 | Name: sessionCookieName, 18 | Value: token, 19 | MaxAge: int(maxSessionAge.Seconds()), 20 | HttpOnly: true, 21 | Path: "/", 22 | } 23 | c.SetCookie(&cookie) 24 | } 25 | 26 | func (s *Service) ClearSessionCookie(c echo.Context) { 27 | cookie := http.Cookie{ 28 | Name: sessionCookieName, 29 | Value: "", 30 | MaxAge: -1, 31 | HttpOnly: true, 32 | Path: "/", 33 | } 34 | c.SetCookie(&cookie) 35 | } 36 | 37 | func (s *Service) GetUserFromSessionCookie(c echo.Context) ( 38 | bool, dbgen.AuthServiceGetUserByTokenRow, error, 39 | ) { 40 | ctx := c.Request().Context() 41 | 42 | cookie, err := c.Cookie(sessionCookieName) 43 | if err != nil && errors.Is(err, http.ErrNoCookie) { 44 | return false, dbgen.AuthServiceGetUserByTokenRow{}, nil 45 | } 46 | if err != nil { 47 | return false, dbgen.AuthServiceGetUserByTokenRow{}, err 48 | } 49 | 50 | if cookie.Value == "" { 51 | return false, dbgen.AuthServiceGetUserByTokenRow{}, nil 52 | } 53 | 54 | return s.GetUserByToken(ctx, cookie.Value) 55 | } 56 | -------------------------------------------------------------------------------- /internal/service/auth/delete_all_user_sessions.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func (s *Service) DeleteAllUserSessions( 10 | ctx context.Context, userID uuid.UUID, 11 | ) error { 12 | return s.dbgen.AuthServiceDeleteAllUserSessions(ctx, userID) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/auth/delete_all_user_sessions.sql: -------------------------------------------------------------------------------- 1 | -- name: AuthServiceDeleteAllUserSessions :exec 2 | DELETE FROM sessions WHERE user_id = @user_id; 3 | -------------------------------------------------------------------------------- /internal/service/auth/delete_old_sessions.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/eduardolat/pgbackweb/internal/logger" 8 | ) 9 | 10 | func (s *Service) DeleteOldSessions() { 11 | ctx := context.Background() 12 | dateThreshold := time.Now().Add(-maxSessionAge) 13 | 14 | err := s.dbgen.AuthServiceDeleteOldSessions(ctx, dateThreshold) 15 | if err != nil { 16 | logger.Error( 17 | "error deleting old sessions", logger.KV{"error": err}, 18 | ) 19 | return 20 | } 21 | 22 | logger.Info("old sessions deleted") 23 | } 24 | -------------------------------------------------------------------------------- /internal/service/auth/delete_old_sessions.sql: -------------------------------------------------------------------------------- 1 | -- name: AuthServiceDeleteOldSessions :exec 2 | DELETE FROM sessions WHERE created_at <= @date_threshold; 3 | -------------------------------------------------------------------------------- /internal/service/auth/delete_session.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func (s *Service) DeleteSession( 10 | ctx context.Context, sessionID uuid.UUID, 11 | ) error { 12 | return s.dbgen.AuthServiceDeleteSession(ctx, sessionID) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/auth/delete_session.sql: -------------------------------------------------------------------------------- 1 | -- name: AuthServiceDeleteSession :exec 2 | DELETE FROM sessions WHERE id = @id; 3 | -------------------------------------------------------------------------------- /internal/service/auth/get_user_by_token.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | 8 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 9 | ) 10 | 11 | func (s *Service) GetUserByToken( 12 | ctx context.Context, token string, 13 | ) (bool, dbgen.AuthServiceGetUserByTokenRow, error) { 14 | user, err := s.dbgen.AuthServiceGetUserByToken( 15 | ctx, dbgen.AuthServiceGetUserByTokenParams{ 16 | Token: token, 17 | EncryptionKey: s.env.PBW_ENCRYPTION_KEY, 18 | }, 19 | ) 20 | if err != nil && errors.Is(err, sql.ErrNoRows) { 21 | return false, user, nil 22 | } 23 | if err != nil { 24 | return false, user, err 25 | } 26 | 27 | return true, user, nil 28 | } 29 | -------------------------------------------------------------------------------- /internal/service/auth/get_user_by_token.sql: -------------------------------------------------------------------------------- 1 | -- name: AuthServiceGetUserByToken :one 2 | SELECT 3 | users.*, 4 | sessions.id as session_id 5 | FROM sessions 6 | JOIN users ON users.id = sessions.user_id 7 | WHERE pgp_sym_decrypt(sessions.token, @encryption_key) = @token::TEXT; 8 | -------------------------------------------------------------------------------- /internal/service/auth/get_user_sessions.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func (s *Service) GetUserSessions( 11 | ctx context.Context, userID uuid.UUID, 12 | ) ([]dbgen.Session, error) { 13 | return s.dbgen.AuthServiceGetUserSessions(ctx, userID) 14 | } 15 | -------------------------------------------------------------------------------- /internal/service/auth/get_user_sessions.sql: -------------------------------------------------------------------------------- 1 | -- name: AuthServiceGetUserSessions :many 2 | SELECT * FROM sessions WHERE user_id = @user_id ORDER BY created_at DESC; 3 | -------------------------------------------------------------------------------- /internal/service/auth/login.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 8 | "github.com/eduardolat/pgbackweb/internal/util/cryptoutil" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | func (s *Service) Login( 13 | ctx context.Context, email, password, ip, userAgent string, 14 | ) (dbgen.AuthServiceLoginCreateSessionRow, error) { 15 | user, err := s.dbgen.AuthServiceLoginGetUserByEmail(ctx, email) 16 | if err != nil { 17 | return dbgen.AuthServiceLoginCreateSessionRow{}, err 18 | } 19 | 20 | if err := cryptoutil.VerifyBcryptHash(password, user.Password); err != nil { 21 | return dbgen.AuthServiceLoginCreateSessionRow{}, fmt.Errorf("invalid password") 22 | } 23 | 24 | session, err := s.dbgen.AuthServiceLoginCreateSession( 25 | ctx, dbgen.AuthServiceLoginCreateSessionParams{ 26 | UserID: user.ID, 27 | Ip: ip, 28 | UserAgent: userAgent, 29 | Token: uuid.NewString(), 30 | EncryptionKey: s.env.PBW_ENCRYPTION_KEY, 31 | }, 32 | ) 33 | if err != nil { 34 | return dbgen.AuthServiceLoginCreateSessionRow{}, err 35 | } 36 | 37 | return session, nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/service/auth/login.sql: -------------------------------------------------------------------------------- 1 | -- name: AuthServiceLoginGetUserByEmail :one 2 | SELECT * FROM users WHERE email = @email; 3 | 4 | -- name: AuthServiceLoginCreateSession :one 5 | INSERT INTO sessions ( 6 | user_id, token, ip, user_agent 7 | ) VALUES ( 8 | @user_id, pgp_sym_encrypt(@token::TEXT, @encryption_key::TEXT), @ip, @user_agent 9 | ) RETURNING *, pgp_sym_decrypt(token, @encryption_key::TEXT) AS decrypted_token; 10 | -------------------------------------------------------------------------------- /internal/service/backups/backups.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/cron" 5 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 6 | "github.com/eduardolat/pgbackweb/internal/service/executions" 7 | ) 8 | 9 | type Service struct { 10 | dbgen *dbgen.Queries 11 | cr *cron.Cron 12 | executionsService *executions.Service 13 | } 14 | 15 | func New( 16 | dbgen *dbgen.Queries, 17 | cr *cron.Cron, 18 | executionsService *executions.Service, 19 | ) *Service { 20 | return &Service{ 21 | dbgen: dbgen, 22 | cr: cr, 23 | executionsService: executionsService, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/service/backups/create_backup.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 8 | "github.com/eduardolat/pgbackweb/internal/validate" 9 | ) 10 | 11 | func (s *Service) CreateBackup( 12 | ctx context.Context, params dbgen.BackupsServiceCreateBackupParams, 13 | ) (dbgen.Backup, error) { 14 | if !validate.CronExpression(params.CronExpression) { 15 | return dbgen.Backup{}, fmt.Errorf("invalid cron expression") 16 | } 17 | 18 | backup, err := s.dbgen.BackupsServiceCreateBackup(ctx, params) 19 | if err != nil { 20 | return backup, err 21 | } 22 | 23 | if !backup.IsActive { 24 | return backup, s.jobRemove(backup.ID) 25 | } 26 | 27 | return backup, s.jobUpsert(backup.ID, backup.TimeZone, backup.CronExpression) 28 | } 29 | -------------------------------------------------------------------------------- /internal/service/backups/create_backup.sql: -------------------------------------------------------------------------------- 1 | -- name: BackupsServiceCreateBackup :one 2 | INSERT INTO backups ( 3 | database_id, destination_id, is_local, name, cron_expression, time_zone, 4 | is_active, dest_dir, retention_days, opt_data_only, opt_schema_only, 5 | opt_clean, opt_if_exists, opt_create, opt_no_comments 6 | ) 7 | VALUES ( 8 | @database_id, @destination_id, @is_local, @name, @cron_expression, @time_zone, 9 | @is_active, @dest_dir, @retention_days, @opt_data_only, @opt_schema_only, 10 | @opt_clean, @opt_if_exists, @opt_create, @opt_no_comments 11 | ) 12 | RETURNING *; 13 | -------------------------------------------------------------------------------- /internal/service/backups/delete_backup.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func (s *Service) DeleteBackup( 10 | ctx context.Context, id uuid.UUID, 11 | ) error { 12 | err := s.jobRemove(id) 13 | if err != nil { 14 | return err 15 | } 16 | 17 | return s.dbgen.BackupsServiceDeleteBackup(ctx, id) 18 | } 19 | -------------------------------------------------------------------------------- /internal/service/backups/delete_backup.sql: -------------------------------------------------------------------------------- 1 | -- name: BackupsServiceDeleteBackup :exec 2 | DELETE FROM backups 3 | WHERE id = @id; 4 | -------------------------------------------------------------------------------- /internal/service/backups/duplicate_backup.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func (s *Service) DuplicateBackup( 11 | ctx context.Context, backupID uuid.UUID, 12 | ) (dbgen.Backup, error) { 13 | return s.dbgen.BackupsServiceDuplicateBackup(ctx, backupID) 14 | } 15 | -------------------------------------------------------------------------------- /internal/service/backups/duplicate_backup.sql: -------------------------------------------------------------------------------- 1 | -- name: BackupsServiceDuplicateBackup :one 2 | INSERT INTO backups 3 | SELECT ( 4 | backups 5 | #= hstore('id', uuid_generate_v4()::text) 6 | #= hstore('name', (backups.name || ' (copy)')::text) 7 | #= hstore('is_active', false::text) 8 | #= hstore('created_at', now()::text) 9 | #= hstore('updated_at', now()::text) 10 | ).* 11 | FROM backups 12 | WHERE backups.id = @backup_id 13 | RETURNING *; 14 | -------------------------------------------------------------------------------- /internal/service/backups/get_all_backups.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) GetAllBackups( 10 | ctx context.Context, 11 | ) ([]dbgen.Backup, error) { 12 | return s.dbgen.BackupsServiceGetAllBackups(ctx) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/backups/get_all_backups.sql: -------------------------------------------------------------------------------- 1 | -- name: BackupsServiceGetAllBackups :many 2 | SELECT * FROM backups 3 | ORDER BY created_at DESC; 4 | -------------------------------------------------------------------------------- /internal/service/backups/get_backup.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func (s *Service) GetBackup( 11 | ctx context.Context, id uuid.UUID, 12 | ) (dbgen.Backup, error) { 13 | return s.dbgen.BackupsServiceGetBackup(ctx, id) 14 | } 15 | -------------------------------------------------------------------------------- /internal/service/backups/get_backup.sql: -------------------------------------------------------------------------------- 1 | -- name: BackupsServiceGetBackup :one 2 | SELECT * FROM backups 3 | WHERE id = @id; 4 | -------------------------------------------------------------------------------- /internal/service/backups/get_backups_qty.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) GetBackupsQty( 10 | ctx context.Context, 11 | ) (dbgen.BackupsServiceGetBackupsQtyRow, error) { 12 | return s.dbgen.BackupsServiceGetBackupsQty(ctx) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/backups/get_backups_qty.sql: -------------------------------------------------------------------------------- 1 | -- name: BackupsServiceGetBackupsQty :one 2 | SELECT 3 | COUNT(*) AS all, 4 | COALESCE(SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END), 0)::INTEGER AS active, 5 | COALESCE(SUM(CASE WHEN is_active = false THEN 1 ELSE 0 END), 0)::INTEGER AS inactive 6 | FROM backups; 7 | -------------------------------------------------------------------------------- /internal/service/backups/job_remove.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import "github.com/google/uuid" 4 | 5 | func (s *Service) jobRemove(backupID uuid.UUID) error { 6 | return s.cr.RemoveJob(backupID) 7 | } 8 | -------------------------------------------------------------------------------- /internal/service/backups/job_upsert.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func (s *Service) jobUpsert( 10 | backupID uuid.UUID, timeZone string, cronExpression string, 11 | ) error { 12 | return s.cr.UpsertJob( 13 | backupID, timeZone, cronExpression, 14 | s.executionsService.RunExecution, context.Background(), backupID, 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /internal/service/backups/paginate_backups.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/eduardolat/pgbackweb/internal/util/paginateutil" 8 | ) 9 | 10 | type PaginateBackupsParams struct { 11 | Page int 12 | Limit int 13 | } 14 | 15 | func (s *Service) PaginateBackups( 16 | ctx context.Context, params PaginateBackupsParams, 17 | ) (paginateutil.PaginateResponse, []dbgen.BackupsServicePaginateBackupsRow, error) { 18 | page := max(params.Page, 1) 19 | limit := min(max(params.Limit, 1), 100) 20 | 21 | count, err := s.dbgen.BackupsServicePaginateBackupsCount(ctx) 22 | if err != nil { 23 | return paginateutil.PaginateResponse{}, nil, err 24 | } 25 | 26 | paginateParams := paginateutil.PaginateParams{ 27 | Page: page, 28 | Limit: limit, 29 | } 30 | offset := paginateutil.CreateOffsetFromParams(paginateParams) 31 | paginateResponse := paginateutil.CreatePaginateResponse(paginateParams, int(count)) 32 | 33 | backups, err := s.dbgen.BackupsServicePaginateBackups( 34 | ctx, dbgen.BackupsServicePaginateBackupsParams{ 35 | Limit: int32(params.Limit), 36 | Offset: int32(offset), 37 | }, 38 | ) 39 | if err != nil { 40 | return paginateutil.PaginateResponse{}, nil, err 41 | } 42 | 43 | return paginateResponse, backups, nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/service/backups/paginate_backups.sql: -------------------------------------------------------------------------------- 1 | -- name: BackupsServicePaginateBackupsCount :one 2 | SELECT COUNT(*) FROM backups; 3 | 4 | -- name: BackupsServicePaginateBackups :many 5 | SELECT 6 | backups.*, 7 | databases.name AS database_name, 8 | destinations.name AS destination_name 9 | FROM backups 10 | INNER JOIN databases ON backups.database_id = databases.id 11 | LEFT JOIN destinations ON backups.destination_id = destinations.id 12 | ORDER BY backups.created_at DESC 13 | LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); 14 | -------------------------------------------------------------------------------- /internal/service/backups/schedule_all.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/logger" 7 | ) 8 | 9 | func (s *Service) ScheduleAll() { 10 | activeBackups, err := s.dbgen.BackupsServiceGetScheduleAllData( 11 | context.Background(), 12 | ) 13 | if err != nil { 14 | logger.Error("error getting all active backups", logger.KV{"error": err}) 15 | } 16 | 17 | for _, backup := range activeBackups { 18 | if !backup.IsActive { 19 | err := s.jobRemove(backup.ID) 20 | if err != nil { 21 | logger.Error("error removing inactive backup", logger.KV{"error": err}) 22 | } 23 | } 24 | 25 | if backup.IsActive { 26 | err := s.jobUpsert(backup.ID, backup.TimeZone, backup.CronExpression) 27 | if err != nil { 28 | logger.Error("error scheduling backup", logger.KV{"error": err}) 29 | } 30 | } 31 | } 32 | 33 | logger.Info("all active backups scheduled") 34 | } 35 | -------------------------------------------------------------------------------- /internal/service/backups/schedule_all.sql: -------------------------------------------------------------------------------- 1 | -- name: BackupsServiceGetScheduleAllData :many 2 | SELECT 3 | id, 4 | is_active, 5 | cron_expression, 6 | time_zone 7 | FROM backups 8 | ORDER BY created_at DESC; 9 | -------------------------------------------------------------------------------- /internal/service/backups/toggle_is_active.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func (s *Service) ToggleIsActive(ctx context.Context, backupID uuid.UUID) error { 10 | backup, err := s.dbgen.BackupsServiceToggleIsActive(ctx, backupID) 11 | if err != nil { 12 | return err 13 | } 14 | 15 | if !backup.IsActive { 16 | return s.jobRemove(backupID) 17 | } 18 | 19 | return s.jobUpsert(backupID, backup.TimeZone, backup.CronExpression) 20 | } 21 | -------------------------------------------------------------------------------- /internal/service/backups/toggle_is_active.sql: -------------------------------------------------------------------------------- 1 | -- name: BackupsServiceToggleIsActive :one 2 | UPDATE backups 3 | SET is_active = NOT is_active 4 | WHERE id = @backup_id 5 | RETURNING *; 6 | -------------------------------------------------------------------------------- /internal/service/backups/update_backup.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 8 | "github.com/eduardolat/pgbackweb/internal/validate" 9 | ) 10 | 11 | func (s *Service) UpdateBackup( 12 | ctx context.Context, params dbgen.BackupsServiceUpdateBackupParams, 13 | ) (dbgen.Backup, error) { 14 | if !validate.CronExpression(params.CronExpression.String) { 15 | return dbgen.Backup{}, fmt.Errorf("invalid cron expression") 16 | } 17 | 18 | backup, err := s.dbgen.BackupsServiceUpdateBackup(ctx, params) 19 | if err != nil { 20 | return backup, err 21 | } 22 | 23 | if !backup.IsActive { 24 | return backup, s.jobRemove(backup.ID) 25 | } 26 | 27 | return backup, s.jobUpsert(backup.ID, backup.TimeZone, backup.CronExpression) 28 | } 29 | -------------------------------------------------------------------------------- /internal/service/backups/update_backup.sql: -------------------------------------------------------------------------------- 1 | -- name: BackupsServiceUpdateBackup :one 2 | UPDATE backups 3 | SET 4 | name = COALESCE(sqlc.narg('name'), @name), 5 | cron_expression = COALESCE(sqlc.narg('cron_expression'), cron_expression), 6 | time_zone = COALESCE(sqlc.narg('time_zone'), time_zone), 7 | is_active = COALESCE(sqlc.narg('is_active'), is_active), 8 | dest_dir = COALESCE(sqlc.narg('dest_dir'), dest_dir), 9 | retention_days = COALESCE(sqlc.narg('retention_days'), retention_days), 10 | opt_data_only = COALESCE(sqlc.narg('opt_data_only'), opt_data_only), 11 | opt_schema_only = COALESCE(sqlc.narg('opt_schema_only'), opt_schema_only), 12 | opt_clean = COALESCE(sqlc.narg('opt_clean'), opt_clean), 13 | opt_if_exists = COALESCE(sqlc.narg('opt_if_exists'), opt_if_exists), 14 | opt_create = COALESCE(sqlc.narg('opt_create'), opt_create), 15 | opt_no_comments = COALESCE(sqlc.narg('opt_no_comments'), opt_no_comments) 16 | WHERE id = @id 17 | RETURNING *; 18 | -------------------------------------------------------------------------------- /internal/service/databases/create_database.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) CreateDatabase( 10 | ctx context.Context, params dbgen.DatabasesServiceCreateDatabaseParams, 11 | ) (dbgen.Database, error) { 12 | err := s.TestDatabase(ctx, params.PgVersion, params.ConnectionString) 13 | if err != nil { 14 | return dbgen.Database{}, err 15 | } 16 | 17 | params.EncryptionKey = s.env.PBW_ENCRYPTION_KEY 18 | db, err := s.dbgen.DatabasesServiceCreateDatabase(ctx, params) 19 | 20 | _ = s.TestDatabaseAndStoreResult(ctx, db.ID) 21 | 22 | return db, err 23 | } 24 | -------------------------------------------------------------------------------- /internal/service/databases/create_database.sql: -------------------------------------------------------------------------------- 1 | -- name: DatabasesServiceCreateDatabase :one 2 | INSERT INTO databases ( 3 | name, connection_string, pg_version 4 | ) 5 | VALUES ( 6 | @name, pgp_sym_encrypt(@connection_string, @encryption_key), @pg_version 7 | ) 8 | RETURNING *; 9 | -------------------------------------------------------------------------------- /internal/service/databases/databases.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/config" 5 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 6 | "github.com/eduardolat/pgbackweb/internal/integration" 7 | "github.com/eduardolat/pgbackweb/internal/service/webhooks" 8 | ) 9 | 10 | type Service struct { 11 | env config.Env 12 | dbgen *dbgen.Queries 13 | ints *integration.Integration 14 | webhooksService *webhooks.Service 15 | } 16 | 17 | func New( 18 | env config.Env, dbgen *dbgen.Queries, ints *integration.Integration, 19 | webhooksService *webhooks.Service, 20 | ) *Service { 21 | return &Service{ 22 | env: env, 23 | dbgen: dbgen, 24 | ints: ints, 25 | webhooksService: webhooksService, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/service/databases/delete_database.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func (s *Service) DeleteDatabase( 10 | ctx context.Context, id uuid.UUID, 11 | ) error { 12 | return s.dbgen.DatabasesServiceDeleteDatabase(ctx, id) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/databases/delete_database.sql: -------------------------------------------------------------------------------- 1 | -- name: DatabasesServiceDeleteDatabase :exec 2 | DELETE FROM databases 3 | WHERE id = @id; 4 | -------------------------------------------------------------------------------- /internal/service/databases/get_all_databases.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) GetAllDatabases( 10 | ctx context.Context, 11 | ) ([]dbgen.DatabasesServiceGetAllDatabasesRow, error) { 12 | return s.dbgen.DatabasesServiceGetAllDatabases( 13 | ctx, s.env.PBW_ENCRYPTION_KEY, 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /internal/service/databases/get_all_databases.sql: -------------------------------------------------------------------------------- 1 | -- name: DatabasesServiceGetAllDatabases :many 2 | SELECT 3 | *, 4 | pgp_sym_decrypt(connection_string, @encryption_key) AS decrypted_connection_string 5 | FROM databases 6 | ORDER BY created_at DESC; 7 | -------------------------------------------------------------------------------- /internal/service/databases/get_database.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // GetDatabase retrieves a database entry by ID. 11 | func (s *Service) GetDatabase( 12 | ctx context.Context, id uuid.UUID, 13 | ) (dbgen.DatabasesServiceGetDatabaseRow, error) { 14 | return s.dbgen.DatabasesServiceGetDatabase( 15 | ctx, dbgen.DatabasesServiceGetDatabaseParams{ 16 | ID: id, 17 | EncryptionKey: s.env.PBW_ENCRYPTION_KEY, 18 | }, 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /internal/service/databases/get_database.sql: -------------------------------------------------------------------------------- 1 | -- name: DatabasesServiceGetDatabase :one 2 | SELECT 3 | *, 4 | pgp_sym_decrypt(connection_string, @encryption_key) AS decrypted_connection_string 5 | FROM databases 6 | WHERE id = @id; 7 | -------------------------------------------------------------------------------- /internal/service/databases/get_databases_qty.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) GetDatabasesQty( 10 | ctx context.Context, 11 | ) (dbgen.DatabasesServiceGetDatabasesQtyRow, error) { 12 | return s.dbgen.DatabasesServiceGetDatabasesQty(ctx) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/databases/get_databases_qty.sql: -------------------------------------------------------------------------------- 1 | -- name: DatabasesServiceGetDatabasesQty :one 2 | SELECT 3 | COUNT(*) AS all, 4 | COALESCE(SUM(CASE WHEN test_ok = true THEN 1 ELSE 0 END), 0)::INTEGER AS healthy, 5 | COALESCE(SUM(CASE WHEN test_ok = false THEN 1 ELSE 0 END), 0)::INTEGER AS unhealthy 6 | FROM databases; 7 | -------------------------------------------------------------------------------- /internal/service/databases/paginate_databases.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/eduardolat/pgbackweb/internal/util/paginateutil" 8 | ) 9 | 10 | type PaginateDatabasesParams struct { 11 | Page int 12 | Limit int 13 | } 14 | 15 | func (s *Service) PaginateDatabases( 16 | ctx context.Context, params PaginateDatabasesParams, 17 | ) (paginateutil.PaginateResponse, []dbgen.DatabasesServicePaginateDatabasesRow, error) { 18 | page := max(params.Page, 1) 19 | limit := min(max(params.Limit, 1), 100) 20 | 21 | count, err := s.dbgen.DatabasesServicePaginateDatabasesCount(ctx) 22 | if err != nil { 23 | return paginateutil.PaginateResponse{}, nil, err 24 | } 25 | 26 | paginateParams := paginateutil.PaginateParams{ 27 | Page: page, 28 | Limit: limit, 29 | } 30 | offset := paginateutil.CreateOffsetFromParams(paginateParams) 31 | paginateResponse := paginateutil.CreatePaginateResponse(paginateParams, int(count)) 32 | 33 | databases, err := s.dbgen.DatabasesServicePaginateDatabases( 34 | ctx, dbgen.DatabasesServicePaginateDatabasesParams{ 35 | EncryptionKey: s.env.PBW_ENCRYPTION_KEY, 36 | Limit: int32(params.Limit), 37 | Offset: int32(offset), 38 | }, 39 | ) 40 | if err != nil { 41 | return paginateutil.PaginateResponse{}, nil, err 42 | } 43 | 44 | return paginateResponse, databases, nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/service/databases/paginate_databases.sql: -------------------------------------------------------------------------------- 1 | -- name: DatabasesServicePaginateDatabasesCount :one 2 | SELECT COUNT(*) FROM databases; 3 | 4 | -- name: DatabasesServicePaginateDatabases :many 5 | SELECT 6 | *, 7 | pgp_sym_decrypt(connection_string, @encryption_key) AS decrypted_connection_string 8 | FROM databases 9 | ORDER BY created_at DESC 10 | LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); 11 | -------------------------------------------------------------------------------- /internal/service/databases/test_all_databases.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/logger" 7 | "golang.org/x/sync/errgroup" 8 | ) 9 | 10 | func (s *Service) TestAllDatabases() { 11 | ctx := context.Background() 12 | 13 | databases, err := s.GetAllDatabases(ctx) 14 | if err != nil { 15 | logger.Error( 16 | "error getting all databases to test them", logger.KV{"error": err}, 17 | ) 18 | return 19 | } 20 | 21 | eg, ctx := errgroup.WithContext(ctx) 22 | eg.SetLimit(5) 23 | 24 | for _, db := range databases { 25 | db := db 26 | eg.Go(func() error { 27 | err := s.TestDatabaseAndStoreResult(ctx, db.ID) 28 | if err != nil { 29 | logger.Error( 30 | "error testing database", 31 | logger.KV{"database_id": db.ID, "error": err}, 32 | ) 33 | } 34 | return nil 35 | }) 36 | } 37 | 38 | _ = eg.Wait() 39 | logger.Info("all databases tested") 40 | 41 | } 42 | -------------------------------------------------------------------------------- /internal/service/databases/test_database.sql: -------------------------------------------------------------------------------- 1 | -- name: DatabasesServiceSetTestData :exec 2 | UPDATE databases 3 | SET test_ok = @test_ok, 4 | test_error = @test_error, 5 | last_test_at = NOW() 6 | WHERE id = @database_id; 7 | -------------------------------------------------------------------------------- /internal/service/databases/update_database.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | // UpdateDatabase updates an existing database entry. 10 | func (s *Service) UpdateDatabase( 11 | ctx context.Context, params dbgen.DatabasesServiceUpdateDatabaseParams, 12 | ) (dbgen.Database, error) { 13 | err := s.TestDatabase( 14 | ctx, params.PgVersion.String, params.ConnectionString.String, 15 | ) 16 | if err != nil { 17 | return dbgen.Database{}, err 18 | } 19 | 20 | params.EncryptionKey = s.env.PBW_ENCRYPTION_KEY 21 | db, err := s.dbgen.DatabasesServiceUpdateDatabase(ctx, params) 22 | 23 | _ = s.TestDatabaseAndStoreResult(ctx, db.ID) 24 | 25 | return db, err 26 | } 27 | -------------------------------------------------------------------------------- /internal/service/databases/update_database.sql: -------------------------------------------------------------------------------- 1 | -- name: DatabasesServiceUpdateDatabase :one 2 | UPDATE databases 3 | SET 4 | name = COALESCE(sqlc.narg('name'), name), 5 | pg_version = COALESCE(sqlc.narg('pg_version'), pg_version), 6 | connection_string = CASE 7 | WHEN sqlc.narg('connection_string')::TEXT IS NOT NULL 8 | THEN pgp_sym_encrypt( 9 | sqlc.narg('connection_string')::TEXT, sqlc.arg('encryption_key')::TEXT 10 | ) 11 | ELSE connection_string 12 | END 13 | WHERE id = @id 14 | RETURNING *; 15 | -------------------------------------------------------------------------------- /internal/service/destinations/create_destination.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) CreateDestination( 10 | ctx context.Context, params dbgen.DestinationsServiceCreateDestinationParams, 11 | ) (dbgen.Destination, error) { 12 | err := s.TestDestination( 13 | params.AccessKey, params.SecretKey, params.Region, params.Endpoint, 14 | params.BucketName, 15 | ) 16 | if err != nil { 17 | return dbgen.Destination{}, err 18 | } 19 | 20 | params.EncryptionKey = s.env.PBW_ENCRYPTION_KEY 21 | dest, err := s.dbgen.DestinationsServiceCreateDestination(ctx, params) 22 | 23 | _ = s.TestDestinationAndStoreResult(ctx, dest.ID) 24 | 25 | return dest, err 26 | } 27 | -------------------------------------------------------------------------------- /internal/service/destinations/create_destination.sql: -------------------------------------------------------------------------------- 1 | -- name: DestinationsServiceCreateDestination :one 2 | INSERT INTO destinations ( 3 | name, bucket_name, region, endpoint, 4 | access_key, secret_key 5 | ) 6 | VALUES ( 7 | @name, @bucket_name, @region, @endpoint, 8 | pgp_sym_encrypt(@access_key, @encryption_key), 9 | pgp_sym_encrypt(@secret_key, @encryption_key) 10 | ) 11 | RETURNING *; 12 | -------------------------------------------------------------------------------- /internal/service/destinations/delete_destination.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func (s *Service) DeleteDestination( 10 | ctx context.Context, id uuid.UUID, 11 | ) error { 12 | return s.dbgen.DestinationsServiceDeleteDestination(ctx, id) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/destinations/delete_destination.sql: -------------------------------------------------------------------------------- 1 | -- name: DestinationsServiceDeleteDestination :exec 2 | DELETE FROM destinations 3 | WHERE id = @id; 4 | -------------------------------------------------------------------------------- /internal/service/destinations/destinations.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/config" 5 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 6 | "github.com/eduardolat/pgbackweb/internal/integration" 7 | "github.com/eduardolat/pgbackweb/internal/service/webhooks" 8 | ) 9 | 10 | type Service struct { 11 | env config.Env 12 | dbgen *dbgen.Queries 13 | ints *integration.Integration 14 | webhooksService *webhooks.Service 15 | } 16 | 17 | func New( 18 | env config.Env, dbgen *dbgen.Queries, ints *integration.Integration, 19 | webhooksService *webhooks.Service, 20 | ) *Service { 21 | return &Service{ 22 | env: env, 23 | dbgen: dbgen, 24 | ints: ints, 25 | webhooksService: webhooksService, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/service/destinations/get_all_destinations.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) GetAllDestinations( 10 | ctx context.Context, 11 | ) ([]dbgen.DestinationsServiceGetAllDestinationsRow, error) { 12 | return s.dbgen.DestinationsServiceGetAllDestinations( 13 | ctx, s.env.PBW_ENCRYPTION_KEY, 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /internal/service/destinations/get_all_destinations.sql: -------------------------------------------------------------------------------- 1 | -- name: DestinationsServiceGetAllDestinations :many 2 | SELECT 3 | *, 4 | pgp_sym_decrypt(access_key, @encryption_key) AS decrypted_access_key, 5 | pgp_sym_decrypt(secret_key, @encryption_key) AS decrypted_secret_key 6 | FROM destinations 7 | ORDER BY created_at DESC; 8 | -------------------------------------------------------------------------------- /internal/service/destinations/get_destination.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func (s *Service) GetDestination( 11 | ctx context.Context, id uuid.UUID, 12 | ) (dbgen.DestinationsServiceGetDestinationRow, error) { 13 | return s.dbgen.DestinationsServiceGetDestination( 14 | ctx, dbgen.DestinationsServiceGetDestinationParams{ 15 | ID: id, 16 | EncryptionKey: s.env.PBW_ENCRYPTION_KEY, 17 | }, 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /internal/service/destinations/get_destination.sql: -------------------------------------------------------------------------------- 1 | -- name: DestinationsServiceGetDestination :one 2 | SELECT 3 | *, 4 | pgp_sym_decrypt(access_key, @encryption_key) AS decrypted_access_key, 5 | pgp_sym_decrypt(secret_key, @encryption_key) AS decrypted_secret_key 6 | FROM destinations 7 | WHERE id = @id; 8 | -------------------------------------------------------------------------------- /internal/service/destinations/get_destinations_qty.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) GetDestinationsQty( 10 | ctx context.Context, 11 | ) (dbgen.DestinationsServiceGetDestinationsQtyRow, error) { 12 | return s.dbgen.DestinationsServiceGetDestinationsQty(ctx) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/destinations/get_destinations_qty.sql: -------------------------------------------------------------------------------- 1 | -- name: DestinationsServiceGetDestinationsQty :one 2 | SELECT 3 | COUNT(*) AS all, 4 | COALESCE(SUM(CASE WHEN test_ok = true THEN 1 ELSE 0 END), 0)::INTEGER AS healthy, 5 | COALESCE(SUM(CASE WHEN test_ok = false THEN 1 ELSE 0 END), 0)::INTEGER AS unhealthy 6 | FROM destinations; 7 | -------------------------------------------------------------------------------- /internal/service/destinations/paginate_destinations.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/eduardolat/pgbackweb/internal/util/paginateutil" 8 | ) 9 | 10 | type PaginateDestinationsParams struct { 11 | Page int 12 | Limit int 13 | } 14 | 15 | func (s *Service) PaginateDestinations( 16 | ctx context.Context, params PaginateDestinationsParams, 17 | ) (paginateutil.PaginateResponse, []dbgen.DestinationsServicePaginateDestinationsRow, error) { 18 | page := max(params.Page, 1) 19 | limit := min(max(params.Limit, 1), 100) 20 | 21 | count, err := s.dbgen.DestinationsServicePaginateDestinationsCount(ctx) 22 | if err != nil { 23 | return paginateutil.PaginateResponse{}, nil, err 24 | } 25 | 26 | paginateParams := paginateutil.PaginateParams{ 27 | Page: page, 28 | Limit: limit, 29 | } 30 | offset := paginateutil.CreateOffsetFromParams(paginateParams) 31 | paginateResponse := paginateutil.CreatePaginateResponse(paginateParams, int(count)) 32 | 33 | destinations, err := s.dbgen.DestinationsServicePaginateDestinations( 34 | ctx, dbgen.DestinationsServicePaginateDestinationsParams{ 35 | EncryptionKey: s.env.PBW_ENCRYPTION_KEY, 36 | Limit: int32(params.Limit), 37 | Offset: int32(offset), 38 | }, 39 | ) 40 | if err != nil { 41 | return paginateutil.PaginateResponse{}, nil, err 42 | } 43 | 44 | return paginateResponse, destinations, nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/service/destinations/paginate_destinations.sql: -------------------------------------------------------------------------------- 1 | -- name: DestinationsServicePaginateDestinationsCount :one 2 | SELECT COUNT(*) FROM destinations; 3 | 4 | -- name: DestinationsServicePaginateDestinations :many 5 | SELECT 6 | *, 7 | pgp_sym_decrypt(access_key, @encryption_key) AS decrypted_access_key, 8 | pgp_sym_decrypt(secret_key, @encryption_key) AS decrypted_secret_key 9 | FROM destinations 10 | ORDER BY created_at DESC 11 | LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); 12 | -------------------------------------------------------------------------------- /internal/service/destinations/test_all_destinations.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/logger" 7 | "golang.org/x/sync/errgroup" 8 | ) 9 | 10 | func (s *Service) TestAllDestinations() { 11 | ctx := context.Background() 12 | 13 | destinations, err := s.GetAllDestinations(ctx) 14 | if err != nil { 15 | logger.Error( 16 | "error getting all destinations to test them", logger.KV{"error": err}, 17 | ) 18 | return 19 | } 20 | 21 | eg, ctx := errgroup.WithContext(ctx) 22 | eg.SetLimit(5) 23 | 24 | for _, dest := range destinations { 25 | dest := dest 26 | eg.Go(func() error { 27 | err := s.TestDestinationAndStoreResult(ctx, dest.ID) 28 | if err != nil { 29 | logger.Error( 30 | "error testing destination", 31 | logger.KV{"destination_id": dest.ID, "error": err}, 32 | ) 33 | } 34 | return nil 35 | }) 36 | } 37 | 38 | _ = eg.Wait() 39 | logger.Info("all destinations tested") 40 | } 41 | -------------------------------------------------------------------------------- /internal/service/destinations/test_destination.sql: -------------------------------------------------------------------------------- 1 | -- name: DestinationsServiceSetTestData :exec 2 | UPDATE destinations 3 | SET test_ok = @test_ok, 4 | test_error = @test_error, 5 | last_test_at = NOW() 6 | WHERE id = @destination_id; 7 | -------------------------------------------------------------------------------- /internal/service/destinations/update_destination.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) UpdateDestination( 10 | ctx context.Context, params dbgen.DestinationsServiceUpdateDestinationParams, 11 | ) (dbgen.Destination, error) { 12 | err := s.TestDestination( 13 | params.AccessKey.String, params.SecretKey.String, params.Region.String, 14 | params.Endpoint.String, params.BucketName.String, 15 | ) 16 | if err != nil { 17 | return dbgen.Destination{}, err 18 | } 19 | 20 | params.EncryptionKey = s.env.PBW_ENCRYPTION_KEY 21 | dest, err := s.dbgen.DestinationsServiceUpdateDestination(ctx, params) 22 | 23 | _ = s.TestDestinationAndStoreResult(ctx, dest.ID) 24 | 25 | return dest, err 26 | } 27 | -------------------------------------------------------------------------------- /internal/service/destinations/update_destination.sql: -------------------------------------------------------------------------------- 1 | -- name: DestinationsServiceUpdateDestination :one 2 | UPDATE destinations 3 | SET 4 | name = COALESCE(sqlc.narg('name'), name), 5 | bucket_name = COALESCE(sqlc.narg('bucket_name'), bucket_name), 6 | region = COALESCE(sqlc.narg('region'), region), 7 | endpoint = COALESCE(sqlc.narg('endpoint'), endpoint), 8 | access_key = CASE 9 | WHEN sqlc.narg('access_key')::TEXT IS NOT NULL 10 | THEN pgp_sym_encrypt(sqlc.narg('access_key')::TEXT, sqlc.arg('encryption_key')::TEXT) 11 | ELSE access_key 12 | END, 13 | secret_key = CASE 14 | WHEN sqlc.narg('secret_key')::TEXT IS NOT NULL 15 | THEN pgp_sym_encrypt(sqlc.narg('secret_key')::TEXT, sqlc.arg('encryption_key')::TEXT) 16 | ELSE secret_key 17 | END 18 | WHERE id = @id 19 | RETURNING *; 20 | -------------------------------------------------------------------------------- /internal/service/executions/create_execution.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) CreateExecution( 10 | ctx context.Context, params dbgen.ExecutionsServiceCreateExecutionParams, 11 | ) (dbgen.Execution, error) { 12 | return s.dbgen.ExecutionsServiceCreateExecution(ctx, params) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/executions/create_execution.sql: -------------------------------------------------------------------------------- 1 | -- name: ExecutionsServiceCreateExecution :one 2 | INSERT INTO executions (backup_id, status, message, path) 3 | VALUES (@backup_id, @status, @message, @path) 4 | RETURNING *; 5 | -------------------------------------------------------------------------------- /internal/service/executions/executions.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/config" 5 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 6 | "github.com/eduardolat/pgbackweb/internal/integration" 7 | "github.com/eduardolat/pgbackweb/internal/service/webhooks" 8 | ) 9 | 10 | type Service struct { 11 | env config.Env 12 | dbgen *dbgen.Queries 13 | ints *integration.Integration 14 | webhooksService *webhooks.Service 15 | } 16 | 17 | func New( 18 | env config.Env, dbgen *dbgen.Queries, ints *integration.Integration, 19 | webhooksService *webhooks.Service, 20 | ) *Service { 21 | return &Service{ 22 | env: env, 23 | dbgen: dbgen, 24 | ints: ints, 25 | webhooksService: webhooksService, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/service/executions/get_execution.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func (s *Service) GetExecution( 11 | ctx context.Context, id uuid.UUID, 12 | ) (dbgen.ExecutionsServiceGetExecutionRow, error) { 13 | return s.dbgen.ExecutionsServiceGetExecution(ctx, id) 14 | } 15 | -------------------------------------------------------------------------------- /internal/service/executions/get_execution.sql: -------------------------------------------------------------------------------- 1 | -- name: ExecutionsServiceGetExecution :one 2 | SELECT 3 | executions.*, 4 | databases.id AS database_id, 5 | databases.pg_version AS database_pg_version 6 | FROM executions 7 | INNER JOIN backups ON backups.id = executions.backup_id 8 | INNER JOIN databases ON databases.id = backups.database_id 9 | WHERE executions.id = @id; 10 | -------------------------------------------------------------------------------- /internal/service/executions/get_execution_download_link_or_path.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | // GetExecutionDownloadLinkOrPath returns a download link for the file associated 13 | // with the given execution. If the execution is stored locally, the link will 14 | // be a file path. 15 | // 16 | // Returns a boolean indicating if the file is locally stored and the download 17 | // link/path. 18 | func (s *Service) GetExecutionDownloadLinkOrPath( 19 | ctx context.Context, executionID uuid.UUID, 20 | ) (bool, string, error) { 21 | data, err := s.dbgen.ExecutionsServiceGetDownloadLinkOrPathData( 22 | ctx, dbgen.ExecutionsServiceGetDownloadLinkOrPathDataParams{ 23 | ExecutionID: executionID, 24 | DecryptionKey: s.env.PBW_ENCRYPTION_KEY, 25 | }, 26 | ) 27 | if err != nil { 28 | return false, "", err 29 | } 30 | 31 | if !data.Path.Valid { 32 | return false, "", fmt.Errorf("execution has no file associated") 33 | } 34 | 35 | if data.IsLocal { 36 | return true, s.ints.StorageClient.LocalGetFullPath(data.Path.String), nil 37 | } 38 | 39 | link, err := s.ints.StorageClient.S3GetDownloadLink( 40 | data.DecryptedAccessKey, data.DecryptedSecretKey, data.Region.String, 41 | data.Endpoint.String, data.BucketName.String, data.Path.String, time.Hour*12, 42 | ) 43 | if err != nil { 44 | return false, "", err 45 | } 46 | return false, link, nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/service/executions/get_execution_download_link_or_path.sql: -------------------------------------------------------------------------------- 1 | -- name: ExecutionsServiceGetDownloadLinkOrPathData :one 2 | SELECT 3 | executions.path AS path, 4 | backups.is_local AS is_local, 5 | destinations.bucket_name AS bucket_name, 6 | destinations.region AS region, 7 | destinations.endpoint AS endpoint, 8 | destinations.endpoint as destination_endpoint, 9 | ( 10 | CASE WHEN destinations.access_key IS NOT NULL 11 | THEN pgp_sym_decrypt(destinations.access_key, sqlc.arg('decryption_key')::TEXT) 12 | ELSE '' 13 | END 14 | ) AS decrypted_access_key, 15 | ( 16 | CASE WHEN destinations.secret_key IS NOT NULL 17 | THEN pgp_sym_decrypt(destinations.secret_key, sqlc.arg('decryption_key')::TEXT) 18 | ELSE '' 19 | END 20 | ) AS decrypted_secret_key 21 | FROM executions 22 | INNER JOIN backups ON backups.id = executions.backup_id 23 | LEFT JOIN destinations ON destinations.id = backups.destination_id 24 | WHERE executions.id = @execution_id; 25 | -------------------------------------------------------------------------------- /internal/service/executions/get_executions_qty.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) GetExecutionsQty( 10 | ctx context.Context, 11 | ) (dbgen.ExecutionsServiceGetExecutionsQtyRow, error) { 12 | return s.dbgen.ExecutionsServiceGetExecutionsQty(ctx) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/executions/get_executions_qty.sql: -------------------------------------------------------------------------------- 1 | -- name: ExecutionsServiceGetExecutionsQty :one 2 | SELECT 3 | COUNT(*) AS all, 4 | COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0)::INTEGER AS running, 5 | COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0)::INTEGER AS success, 6 | COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0)::INTEGER AS failed, 7 | COALESCE(SUM(CASE WHEN status = 'deleted' THEN 1 ELSE 0 END), 0)::INTEGER AS deleted 8 | FROM executions; 9 | -------------------------------------------------------------------------------- /internal/service/executions/list_backup_executions.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func (s *Service) ListBackupExecutions( 11 | ctx context.Context, backupID uuid.UUID, 12 | ) ([]dbgen.Execution, error) { 13 | return s.dbgen.ExecutionsServiceListBackupExecutions(ctx, backupID) 14 | } 15 | -------------------------------------------------------------------------------- /internal/service/executions/list_backup_executions.sql: -------------------------------------------------------------------------------- 1 | -- name: ExecutionsServiceListBackupExecutions :many 2 | SELECT * FROM executions 3 | WHERE backup_id = @backup_id 4 | ORDER BY started_at DESC; 5 | -------------------------------------------------------------------------------- /internal/service/executions/run_execution.sql: -------------------------------------------------------------------------------- 1 | -- name: ExecutionsServiceGetBackupData :one 2 | SELECT 3 | backups.is_active as backup_is_active, 4 | backups.is_local as backup_is_local, 5 | backups.dest_dir as backup_dest_dir, 6 | backups.opt_data_only as backup_opt_data_only, 7 | backups.opt_schema_only as backup_opt_schema_only, 8 | backups.opt_clean as backup_opt_clean, 9 | backups.opt_if_exists as backup_opt_if_exists, 10 | backups.opt_create as backup_opt_create, 11 | backups.opt_no_comments as backup_opt_no_comments, 12 | 13 | pgp_sym_decrypt(databases.connection_string, @encryption_key) AS decrypted_database_connection_string, 14 | databases.pg_version as database_pg_version, 15 | 16 | destinations.bucket_name as destination_bucket_name, 17 | destinations.region as destination_region, 18 | destinations.endpoint as destination_endpoint, 19 | ( 20 | CASE WHEN destinations.access_key IS NOT NULL 21 | THEN pgp_sym_decrypt(destinations.access_key, @encryption_key) 22 | ELSE '' 23 | END 24 | ) AS decrypted_destination_access_key, 25 | ( 26 | CASE WHEN destinations.secret_key IS NOT NULL 27 | THEN pgp_sym_decrypt(destinations.secret_key, @encryption_key) 28 | ELSE '' 29 | END 30 | ) AS decrypted_destination_secret_key 31 | FROM backups 32 | INNER JOIN databases ON backups.database_id = databases.id 33 | LEFT JOIN destinations ON backups.destination_id = destinations.id 34 | WHERE backups.id = @backup_id; 35 | -------------------------------------------------------------------------------- /internal/service/executions/soft_delete_execution.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | 8 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | func (s *Service) SoftDeleteExecution( 13 | ctx context.Context, executionID uuid.UUID, 14 | ) error { 15 | execution, err := s.dbgen.ExecutionsServiceGetExecutionForSoftDelete( 16 | ctx, dbgen.ExecutionsServiceGetExecutionForSoftDeleteParams{ 17 | ExecutionID: executionID, 18 | EncryptionKey: s.env.PBW_ENCRYPTION_KEY, 19 | }, 20 | ) 21 | if err != nil && errors.Is(err, sql.ErrNoRows) { 22 | return nil 23 | } 24 | if err != nil { 25 | return err 26 | } 27 | 28 | if execution.ExecutionPath.Valid && !execution.BackupIsLocal { 29 | err := s.ints.StorageClient.S3Delete( 30 | execution.DecryptedDestinationAccessKey, execution.DecryptedDestinationSecretKey, 31 | execution.DestinationRegion.String, execution.DestinationEndpoint.String, 32 | execution.DestinationBucketName.String, execution.ExecutionPath.String, 33 | ) 34 | if err != nil { 35 | return err 36 | } 37 | } 38 | 39 | if execution.ExecutionPath.Valid && execution.BackupIsLocal { 40 | err := s.ints.StorageClient.LocalDelete(execution.ExecutionPath.String) 41 | if err != nil { 42 | return err 43 | } 44 | } 45 | 46 | return s.dbgen.ExecutionsServiceSoftDeleteExecution(ctx, executionID) 47 | } 48 | -------------------------------------------------------------------------------- /internal/service/executions/soft_delete_execution.sql: -------------------------------------------------------------------------------- 1 | -- name: ExecutionsServiceGetExecutionForSoftDelete :one 2 | SELECT 3 | executions.id as execution_id, 4 | executions.path as execution_path, 5 | 6 | backups.id as backup_id, 7 | backups.is_local as backup_is_local, 8 | 9 | destinations.bucket_name as destination_bucket_name, 10 | destinations.region as destination_region, 11 | destinations.endpoint as destination_endpoint, 12 | ( 13 | CASE WHEN destinations.access_key IS NOT NULL 14 | THEN pgp_sym_decrypt(destinations.access_key, sqlc.arg('encryption_key')::TEXT) 15 | ELSE '' 16 | END 17 | ) AS decrypted_destination_access_key, 18 | ( 19 | CASE WHEN destinations.secret_key IS NOT NULL 20 | THEN pgp_sym_decrypt(destinations.secret_key, sqlc.arg('encryption_key')::TEXT) 21 | ELSE '' 22 | END 23 | ) AS decrypted_destination_secret_key 24 | FROM executions 25 | INNER JOIN backups ON backups.id = executions.backup_id 26 | LEFT JOIN destinations ON destinations.id = backups.destination_id 27 | WHERE executions.id = @execution_id; 28 | 29 | -- name: ExecutionsServiceSoftDeleteExecution :exec 30 | UPDATE executions 31 | SET 32 | status = 'deleted', 33 | deleted_at = NOW() 34 | WHERE id = @id; 35 | -------------------------------------------------------------------------------- /internal/service/executions/soft_delete_expired_executions.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/logger" 7 | ) 8 | 9 | func (s *Service) SoftDeleteExpiredExecutions() { 10 | ctx := context.Background() 11 | 12 | expiredExecutions, err := s.dbgen.ExecutionsServiceGetExpiredExecutions(ctx) 13 | if err != nil { 14 | logger.Error( 15 | "error soft deleting expired executions", 16 | logger.KV{"error": err}, 17 | ) 18 | return 19 | } 20 | 21 | for _, execution := range expiredExecutions { 22 | if err := s.SoftDeleteExecution(ctx, execution.ID); err != nil { 23 | logger.Error( 24 | "error soft deleting expired executions", 25 | logger.KV{"id": execution.ID.String(), "error": err}, 26 | ) 27 | return 28 | } 29 | } 30 | 31 | logger.Info("expired executions soft deleted") 32 | } 33 | -------------------------------------------------------------------------------- /internal/service/executions/soft_delete_expired_executions.sql: -------------------------------------------------------------------------------- 1 | -- name: ExecutionsServiceGetExpiredExecutions :many 2 | SELECT executions.* 3 | FROM executions 4 | JOIN backups ON executions.backup_id = backups.id 5 | WHERE 6 | backups.retention_days > 0 7 | AND executions.status != 'deleted' 8 | AND executions.finished_at IS NOT NULL 9 | AND ( 10 | executions.finished_at + (backups.retention_days || ' days')::INTERVAL 11 | ) < NOW(); 12 | -------------------------------------------------------------------------------- /internal/service/executions/update_execution.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) UpdateExecution( 10 | ctx context.Context, params dbgen.ExecutionsServiceUpdateExecutionParams, 11 | ) (dbgen.Execution, error) { 12 | return s.dbgen.ExecutionsServiceUpdateExecution(ctx, params) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/executions/update_execution.sql: -------------------------------------------------------------------------------- 1 | -- name: ExecutionsServiceUpdateExecution :one 2 | UPDATE executions 3 | SET 4 | status = COALESCE(sqlc.narg('status'), status), 5 | message = COALESCE(sqlc.narg('message'), message), 6 | path = COALESCE(sqlc.narg('path'), path), 7 | finished_at = COALESCE(sqlc.narg('finished_at'), finished_at), 8 | deleted_at = COALESCE(sqlc.narg('deleted_at'), deleted_at), 9 | file_size = COALESCE(sqlc.narg('file_size'), file_size) 10 | WHERE id = @id 11 | RETURNING *; 12 | -------------------------------------------------------------------------------- /internal/service/restorations/create_restoration.go: -------------------------------------------------------------------------------- 1 | package restorations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) CreateRestoration( 10 | ctx context.Context, params dbgen.RestorationsServiceCreateRestorationParams, 11 | ) (dbgen.Restoration, error) { 12 | return s.dbgen.RestorationsServiceCreateRestoration(ctx, params) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/restorations/create_restoration.sql: -------------------------------------------------------------------------------- 1 | -- name: RestorationsServiceCreateRestoration :one 2 | INSERT INTO restorations (execution_id, database_id, status, message) 3 | VALUES (@execution_id, @database_id, @status, @message) 4 | RETURNING *; 5 | -------------------------------------------------------------------------------- /internal/service/restorations/get_restorations_qty.go: -------------------------------------------------------------------------------- 1 | package restorations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) GetRestorationsQty( 10 | ctx context.Context, 11 | ) (dbgen.RestorationsServiceGetRestorationsQtyRow, error) { 12 | return s.dbgen.RestorationsServiceGetRestorationsQty(ctx) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/restorations/get_restorations_qty.sql: -------------------------------------------------------------------------------- 1 | -- name: RestorationsServiceGetRestorationsQty :one 2 | SELECT 3 | COUNT(*) AS all, 4 | COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0)::INTEGER AS running, 5 | COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0)::INTEGER AS success, 6 | COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0)::INTEGER AS failed 7 | FROM restorations; 8 | -------------------------------------------------------------------------------- /internal/service/restorations/paginate_restorations.sql: -------------------------------------------------------------------------------- 1 | -- name: RestorationsServicePaginateRestorationsCount :one 2 | SELECT COUNT(restorations.*) 3 | FROM restorations 4 | INNER JOIN executions ON executions.id = restorations.execution_id 5 | INNER JOIN backups ON backups.id = executions.backup_id 6 | LEFT JOIN databases ON databases.id = restorations.database_id 7 | WHERE 8 | ( 9 | sqlc.narg('execution_id')::UUID IS NULL 10 | OR 11 | restorations.execution_id = sqlc.narg('execution_id')::UUID 12 | ) 13 | AND 14 | ( 15 | sqlc.narg('database_id')::UUID IS NULL 16 | OR 17 | restorations.database_id = sqlc.narg('database_id')::UUID 18 | ); 19 | 20 | -- name: RestorationsServicePaginateRestorations :many 21 | SELECT 22 | restorations.*, 23 | databases.name AS database_name, 24 | backups.name AS backup_name 25 | FROM restorations 26 | INNER JOIN executions ON executions.id = restorations.execution_id 27 | INNER JOIN backups ON backups.id = executions.backup_id 28 | LEFT JOIN databases ON databases.id = restorations.database_id 29 | WHERE 30 | ( 31 | sqlc.narg('execution_id')::UUID IS NULL 32 | OR 33 | restorations.execution_id = sqlc.narg('execution_id')::UUID 34 | ) 35 | AND 36 | ( 37 | sqlc.narg('database_id')::UUID IS NULL 38 | OR 39 | restorations.database_id = sqlc.narg('database_id')::UUID 40 | ) 41 | ORDER BY restorations.started_at DESC 42 | LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); 43 | -------------------------------------------------------------------------------- /internal/service/restorations/restorations.go: -------------------------------------------------------------------------------- 1 | package restorations 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 5 | "github.com/eduardolat/pgbackweb/internal/integration" 6 | "github.com/eduardolat/pgbackweb/internal/service/databases" 7 | "github.com/eduardolat/pgbackweb/internal/service/destinations" 8 | "github.com/eduardolat/pgbackweb/internal/service/executions" 9 | ) 10 | 11 | type Service struct { 12 | dbgen *dbgen.Queries 13 | ints *integration.Integration 14 | executionsService *executions.Service 15 | databasesService *databases.Service 16 | destinationsService *destinations.Service 17 | } 18 | 19 | func New( 20 | dbgen *dbgen.Queries, ints *integration.Integration, 21 | executionsService *executions.Service, databasesService *databases.Service, 22 | destinationsService *destinations.Service, 23 | ) *Service { 24 | return &Service{ 25 | dbgen: dbgen, 26 | ints: ints, 27 | executionsService: executionsService, 28 | databasesService: databasesService, 29 | destinationsService: destinationsService, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/service/restorations/update_restoration.go: -------------------------------------------------------------------------------- 1 | package restorations 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) UpdateRestoration( 10 | ctx context.Context, params dbgen.RestorationsServiceUpdateRestorationParams, 11 | ) (dbgen.Restoration, error) { 12 | return s.dbgen.RestorationsServiceUpdateRestoration(ctx, params) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/restorations/update_restoration.sql: -------------------------------------------------------------------------------- 1 | -- name: RestorationsServiceUpdateRestoration :one 2 | UPDATE restorations 3 | SET 4 | status = COALESCE(sqlc.narg('status'), status), 5 | message = COALESCE(sqlc.narg('message'), message), 6 | finished_at = COALESCE(sqlc.narg('finished_at'), finished_at) 7 | WHERE id = @id 8 | RETURNING *; 9 | -------------------------------------------------------------------------------- /internal/service/users/change_password.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) ChangePassword( 10 | ctx context.Context, params dbgen.UsersServiceChangePasswordParams, 11 | ) error { 12 | return s.dbgen.UsersServiceChangePassword(ctx, params) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/users/change_password.sql: -------------------------------------------------------------------------------- 1 | -- name: UsersServiceChangePassword :exec 2 | UPDATE users 3 | SET password = @password 4 | WHERE id = @id; 5 | -------------------------------------------------------------------------------- /internal/service/users/create_user.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/eduardolat/pgbackweb/internal/util/cryptoutil" 8 | ) 9 | 10 | func (s *Service) CreateUser( 11 | ctx context.Context, params dbgen.UsersServiceCreateUserParams, 12 | ) (dbgen.User, error) { 13 | hash, err := cryptoutil.CreateBcryptHash(params.Password) 14 | if err != nil { 15 | return dbgen.User{}, err 16 | } 17 | params.Password = hash 18 | 19 | return s.dbgen.UsersServiceCreateUser(ctx, params) 20 | } 21 | -------------------------------------------------------------------------------- /internal/service/users/create_user.sql: -------------------------------------------------------------------------------- 1 | -- name: UsersServiceCreateUser :one 2 | INSERT INTO users (name, email, password) 3 | VALUES (@name, lower(@email), @password) 4 | RETURNING *; 5 | -------------------------------------------------------------------------------- /internal/service/users/get_user_by_email.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) GetUserByEmail( 10 | ctx context.Context, email string, 11 | ) (dbgen.User, error) { 12 | return s.dbgen.UsersServiceGetUserByEmail(ctx, email) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/users/get_user_by_email.sql: -------------------------------------------------------------------------------- 1 | -- name: UsersServiceGetUserByEmail :one 2 | SELECT * FROM users WHERE email = @email; 3 | -------------------------------------------------------------------------------- /internal/service/users/get_users_qty.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import "context" 4 | 5 | func (s *Service) GetUsersQty(ctx context.Context) (int64, error) { 6 | return s.dbgen.UsersServiceGetUsersQty(ctx) 7 | } 8 | -------------------------------------------------------------------------------- /internal/service/users/get_users_qty.sql: -------------------------------------------------------------------------------- 1 | -- name: UsersServiceGetUsersQty :one 2 | SELECT COUNT(*) FROM users; 3 | -------------------------------------------------------------------------------- /internal/service/users/update_user.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/eduardolat/pgbackweb/internal/util/cryptoutil" 8 | ) 9 | 10 | func (s *Service) UpdateUser( 11 | ctx context.Context, params dbgen.UsersServiceUpdateUserParams, 12 | ) (dbgen.User, error) { 13 | if params.Password.Valid { 14 | hashedPassword, err := cryptoutil.CreateBcryptHash(params.Password.String) 15 | if err != nil { 16 | return dbgen.User{}, err 17 | } 18 | params.Password.String = hashedPassword 19 | } 20 | 21 | return s.dbgen.UsersServiceUpdateUser(ctx, params) 22 | } 23 | -------------------------------------------------------------------------------- /internal/service/users/update_user.sql: -------------------------------------------------------------------------------- 1 | -- name: UsersServiceUpdateUser :one 2 | UPDATE users 3 | SET 4 | name = COALESCE(sqlc.narg('name'), name), 5 | email = lower(COALESCE(sqlc.narg('email'), email)), 6 | password = COALESCE(sqlc.narg('password'), password) 7 | WHERE id = @id 8 | RETURNING *; 9 | -------------------------------------------------------------------------------- /internal/service/users/users.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import "github.com/eduardolat/pgbackweb/internal/database/dbgen" 4 | 5 | type Service struct { 6 | dbgen *dbgen.Queries 7 | } 8 | 9 | func New(dbgen *dbgen.Queries) *Service { 10 | return &Service{ 11 | dbgen: dbgen, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/webhooks/create_webhook.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) CreateWebhook( 10 | ctx context.Context, params dbgen.WebhooksServiceCreateWebhookParams, 11 | ) (dbgen.Webhook, error) { 12 | return s.dbgen.WebhooksServiceCreateWebhook(ctx, params) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/webhooks/create_webhook.sql: -------------------------------------------------------------------------------- 1 | -- name: WebhooksServiceCreateWebhook :one 2 | INSERT INTO webhooks ( 3 | name, is_active, event_type, target_ids, 4 | url, method, headers, body 5 | ) VALUES ( 6 | @name, @is_active, @event_type, @target_ids, 7 | @url, @method, @headers, @body 8 | ) RETURNING *; 9 | -------------------------------------------------------------------------------- /internal/service/webhooks/delete_webhook.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | func (s *Service) DeleteWebhook( 10 | ctx context.Context, id uuid.UUID, 11 | ) error { 12 | return s.dbgen.WebhooksServiceDeleteWebhook(ctx, id) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/webhooks/delete_webhook.sql: -------------------------------------------------------------------------------- 1 | -- name: WebhooksServiceDeleteWebhook :exec 2 | DELETE FROM webhooks WHERE id = @webhook_id; 3 | -------------------------------------------------------------------------------- /internal/service/webhooks/duplicate_webhook.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func (s *Service) DuplicateWebhook( 11 | ctx context.Context, webhookID uuid.UUID, 12 | ) (dbgen.Webhook, error) { 13 | return s.dbgen.WebhooksServiceDuplicateWebhook(ctx, webhookID) 14 | } 15 | -------------------------------------------------------------------------------- /internal/service/webhooks/duplicate_webhook.sql: -------------------------------------------------------------------------------- 1 | -- name: WebhooksServiceDuplicateWebhook :one 2 | INSERT INTO webhooks 3 | SELECT ( 4 | webhooks 5 | #= hstore('id', uuid_generate_v4()::text) 6 | #= hstore('name', (webhooks.name || ' (copy)')::text) 7 | #= hstore('is_active', false::text) 8 | #= hstore('created_at', now()::text) 9 | #= hstore('updated_at', now()::text) 10 | ).* 11 | FROM webhooks 12 | WHERE webhooks.id = @webhook_id 13 | RETURNING *; 14 | -------------------------------------------------------------------------------- /internal/service/webhooks/get_webhook.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func (s *Service) GetWebhook( 11 | ctx context.Context, id uuid.UUID, 12 | ) (dbgen.Webhook, error) { 13 | return s.dbgen.WebhooksServiceGetWebhook(ctx, id) 14 | } 15 | -------------------------------------------------------------------------------- /internal/service/webhooks/get_webhook.sql: -------------------------------------------------------------------------------- 1 | -- name: WebhooksServiceGetWebhook :one 2 | SELECT * FROM webhooks WHERE id = @webhook_id; 3 | -------------------------------------------------------------------------------- /internal/service/webhooks/paginate_webhook_executions.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/eduardolat/pgbackweb/internal/util/paginateutil" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type PaginateWebhookExecutionsParams struct { 12 | WebhookID uuid.UUID 13 | Page int 14 | Limit int 15 | } 16 | 17 | func (s *Service) PaginateWebhookExecutions( 18 | ctx context.Context, params PaginateWebhookExecutionsParams, 19 | ) (paginateutil.PaginateResponse, []dbgen.WebhookExecution, error) { 20 | page := max(params.Page, 1) 21 | limit := min(max(params.Limit, 1), 100) 22 | 23 | count, err := s.dbgen.WebhooksServicePaginateWebhookExecutionsCount( 24 | ctx, params.WebhookID, 25 | ) 26 | if err != nil { 27 | return paginateutil.PaginateResponse{}, nil, err 28 | } 29 | 30 | paginateParams := paginateutil.PaginateParams{ 31 | Page: page, 32 | Limit: limit, 33 | } 34 | offset := paginateutil.CreateOffsetFromParams(paginateParams) 35 | paginateResponse := paginateutil.CreatePaginateResponse(paginateParams, int(count)) 36 | 37 | webhookExecutions, err := s.dbgen.WebhooksServicePaginateWebhookExecutions( 38 | ctx, dbgen.WebhooksServicePaginateWebhookExecutionsParams{ 39 | WebhookID: params.WebhookID, 40 | Limit: int32(params.Limit), 41 | Offset: int32(offset), 42 | }, 43 | ) 44 | if err != nil { 45 | return paginateutil.PaginateResponse{}, nil, err 46 | } 47 | 48 | return paginateResponse, webhookExecutions, nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/service/webhooks/paginate_webhook_executions.sql: -------------------------------------------------------------------------------- 1 | -- name: WebhooksServicePaginateWebhookExecutionsCount :one 2 | SELECT COUNT(*) FROM webhook_executions 3 | WHERE webhook_id = @webhook_id; 4 | 5 | -- name: WebhooksServicePaginateWebhookExecutions :many 6 | SELECT * FROM webhook_executions 7 | WHERE webhook_id = @webhook_id 8 | ORDER BY created_at DESC 9 | LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); -------------------------------------------------------------------------------- /internal/service/webhooks/paginate_webhooks.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/eduardolat/pgbackweb/internal/util/paginateutil" 8 | ) 9 | 10 | type PaginateWebhooksParams struct { 11 | Page int 12 | Limit int 13 | } 14 | 15 | func (s *Service) PaginateWebhooks( 16 | ctx context.Context, params PaginateWebhooksParams, 17 | ) (paginateutil.PaginateResponse, []dbgen.Webhook, error) { 18 | page := max(params.Page, 1) 19 | limit := min(max(params.Limit, 1), 100) 20 | 21 | count, err := s.dbgen.WebhooksServicePaginateWebhooksCount(ctx) 22 | if err != nil { 23 | return paginateutil.PaginateResponse{}, nil, err 24 | } 25 | 26 | paginateParams := paginateutil.PaginateParams{ 27 | Page: page, 28 | Limit: limit, 29 | } 30 | offset := paginateutil.CreateOffsetFromParams(paginateParams) 31 | paginateResponse := paginateutil.CreatePaginateResponse(paginateParams, int(count)) 32 | 33 | webhooks, err := s.dbgen.WebhooksServicePaginateWebhooks( 34 | ctx, dbgen.WebhooksServicePaginateWebhooksParams{ 35 | Limit: int32(params.Limit), 36 | Offset: int32(offset), 37 | }, 38 | ) 39 | if err != nil { 40 | return paginateutil.PaginateResponse{}, nil, err 41 | } 42 | 43 | return paginateResponse, webhooks, nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/service/webhooks/paginate_webhooks.sql: -------------------------------------------------------------------------------- 1 | -- name: WebhooksServicePaginateWebhooksCount :one 2 | SELECT COUNT(*) FROM webhooks; 3 | 4 | -- name: WebhooksServicePaginateWebhooks :many 5 | SELECT * FROM webhooks 6 | ORDER BY created_at DESC 7 | LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); 8 | -------------------------------------------------------------------------------- /internal/service/webhooks/run_webhook.sql: -------------------------------------------------------------------------------- 1 | -- name: WebhooksServiceGetWebhooksToRun :many 2 | SELECT * FROM webhooks 3 | WHERE is_active = true 4 | AND event_type = @event_type 5 | AND @target_id::UUID = ANY(target_ids); 6 | 7 | -- name: WebhooksServiceCreateWebhookExecution :one 8 | INSERT INTO webhook_executions ( 9 | webhook_id, req_method, req_headers, req_body, 10 | res_status, res_headers, res_body, res_duration 11 | ) 12 | VALUES ( 13 | @webhook_id, @req_method, @req_headers, @req_body, 14 | @res_status, @res_headers, @res_body, @res_duration 15 | ) 16 | RETURNING *; 17 | -------------------------------------------------------------------------------- /internal/service/webhooks/update_webhook.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | ) 8 | 9 | func (s *Service) UpdateWebhook( 10 | ctx context.Context, params dbgen.WebhooksServiceUpdateWebhookParams, 11 | ) (dbgen.Webhook, error) { 12 | return s.dbgen.WebhooksServiceUpdateWebhook(ctx, params) 13 | } 14 | -------------------------------------------------------------------------------- /internal/service/webhooks/update_webhook.sql: -------------------------------------------------------------------------------- 1 | -- name: WebhooksServiceUpdateWebhook :one 2 | UPDATE webhooks 3 | SET 4 | name = COALESCE(sqlc.narg('name'), name), 5 | is_active = COALESCE(sqlc.narg('is_active'), is_active), 6 | event_type = COALESCE(sqlc.narg('event_type'), event_type), 7 | target_ids = COALESCE(sqlc.narg('target_ids'), target_ids), 8 | url = COALESCE(sqlc.narg('url'), url), 9 | method = COALESCE(sqlc.narg('method'), method), 10 | headers = COALESCE(sqlc.narg('headers'), headers), 11 | body = COALESCE(sqlc.narg('body'), body) 12 | WHERE id = @webhook_id 13 | RETURNING *; -------------------------------------------------------------------------------- /internal/staticdata/timezones_test.go: -------------------------------------------------------------------------------- 1 | package staticdata 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestLoadTimezones(t *testing.T) { 11 | for _, tz := range Timezones { 12 | t.Run(tz.Label, func(t *testing.T) { 13 | _, err := time.LoadLocation(tz.TzCode) 14 | assert.NoError(t, err) 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /internal/util/cryptoutil/bcrypt.go: -------------------------------------------------------------------------------- 1 | package cryptoutil 2 | 3 | import ( 4 | "fmt" 5 | 6 | "golang.org/x/crypto/bcrypt" 7 | ) 8 | 9 | // CreateBcryptHash creates a bcrypt hash of the given password 10 | func CreateBcryptHash(password string) (string, error) { 11 | hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 12 | if err != nil { 13 | return "", err 14 | } 15 | return string(hash), nil 16 | } 17 | 18 | // VerifyBcryptHash verifies the given password against the bcrypt hash 19 | func VerifyBcryptHash(password, hash string) error { 20 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 21 | if err != nil { 22 | return fmt.Errorf("invalid password") 23 | } 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/util/cryptoutil/bcrypt_test.go: -------------------------------------------------------------------------------- 1 | package cryptoutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCreateBcryptHash(t *testing.T) { 10 | password := "mysecretpassword" 11 | 12 | hash, err := CreateBcryptHash(password) 13 | assert.NoError(t, err) 14 | assert.NotEmpty(t, hash) 15 | } 16 | 17 | func TestVerifyBcryptHash(t *testing.T) { 18 | password := "mysecretpassword" 19 | 20 | hash, err := CreateBcryptHash(password) 21 | assert.NoError(t, err) 22 | assert.NotEmpty(t, hash) 23 | 24 | err = VerifyBcryptHash(password, hash) 25 | assert.NoError(t, err) 26 | } 27 | 28 | func TestVerifyBcryptHash_InvalidPassword(t *testing.T) { 29 | password := "mysecretpassword" 30 | invalidPassword := "invalidpassword" 31 | 32 | hash, err := CreateBcryptHash(password) 33 | assert.NoError(t, err) 34 | assert.NotEmpty(t, hash) 35 | 36 | err = VerifyBcryptHash(invalidPassword, hash) 37 | assert.Error(t, err) 38 | assert.Equal(t, "invalid password", err.Error()) 39 | } 40 | -------------------------------------------------------------------------------- /internal/util/cryptoutil/get_sha256_from_fs.go: -------------------------------------------------------------------------------- 1 | package cryptoutil 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "io" 7 | "io/fs" 8 | ) 9 | 10 | // GetSHA256FromFS takes a fs.FS and returns a SHA256 hash of the combined contents 11 | // of all files in the filesystem. 12 | // 13 | // If there is an error, it returns an empty string. 14 | func GetSHA256FromFS(fsys fs.FS) string { 15 | hash := sha256.New() 16 | 17 | err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { 18 | if err != nil { 19 | return err 20 | } 21 | 22 | if d.IsDir() { 23 | return nil 24 | } 25 | 26 | file, err := fsys.Open(path) 27 | if err != nil { 28 | return err 29 | } 30 | defer file.Close() 31 | 32 | if _, err := io.Copy(hash, file); err != nil { 33 | return err 34 | } 35 | 36 | return nil 37 | }) 38 | 39 | if err != nil { 40 | return "" 41 | } 42 | 43 | return hex.EncodeToString(hash.Sum(nil)) 44 | } 45 | -------------------------------------------------------------------------------- /internal/util/cryptoutil/get_sha256_from_fs_test.go: -------------------------------------------------------------------------------- 1 | package cryptoutil 2 | 3 | import ( 4 | "crypto/sha256" 5 | "embed" 6 | "encoding/hex" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | //go:embed get_sha256_from_fs_test_data/* 13 | var testFS embed.FS 14 | 15 | func TestGetSHA256FromFS_ValidFS(t *testing.T) { 16 | hash := GetSHA256FromFS(testFS) 17 | assert.NotEmpty(t, hash) 18 | 19 | // To generate the expected hash, you must combine the contents of all the 20 | // files in the test_data directory and calculate the SHA256 hash of the 21 | // resulting string. 22 | expectedHash := "d2c58b6783050a95542286a58250d4dc872877a6cf28610669516dcfacf954af" 23 | assert.Equal(t, expectedHash, hash) 24 | } 25 | 26 | func TestGetSHA256FromFS_EmptyFS(t *testing.T) { 27 | var emptyFS embed.FS 28 | hash := GetSHA256FromFS(emptyFS) 29 | 30 | expectedHash := sha256.New().Sum(nil) 31 | assert.Equal(t, hex.EncodeToString(expectedHash), hash) 32 | } 33 | -------------------------------------------------------------------------------- /internal/util/cryptoutil/get_sha256_from_fs_test_data/file1.txt: -------------------------------------------------------------------------------- 1 | file 1 contents -------------------------------------------------------------------------------- /internal/util/cryptoutil/get_sha256_from_fs_test_data/file2.txt: -------------------------------------------------------------------------------- 1 | file 2 contents -------------------------------------------------------------------------------- /internal/util/cryptoutil/get_sha256_from_fs_test_data/subfolder/subfolder/file3.txt: -------------------------------------------------------------------------------- 1 | file 3 contents -------------------------------------------------------------------------------- /internal/util/echoutil/render_nodx.go: -------------------------------------------------------------------------------- 1 | package echoutil 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | type renderer interface { 12 | Render(w io.Writer) error 13 | } 14 | 15 | // RenderNodx renders a NodX component to the response of 16 | // the echo context. 17 | func RenderNodx(c echo.Context, code int, component renderer) error { 18 | if component == nil { 19 | return c.NoContent(code) 20 | } 21 | 22 | buf := bytes.Buffer{} 23 | err := component.Render(&buf) 24 | if err != nil { 25 | return c.String(http.StatusInternalServerError, err.Error()) 26 | } 27 | return c.HTML(code, buf.String()) 28 | } 29 | -------------------------------------------------------------------------------- /internal/util/maputil/get_sorted_string_keys.go: -------------------------------------------------------------------------------- 1 | package maputil 2 | 3 | import "sort" 4 | 5 | // GetSortedStringKeys returns the keys of a map sorted in lexicographical order. 6 | func GetSortedStringKeys[T any](m map[string]T) []string { 7 | keys := make([]string, 0, len(m)) 8 | for k := range m { 9 | keys = append(keys, k) 10 | } 11 | sort.Strings(keys) 12 | return keys 13 | } 14 | -------------------------------------------------------------------------------- /internal/util/maputil/get_sorted_string_keys_test.go: -------------------------------------------------------------------------------- 1 | package maputil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetSortedStringKeys(t *testing.T) { 10 | // Create a map with unsorted keys 11 | m := map[string]any{ 12 | "banana": 1, 13 | "apple": 2, 14 | "cherry": 3, 15 | } 16 | 17 | // Call the function 18 | keys := GetSortedStringKeys(m) 19 | 20 | // Assert that the keys are sorted 21 | expectedKeys := []string{"apple", "banana", "cherry"} 22 | assert.Equal(t, expectedKeys, keys) 23 | } 24 | 25 | func TestGetSortedStringKeysEmptyMap(t *testing.T) { 26 | // Create an empty map 27 | m := map[string]any{} 28 | 29 | // Call the function 30 | keys := GetSortedStringKeys(m) 31 | 32 | // Assert that the keys are empty 33 | assert.Empty(t, keys) 34 | } 35 | -------------------------------------------------------------------------------- /internal/util/numutil/int_with_commas.go: -------------------------------------------------------------------------------- 1 | package numutil 2 | 3 | import "fmt" 4 | 5 | // IntWithCommas returns a string representation of an integer with commas. 6 | // 7 | // Example: 8 | // 9 | // 12345 -> "12,345" 10 | func IntWithCommas[T int | int32 | int64 | uint | uint32 | uint64](i T) string { 11 | if i < 0 { 12 | return "-" + IntWithCommas(-i) 13 | } 14 | if i < 1000 { 15 | return fmt.Sprintf("%d", i) 16 | } 17 | return IntWithCommas(i/1000) + "," + fmt.Sprintf("%03d", i%1000) 18 | } 19 | -------------------------------------------------------------------------------- /internal/util/paginateutil/create_offset_from_params.go: -------------------------------------------------------------------------------- 1 | package paginateutil 2 | 3 | // CreateOffsetFromParams creates an offset from the given 4 | // PaginateParams. 5 | func CreateOffsetFromParams(paginateParams PaginateParams) int { 6 | if paginateParams.Page <= 0 || paginateParams.Limit <= 0 { 7 | return 0 8 | } 9 | 10 | return (paginateParams.Page - 1) * paginateParams.Limit 11 | } 12 | -------------------------------------------------------------------------------- /internal/util/paginateutil/create_offset_from_params_test.go: -------------------------------------------------------------------------------- 1 | package paginateutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCreateOffsetFromParams(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | params PaginateParams 13 | expected int 14 | }{ 15 | { 16 | name: "Test 1: Page 1, Limit 10", 17 | params: PaginateParams{ 18 | Page: 1, 19 | Limit: 10, 20 | }, 21 | expected: 0, 22 | }, 23 | { 24 | name: "Test 2: Page 2, Limit 10", 25 | params: PaginateParams{ 26 | Page: 2, 27 | Limit: 10, 28 | }, 29 | expected: 10, 30 | }, 31 | { 32 | name: "Test 3: Page 3, Limit 20", 33 | params: PaginateParams{ 34 | Page: 3, 35 | Limit: 20, 36 | }, 37 | expected: 40, 38 | }, 39 | { 40 | name: "Test 4: Page 0, Limit 10", 41 | params: PaginateParams{ 42 | Page: 0, 43 | Limit: 10, 44 | }, 45 | expected: 0, 46 | }, 47 | { 48 | name: "Test 5: Page 5, Limit 0", 49 | params: PaginateParams{ 50 | Page: 5, 51 | Limit: 0, 52 | }, 53 | expected: 0, 54 | }, 55 | { 56 | name: "Test 6: Page 0, Limit 0", 57 | params: PaginateParams{ 58 | Page: 0, 59 | Limit: 0, 60 | }, 61 | expected: 0, 62 | }, 63 | } 64 | 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | result := CreateOffsetFromParams(tt.params) 68 | assert.Equal(t, tt.expected, result) 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /internal/util/paginateutil/create_paginate_response.go: -------------------------------------------------------------------------------- 1 | package paginateutil 2 | 3 | // PaginateResponse is the response for a paginated request. 4 | type PaginateResponse struct { 5 | TotalItems int `json:"total_items"` 6 | TotalPages int `json:"total_pages"` 7 | ItemsPerPage int `json:"items_per_page"` 8 | PreviousPage int `json:"previous_page"` 9 | HasPreviousPage bool `json:"has_previous_page"` 10 | CurrentPage int `json:"current_page"` 11 | NextPage int `json:"next_page"` 12 | HasNextPage bool `json:"has_next_page"` 13 | } 14 | 15 | // CreatePaginateResponse creates a PaginateResponse from 16 | // the given parameters. 17 | func CreatePaginateResponse( 18 | paginateParams PaginateParams, 19 | totalItems int, 20 | ) PaginateResponse { 21 | limit := paginateParams.Limit 22 | 23 | totalPages := totalItems / limit 24 | if totalItems%limit != 0 { 25 | totalPages++ 26 | } 27 | 28 | currentPage := paginateParams.Page 29 | previousPage := currentPage - 1 30 | nextPage := currentPage + 1 31 | 32 | if previousPage <= 0 { 33 | previousPage = 0 34 | } 35 | 36 | if totalPages < nextPage { 37 | nextPage = 0 38 | } 39 | 40 | hasPreviousPage := previousPage > 0 41 | hasNextPage := nextPage > 0 42 | 43 | return PaginateResponse{ 44 | TotalItems: totalItems, 45 | TotalPages: totalPages, 46 | ItemsPerPage: limit, 47 | PreviousPage: previousPage, 48 | HasPreviousPage: hasPreviousPage, 49 | CurrentPage: currentPage, 50 | NextPage: nextPage, 51 | HasNextPage: hasNextPage, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/util/paginateutil/paginate_params.go: -------------------------------------------------------------------------------- 1 | package paginateutil 2 | 3 | // PaginateParams are the parameters for a paginated request. 4 | type PaginateParams struct { 5 | Limit int `query:"limit" form:"limit" json:"limit"` 6 | Page int `query:"page" form:"page" json:"page"` 7 | } 8 | -------------------------------------------------------------------------------- /internal/util/strutil/add_query_param_to_url.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import ( 4 | "fmt" 5 | nurl "net/url" 6 | "strings" 7 | ) 8 | 9 | func AddQueryParamToUrl(url, key, value string) string { 10 | if url == "" { 11 | return "" 12 | } 13 | 14 | if key == "" || value == "" { 15 | return url 16 | } 17 | 18 | value = nurl.PathEscape(value) 19 | 20 | if !strings.Contains(url, "?") { 21 | return fmt.Sprintf("%s?%s=%s", url, key, value) 22 | } 23 | if strings.HasSuffix(url, "?") || strings.HasSuffix(url, "&") { 24 | return fmt.Sprintf("%s%s=%s", url, key, value) 25 | } 26 | return fmt.Sprintf("%s&%s=%s", url, key, value) 27 | } 28 | -------------------------------------------------------------------------------- /internal/util/strutil/create_path.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | // CreatePath creates a path from the given parts. 4 | // 5 | // If addLeadingSlash is true, a leading slash will be added to the path. 6 | // 7 | // All colliding slashes will be converted to a single slash. 8 | func CreatePath(addLeadingSlash bool, parts ...string) string { 9 | var path string 10 | 11 | for i, part := range parts { 12 | if part == "" { 13 | continue 14 | } 15 | 16 | cleanPart := RemoveLeadingSlash(part) 17 | path += "/" + cleanPart 18 | 19 | // Remove trailing slashes for all parts except the last one 20 | if i != len(parts)-1 { 21 | path = RemoveTrailingSlash(path) 22 | } 23 | } 24 | 25 | if !addLeadingSlash { 26 | path = RemoveLeadingSlash(path) 27 | } 28 | 29 | return path 30 | } 31 | -------------------------------------------------------------------------------- /internal/util/strutil/format_file_size.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import "fmt" 4 | 5 | // FormatFileSize pretty prints a file size (in bytes) to a human-readable format 6 | // 7 | // e.g. 1024 -> 1 KB 8 | func FormatFileSize(size int64) string { 9 | if size < 1024 { 10 | return fmt.Sprintf("%d B", size) 11 | } 12 | 13 | if size < 1024*1024 { 14 | return fmt.Sprintf("%.2f KB", float64(size)/1024) 15 | } 16 | 17 | if size < 1024*1024*1024 { 18 | return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024)) 19 | } 20 | 21 | return fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024)) 22 | } 23 | -------------------------------------------------------------------------------- /internal/util/strutil/format_file_size_test.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestFormatFileSize(t *testing.T) { 9 | tests := []struct { 10 | size int64 11 | expected string 12 | }{ 13 | {size: 0, expected: "0 B"}, 14 | {size: 1, expected: "1 B"}, 15 | {size: 1023, expected: "1023 B"}, 16 | {size: 1024, expected: "1.00 KB"}, 17 | {size: 1024*1024 - 10, expected: "1023.99 KB"}, 18 | {size: 1024 * 1024, expected: "1.00 MB"}, 19 | {size: 1024*1024*1024 - 10_000, expected: "1023.99 MB"}, 20 | {size: 1024 * 1024 * 1024, expected: "1.00 GB"}, 21 | {size: 1024*1024*1024*1024 - 10_000_000, expected: "1023.99 GB"}, 22 | } 23 | 24 | for _, test := range tests { 25 | t.Run(test.expected, func(t *testing.T) { 26 | assert.Equal(t, test.expected, FormatFileSize(test.size)) 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/util/strutil/get_content_type_from_file_name.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import "strings" 4 | 5 | // GetContentTypeFromFileName returns the content type 6 | // of a file based on its file extension from the name. 7 | // 8 | // If the file extension is not recognized, it returns 9 | // "application/octet-stream". 10 | func GetContentTypeFromFileName(fileName string) string { 11 | fileName = strings.ToLower(fileName) 12 | 13 | if strings.HasSuffix(fileName, ".pdf") { 14 | return "application/pdf" 15 | } 16 | 17 | if strings.HasSuffix(fileName, ".png") { 18 | return "image/png" 19 | } 20 | 21 | if strings.HasSuffix(fileName, ".jpg") || strings.HasSuffix(fileName, ".jpeg") { 22 | return "image/jpeg" 23 | } 24 | 25 | if strings.HasSuffix(fileName, ".gif") { 26 | return "image/gif" 27 | } 28 | 29 | if strings.HasSuffix(fileName, ".bmp") { 30 | return "image/bmp" 31 | } 32 | 33 | if strings.HasSuffix(fileName, ".json") { 34 | return "application/json" 35 | } 36 | 37 | if strings.HasSuffix(fileName, ".csv") { 38 | return "text/csv" 39 | } 40 | 41 | if strings.HasSuffix(fileName, ".xml") { 42 | return "application/xml" 43 | } 44 | 45 | if strings.HasSuffix(fileName, ".txt") { 46 | return "text/plain" 47 | } 48 | 49 | if strings.HasSuffix(fileName, ".html") { 50 | return "text/html" 51 | } 52 | 53 | if strings.HasSuffix(fileName, ".zip") { 54 | return "application/zip" 55 | } 56 | 57 | if strings.HasSuffix(fileName, ".sql") { 58 | return "application/sql" 59 | } 60 | 61 | return "application/octet-stream" 62 | } 63 | -------------------------------------------------------------------------------- /internal/util/strutil/get_content_type_from_file_name_test.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetContentTypeFromFileName(t *testing.T) { 10 | tests := []struct { 11 | fileName string 12 | expected string 13 | }{ 14 | {"documento.pdf", "application/pdf"}, 15 | {"imagen.png", "image/png"}, 16 | {"foto.jpg", "image/jpeg"}, 17 | {"foto.jpeg", "image/jpeg"}, 18 | {"animacion.gif", "image/gif"}, 19 | {"imagen.bmp", "image/bmp"}, 20 | {"datos.json", "application/json"}, 21 | {"tabla.csv", "text/csv"}, 22 | {"config.xml", "application/xml"}, 23 | {"nota.txt", "text/plain"}, 24 | {"pagina.html", "text/html"}, 25 | {"archivo.zip", "application/zip"}, 26 | {"archivo.sql", "application/sql"}, 27 | {"archivo.desconocido", "application/octet-stream"}, // unknown extension 28 | {"MAYUSCULAS.JPG", "image/jpeg"}, // upper case 29 | {"MezclaDeMayusculasYMinusculas.PnG", "image/png"}, // mixed case 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.fileName, func(t *testing.T) { 34 | result := GetContentTypeFromFileName(tt.fileName) 35 | assert.Equal(t, tt.expected, result) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/util/strutil/remove_leading_slash.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | // RemoveLeadingSlash removes the leading slash from a string. 4 | func RemoveLeadingSlash(str string) string { 5 | if len(str) > 0 && str[0] == '/' { 6 | return str[1:] 7 | } 8 | 9 | return str 10 | } 11 | -------------------------------------------------------------------------------- /internal/util/strutil/remove_leading_slash_test.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import "testing" 4 | 5 | func TestRemoveLeadingSlash(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | str string 9 | want string 10 | }{ 11 | { 12 | name: "Remove leading slash", 13 | str: "/test/", 14 | want: "test/", 15 | }, 16 | { 17 | name: "Remove leading slash from empty string", 18 | str: "", 19 | want: "", 20 | }, 21 | { 22 | name: "Remove leading slash from string without leading slash", 23 | str: "test/", 24 | want: "test/", 25 | }, 26 | { 27 | name: "Remove leading slash from string with multiple leading slashes", 28 | str: "//test/", 29 | want: "/test/", 30 | }, 31 | { 32 | name: "With special characters", 33 | str: "/test/!@#$%^&*()_+/", 34 | want: "test/!@#$%^&*()_+/", 35 | }, 36 | { 37 | name: "With special characters (emojis)", 38 | str: "/test/!@#$%^&*()_+😀/", 39 | want: "test/!@#$%^&*()_+😀/", 40 | }, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | if got := RemoveLeadingSlash(tt.str); got != tt.want { 46 | t.Errorf("RemoveLeadingSlash() = %v, want %v", got, tt.want) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/util/strutil/remove_trailing_slash.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | // RemoveTrailingSlash removes the trailing slash from a string. 4 | func RemoveTrailingSlash(str string) string { 5 | if len(str) > 0 && str[len(str)-1] == '/' { 6 | return str[:len(str)-1] 7 | } 8 | 9 | return str 10 | } 11 | -------------------------------------------------------------------------------- /internal/util/strutil/remove_trailing_slash_test.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import "testing" 4 | 5 | func TestRemoveTrailingSlash(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | str string 9 | want string 10 | }{ 11 | { 12 | name: "Remove trailing slash", 13 | str: "/test/", 14 | want: "/test", 15 | }, 16 | { 17 | name: "Remove trailing slash from empty string", 18 | str: "", 19 | want: "", 20 | }, 21 | { 22 | name: "Remove trailing slash from string without trailing slash", 23 | str: "/test", 24 | want: "/test", 25 | }, 26 | { 27 | name: "Remove trailing slash from string with multiple trailing slashes", 28 | str: "/test//", 29 | want: "/test/", 30 | }, 31 | { 32 | name: "With special characters", 33 | str: "/test/!@#$%^&*()_+/", 34 | want: "/test/!@#$%^&*()_+", 35 | }, 36 | { 37 | name: "With special characters (emojis)", 38 | str: "/test/!@#$%^&*()_+😀/", 39 | want: "/test/!@#$%^&*()_+😀", 40 | }, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.name, func(t *testing.T) { 45 | if got := RemoveTrailingSlash(tt.str); got != tt.want { 46 | t.Errorf("RemoveTrailingSlash() = %v, want %v", got, tt.want) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/validate/README.md: -------------------------------------------------------------------------------- 1 | # Validate functions 2 | 3 | All the functions in this directory are used to validate data, all of them _MUST 4 | BE PURE FUNCTIONS_. 5 | 6 | https://en.wikipedia.org/wiki/Pure_function 7 | -------------------------------------------------------------------------------- /internal/validate/cron_expression.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/adhocore/gronx" 7 | ) 8 | 9 | // CronExpression validates a cron expression. 10 | // 11 | // It only supports 5 fields (minute, hour, day of month, month, day of week). 12 | // 13 | // It returns a boolean indicating whether the expression is valid or not. 14 | func CronExpression(expression string) bool { 15 | fields := strings.Fields(expression) 16 | if len(fields) != 5 { 17 | return false 18 | } 19 | 20 | gron := gronx.New() 21 | return gron.IsValid(expression) 22 | } 23 | -------------------------------------------------------------------------------- /internal/validate/cron_expression_test.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCronExpression(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | expression string 13 | want bool 14 | }{ 15 | { 16 | name: "Valid 5-field expression", 17 | expression: "0 12 * * 1-5", 18 | want: true, 19 | }, 20 | { 21 | name: "Invalid: too few fields", 22 | expression: "0 12 * *", 23 | want: false, 24 | }, 25 | { 26 | name: "Invalid: too many fields", 27 | expression: "0 12 * * 1-5 2023", 28 | want: false, 29 | }, 30 | { 31 | name: "Invalid: incorrect minute", 32 | expression: "60 12 * * 1-5", 33 | want: false, 34 | }, 35 | { 36 | name: "Valid: complex expression", 37 | expression: "*/15 2,8-17 * * 1-5", 38 | want: true, 39 | }, 40 | { 41 | name: "Invalid: incorrect day of week", 42 | expression: "0 12 * * 8", 43 | want: false, 44 | }, 45 | { 46 | name: "Valid: using names", 47 | expression: "0 12 * JAN-DEC MON-FRI", 48 | want: true, 49 | }, 50 | } 51 | 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | got := CronExpression(tt.expression) 55 | assert.Equal(t, tt.want, got, "CronExpression(%v)", tt.expression) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/validate/email.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import "regexp" 4 | 5 | // Email validates an email address. 6 | // It returns a boolean indicating whether 7 | // the email is valid or not. 8 | func Email(email string) bool { 9 | // Regular expression to match email format 10 | regex := `^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$` 11 | 12 | // Compile the regular expression 13 | re := regexp.MustCompile(regex) 14 | 15 | // Match the email against the regular expression 16 | return re.MatchString(email) 17 | } 18 | -------------------------------------------------------------------------------- /internal/validate/email_test.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import "testing" 4 | 5 | func TestEmail(t *testing.T) { 6 | tests := []struct { 7 | email string 8 | valid bool 9 | }{ 10 | {"", false}, 11 | {"test", false}, 12 | {"test@", false}, 13 | {"@example.com", false}, 14 | {"test@example", false}, 15 | {"test@example.com", true}, 16 | {"test@example.com.gt", true}, 17 | } 18 | 19 | for _, testItem := range tests { 20 | isValid := Email(testItem.email) 21 | if isValid != testItem.valid { 22 | t.Errorf("Email(%s) expected %v, got %v", testItem.email, testItem.valid, isValid) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/validate/json.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // JSON validates a JSON string, it returns a boolean indicating whether 8 | // the JSON is valid or not. 9 | func JSON(jsonStr string) bool { 10 | var js json.RawMessage 11 | return json.Unmarshal([]byte(jsonStr), &js) == nil 12 | } 13 | -------------------------------------------------------------------------------- /internal/validate/json_test.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestJSON(t *testing.T) { 10 | validJSON := []string{ 11 | `{"name": "John", "age": 30}`, 12 | `{"name": "John", "friends": ["Alice", "Bob"]}`, 13 | `{"items": [{"id": 1, "name": "Item1"}, {"id": 2, "name": "Item2"}]}`, 14 | `{"emptyArray": [], "emptyObject": {}}`, 15 | `{"nullValue": null}`, 16 | `{"booleanTrue": true, "booleanFalse": false}`, 17 | `{"numberInt": 123, "numberFloat": 123.456}`, 18 | `{"string": "Hello, World!"}`, 19 | `{"nested": {"level1": {"level2": {"level3": "value"}}}}`, 20 | `{"escapedString": "This is a quote: \"}"}`, 21 | } 22 | 23 | for _, jsonStr := range validJSON { 24 | assert.True(t, JSON(jsonStr), "Expected JSON to be valid: %s", jsonStr) 25 | } 26 | 27 | invalidJSON := []string{ 28 | `{"name": "John", "age": 30`, 29 | `{"name": "John", "friends": ["Alice", "Bob"}`, 30 | `{"items": [{"id": 1, "name": "Item1"}, {"id": 2, "name": "Item2"]]}`, 31 | `{"unclosedString": "Hello}`, 32 | `{"unexpectedToken": tru}`, 33 | `{"incompleteObject": {}`, 34 | `{name: "John", "age": 30}`, 35 | `{"missingComma": "value1" "value2"}`, 36 | `["mismatch": "value"]`, 37 | `some other thing`, 38 | ``, 39 | } 40 | 41 | for _, jsonStr := range invalidJSON { 42 | assert.False(t, JSON(jsonStr), "Expected JSON to be invalid: %s", jsonStr) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/validate/listen_host.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | // ListenHost validates if addr is a valid host to listen on. 8 | func ListenHost(addr string) bool { 9 | re := regexp.MustCompile(`^([0-9]{1,3}\.){3}[0-9]{1,3}($|/[0-9]{2})$`) 10 | return re.MatchString(addr) 11 | } 12 | -------------------------------------------------------------------------------- /internal/validate/listen_host_test.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestListenHost(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | addr string 13 | wantValid bool 14 | }{ 15 | { 16 | name: "valid ip address", 17 | addr: "127.0.0.1", 18 | wantValid: true, 19 | }, 20 | { 21 | name: "valid ip address with CIDR", 22 | addr: "192.168.1.1/24", 23 | wantValid: true, 24 | }, 25 | { 26 | name: "valid ip address zeros", 27 | addr: "0.0.0.0", 28 | wantValid: true, 29 | }, 30 | { 31 | name: "invalid string", 32 | addr: "invalid", 33 | wantValid: false, 34 | }, 35 | { 36 | name: "empty string", 37 | addr: "", 38 | wantValid: false, 39 | }, 40 | { 41 | name: "invalid format with dots", 42 | addr: "192.168.1", 43 | wantValid: false, 44 | }, 45 | } 46 | 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | assert.Equal(t, tt.wantValid, ListenHost(tt.addr)) 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/validate/port.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | ) 7 | 8 | // Port validates if port is a valid port number. 9 | func Port(port string) bool { 10 | re := regexp.MustCompile(`^\d{1,5}$`) 11 | if !re.MatchString(port) { 12 | return false 13 | } 14 | 15 | portInt, err := strconv.Atoi(port) 16 | if err != nil { 17 | return false 18 | } 19 | 20 | if portInt < 1 || portInt > 65535 { 21 | return false 22 | } 23 | 24 | return true 25 | } 26 | -------------------------------------------------------------------------------- /internal/validate/port_test.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestPort(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | port string 13 | wantValid bool 14 | }{ 15 | { 16 | name: "valid port number", 17 | port: "8080", 18 | wantValid: true, 19 | }, 20 | { 21 | name: "valid minimum port", 22 | port: "1", 23 | wantValid: true, 24 | }, 25 | { 26 | name: "valid maximum port", 27 | port: "65535", 28 | wantValid: true, 29 | }, 30 | { 31 | name: "invalid minimum port", 32 | port: "0", 33 | wantValid: false, 34 | }, 35 | { 36 | name: "invalid maximum port", 37 | port: "65536", 38 | wantValid: false, 39 | }, 40 | { 41 | name: "invalid port - letters", 42 | port: "abc", 43 | wantValid: false, 44 | }, 45 | { 46 | name: "invalid port - empty", 47 | port: "", 48 | wantValid: false, 49 | }, 50 | { 51 | name: "invalid port - special chars", 52 | port: "8080!", 53 | wantValid: false, 54 | }, 55 | { 56 | name: "invalid port - decimal", 57 | port: "8080.1", 58 | wantValid: false, 59 | }, 60 | } 61 | 62 | for _, tt := range tests { 63 | t.Run(tt.name, func(t *testing.T) { 64 | assert.Equal(t, tt.wantValid, Port(tt.port)) 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/view/api/health.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | func (h *handlers) healthHandler(c echo.Context) error { 10 | ctx := c.Request().Context() 11 | 12 | var queryData struct { 13 | IncludeDatabases bool `query:"databases"` 14 | IncludeDestinations bool `query:"destinations"` 15 | } 16 | if err := c.Bind(&queryData); err != nil { 17 | return c.String(http.StatusBadRequest, err.Error()) 18 | } 19 | 20 | databasesHealthy, destinationsHealthy := true, true 21 | 22 | if queryData.IncludeDatabases { 23 | databases, err := h.servs.DatabasesService.GetAllDatabases(ctx) 24 | if err != nil { 25 | return c.String(http.StatusInternalServerError, err.Error()) 26 | } 27 | 28 | for _, db := range databases { 29 | if db.TestOk.Valid && !db.TestOk.Bool { 30 | databasesHealthy = false 31 | break 32 | } 33 | } 34 | 35 | } 36 | 37 | if queryData.IncludeDestinations { 38 | destinations, err := h.servs.DestinationsService.GetAllDestinations(ctx) 39 | if err != nil { 40 | return c.String(http.StatusInternalServerError, err.Error()) 41 | } 42 | 43 | for _, dest := range destinations { 44 | if dest.TestOk.Valid && !dest.TestOk.Bool { 45 | destinationsHealthy = false 46 | break 47 | } 48 | } 49 | } 50 | 51 | response := map[string]any{ 52 | "server_healthy": true, 53 | } 54 | if queryData.IncludeDatabases { 55 | response["databases_healthy"] = databasesHealthy 56 | } 57 | if queryData.IncludeDestinations { 58 | response["destinations_healthy"] = destinationsHealthy 59 | } 60 | 61 | return c.JSON(http.StatusOK, response) 62 | } 63 | -------------------------------------------------------------------------------- /internal/view/api/router.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/service" 5 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type handlers struct { 10 | servs *service.Service 11 | } 12 | 13 | func MountRouter( 14 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 15 | ) { 16 | v1 := parent.Group("/v1") 17 | 18 | h := &handlers{ 19 | servs: servs, 20 | } 21 | v1.GET("/health", h.healthHandler) 22 | } 23 | -------------------------------------------------------------------------------- /internal/view/middleware/browser_cache.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | type BrowserCacheMiddlewareConfig struct { 11 | CacheDuration time.Duration 12 | ExcludedFiles []string 13 | } 14 | 15 | // NewBrowserCacheMiddleware creates a new CacheMiddleware with the specified 16 | // cache duration and a list of excluded files that will bypass the cache. 17 | func (Middleware) NewBrowserCacheMiddleware( 18 | config BrowserCacheMiddlewareConfig, 19 | ) echo.MiddlewareFunc { 20 | return func(next echo.HandlerFunc) echo.HandlerFunc { 21 | return func(c echo.Context) error { 22 | path := c.Request().URL.Path 23 | for _, excluded := range config.ExcludedFiles { 24 | if path == excluded { 25 | return next(c) 26 | } 27 | } 28 | 29 | cacheDuration := config.CacheDuration 30 | c.Response().Header().Set( 31 | "Cache-Control", 32 | "public, max-age="+strconv.Itoa(int(cacheDuration.Seconds())), 33 | ) 34 | 35 | return next(c) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/view/middleware/inject_reqctx.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/eduardolat/pgbackweb/internal/logger" 8 | "github.com/eduardolat/pgbackweb/internal/view/reqctx" 9 | "github.com/labstack/echo/v4" 10 | htmx "github.com/nodxdev/nodxgo-htmx" 11 | ) 12 | 13 | func (m *Middleware) InjectReqctx(next echo.HandlerFunc) echo.HandlerFunc { 14 | return func(c echo.Context) error { 15 | reqCtx := reqctx.Ctx{ 16 | IsHTMXBoosted: htmx.ServerGetIsBoosted(c.Request().Header), 17 | } 18 | 19 | found, user, err := m.servs.AuthService.GetUserFromSessionCookie(c) 20 | if err != nil { 21 | logger.Error("failed to get user from session cookie", logger.KV{ 22 | "ip": c.RealIP(), 23 | "ua": c.Request().UserAgent(), 24 | "error": err, 25 | }) 26 | return c.String(http.StatusInternalServerError, "Internal server error") 27 | } 28 | 29 | if found { 30 | reqCtx.IsAuthed = true 31 | reqCtx.SessionID = user.SessionID 32 | reqCtx.User = dbgen.User{ 33 | ID: user.ID, 34 | Name: user.Name, 35 | Email: user.Email, 36 | CreatedAt: user.CreatedAt, 37 | UpdatedAt: user.UpdatedAt, 38 | } 39 | } 40 | 41 | reqctx.SetCtx(c, reqCtx) 42 | return next(c) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/view/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "github.com/eduardolat/pgbackweb/internal/service" 4 | 5 | type Middleware struct { 6 | servs *service.Service 7 | } 8 | 9 | func New(servs *service.Service) *Middleware { 10 | return &Middleware{servs: servs} 11 | } 12 | -------------------------------------------------------------------------------- /internal/view/middleware/rate_limit.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | "time" 7 | 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | // RateLimitConfig defines the config for RateLimit middleware. 12 | type RateLimitConfig struct { 13 | // Limit is the maximum number of requests to allow per period. 14 | Limit int 15 | // Period is the duration in which the limit is enforced. 16 | Period time.Duration 17 | } 18 | 19 | // RateLimit creates a rate limiting middleware. 20 | func (Middleware) RateLimit(config RateLimitConfig) echo.MiddlewareFunc { 21 | var mu sync.Mutex 22 | var hits = make(map[string]int) 23 | 24 | // Reset the hits map every "period". 25 | go func() { 26 | for { 27 | time.Sleep(config.Period) 28 | mu.Lock() 29 | hits = make(map[string]int) 30 | mu.Unlock() 31 | } 32 | }() 33 | 34 | return func(next echo.HandlerFunc) echo.HandlerFunc { 35 | return func(c echo.Context) error { 36 | mu.Lock() 37 | defer mu.Unlock() 38 | 39 | ip := c.RealIP() 40 | if hits[ip] >= config.Limit { 41 | return c.String(http.StatusTooManyRequests, "too many requests") 42 | } 43 | 44 | hits[ip]++ 45 | return next(c) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/view/middleware/require_auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/logger" 7 | "github.com/eduardolat/pgbackweb/internal/view/reqctx" 8 | "github.com/labstack/echo/v4" 9 | htmx "github.com/nodxdev/nodxgo-htmx" 10 | ) 11 | 12 | func (m *Middleware) RequireAuth(next echo.HandlerFunc) echo.HandlerFunc { 13 | return func(c echo.Context) error { 14 | ctx := c.Request().Context() 15 | reqCtx := reqctx.GetCtx(c) 16 | 17 | if reqCtx.IsAuthed { 18 | return next(c) 19 | } 20 | 21 | usersQty, err := m.servs.UsersService.GetUsersQty(ctx) 22 | if err != nil { 23 | logger.Error("failed to get users qty", logger.KV{ 24 | "ip": c.RealIP(), 25 | "ua": c.Request().UserAgent(), 26 | "error": err, 27 | }) 28 | return c.String(http.StatusInternalServerError, "Internal server error") 29 | } 30 | 31 | if usersQty == 0 { 32 | htmx.ServerSetRedirect(c.Response().Header(), "/auth/create-first-user") 33 | return c.Redirect(http.StatusFound, "/auth/create-first-user") 34 | } 35 | 36 | htmx.ServerSetRedirect(c.Response().Header(), "/auth/login") 37 | return c.Redirect(http.StatusFound, "/auth/login") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/view/middleware/require_no_auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/view/reqctx" 7 | "github.com/labstack/echo/v4" 8 | htmx "github.com/nodxdev/nodxgo-htmx" 9 | ) 10 | 11 | func (m *Middleware) RequireNoAuth(next echo.HandlerFunc) echo.HandlerFunc { 12 | return func(c echo.Context) error { 13 | reqCtx := reqctx.GetCtx(c) 14 | 15 | if reqCtx.IsAuthed { 16 | htmx.ServerSetRedirect(c.Response().Header(), "/dashboard") 17 | return c.Redirect(http.StatusFound, "/dashboard") 18 | } 19 | 20 | return next(c) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/view/reqctx/README.md: -------------------------------------------------------------------------------- 1 | # reqctx 2 | 3 | `reqctx` is a utility package designed to manage request-specific context values 4 | in this project. It helps to encapsulate authentication status and user 5 | information within the Echo request context in a type-safe manner. 6 | 7 | ## Purpose 8 | 9 | When developing web applications, it is common to pass values such as 10 | authentication status and user information through the request lifecycle. Using 11 | Echo's built-in context (`echo.Context`) can lead to potential issues such as 12 | typographical errors, lack of type safety, and reduced code readability. 13 | 14 | `reqctx` addresses these issues by providing a structured way to manage these 15 | context values. 16 | -------------------------------------------------------------------------------- /internal/view/reqctx/ctx.go: -------------------------------------------------------------------------------- 1 | package reqctx 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 5 | "github.com/google/uuid" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | const ( 10 | ctxKey = "PGBackWebCTX" 11 | ) 12 | 13 | // Ctx represents the values passed through a single request context. 14 | type Ctx struct { 15 | IsHTMXBoosted bool 16 | IsAuthed bool 17 | SessionID uuid.UUID 18 | User dbgen.User 19 | } 20 | 21 | // SetCtx inserts values into the Echo request context. 22 | func SetCtx(c echo.Context, ctx Ctx) { 23 | c.Set(ctxKey, ctx) 24 | } 25 | 26 | // GetCtx retrieves values from the Echo request context. 27 | func GetCtx(c echo.Context) Ctx { 28 | ctx, ok := c.Get(ctxKey).(Ctx) 29 | if !ok { 30 | return Ctx{} 31 | } 32 | return ctx 33 | } 34 | -------------------------------------------------------------------------------- /internal/view/reqctx/ctx_test.go: -------------------------------------------------------------------------------- 1 | package reqctx 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 9 | "github.com/google/uuid" 10 | "github.com/labstack/echo/v4" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestCtxFuncs(t *testing.T) { 15 | testUser := dbgen.User{ 16 | ID: uuid.New(), 17 | Email: "user@example.com", 18 | Name: "John", 19 | } 20 | 21 | testSessionID := uuid.New() 22 | 23 | e := echo.New() 24 | req := httptest.NewRequest(http.MethodGet, "/", nil) 25 | rec := httptest.NewRecorder() 26 | c := e.NewContext(req, rec) 27 | 28 | t.Run("Create authentication values in context", func(t *testing.T) { 29 | authData := Ctx{ 30 | IsAuthed: true, 31 | SessionID: testSessionID, 32 | User: testUser, 33 | } 34 | 35 | SetCtx(c, authData) 36 | auth := GetCtx(c) 37 | 38 | assert.True(t, auth.IsAuthed) 39 | assert.Equal(t, testUser, auth.User) 40 | assert.Equal(t, testSessionID, auth.SessionID) 41 | assert.Equal(t, testUser.Email, auth.User.Email) 42 | }) 43 | 44 | t.Run("Create authentication values in context with only IsAuthed", func(t *testing.T) { 45 | authData := Ctx{ 46 | IsAuthed: true, 47 | } 48 | 49 | SetCtx(c, authData) 50 | auth := GetCtx(c) 51 | 52 | assert.True(t, auth.IsAuthed) 53 | assert.Equal(t, uuid.Nil, auth.SessionID) 54 | assert.Empty(t, auth.User) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /internal/view/router.go: -------------------------------------------------------------------------------- 1 | package view 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/service" 7 | "github.com/eduardolat/pgbackweb/internal/view/api" 8 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 9 | "github.com/eduardolat/pgbackweb/internal/view/static" 10 | "github.com/eduardolat/pgbackweb/internal/view/web" 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | func MountRouter(app *echo.Echo, servs *service.Service) { 15 | mids := middleware.New(servs) 16 | 17 | browserCache := mids.NewBrowserCacheMiddleware( 18 | middleware.BrowserCacheMiddlewareConfig{ 19 | CacheDuration: time.Hour * 24 * 30, 20 | ExcludedFiles: []string{"/robots.txt"}, 21 | }, 22 | ) 23 | app.Group("", browserCache).StaticFS("", static.StaticFs) 24 | 25 | apiGroup := app.Group("/api") 26 | api.MountRouter(apiGroup, mids, servs) 27 | 28 | webGroup := app.Group("", mids.InjectReqctx) 29 | web.MountRouter(webGroup, mids, servs) 30 | } 31 | -------------------------------------------------------------------------------- /internal/view/static/.gitignore: -------------------------------------------------------------------------------- 1 | # The build directory stores the JavaScript and CSS files that are generated 2 | # by the build process. 3 | build/ 4 | -------------------------------------------------------------------------------- /internal/view/static/css/partials/alpine.css: -------------------------------------------------------------------------------- 1 | [x-cloak] { 2 | display: none !important; 3 | } 4 | -------------------------------------------------------------------------------- /internal/view/static/css/partials/general.css: -------------------------------------------------------------------------------- 1 | html { 2 | overflow-x: hidden; 3 | overflow-y: auto; 4 | } 5 | 6 | .table tbody tr { 7 | @apply hover:bg-base-200; 8 | } 9 | -------------------------------------------------------------------------------- /internal/view/static/css/partials/htmx.css: -------------------------------------------------------------------------------- 1 | .htmx-indicator { 2 | display: none; 3 | } 4 | 5 | .htmx-request .htmx-indicator { 6 | display: block; 7 | } 8 | 9 | .htmx-request.htmx-indicator { 10 | display: block; 11 | } 12 | -------------------------------------------------------------------------------- /internal/view/static/css/partials/nodx-lucide.css: -------------------------------------------------------------------------------- 1 | svg[data-nodxgo="lucide"]:not([class*="size-"]) { 2 | @apply size-4; 3 | } 4 | -------------------------------------------------------------------------------- /internal/view/static/css/partials/notyf.css: -------------------------------------------------------------------------------- 1 | .notyf__toast { 2 | @apply rounded-btn break-all !important; 3 | } 4 | -------------------------------------------------------------------------------- /internal/view/static/css/partials/scrollbar.css: -------------------------------------------------------------------------------- 1 | *, 2 | *:hover { 3 | /* Reset scrollbar*/ 4 | scrollbar-color: auto; 5 | } 6 | 7 | *::-webkit-scrollbar { 8 | -webkit-appearance: none; 9 | width: 6px; 10 | height: 6px; 11 | } 12 | 13 | *::-webkit-scrollbar-thumb { 14 | @apply bg-gray-500; 15 | border-radius: 3px; 16 | } 17 | 18 | /* Fallback scrollbar style for Firefox */ 19 | @-moz-document url-prefix() { 20 | * { 21 | scrollbar-width: thin; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/view/static/css/partials/slim-select.css: -------------------------------------------------------------------------------- 1 | /* 2 | For Slim Select, this takes some styles from: 3 | https://github.com/saadeghi/daisyui/blob/master/src/components/styled/input.css 4 | */ 5 | 6 | .ss-main { 7 | all: unset; 8 | @apply input input-bordered text-base-content !important; 9 | } 10 | 11 | .ss-content { 12 | all: unset; 13 | @apply bg-base-100 text-base-content rounded-btn !important; 14 | @apply px-2 py-2 space-y-1 !important; 15 | @apply border border-base-content/20 !important; 16 | } 17 | 18 | .ss-list { 19 | all: unset; 20 | @apply space-y-1 !important; 21 | } 22 | 23 | .ss-search input { 24 | all: unset; 25 | @apply input input-bordered input-sm !important; 26 | } 27 | 28 | .ss-option { 29 | all: unset; 30 | @apply bg-base-100 text-base-content rounded-btn !important; 31 | } 32 | 33 | .ss-option:hover { 34 | all: unset; 35 | @apply bg-base-200 !important; 36 | } 37 | 38 | .ss-option.ss-selected { 39 | all: unset; 40 | @apply bg-base-200 !important; 41 | } 42 | 43 | .ss-option.ss-disabled { 44 | @apply text-opacity-80 !important; 45 | } 46 | 47 | .ss-value { 48 | @apply bg-primary rounded-badge !important; 49 | } 50 | 51 | .ss-value-text { 52 | @apply text-primary-content !important; 53 | } 54 | 55 | .ss-value-delete { 56 | @apply border-0 border-l border-primary-content !important; 57 | } 58 | 59 | .ss-value-delete svg path { 60 | @apply stroke-primary-content !important; 61 | } 62 | -------------------------------------------------------------------------------- /internal/view/static/css/partials/sweetalert2.css: -------------------------------------------------------------------------------- 1 | /* 2 | Fix sweetalert2 scroll issue 3 | https://github.com/sweetalert2/sweetalert2/issues/781#issuecomment-475108658 4 | */ 5 | body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown), 6 | html.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown) { 7 | height: 100% !important; 8 | overflow-y: visible !important; 9 | } 10 | -------------------------------------------------------------------------------- /internal/view/static/css/partials/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @tailwind variants; 5 | -------------------------------------------------------------------------------- /internal/view/static/css/style.css: -------------------------------------------------------------------------------- 1 | @import "./partials/tailwind.css"; 2 | @import "./partials/alpine.css"; 3 | @import "./partials/general.css"; 4 | @import "./partials/nodx-lucide.css"; 5 | @import "./partials/htmx.css"; 6 | @import "./partials/slim-select.css"; 7 | @import "./partials/notyf.css"; 8 | @import "./partials/scrollbar.css"; 9 | @import "./partials/sweetalert2.css"; 10 | -------------------------------------------------------------------------------- /internal/view/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eduardolat/pgbackweb/43d8aff1677610d8adfd0fe9c0e1ab24e2d77c00/internal/view/static/favicon.ico -------------------------------------------------------------------------------- /internal/view/static/images/logo-elephant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eduardolat/pgbackweb/43d8aff1677610d8adfd0fe9c0e1ab24e2d77c00/internal/view/static/images/logo-elephant.png -------------------------------------------------------------------------------- /internal/view/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eduardolat/pgbackweb/43d8aff1677610d8adfd0fe9c0e1ab24e2d77c00/internal/view/static/images/logo.png -------------------------------------------------------------------------------- /internal/view/static/images/plus-circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eduardolat/pgbackweb/43d8aff1677610d8adfd0fe9c0e1ab24e2d77c00/internal/view/static/images/plus-circle.png -------------------------------------------------------------------------------- /internal/view/static/images/plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eduardolat/pgbackweb/43d8aff1677610d8adfd0fe9c0e1ab24e2d77c00/internal/view/static/images/plus.png -------------------------------------------------------------------------------- /internal/view/static/images/third-party/digital-ocean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eduardolat/pgbackweb/43d8aff1677610d8adfd0fe9c0e1ab24e2d77c00/internal/view/static/images/third-party/digital-ocean.png -------------------------------------------------------------------------------- /internal/view/static/images/third-party/hapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eduardolat/pgbackweb/43d8aff1677610d8adfd0fe9c0e1ab24e2d77c00/internal/view/static/images/third-party/hapi.png -------------------------------------------------------------------------------- /internal/view/static/images/third-party/vultr.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eduardolat/pgbackweb/43d8aff1677610d8adfd0fe9c0e1ab24e2d77c00/internal/view/static/images/third-party/vultr.webp -------------------------------------------------------------------------------- /internal/view/static/js/app.js: -------------------------------------------------------------------------------- 1 | import { initThemeHelper } from "./init-theme-helper.js"; 2 | import { initSweetAlert2 } from "./init-sweetalert2.js"; 3 | import { initNotyf } from "./init-notyf.js"; 4 | import { initHTMX } from "./init-htmx.js"; 5 | import { initHelpers } from "./init-helpers.js"; 6 | 7 | initThemeHelper(); 8 | initSweetAlert2(); 9 | initNotyf(); 10 | initHTMX(); 11 | initHelpers(); 12 | -------------------------------------------------------------------------------- /internal/view/static/js/init-sweetalert2.js: -------------------------------------------------------------------------------- 1 | export function initSweetAlert2() { 2 | // Docs at https://sweetalert2.github.io/#configuration 3 | const defaultConfig = { 4 | icon: "info", 5 | confirmButtonText: "Okay", 6 | cancelButtonText: "Cancel", 7 | customClass: { 8 | popup: "rounded-box bg-base-100 text-base-content", 9 | confirmButton: "btn btn-primary", 10 | denyButton: "btn btn-warning", 11 | cancelButton: "btn btn-error", 12 | }, 13 | }; 14 | 15 | async function swalAlert(text) { 16 | return await Swal.fire({ 17 | ...defaultConfig, 18 | title: text, 19 | }); 20 | } 21 | 22 | async function swalConfirm(text) { 23 | return await Swal.fire({ 24 | ...defaultConfig, 25 | icon: "question", 26 | title: text, 27 | confirmButtonText: "Confirm", 28 | showCancelButton: true, 29 | }); 30 | } 31 | 32 | window.swalAlert = swalAlert; 33 | window.swalConfirm = swalConfirm; 34 | } 35 | -------------------------------------------------------------------------------- /internal/view/static/js/init-theme-helper.js: -------------------------------------------------------------------------------- 1 | export function initThemeHelper() { 2 | function getTheme() { 3 | const theme = localStorage.getItem("theme"); 4 | return theme || ""; 5 | } 6 | 7 | function setTheme(theme) { 8 | localStorage.setItem("theme", theme); 9 | document.documentElement.setAttribute("data-theme", theme); 10 | } 11 | 12 | window.getTheme = getTheme; 13 | window.setTheme = setTheme; 14 | 15 | const theme = getTheme(); 16 | setTheme(theme); 17 | } 18 | -------------------------------------------------------------------------------- /internal/view/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /internal/view/static/static_fs.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "embed" 5 | "sync" 6 | 7 | "github.com/eduardolat/pgbackweb/internal/util/cryptoutil" 8 | ) 9 | 10 | //go:embed * 11 | var StaticFs embed.FS 12 | 13 | var ( 14 | staticSHA256 string 15 | staticSHA256Once sync.Once 16 | ) 17 | 18 | // GetStaticSHA256 returns the SHA256 hash of all the files combined in the 19 | // static directory. 20 | func GetStaticSHA256() string { 21 | staticSHA256Once.Do(func() { 22 | staticSHA256 = cryptoutil.GetSHA256FromFS(StaticFs) 23 | }) 24 | return staticSHA256 25 | } 26 | 27 | // GetVersionedFilePath returns a versioned file path by appending a shortened 28 | // SHA256 hash of the static filesystem to the query parameter. 29 | // 30 | // The hash is truncated to the first 8 characters for brevity. 31 | func GetVersionedFilePath(filePath string) string { 32 | hash := GetStaticSHA256() 33 | 34 | if len(hash) > 8 { 35 | hash = hash[:8] 36 | } 37 | 38 | return filePath + "?v=" + hash 39 | } 40 | -------------------------------------------------------------------------------- /internal/view/web/auth/logout.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/reqctx" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | func (h *handlers) logoutHandler(c echo.Context) error { 10 | ctx := c.Request().Context() 11 | reqCtx := reqctx.GetCtx(c) 12 | 13 | if err := h.servs.AuthService.DeleteSession(ctx, reqCtx.SessionID); err != nil { 14 | return respondhtmx.ToastError(c, err.Error()) 15 | } 16 | 17 | h.servs.AuthService.ClearSessionCookie(c) 18 | return respondhtmx.Redirect(c, "/auth/login") 19 | } 20 | 21 | func (h *handlers) logoutAllSessionsHandler(c echo.Context) error { 22 | ctx := c.Request().Context() 23 | reqCtx := reqctx.GetCtx(c) 24 | 25 | err := h.servs.AuthService.DeleteAllUserSessions(ctx, reqCtx.User.ID) 26 | if err != nil { 27 | return respondhtmx.ToastError(c, err.Error()) 28 | } 29 | 30 | h.servs.AuthService.ClearSessionCookie(c) 31 | return respondhtmx.Redirect(c, "/auth/login") 32 | } 33 | -------------------------------------------------------------------------------- /internal/view/web/auth/router.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/service" 7 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | type handlers struct { 12 | servs *service.Service 13 | } 14 | 15 | func MountRouter( 16 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 17 | ) { 18 | h := handlers{servs: servs} 19 | 20 | requireAuth := parent.Group("", mids.RequireAuth) 21 | requireNoAuth := parent.Group("", mids.RequireNoAuth) 22 | 23 | requireNoAuth.GET("/create-first-user", h.createFirstUserPageHandler) 24 | requireNoAuth.POST("/create-first-user", h.createFirstUserHandler) 25 | 26 | requireNoAuth.GET("/login", h.loginPageHandler) 27 | requireNoAuth.POST("/login", h.loginHandler, mids.RateLimit(middleware.RateLimitConfig{ 28 | Limit: 5, 29 | Period: 10 * time.Second, 30 | })) 31 | 32 | requireAuth.POST("/logout", h.logoutHandler) 33 | requireAuth.POST("/logout-all", h.logoutAllSessionsHandler) 34 | } 35 | -------------------------------------------------------------------------------- /internal/view/web/component/change_theme_button.inc.js: -------------------------------------------------------------------------------- 1 | window.alpineChangeThemeButton = function () { 2 | return { 3 | theme: "", 4 | 5 | loadTheme() { 6 | const theme = window.getTheme(); 7 | this.theme = theme || "system"; 8 | }, 9 | 10 | setTheme(theme) { 11 | window.setTheme(theme); 12 | this.theme = theme || "system"; 13 | }, 14 | 15 | init() { 16 | this.loadTheme(); 17 | }, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /internal/view/web/component/empty_results.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | nodx "github.com/nodxdev/nodxgo" 5 | lucide "github.com/nodxdev/nodxgo-lucide" 6 | ) 7 | 8 | type EmptyResultsParams struct { 9 | Title string 10 | Subtitle string 11 | } 12 | 13 | func EmptyResults(params EmptyResultsParams) nodx.Node { 14 | return nodx.Div( 15 | nodx.Class("flex flex-col justify-center items-center space-x-1"), 16 | lucide.FileSearch(nodx.Class("size-8")), 17 | nodx.If( 18 | params.Title != "", 19 | nodx.SpanEl( 20 | nodx.Class("text-xl"), 21 | nodx.Text(params.Title), 22 | ), 23 | ), 24 | nodx.If( 25 | params.Subtitle != "", 26 | nodx.SpanEl( 27 | nodx.Class("text-base"), 28 | nodx.Text(params.Subtitle), 29 | ), 30 | ), 31 | ) 32 | } 33 | 34 | func EmptyResultsTr(params EmptyResultsParams) nodx.Node { 35 | return nodx.Tr( 36 | nodx.Td( 37 | nodx.Colspan("100%"), 38 | nodx.Class("py-10"), 39 | EmptyResults(params), 40 | ), 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /internal/view/web/component/enums.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import "github.com/orsinium-labs/enum" 4 | 5 | type ( 6 | size enum.Member[string] 7 | color enum.Member[string] 8 | dropdownPosition enum.Member[string] 9 | inputType enum.Member[string] 10 | bgBase enum.Member[string] 11 | ) 12 | 13 | var ( 14 | SizeSm = size{"sm"} 15 | SizeMd = size{"md"} 16 | SizeLg = size{"lg"} 17 | 18 | ColorPrimary = color{"primary"} 19 | ColorSecondary = color{"secondary"} 20 | ColorAccent = color{"accent"} 21 | ColorNeutral = color{"neutral"} 22 | ColorInfo = color{"info"} 23 | ColorSuccess = color{"success"} 24 | ColorWarning = color{"warning"} 25 | ColorError = color{"error"} 26 | 27 | DropdownPositionTop = dropdownPosition{"top"} 28 | DropdownPositionBottom = dropdownPosition{"bottom"} 29 | DropdownPositionLeft = dropdownPosition{"left"} 30 | DropdownPositionRight = dropdownPosition{"right"} 31 | 32 | InputTypeText = inputType{"text"} 33 | InputTypePassword = inputType{"password"} 34 | InputTypeEmail = inputType{"email"} 35 | InputTypeNumber = inputType{"number"} 36 | InputTypeTel = inputType{"tel"} 37 | InputTypeUrl = inputType{"url"} 38 | 39 | bgBase100 = bgBase{"bg-base-100"} 40 | bgBase200 = bgBase{"bg-base-200"} 41 | bgBase300 = bgBase{"bg-base-300"} 42 | ) 43 | -------------------------------------------------------------------------------- /internal/view/web/component/help_button_modal.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | nodx "github.com/nodxdev/nodxgo" 5 | lucide "github.com/nodxdev/nodxgo-lucide" 6 | ) 7 | 8 | type HelpButtonModalParams struct { 9 | ModalTitle string 10 | ModalSize size 11 | Children []nodx.Node 12 | } 13 | 14 | func HelpButtonModal(params HelpButtonModalParams) nodx.Node { 15 | mo := Modal(ModalParams{ 16 | Size: params.ModalSize, 17 | Title: params.ModalTitle, 18 | Content: params.Children, 19 | }) 20 | 21 | button := nodx.Button( 22 | mo.OpenerAttr, 23 | nodx.Class("btn btn-neutral btn-ghost btn-circle btn-sm"), 24 | nodx.Type("button"), 25 | lucide.CircleHelp(), 26 | ) 27 | 28 | return nodx.Div( 29 | nodx.Class("inline-block"), 30 | mo.HTML, 31 | button, 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /internal/view/web/component/hx_loading.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | nodx "github.com/nodxdev/nodxgo" 5 | ) 6 | 7 | // HxLoadingSm returns a small loading indicator. 8 | func HxLoadingSm(id ...string) nodx.Node { 9 | return hxLoading(SizeSm, id...) 10 | } 11 | 12 | // HxLoadingMd returns a loading indicator. 13 | func HxLoadingMd(id ...string) nodx.Node { 14 | return hxLoading(SizeMd, id...) 15 | } 16 | 17 | // HxLoadingLg returns a large loading indicator. 18 | func HxLoadingLg(id ...string) nodx.Node { 19 | return hxLoading(SizeLg, id...) 20 | } 21 | 22 | func hxLoading(size size, id ...string) nodx.Node { 23 | pickedID := "" 24 | if len(id) > 0 { 25 | pickedID = id[0] 26 | } 27 | 28 | return nodx.Div( 29 | nodx.If( 30 | pickedID != "", 31 | nodx.Id(pickedID), 32 | ), 33 | nodx.Class("htmx-indicator inline-block"), 34 | func() nodx.Node { 35 | switch size { 36 | case SizeSm: 37 | return SpinnerSm() 38 | case SizeMd: 39 | return SpinnerMd() 40 | case SizeLg: 41 | return SpinnerLg() 42 | default: 43 | return SpinnerMd() 44 | } 45 | }(), 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /internal/view/web/component/logotype.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | nodx "github.com/nodxdev/nodxgo" 5 | ) 6 | 7 | func Logotype() nodx.Node { 8 | return nodx.Div( 9 | nodx.ClassMap{ 10 | "inline space-x-2 select-none": true, 11 | "flex justify-start items-center": true, 12 | }, 13 | nodx.Img( 14 | nodx.Class("w-[60px] h-auto"), 15 | nodx.Src("/images/logo.png"), 16 | nodx.Alt("PG Back Web"), 17 | ), 18 | nodx.SpanEl( 19 | nodx.Class("text-2xl font-bold"), 20 | nodx.Text("PG Back Web"), 21 | ), 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /internal/view/web/component/options_dropdown.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | nodx "github.com/nodxdev/nodxgo" 5 | alpine "github.com/nodxdev/nodxgo-alpine" 6 | lucide "github.com/nodxdev/nodxgo-lucide" 7 | ) 8 | 9 | func OptionsDropdown(children ...nodx.Node) nodx.Node { 10 | return nodx.Div( 11 | nodx.Class("inline-block"), 12 | alpine.XData("alpineOptionsDropdown()"), 13 | alpine.XOn("mouseenter", "open()"), 14 | alpine.XOn("mouseleave", "close()"), 15 | nodx.Button( 16 | alpine.XRef("button"), 17 | nodx.Class("btn btn-sm btn-ghost btn-square"), 18 | alpine.XBind("class", "isOpen ? 'btn-active' : ''"), 19 | lucide.EllipsisVertical( 20 | nodx.Class("transition-transform"), 21 | alpine.XBind("class", "isOpen ? 'rotate-90' : ''"), 22 | ), 23 | ), 24 | nodx.Div( 25 | alpine.XRef("content"), 26 | nodx.ClassMap{ 27 | "fixed hidden": true, 28 | "bg-base-100 rounded-box border border-base-200": true, 29 | "z-40 max-w-[250px] p-2 shadow-md": true, 30 | }, 31 | nodx.Group(children...), 32 | ), 33 | ) 34 | } 35 | 36 | func OptionsDropdownButton(children ...nodx.Node) nodx.Node { 37 | return nodx.Button( 38 | nodx.Class("btn btn-neutral btn-ghost btn-sm w-full flex justify-start"), 39 | nodx.Group(children...), 40 | ) 41 | } 42 | 43 | func OptionsDropdownA(children ...nodx.Node) nodx.Node { 44 | return nodx.A( 45 | nodx.Class("btn btn-neutral btn-ghost btn-sm w-full flex justify-start"), 46 | nodx.Group(children...), 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /internal/view/web/component/options_dropdown.inc.js: -------------------------------------------------------------------------------- 1 | window.alpineOptionsDropdown = function () { 2 | return { 3 | isOpen: false, 4 | buttonEl: null, 5 | contentEl: null, 6 | closeTimeout: null, 7 | 8 | init() { 9 | this.buttonEl = this.$refs.button; 10 | this.contentEl = this.$refs.content; 11 | }, 12 | 13 | open() { 14 | this.isOpen = true; 15 | this.contentEl.classList.remove("hidden"); 16 | this.positionContent(); 17 | 18 | if (this.closeTimeout) { 19 | clearTimeout(this.closeTimeout); 20 | this.closeTimeout = null; 21 | } 22 | }, 23 | 24 | close() { 25 | this.closeTimeout = setTimeout(() => { 26 | this.isOpen = false; 27 | this.contentEl.classList.add("hidden"); 28 | }, 200); 29 | }, 30 | 31 | positionContent() { 32 | const buttonRect = this.buttonEl.getBoundingClientRect(); 33 | const contentHeight = this.contentEl.offsetHeight; 34 | const windowHeight = window.innerHeight; 35 | const moreSpaceBelow = 36 | (windowHeight - buttonRect.bottom) > buttonRect.top; 37 | 38 | this.contentEl.style.left = `${buttonRect.left}px`; 39 | 40 | if (moreSpaceBelow) { 41 | this.contentEl.style.top = `${buttonRect.bottom}px`; 42 | } else { 43 | this.contentEl.style.top = `${buttonRect.top - contentHeight}px`; 44 | } 45 | }, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /internal/view/web/component/pg_version_select_options.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/integration/postgres" 7 | nodx "github.com/nodxdev/nodxgo" 8 | ) 9 | 10 | func PGVersionSelectOptions(selectedVersion sql.NullString) nodx.Node { 11 | return nodx.Map( 12 | postgres.PGVersions, 13 | func(pgVersion postgres.PGVersion) nodx.Node { 14 | return nodx.Option( 15 | nodx.Value(pgVersion.Value.Version), 16 | nodx.Textf("PostgreSQL %s", pgVersion.Value.Version), 17 | nodx.If( 18 | selectedVersion.Valid && selectedVersion.String == pgVersion.Value.Version, 19 | nodx.Selected(""), 20 | ), 21 | ) 22 | }, 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /internal/view/web/component/pretty_destination_name.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "database/sql" 5 | 6 | nodx "github.com/nodxdev/nodxgo" 7 | lucide "github.com/nodxdev/nodxgo-lucide" 8 | ) 9 | 10 | func PrettyDestinationName( 11 | isLocal bool, destinationName sql.NullString, 12 | ) nodx.Node { 13 | icon := lucide.Cloud 14 | if !destinationName.Valid { 15 | destinationName = sql.NullString{ 16 | Valid: true, 17 | String: "Unknown destination", 18 | } 19 | } 20 | 21 | if isLocal { 22 | icon = lucide.HardDrive 23 | destinationName = sql.NullString{ 24 | Valid: true, 25 | String: "Local", 26 | } 27 | } 28 | 29 | return nodx.SpanEl( 30 | nodx.Class("inline flex justify-start items-center space-x-1 font-mono"), 31 | icon(), 32 | SpanText(destinationName.String), 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /internal/view/web/component/pretty_file_size.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/util/strutil" 7 | nodx "github.com/nodxdev/nodxgo" 8 | ) 9 | 10 | // PrettyFileSize pretty prints a file size (in bytes) to a human-readable format. 11 | // If the size is not valid, it returns an empty string. 12 | // 13 | // e.g. 1024 -> 1 KB 14 | func PrettyFileSize( 15 | size sql.NullInt64, 16 | ) nodx.Node { 17 | return nodx.If( 18 | size.Valid, 19 | nodx.SpanEl( 20 | SpanText(strutil.FormatFileSize(size.Int64)), 21 | ), 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /internal/view/web/component/renderable_group.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "bytes" 5 | 6 | nodx "github.com/nodxdev/nodxgo" 7 | ) 8 | 9 | // RenderableGroup renders a group of nodes without a parent element. 10 | // 11 | // This is because nodx.Group() cannot be directly rendered and 12 | // needs to be wrapped in a parent element. 13 | func RenderableGroup(children []nodx.Node) nodx.Node { 14 | buf := bytes.Buffer{} 15 | for _, child := range children { 16 | err := child.Render(&buf) 17 | if err != nil { 18 | return nodx.Raw("Error rendering group") 19 | } 20 | } 21 | return nodx.Raw(buf.String()) 22 | } 23 | -------------------------------------------------------------------------------- /internal/view/web/component/renderable_group_test.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | nodx "github.com/nodxdev/nodxgo" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestRenderableGroupRenderer(t *testing.T) { 12 | t.Run("renders a group of string nodes without a parent element", func(t *testing.T) { 13 | gotRenderer := RenderableGroup([]nodx.Node{ 14 | nodx.Text("foo"), 15 | nodx.Text("bar"), 16 | }) 17 | 18 | got := bytes.Buffer{} 19 | err := gotRenderer.Render(&got) 20 | assert.NoError(t, err) 21 | 22 | expected := "foobar" 23 | 24 | assert.Equal(t, expected, got.String()) 25 | }) 26 | 27 | t.Run("renders a group of tag nodes without a parent element", func(t *testing.T) { 28 | gotRenderer := RenderableGroup([]nodx.Node{ 29 | nodx.SpanEl( 30 | nodx.Text("foo"), 31 | ), 32 | nodx.P( 33 | nodx.Text("bar"), 34 | ), 35 | }) 36 | 37 | got := bytes.Buffer{} 38 | err := gotRenderer.Render(&got) 39 | assert.NoError(t, err) 40 | 41 | expected := `foo

bar

` 42 | 43 | assert.Equal(t, expected, got.String()) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /internal/view/web/component/skeleton.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | nodx "github.com/nodxdev/nodxgo" 5 | ) 6 | 7 | func SkeletonTr(rows int) nodx.Node { 8 | rs := make([]nodx.Node, rows) 9 | for i := range rs { 10 | rs[i] = nodx.Tr( 11 | nodx.Td( 12 | nodx.Colspan("100%"), 13 | nodx.Div( 14 | nodx.Class("animate-pulse h-4 w-full bg-base-300 rounded-badge"), 15 | ), 16 | ), 17 | ) 18 | } 19 | 20 | return nodx.Group(rs...) 21 | 22 | } 23 | -------------------------------------------------------------------------------- /internal/view/web/component/spinner.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | "fmt" 5 | 6 | nodx "github.com/nodxdev/nodxgo" 7 | lucide "github.com/nodxdev/nodxgo-lucide" 8 | ) 9 | 10 | func spinner(size size) nodx.Node { 11 | return lucide.LoaderCircle(nodx.ClassMap{ 12 | "animate-spin inline-block": true, 13 | "size-5": size == SizeSm, 14 | "size-8": size == SizeMd, 15 | "size-12": size == SizeLg, 16 | }) 17 | } 18 | 19 | func SpinnerSm() nodx.Node { 20 | return spinner(SizeSm) 21 | } 22 | 23 | func SpinnerMd() nodx.Node { 24 | return spinner(SizeMd) 25 | } 26 | 27 | func SpinnerLg() nodx.Node { 28 | return spinner(SizeLg) 29 | } 30 | 31 | func spinnerContainer(size size, height string) nodx.Node { 32 | return nodx.Div( 33 | nodx.ClassMap{ 34 | "flex justify-center": true, 35 | "items-center w-full": true, 36 | }, 37 | nodx.StyleAttr(fmt.Sprintf("height: %s;", height)), 38 | spinner(size), 39 | ) 40 | } 41 | 42 | func SpinnerContainerSm(height ...string) nodx.Node { 43 | pickedHeight := "300px" 44 | if len(height) > 0 { 45 | pickedHeight = height[0] 46 | } 47 | return spinnerContainer(SizeSm, pickedHeight) 48 | } 49 | 50 | func SpinnerContainerMd(height ...string) nodx.Node { 51 | pickedHeight := "300px" 52 | if len(height) > 0 { 53 | pickedHeight = height[0] 54 | } 55 | return spinnerContainer(SizeMd, pickedHeight) 56 | } 57 | 58 | func SpinnerContainerLg(height ...string) nodx.Node { 59 | pickedHeight := "300px" 60 | if len(height) > 0 { 61 | pickedHeight = height[0] 62 | } 63 | return spinnerContainer(SizeLg, pickedHeight) 64 | } 65 | -------------------------------------------------------------------------------- /internal/view/web/component/star_on_github.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | nodx "github.com/nodxdev/nodxgo" 5 | alpine "github.com/nodxdev/nodxgo-alpine" 6 | lucide "github.com/nodxdev/nodxgo-lucide" 7 | ) 8 | 9 | func StarOnGithub(size size) nodx.Node { 10 | return nodx.A( 11 | alpine.XData("alpineStarOnGithub()"), 12 | alpine.XCloak(), 13 | nodx.ClassMap{ 14 | "btn btn-neutral": true, 15 | "btn-sm": size == SizeSm, 16 | "btn-lg": size == SizeLg, 17 | }, 18 | nodx.Href("https://github.com/eduardolat/pgbackweb"), 19 | nodx.Target("_blank"), 20 | lucide.Github(), 21 | SpanText("Star"), 22 | nodx.SpanEl( 23 | alpine.XShow("stars"), 24 | alpine.XText("'( ' + stars + ' )'"), 25 | ), 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /internal/view/web/component/star_on_github.inc.js: -------------------------------------------------------------------------------- 1 | window.alpineStarOnGithub = function () { 2 | return { 3 | stars: null, 4 | 5 | async init() { 6 | const stars = await this.getStars(); 7 | if (stars !== null) { 8 | this.stars = stars; 9 | } 10 | }, 11 | 12 | async getStars() { 13 | const cacheKey = "pbw-gh-stars"; 14 | 15 | const cachedJSON = localStorage.getItem(cacheKey); 16 | if (cachedJSON) { 17 | const cached = JSON.parse(cachedJSON); 18 | if (Date.now() - cached.timestamp < 2 * 60 * 1000) { 19 | return cached.value; 20 | } 21 | } 22 | 23 | const url = "https://api.github.com/repos/eduardolat/pgbackweb"; 24 | try { 25 | const response = await fetch(url); 26 | if (!response.ok) { 27 | return null; 28 | } 29 | const data = await response.json(); 30 | const value = data.stargazers_count; 31 | const dataToCache = JSON.stringify({ 32 | value, 33 | timestamp: Date.now(), 34 | }); 35 | localStorage.setItem(cacheKey, dataToCache); 36 | return value; 37 | } catch { 38 | return null; 39 | } 40 | }, 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /internal/view/web/component/status_badge.go: -------------------------------------------------------------------------------- 1 | package component 2 | 3 | import ( 4 | nodx "github.com/nodxdev/nodxgo" 5 | ) 6 | 7 | func StatusBadge(status string) nodx.Node { 8 | class := "" 9 | switch status { 10 | case "running": 11 | class = "badge-info" 12 | case "success": 13 | class = "badge-success" 14 | case "failed": 15 | class = "badge-error" 16 | case "deleted": 17 | class = "badge-warning" 18 | default: 19 | class = "badge-neutral" 20 | } 21 | 22 | return nodx.SpanEl( 23 | nodx.ClassMap{ 24 | "badge": true, 25 | class: true, 26 | }, 27 | nodx.Text(status), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/about/router.go: -------------------------------------------------------------------------------- 1 | package about 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/service" 5 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type handlers struct { 10 | servs *service.Service 11 | } 12 | 13 | func newHandlers(servs *service.Service) *handlers { 14 | return &handlers{servs: servs} 15 | } 16 | 17 | func MountRouter( 18 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 19 | ) { 20 | h := newHandlers(servs) 21 | 22 | parent.GET("", h.indexPageHandler) 23 | } 24 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/backups/delete_backup.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 6 | "github.com/google/uuid" 7 | "github.com/labstack/echo/v4" 8 | nodx "github.com/nodxdev/nodxgo" 9 | htmx "github.com/nodxdev/nodxgo-htmx" 10 | lucide "github.com/nodxdev/nodxgo-lucide" 11 | ) 12 | 13 | func (h *handlers) deleteBackupHandler(c echo.Context) error { 14 | ctx := c.Request().Context() 15 | 16 | backupID, err := uuid.Parse(c.Param("backupID")) 17 | if err != nil { 18 | return respondhtmx.ToastError(c, err.Error()) 19 | } 20 | 21 | if err = h.servs.BackupsService.DeleteBackup(ctx, backupID); err != nil { 22 | return respondhtmx.ToastError(c, err.Error()) 23 | } 24 | 25 | return respondhtmx.Refresh(c) 26 | } 27 | 28 | func deleteBackupButton(backupID uuid.UUID) nodx.Node { 29 | return component.OptionsDropdownButton( 30 | htmx.HxDelete("/dashboard/backups/"+backupID.String()), 31 | htmx.HxConfirm("Are you sure you want to delete this backup task?"), 32 | lucide.Trash(), 33 | component.SpanText("Delete backup task"), 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/backups/duplicate_backup.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 6 | "github.com/google/uuid" 7 | "github.com/labstack/echo/v4" 8 | nodx "github.com/nodxdev/nodxgo" 9 | htmx "github.com/nodxdev/nodxgo-htmx" 10 | lucide "github.com/nodxdev/nodxgo-lucide" 11 | ) 12 | 13 | func (h *handlers) duplicateBackupHandler(c echo.Context) error { 14 | ctx := c.Request().Context() 15 | 16 | backupID, err := uuid.Parse(c.Param("backupID")) 17 | if err != nil { 18 | return respondhtmx.ToastError(c, err.Error()) 19 | } 20 | 21 | if _, err = h.servs.BackupsService.DuplicateBackup(ctx, backupID); err != nil { 22 | return respondhtmx.ToastError(c, err.Error()) 23 | } 24 | 25 | return respondhtmx.Refresh(c) 26 | } 27 | 28 | func duplicateBackupButton(backupID uuid.UUID) nodx.Node { 29 | return component.OptionsDropdownButton( 30 | htmx.HxPost("/dashboard/backups/"+backupID.String()+"/duplicate"), 31 | htmx.HxConfirm("Are you sure you want to duplicate this backup task?"), 32 | lucide.CopyPlus(), 33 | component.SpanText("Duplicate backup task"), 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/backups/manual_run.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 7 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 8 | "github.com/google/uuid" 9 | "github.com/labstack/echo/v4" 10 | nodx "github.com/nodxdev/nodxgo" 11 | htmx "github.com/nodxdev/nodxgo-htmx" 12 | lucide "github.com/nodxdev/nodxgo-lucide" 13 | ) 14 | 15 | func (h *handlers) manualRunHandler(c echo.Context) error { 16 | backupID, err := uuid.Parse(c.Param("backupID")) 17 | if err != nil { 18 | return respondhtmx.ToastError(c, err.Error()) 19 | } 20 | 21 | go func() { 22 | _ = h.servs.ExecutionsService.RunExecution(context.Background(), backupID) 23 | }() 24 | 25 | return respondhtmx.ToastSuccess(c, "Backup started, check the backup executions for more details") 26 | } 27 | 28 | func manualRunbutton(backupID uuid.UUID) nodx.Node { 29 | return component.OptionsDropdownButton( 30 | htmx.HxPost("/dashboard/backups/"+backupID.String()+"/run"), 31 | htmx.HxDisabledELT("this"), 32 | lucide.Zap(), 33 | component.SpanText("Run backup now"), 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/backups/router.go: -------------------------------------------------------------------------------- 1 | package backups 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/service" 5 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type handlers struct { 10 | servs *service.Service 11 | } 12 | 13 | func newHandlers(servs *service.Service) *handlers { 14 | return &handlers{servs: servs} 15 | } 16 | 17 | func MountRouter( 18 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 19 | ) { 20 | h := newHandlers(servs) 21 | 22 | parent.GET("", h.indexPageHandler) 23 | parent.GET("/list", h.listBackupsHandler) 24 | parent.GET("/create-form", h.createBackupFormHandler) 25 | parent.POST("", h.createBackupHandler) 26 | parent.DELETE("/:backupID", h.deleteBackupHandler) 27 | parent.POST("/:backupID/edit", h.editBackupHandler) 28 | parent.POST("/:backupID/run", h.manualRunHandler) 29 | parent.POST("/:backupID/duplicate", h.duplicateBackupHandler) 30 | } 31 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/databases/delete_database.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 6 | "github.com/google/uuid" 7 | "github.com/labstack/echo/v4" 8 | nodx "github.com/nodxdev/nodxgo" 9 | htmx "github.com/nodxdev/nodxgo-htmx" 10 | lucide "github.com/nodxdev/nodxgo-lucide" 11 | ) 12 | 13 | func (h *handlers) deleteDatabaseHandler(c echo.Context) error { 14 | ctx := c.Request().Context() 15 | 16 | databaseID, err := uuid.Parse(c.Param("databaseID")) 17 | if err != nil { 18 | return respondhtmx.ToastError(c, err.Error()) 19 | } 20 | 21 | if err = h.servs.DatabasesService.DeleteDatabase(ctx, databaseID); err != nil { 22 | return respondhtmx.ToastError(c, err.Error()) 23 | } 24 | 25 | return respondhtmx.Refresh(c) 26 | } 27 | 28 | func deleteDatabaseButton(databaseID uuid.UUID) nodx.Node { 29 | return component.OptionsDropdownButton( 30 | htmx.HxDelete("/dashboard/databases/"+databaseID.String()), 31 | htmx.HxConfirm("Are you sure you want to delete this database?"), 32 | lucide.Trash(), 33 | component.SpanText("Delete database"), 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/databases/router.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/service" 5 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type handlers struct { 10 | servs *service.Service 11 | } 12 | 13 | func newHandlers(servs *service.Service) *handlers { 14 | return &handlers{servs: servs} 15 | } 16 | 17 | func MountRouter( 18 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 19 | ) { 20 | h := newHandlers(servs) 21 | 22 | parent.GET("", h.indexPageHandler) 23 | parent.GET("/list", h.listDatabasesHandler) 24 | parent.POST("", h.createDatabaseHandler) 25 | parent.POST("/test", h.testDatabaseHandler) 26 | parent.DELETE("/:databaseID", h.deleteDatabaseHandler) 27 | parent.POST("/:databaseID/edit", h.editDatabaseHandler) 28 | parent.POST("/:databaseID/test", h.testExistingDatabaseHandler) 29 | } 30 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/databases/test_database.go: -------------------------------------------------------------------------------- 1 | package databases 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/validate" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 6 | "github.com/google/uuid" 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | func (h *handlers) testDatabaseHandler(c echo.Context) error { 11 | ctx := c.Request().Context() 12 | 13 | var formData createDatabaseDTO 14 | if err := c.Bind(&formData); err != nil { 15 | return respondhtmx.ToastError(c, err.Error()) 16 | } 17 | if err := validate.Struct(&formData); err != nil { 18 | return respondhtmx.ToastError(c, err.Error()) 19 | } 20 | 21 | err := h.servs.DatabasesService.TestDatabase( 22 | ctx, formData.Version, formData.ConnectionString, 23 | ) 24 | if err != nil { 25 | return respondhtmx.ToastError(c, err.Error()) 26 | } 27 | 28 | return respondhtmx.ToastSuccess(c, "Connection successful") 29 | } 30 | 31 | func (h *handlers) testExistingDatabaseHandler(c echo.Context) error { 32 | ctx := c.Request().Context() 33 | databaseID, err := uuid.Parse(c.Param("databaseID")) 34 | if err != nil { 35 | return respondhtmx.ToastError(c, err.Error()) 36 | } 37 | 38 | err = h.servs.DatabasesService.TestDatabaseAndStoreResult(ctx, databaseID) 39 | if err != nil { 40 | return respondhtmx.ToastError(c, err.Error()) 41 | } 42 | 43 | return respondhtmx.ToastSuccess(c, "Connection successful") 44 | } 45 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/destinations/delete_destination.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 6 | "github.com/google/uuid" 7 | "github.com/labstack/echo/v4" 8 | nodx "github.com/nodxdev/nodxgo" 9 | htmx "github.com/nodxdev/nodxgo-htmx" 10 | lucide "github.com/nodxdev/nodxgo-lucide" 11 | ) 12 | 13 | func (h *handlers) deleteDestinationHandler(c echo.Context) error { 14 | ctx := c.Request().Context() 15 | 16 | destinationID, err := uuid.Parse(c.Param("destinationID")) 17 | if err != nil { 18 | return respondhtmx.ToastError(c, err.Error()) 19 | } 20 | 21 | err = h.servs.DestinationsService.DeleteDestination(ctx, destinationID) 22 | if err != nil { 23 | return respondhtmx.ToastError(c, err.Error()) 24 | } 25 | 26 | return respondhtmx.Refresh(c) 27 | } 28 | 29 | func deleteDestinationButton(destinationID uuid.UUID) nodx.Node { 30 | return component.OptionsDropdownButton( 31 | htmx.HxDelete("/dashboard/destinations/"+destinationID.String()), 32 | htmx.HxConfirm("Are you sure you want to delete this destination?"), 33 | lucide.Trash(), 34 | component.SpanText("Delete destination"), 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/destinations/router.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/service" 5 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type handlers struct { 10 | servs *service.Service 11 | } 12 | 13 | func newHandlers(servs *service.Service) *handlers { 14 | return &handlers{servs: servs} 15 | } 16 | 17 | func MountRouter( 18 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 19 | ) { 20 | h := newHandlers(servs) 21 | 22 | parent.GET("", h.indexPageHandler) 23 | parent.GET("/list", h.listDestinationsHandler) 24 | parent.POST("", h.createDestinationHandler) 25 | parent.POST("/test", h.testDestinationHandler) 26 | parent.DELETE("/:destinationID", h.deleteDestinationHandler) 27 | parent.POST("/:destinationID/edit", h.editDestinationHandler) 28 | parent.POST("/:destinationID/test", h.testExistingDestinationHandler) 29 | } 30 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/destinations/test_destination.go: -------------------------------------------------------------------------------- 1 | package destinations 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/validate" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 6 | "github.com/google/uuid" 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | func (h *handlers) testDestinationHandler(c echo.Context) error { 11 | var formData createDestinationDTO 12 | if err := c.Bind(&formData); err != nil { 13 | return respondhtmx.ToastError(c, err.Error()) 14 | } 15 | if err := validate.Struct(&formData); err != nil { 16 | return respondhtmx.ToastError(c, err.Error()) 17 | } 18 | 19 | err := h.servs.DestinationsService.TestDestination( 20 | formData.AccessKey, formData.SecretKey, formData.Region, formData.Endpoint, 21 | formData.BucketName, 22 | ) 23 | if err != nil { 24 | return respondhtmx.ToastError(c, err.Error()) 25 | } 26 | 27 | return respondhtmx.ToastSuccess(c, "Connection successful") 28 | } 29 | 30 | func (h *handlers) testExistingDestinationHandler(c echo.Context) error { 31 | ctx := c.Request().Context() 32 | destinationID, err := uuid.Parse(c.Param("destinationID")) 33 | if err != nil { 34 | return respondhtmx.ToastError(c, err.Error()) 35 | } 36 | 37 | err = h.servs.DestinationsService.TestDestinationAndStoreResult(ctx, destinationID) 38 | if err != nil { 39 | return respondhtmx.ToastError(c, err.Error()) 40 | } 41 | 42 | return respondhtmx.ToastSuccess(c, "Connection successful") 43 | } 44 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/executions/router.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/service" 5 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type handlers struct { 10 | servs *service.Service 11 | } 12 | 13 | func newHandlers(servs *service.Service) *handlers { 14 | return &handlers{servs: servs} 15 | } 16 | 17 | func MountRouter( 18 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 19 | ) { 20 | h := newHandlers(servs) 21 | 22 | parent.GET("", h.indexPageHandler) 23 | parent.GET("/list", h.listExecutionsHandler) 24 | parent.GET("/:executionID/download", h.downloadExecutionHandler) 25 | parent.DELETE("/:executionID", h.deleteExecutionHandler) 26 | parent.GET("/:executionID/restore-form", h.restoreExecutionFormHandler) 27 | parent.POST("/:executionID/restore", h.restoreExecutionHandler) 28 | } 29 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/executions/soft_delete_execution.go: -------------------------------------------------------------------------------- 1 | package executions 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 6 | "github.com/google/uuid" 7 | "github.com/labstack/echo/v4" 8 | nodx "github.com/nodxdev/nodxgo" 9 | htmx "github.com/nodxdev/nodxgo-htmx" 10 | lucide "github.com/nodxdev/nodxgo-lucide" 11 | ) 12 | 13 | func (h *handlers) deleteExecutionHandler(c echo.Context) error { 14 | ctx := c.Request().Context() 15 | 16 | executionID, err := uuid.Parse(c.Param("executionID")) 17 | if err != nil { 18 | return respondhtmx.ToastError(c, err.Error()) 19 | } 20 | 21 | err = h.servs.ExecutionsService.SoftDeleteExecution(ctx, executionID) 22 | if err != nil { 23 | return respondhtmx.ToastError(c, err.Error()) 24 | } 25 | 26 | return respondhtmx.Refresh(c) 27 | } 28 | 29 | func deleteExecutionButton(executionID uuid.UUID) nodx.Node { 30 | return nodx.Button( 31 | htmx.HxDelete("/dashboard/executions/"+executionID.String()), 32 | htmx.HxDisabledELT("this"), 33 | htmx.HxConfirm("Are you sure you want to delete this execution? It will delete the backup file from the destination and it can't be recovered."), 34 | nodx.Class("btn btn-error btn-outline"), 35 | component.SpanText("Delete"), 36 | lucide.Trash(), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/profile/index.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/database/dbgen" 7 | "github.com/eduardolat/pgbackweb/internal/logger" 8 | "github.com/eduardolat/pgbackweb/internal/util/echoutil" 9 | "github.com/eduardolat/pgbackweb/internal/view/reqctx" 10 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 11 | "github.com/eduardolat/pgbackweb/internal/view/web/layout" 12 | "github.com/labstack/echo/v4" 13 | nodx "github.com/nodxdev/nodxgo" 14 | ) 15 | 16 | func (h *handlers) indexPageHandler(c echo.Context) error { 17 | ctx := c.Request().Context() 18 | reqCtx := reqctx.GetCtx(c) 19 | 20 | sessions, err := h.servs.AuthService.GetUserSessions(ctx, reqCtx.User.ID) 21 | if err != nil { 22 | logger.Error("failed to get user sessions", logger.KV{"err": err}) 23 | return c.String(http.StatusInternalServerError, "failed to get user sessions") 24 | } 25 | 26 | return echoutil.RenderNodx( 27 | c, http.StatusOK, indexPage(reqCtx, sessions), 28 | ) 29 | } 30 | 31 | func indexPage(reqCtx reqctx.Ctx, sessions []dbgen.Session) nodx.Node { 32 | content := []nodx.Node{ 33 | component.H1Text("Profile"), 34 | 35 | nodx.Div( 36 | nodx.Class("mt-4 grid grid-cols-2 gap-4"), 37 | nodx.Div(updateUserForm(reqCtx.User)), 38 | nodx.Div(closeAllSessionsForm(sessions)), 39 | ), 40 | } 41 | 42 | return layout.Dashboard(reqCtx, layout.DashboardParams{ 43 | Title: "Profile", 44 | Body: content, 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/profile/router.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/service" 5 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type handlers struct { 10 | servs *service.Service 11 | } 12 | 13 | func newHandlers(servs *service.Service) *handlers { 14 | return &handlers{servs: servs} 15 | } 16 | 17 | func MountRouter( 18 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 19 | ) { 20 | h := newHandlers(servs) 21 | 22 | parent.GET("", h.indexPageHandler) 23 | parent.POST("", h.updateUserHandler) 24 | } 25 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/restorations/router.go: -------------------------------------------------------------------------------- 1 | package restorations 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/service" 5 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type handlers struct { 10 | servs *service.Service 11 | } 12 | 13 | func newHandlers(servs *service.Service) *handlers { 14 | return &handlers{servs: servs} 15 | } 16 | 17 | func MountRouter( 18 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 19 | ) { 20 | h := newHandlers(servs) 21 | 22 | parent.GET("", h.indexPageHandler) 23 | parent.GET("/list", h.listRestorationsHandler) 24 | } 25 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/summary/index_how_to.inc.js: -------------------------------------------------------------------------------- 1 | window.alpineSummaryHowToSlider = function () { 2 | return { 3 | slidesQty: 4, 4 | currentSlide: 1, 5 | 6 | get hasNextSlide() { 7 | return this.currentSlide < this.slidesQty; 8 | }, 9 | 10 | get hasPrevSlide() { 11 | return this.currentSlide > 1; 12 | }, 13 | 14 | nextSlide() { 15 | if (this.hasNextSlide) this.currentSlide++; 16 | }, 17 | 18 | prevSlide() { 19 | if (this.hasPrevSlide) this.currentSlide--; 20 | }, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/summary/router.go: -------------------------------------------------------------------------------- 1 | package summary 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/service" 5 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type handlers struct { 10 | servs *service.Service 11 | } 12 | 13 | func newHandlers(servs *service.Service) *handlers { 14 | return &handlers{servs: servs} 15 | } 16 | 17 | func MountRouter( 18 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 19 | ) { 20 | h := newHandlers(servs) 21 | 22 | parent.GET("", h.indexPageHandler) 23 | } 24 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/webhooks/delete_webhook.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 6 | "github.com/google/uuid" 7 | "github.com/labstack/echo/v4" 8 | nodx "github.com/nodxdev/nodxgo" 9 | htmx "github.com/nodxdev/nodxgo-htmx" 10 | lucide "github.com/nodxdev/nodxgo-lucide" 11 | ) 12 | 13 | func (h *handlers) deleteWebhookHandler(c echo.Context) error { 14 | ctx := c.Request().Context() 15 | 16 | webhookID, err := uuid.Parse(c.Param("webhookID")) 17 | if err != nil { 18 | return respondhtmx.ToastError(c, err.Error()) 19 | } 20 | 21 | if err = h.servs.WebhooksService.DeleteWebhook(ctx, webhookID); err != nil { 22 | return respondhtmx.ToastError(c, err.Error()) 23 | } 24 | 25 | return respondhtmx.Refresh(c) 26 | } 27 | 28 | func deleteWebhookButton(webhookID uuid.UUID) nodx.Node { 29 | return component.OptionsDropdownButton( 30 | htmx.HxDelete("/dashboard/webhooks/"+webhookID.String()), 31 | htmx.HxConfirm("Are you sure you want to delete this webhook?"), 32 | lucide.Trash(), 33 | component.SpanText("Delete webhook"), 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/webhooks/duplicate_webhook.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/respondhtmx" 6 | "github.com/google/uuid" 7 | "github.com/labstack/echo/v4" 8 | nodx "github.com/nodxdev/nodxgo" 9 | htmx "github.com/nodxdev/nodxgo-htmx" 10 | lucide "github.com/nodxdev/nodxgo-lucide" 11 | ) 12 | 13 | func (h *handlers) duplicateWebhookHandler(c echo.Context) error { 14 | ctx := c.Request().Context() 15 | 16 | webhookID, err := uuid.Parse(c.Param("webhookID")) 17 | if err != nil { 18 | return respondhtmx.ToastError(c, err.Error()) 19 | } 20 | 21 | if _, err = h.servs.WebhooksService.DuplicateWebhook(ctx, webhookID); err != nil { 22 | return respondhtmx.ToastError(c, err.Error()) 23 | } 24 | 25 | return respondhtmx.Refresh(c) 26 | } 27 | 28 | func duplicateWebhookButton(webhookID uuid.UUID) nodx.Node { 29 | return component.OptionsDropdownButton( 30 | htmx.HxPost("/dashboard/webhooks/"+webhookID.String()+"/duplicate"), 31 | htmx.HxConfirm("Are you sure you want to duplicate this webhook?"), 32 | lucide.CopyPlus(), 33 | component.SpanText("Duplicate webhook"), 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /internal/view/web/dashboard/webhooks/router.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/service" 5 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type handlers struct { 10 | servs *service.Service 11 | } 12 | 13 | func newHandlers(servs *service.Service) *handlers { 14 | return &handlers{servs: servs} 15 | } 16 | 17 | func MountRouter( 18 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 19 | ) { 20 | h := newHandlers(servs) 21 | 22 | parent.GET("", h.indexPageHandler) 23 | parent.GET("/list", h.listWebhooksHandler) 24 | parent.GET("/create", h.createWebhookFormHandler) 25 | parent.POST("/create", h.createWebhookHandler) 26 | parent.GET("/:webhookID/edit", h.editWebhookFormHandler) 27 | parent.POST("/:webhookID/edit", h.editWebhookHandler) 28 | parent.POST("/:webhookID/run", h.runWebhookHandler) 29 | parent.POST("/:webhookID/duplicate", h.duplicateWebhookHandler) 30 | parent.GET("/:webhookID/executions", h.paginateWebhookExecutionsHandler) 31 | parent.DELETE("/:webhookID", h.deleteWebhookHandler) 32 | } 33 | -------------------------------------------------------------------------------- /internal/view/web/layout/auth.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 5 | nodx "github.com/nodxdev/nodxgo" 6 | ) 7 | 8 | type AuthParams struct { 9 | Title string 10 | Body []nodx.Node 11 | } 12 | 13 | func Auth(params AuthParams) nodx.Node { 14 | title := "PG Back Web" 15 | if params.Title != "" { 16 | title = params.Title + " - " + title 17 | } 18 | 19 | body := nodx.Group( 20 | nodx.ClassMap{ 21 | "w-screen h-screen px-4 py-[40px]": true, 22 | "grid grid-cols-1 place-items-center": true, 23 | "bg-base-300 overflow-y-auto": true, 24 | }, 25 | nodx.Div( 26 | nodx.Class("w-full max-w-[600px] space-y-4"), 27 | nodx.Div( 28 | nodx.Class("flex justify-center"), 29 | component.Logotype(), 30 | ), 31 | nodx.Main( 32 | nodx.Class("rounded-box shadow-md bg-base-100 p-4"), 33 | nodx.Group(params.Body...), 34 | ), 35 | nodx.Div( 36 | nodx.Class("flex justify-start space-x-2 items-center"), 37 | component.ChangeThemeButton(component.ChangeThemeButtonParams{ 38 | Position: component.DropdownPositionTop, 39 | AlignsToEnd: false, 40 | Size: component.SizeMd, 41 | }), 42 | component.StarOnGithub(component.SizeMd), 43 | ), 44 | ), 45 | ) 46 | 47 | return commonHtmlDoc(title, body) 48 | } 49 | -------------------------------------------------------------------------------- /internal/view/web/layout/dashboard.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/reqctx" 5 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 6 | nodx "github.com/nodxdev/nodxgo" 7 | ) 8 | 9 | type DashboardParams struct { 10 | Title string 11 | Body []nodx.Node 12 | } 13 | 14 | func Dashboard(reqCtx reqctx.Ctx, params DashboardParams) nodx.Node { 15 | title := "PG Back Web" 16 | if params.Title != "" { 17 | title = params.Title + " - " + title 18 | } 19 | 20 | if reqCtx.IsHTMXBoosted { 21 | body := append(params.Body, nodx.TitleEl(nodx.Text(title))) 22 | return component.RenderableGroup(body) 23 | } 24 | 25 | body := nodx.Group( 26 | nodx.ClassMap{ 27 | "w-screen h-screen bg-base-200": true, 28 | "flex justify-start overflow-hidden": true, 29 | }, 30 | dashboardAside(), 31 | nodx.Div( 32 | nodx.Class("flex-grow overflow-y-auto"), 33 | dashboardHeader(), 34 | nodx.Main( 35 | nodx.Id("dashboard-main"), 36 | nodx.Class("p-4"), 37 | nodx.Group(params.Body...), 38 | ), 39 | ), 40 | ) 41 | 42 | return commonHtmlDoc(title, body) 43 | } 44 | -------------------------------------------------------------------------------- /internal/view/web/layout/dashboard_aside.inc.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function () { 2 | const el = document.getElementById("dashboard-aside"); 3 | const key = "dashboard-aside-scroll-position"; 4 | 5 | if (!el) return; 6 | 7 | const saveScrollPosition = window.debounce( 8 | () => { 9 | const scrollPosition = el.scrollTop; 10 | localStorage.setItem(key, scrollPosition); 11 | }, 12 | 200, 13 | ); 14 | el.addEventListener("scroll", saveScrollPosition); 15 | 16 | const scrollPosition = localStorage.getItem(key); 17 | if (scrollPosition) { 18 | el.scrollTop = parseInt(scrollPosition, 10); 19 | } 20 | }); 21 | 22 | window.alpineDashboardAsideItem = function (link = "", strict = false) { 23 | return { 24 | link, 25 | strict, 26 | is_active: false, 27 | 28 | checkActive() { 29 | if (this.strict) { 30 | this.is_active = window.location.pathname === this.link; 31 | return; 32 | } 33 | 34 | this.is_active = window.location.pathname.startsWith(this.link); 35 | }, 36 | 37 | init() { 38 | this.checkActive(); 39 | 40 | const originalPushState = window.history.pushState; 41 | window.history.pushState = (...args) => { 42 | originalPushState.apply(window.history, args); 43 | this.checkActive(); 44 | }; 45 | 46 | const originalReplaceState = window.history.replaceState; 47 | window.history.replaceState = (...args) => { 48 | originalReplaceState.apply(window.history, args); 49 | this.checkActive(); 50 | }; 51 | }, 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /internal/view/web/layout/dashboard_header.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 5 | nodx "github.com/nodxdev/nodxgo" 6 | htmx "github.com/nodxdev/nodxgo-htmx" 7 | lucide "github.com/nodxdev/nodxgo-lucide" 8 | ) 9 | 10 | func dashboardHeader() nodx.Node { 11 | return nodx.Header( 12 | nodx.ClassMap{ 13 | "sticky top-0 z-50": true, 14 | "space-x-4 p-4 min-w-max": true, 15 | "w-[full] bg-base-200 shadow-sm": true, 16 | "flex items-center justify-between": true, 17 | }, 18 | nodx.Div( 19 | nodx.Class("flex justify-start items-center space-x-2"), 20 | component.SupportProjectButton(component.SizeSm), 21 | component.ChangeThemeButton(component.ChangeThemeButtonParams{ 22 | Position: component.DropdownPositionBottom, 23 | Size: component.SizeSm, 24 | }), 25 | component.StarOnGithub(component.SizeSm), 26 | dashboardHeaderUpdates(), 27 | ), 28 | nodx.Div( 29 | nodx.Class("flex justify-end items-center space-x-2"), 30 | nodx.Div( 31 | htmx.HxGet("/dashboard/health-button"), 32 | htmx.HxSwap("outerHTML"), 33 | htmx.HxTrigger("load once"), 34 | ), 35 | nodx.A( 36 | nodx.Href("https://discord.gg/BmAwq29UZ8"), 37 | nodx.Target("_blank"), 38 | nodx.Class("btn btn-ghost btn-neutral"), 39 | component.SpanText("Chat on Discord"), 40 | lucide.ExternalLink(), 41 | ), 42 | nodx.Button( 43 | htmx.HxPost("/auth/logout"), 44 | htmx.HxDisabledELT("this"), 45 | nodx.Class("btn btn-ghost btn-neutral"), 46 | component.SpanText("Log out"), 47 | lucide.LogOut(), 48 | ), 49 | ), 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /internal/view/web/layout/dashboard_header_updates.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/config" 7 | "github.com/eduardolat/pgbackweb/internal/view/web/component" 8 | nodx "github.com/nodxdev/nodxgo" 9 | alpine "github.com/nodxdev/nodxgo-alpine" 10 | lucide "github.com/nodxdev/nodxgo-lucide" 11 | ) 12 | 13 | func dashboardHeaderUpdates() nodx.Node { 14 | return nodx.A( 15 | alpine.XData("alpineDashboardHeaderUpdates()"), 16 | alpine.XCloak(), 17 | alpine.XShow(fmt.Sprintf( 18 | "latestRelease !== null && latestRelease !== '%s'", 19 | config.Version, 20 | )), 21 | 22 | nodx.Class("btn btn-warning btn-sm"), 23 | nodx.Href("https://github.com/eduardolat/pgbackweb/releases"), 24 | nodx.Target("_blank"), 25 | lucide.ExternalLink(), 26 | component.SpanText("Update available"), 27 | nodx.SpanEl( 28 | alpine.XText("'( ' + latestRelease + ' )'"), 29 | ), 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /internal/view/web/layout/dashboard_header_updates.inc.js: -------------------------------------------------------------------------------- 1 | window.alpineDashboardHeaderUpdates = function () { 2 | return { 3 | latestRelease: null, 4 | 5 | async init() { 6 | const latestRelease = await this.getLatestRelease(); 7 | if (latestRelease !== null) { 8 | this.latestRelease = latestRelease; 9 | } 10 | }, 11 | 12 | async getLatestRelease() { 13 | const cacheKey = "pbw-gh-last-release"; 14 | 15 | const cachedJSON = localStorage.getItem(cacheKey); 16 | if (cachedJSON) { 17 | const cached = JSON.parse(cachedJSON); 18 | if (Date.now() - cached.timestamp < 2 * 60 * 1000) { 19 | return cached.value; 20 | } 21 | } 22 | 23 | const url = 24 | "https://api.github.com/repos/eduardolat/pgbackweb/releases/latest"; 25 | try { 26 | const response = await fetch(url); 27 | if (!response.ok) { 28 | return null; 29 | } 30 | const data = await response.json(); 31 | const value = data.name; 32 | const dataToCache = JSON.stringify({ 33 | value, 34 | timestamp: Date.now(), 35 | }); 36 | localStorage.setItem(cacheKey, dataToCache); 37 | return value; 38 | } catch { 39 | return null; 40 | } 41 | }, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /internal/view/web/router.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/eduardolat/pgbackweb/internal/logger" 7 | "github.com/eduardolat/pgbackweb/internal/service" 8 | "github.com/eduardolat/pgbackweb/internal/view/middleware" 9 | "github.com/eduardolat/pgbackweb/internal/view/reqctx" 10 | "github.com/eduardolat/pgbackweb/internal/view/web/auth" 11 | "github.com/eduardolat/pgbackweb/internal/view/web/dashboard" 12 | "github.com/labstack/echo/v4" 13 | ) 14 | 15 | func MountRouter( 16 | parent *echo.Group, mids *middleware.Middleware, servs *service.Service, 17 | ) { 18 | // GET / -> Handle the root path redirects 19 | parent.GET("", func(c echo.Context) error { 20 | ctx := c.Request().Context() 21 | reqCtx := reqctx.GetCtx(c) 22 | 23 | if reqCtx.IsAuthed { 24 | return c.Redirect(http.StatusFound, "/dashboard") 25 | } 26 | 27 | usersQty, err := servs.UsersService.GetUsersQty(ctx) 28 | if err != nil { 29 | logger.Error("failed to get users qty", logger.KV{ 30 | "ip": c.RealIP(), 31 | "ua": c.Request().UserAgent(), 32 | "error": err, 33 | }) 34 | return c.String(http.StatusInternalServerError, "Internal server error") 35 | } 36 | 37 | if usersQty == 0 { 38 | return c.Redirect(http.StatusFound, "/auth/create-first-user") 39 | } 40 | 41 | return c.Redirect(http.StatusFound, "/auth/login") 42 | }) 43 | 44 | authGroup := parent.Group("/auth") 45 | auth.MountRouter(authGroup, mids, servs) 46 | 47 | dashboardGroup := parent.Group("/dashboard", mids.RequireAuth) 48 | dashboard.MountRouter(dashboardGroup, mids, servs) 49 | } 50 | -------------------------------------------------------------------------------- /scripts/startup.sh: -------------------------------------------------------------------------------- 1 | # Define command aliases 2 | alias t='task' 3 | alias td='task dev' 4 | alias tb='task build' 5 | alias tt='task test' 6 | alias tl='task lint' 7 | alias tls='task --list' 8 | alias tf='task format' 9 | alias ll='ls -alF' 10 | alias la='ls -A' 11 | alias l='ls -CF' 12 | alias ..='cd ..' 13 | alias c='clear' 14 | echo "[OK] aliases set" 15 | 16 | # Set the user file-creation mode mask to 000, which allows all 17 | # users read, write, and execute permissions for newly created files. 18 | umask 000 19 | echo "[OK] umask set" 20 | 21 | # Run the 'fixperms' task that fixes the permissions of the files and 22 | # directories in the project. 23 | task fixperms 24 | echo "[OK] permissions fixed" 25 | 26 | # Configure Git to ignore ownership and file mode changes. 27 | git config --global --add safe.directory '*' 28 | git config --global core.fileMode false 29 | git config --unset core.fileMode 30 | git config core.fileMode false 31 | echo "[OK] git configured" 32 | 33 | echo " 34 | ─────────────────────────────────────────────── 35 | ── Website: https://eduardo.lat ─────────────── 36 | ── Github: https://github.com/eduardolat ────── 37 | ─────────────────────────────────────────────── 38 | ── Development environment is ready to use! ─── 39 | ─────────────────────────────────────────────── 40 | " -------------------------------------------------------------------------------- /sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | sql: 4 | - engine: "postgresql" 5 | schema: "./internal/database/migrations/" 6 | queries: "./internal/database/dbgen/queries.gen.sql" 7 | gen: 8 | go: 9 | package: "dbgen" 10 | out: "./internal/database/dbgen/" 11 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import daisyui from "daisyui"; 3 | import * as daisyuiThemes from "daisyui/src/theming/themes"; 4 | 5 | export default { 6 | content: ["./internal/view/web/**/*.go"], 7 | // deno-lint-ignore no-explicit-any 8 | plugins: [daisyui as any], 9 | daisyui: { 10 | logs: false, 11 | themes: [ 12 | { 13 | light: { 14 | ...daisyuiThemes.light, 15 | primary: "#2be7c8", 16 | "success-content": "#ffffff", 17 | "error-content": "#ffffff", 18 | }, 19 | dark: { 20 | ...daisyuiThemes.dracula, 21 | primary: "#2be7c8", 22 | }, 23 | }, 24 | ], 25 | darkTheme: "dark", 26 | }, 27 | theme: { 28 | screens: { 29 | desk: "768px", // only one breakpoint to keep it simple 30 | }, 31 | extend: {}, 32 | }, 33 | } satisfies Config; 34 | --------------------------------------------------------------------------------