Compare commits

..

1 Commits

Author SHA1 Message Date
tonyaellie
12d6b17318 feat: mock ui 2025-06-02 12:08:29 +00:00
304 changed files with 9810 additions and 47902 deletions

View File

@@ -29,6 +29,6 @@
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node",
"features": {
"ghcr.io/devcontainers/features/go:1": "1.24"
"ghcr.io/devcontainers/features/go:1": "1.21"
}
}

40
.github/AGENTS.md vendored
View File

@@ -1,40 +0,0 @@
This is a Go based repository with a VueJS client for the frontend built with Vite and Nuxt, with ShadCN.
To make life easier, the use of a Taskfile is included for the majority of development commands.
Please follow these guidelines when contributing:
## Required Before Each Commit
- Generate Swagger Files: `task swag --force`
- Generate JS API Client: `task typescript-types --force`
- Lint Golang: `task go:lint`
- Lint frontend: `task ui:fix`
## Repository Structure
### Backend
- `backend/`: Contains the backend folders
- `backend/app`: Contains main app code including API endpoints
- `backend/internal/core`: Contains basic services such as currencies
- `backend/data`: Contains all information related to data, including `ent` schemas, repos, migrations, etc.
- `backend/data/migrations`: Contains migration data, the `sqlite3` sub-folder contains sqlite migrations, `postgres` sub-folder the postgres migrations, BOTH are REQUIRED.
- `backend/data/ent/schema`: Contains the actual `ent` data models.
- `backend/data/repo`: Contains the data repositories
- `backend/pkgs`: Contains general helper functions and services
### Frontend
- `frontend/`: Contains initial frontend files
- `frontend/components`: Contains the ShadCN components
- `frontend/locales`: Contains the i18n JSON for languages
- `frontend/pages`: Contains VueJS pages
- `frontend/test`: Contains Playwright setup
- `frontend/test/e2e`: Contains actual Playwright test files
### Docs
- `docs/`: Contains VitePress based documentation
## Key Guidelines
1. Follow best practices for the various programming languages
2. Maintain existing code structure and organization when possible
3. Use dependency injection when reasonable
4. Write tests for new functionality and after fixing bugs to validate they're fixed
5. Document changes to the `docs/` folder when appropriate

1
.github/FUNDING.yml vendored
View File

@@ -1,2 +1 @@
open_collective: homebox
github: [tankerkiller125,katosdev,tonyaellie]

View File

@@ -58,17 +58,6 @@ body:
- Other
validations:
required: true
- type: dropdown
id: database
attributes:
label: Database Type
description: What database backend are you using?
multiple: false
options:
- SQLite
- PostgreSQL
validations:
required: true
- type: dropdown
id: arch
attributes:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -1,8 +0,0 @@
# Screenshots
These screenshots are taken from our public [Demo](https://demo.homebox.software) instance.
Note that whilst we will make every effort to ensure that these are maintained and updated, they may be outdated or missing functionality and we would always advise reviewing our demo instances:
- [Demo](https://demo.homebox.software)
- [Nightly](https://nightly.homebox.software)
- [VNext](https://vnext.homebox.software/)

View File

@@ -1,33 +1,16 @@
#!/usr/bin/env python3
import csv
import io
import json
import logging
import os
import sys
from pathlib import Path
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from requests.adapters import HTTPAdapter, Retry
API_URL = 'https://restcountries.com/v3.1/all?fields=name,common,currencies'
# Default to a pinned commit for supply-chain security
DEFAULT_ISO_4217_URL = 'https://raw.githubusercontent.com/datasets/currency-codes/052b3088938ba32028a14e75040c286c5e142145/data/codes-all.csv'
ISO_4217_URL = os.environ.get('ISO_4217_URL', DEFAULT_ISO_4217_URL)
SAVE_PATH = Path('backend/internal/core/currencies/currencies.json')
TIMEOUT = 10 # seconds
# Known currency decimal overrides
CURRENCY_DECIMAL_OVERRIDES = {
"BTC": 8, # Bitcoin uses 8 decimal places
"JPY": 0, # Japanese Yen has no decimal places
"BHD": 3, # Bahraini Dinar uses 3 decimal places
}
DEFAULT_DECIMALS = 2
MIN_DECIMALS = 0
MAX_DECIMALS = 6
def setup_logging():
logging.basicConfig(
@@ -36,93 +19,7 @@ def setup_logging():
)
def get_currency_decimals(code, iso_data):
"""
Get the decimal places for a currency code.
Checks overrides first, then ISO data, then uses default.
Clamps result to safe range [MIN_DECIMALS, MAX_DECIMALS].
"""
# Normalize the input code
normalized_code = (code or "").strip().upper()
# First check overrides
if normalized_code in CURRENCY_DECIMAL_OVERRIDES:
decimals = CURRENCY_DECIMAL_OVERRIDES[normalized_code]
# Then check ISO data
elif normalized_code in iso_data:
decimals = iso_data[normalized_code]
# Finally use default
else:
decimals = DEFAULT_DECIMALS
# Ensure it's an integer and clamp to safe range
try:
decimals = int(decimals)
except (ValueError, TypeError):
decimals = DEFAULT_DECIMALS
return max(MIN_DECIMALS, min(MAX_DECIMALS, decimals))
def fetch_iso_4217_data():
"""
Fetch ISO 4217 currency data to get minor units (decimal places).
Returns a dict mapping currency code to minor units.
"""
# Log the resolved URL for transparency
logging.info("Fetching ISO 4217 data from: %s", ISO_4217_URL)
if not ISO_4217_URL.lower().startswith("https://"):
logging.error("Refusing non-HTTPS ISO_4217_URL: %s", ISO_4217_URL)
return {}
session = requests.Session()
retries = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=frozenset(['GET'])
)
session.mount('https://', HTTPAdapter(max_retries=retries))
try:
# Add Accept header for CSV content
headers = {'Accept': 'text/csv'}
resp = session.get(ISO_4217_URL, timeout=TIMEOUT, headers=headers)
resp.raise_for_status()
except requests.exceptions.RequestException as e:
logging.error("Failed to fetch ISO 4217 data: %s", e)
return {}
# Parse CSV data
iso_data = {}
try:
# Decode with utf-8-sig to strip BOM if present
csv_content = resp.content.decode('utf-8-sig')
csv_reader = csv.DictReader(io.StringIO(csv_content))
for row in csv_reader:
code = row.get('AlphabeticCode', '').strip()
minor_unit = row.get('MinorUnit', '').strip()
if code and minor_unit != 'N.A.':
try:
# Convert minor unit to int (decimal places)
iso_data[code] = int(minor_unit) if minor_unit.isdigit() else 2
except (ValueError, TypeError):
iso_data[code] = 2 # Default to 2 if parsing fails
logging.info("Successfully loaded decimal data for %d currencies from ISO 4217", len(iso_data))
return iso_data
except Exception as e:
logging.error("Failed to parse ISO 4217 CSV data: %s", e)
return {}
def fetch_currencies():
# First, fetch ISO 4217 data for decimal places
iso_data = fetch_iso_4217_data()
session = requests.Session()
retries = Retry(
total=3,
@@ -149,15 +46,11 @@ def fetch_currencies():
for country in countries:
country_name = country.get('name', {}).get('common') or "Unknown"
for code, info in country.get('currencies', {}).items():
# Get decimal places using the helper function
decimals = get_currency_decimals(code, iso_data)
results.append({
'code': code,
'local': country_name,
'symbol': info.get('symbol', ''),
'name': info.get('name', ''),
'decimals': decimals
'code': code,
'local': country_name,
'symbol': info.get('symbol', ''),
'name': info.get('name', '')
})
# sort by country name for consistency

View File

@@ -1,7 +1,6 @@
name: Publish Release Binaries
on:
workflow_dispatch:
push:
tags: [ 'v*.*.*' ]
@@ -9,12 +8,6 @@ jobs:
goreleaser:
name: goreleaser
runs-on: ubuntu-latest
outputs:
hashes: ${{ steps.binary.outputs.hashes }}
permissions:
contents: write
packages: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -23,9 +16,6 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
cache-dependency-path: backend/go.mod
- uses: pnpm/action-setup@v2
with:
@@ -44,85 +34,11 @@ jobs:
go install github.com/sigstore/cosign/cmd/cosign@latest
- name: Run GoReleaser
id: releaser
if: startsWith(github.ref, 'refs/tags/')
uses: goreleaser/goreleaser-action@v5
with:
workdir: "backend"
distribution: goreleaser
version: "~> v2"
args: release --rm-dist
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COSIGN_PWD: ${{ secrets.COSIGN_PWD }}
COSIGN_YES: "true"
- name: Generate binary hashes
if: startsWith(github.ref, 'refs/tags/')
id: binary
env:
ARTIFACTS: "${{ steps.releaser.outputs.artifacts }}"
run: |
set -euo pipefail
checksum_file=$(echo "$ARTIFACTS" | jq -r '.[] | select (.type=="Checksum") | .path')
echo "hashes=$(cat $checksum_file | base64 -w0)" >> "$GITHUB_OUTPUT"
- name: Run GoReleaser No Release
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: goreleaser/goreleaser-action@v5
with:
workdir: "backend"
distribution: goreleaser
version: "~> v2"
args: release --clean --snapshot --skip=publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COSIGN_PWD: ${{ secrets.COSIGN_PWD }}
COSIGN_YES: "true"
binary-provenance:
if: startsWith(github.ref, 'refs/tags/')
needs: [ goreleaser ]
permissions:
actions: read # To read the workflow path.
id-token: write # To sign the provenance.
contents: write # To add assets to a release.
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.9.0
with:
base64-subjects: "${{ needs.goreleaser.outputs.hashes }}"
upload-assets: true # upload to a new release
verification-with-slsa-verifier:
if: startsWith(github.ref, 'refs/tags/')
needs: [goreleaser, binary-provenance]
runs-on: ubuntu-latest
permissions: read-all
steps:
- name: Install the verifier
uses: slsa-framework/slsa-verifier/actions/installer@v2.4.0
- name: Download assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROVENANCE: "${{ needs.binary-provenance.outputs.provenance-name }}"
run: |
set -euo pipefail
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "*.tar.gz"
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "*.zip"
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "$PROVENANCE"
- name: Verify assets
env:
CHECKSUMS: ${{ needs.goreleaser.outputs.hashes }}
PROVENANCE: "${{ needs.binary-provenance.outputs.provenance-name }}"
run: |
set -euo pipefail
checksums=$(echo "$CHECKSUMS" | base64 -d)
while read -r line; do
fn=$(echo $line | cut -d ' ' -f2)
echo "Verifying $fn"
slsa-verifier verify-artifact --provenance-path "$PROVENANCE" \
--source-uri "github.com/$GITHUB_REPOSITORY" \
--source-tag "$GITHUB_REF_NAME" \
"$fn"
done <<<"$checksums"

View File

@@ -1,52 +0,0 @@
name: "Copilot Setup Steps"
# Automatically run the setup steps when they are changed to allow for easy validation, and
# allow manual testing through the repository's "Actions" tab
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
copilot-setup-steps:
runs-on: ubuntu-latest
# Set the permissions to the lowest permissions possible needed for your steps.
# Copilot will be given its own token for its operations.
permissions:
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
contents: read
# You can define any steps you want, and they will run before the agent starts.
# If you do not check out your code, Copilot will do this for you.
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- uses: pnpm/action-setup@v3.0.0
with:
version: 9.12.2
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
cache-dependency-path: backend/go.mod
- name: Install Task
uses: arduino/setup-task@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Perform setup
run: task setup

View File

@@ -1,208 +0,0 @@
name: Docker publish hardened
on:
schedule:
- cron: '00 0 * * *'
push:
branches: [ "main" ]
paths:
- 'backend/**'
- 'frontend/**'
- 'Dockerfile.hardened'
- '.dockerignore'
- '.github/workflows/docker-publish-hardened.yaml'
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main" ]
paths:
- 'backend/**'
- 'frontend/**'
- 'Dockerfile.hardened'
- '.dockerignore'
- '.github/workflows/docker-publish-hardened.yaml'
permissions:
contents: read # Access to repository contents
packages: write # Write access for pushing to GHCR
id-token: write # Required for OIDC authentication (if used)
attestations: write # Required for signing and attestation (if needed)
env:
DOCKERHUB_REPO: sysadminsmedia/homebox
GHCR_REPO: ghcr.io/${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
- linux/arm/v7
steps:
- name: Enable Debug Logs
run: echo "##[debug]Enabling debug logging"
env:
ACTIONS_RUNNER_DEBUG: true
ACTIONS_STEP_DEBUG: true
- name: Checkout repository
uses: actions/checkout@v4
- name: Prepare
run: |
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
branch=${{ github.event.pull_request.number || github.ref_name }}
echo "BRANCH=${branch//\//-}" >> $GITHUB_ENV
echo "DOCKERNAMES=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}" >> $GITHUB_ENV
if [[ "${{ github.event_name }}" != "schedule" ]] || [[ "${{ github.ref }}" != refs/tags/* ]]; then
echo "DOCKERNAMES=${{ env.GHCR_REPO }}" >> $GITHUB_ENV
fi
- name: Docker meta
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
with:
images: |
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
name=${{ env.GHCR_REPO }}
- name: Login to Docker Hub
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
with:
image: ghcr.io/amitie10g/binfmt:latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
with:
driver-opts: |
image=ghcr.io/amitie10g/buildkit:master
- name: Build and push by digest
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83
with:
context: . # Explicitly specify the build context
file: ./Dockerfile.hardened # Explicitly specify the Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.DOCKERNAMES }}",push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=registry,ref=ghcr.io/sysadminsmedia/devcache:${{ env.PLATFORM_PAIR }}-${{ env.BRANCH }}-hardened
cache-to: type=registry,ref=ghcr.io/sysadminsmedia/devcache:${{ env.PLATFORM_PAIR }}-${{ env.BRANCH }}-hardened,mode=max,ignore-error=true
build-args: |
VERSION=${{ github.ref_name }}
COMMIT=${{ github.sha }}
BUILD_TIME=${{ env.BUILD_TIME }}
provenance: true
sbom: true
annotations: ${{ steps.meta.outputs.annotations }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
with:
driver-opts: |
image=ghcr.io/amitie10g/buildkit:master
- name: Docker meta
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
with:
images: |
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
name=${{ env.GHCR_REPO }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=schedule,pattern=nightly
flavor: |
suffix=-hardened,onlatest=true
- name: Create manifest list and push GHCR
id: push-ghcr
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)
- name: Create manifest list and push Dockerhub
id: push-dockerhub
working-directory: /tmp/digests
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)

View File

@@ -8,7 +8,7 @@ on:
paths:
- 'backend/**'
- 'frontend/**'
- 'Dockerfile.rootless'
- 'Dockerfile'
- '.dockerignore'
- '.github/workflows/docker-publish-rootless.yaml'
ignore:
@@ -19,7 +19,7 @@ on:
paths:
- 'backend/**'
- 'frontend/**'
- 'Dockerfile.rootless'
- 'Dockerfile'
- '.dockerignore'
- '.github/workflows/docker-publish-rootless.yaml'
ignore:
@@ -33,7 +33,7 @@ permissions:
env:
DOCKERHUB_REPO: sysadminsmedia/homebox
GHCR_REPO: ghcr.io/${{ github.repository }}
GHCR_REPO: ghcr.io/sysadminsmedia/homebox
jobs:
build:
@@ -83,7 +83,7 @@ jobs:
- name: Login to Docker Hub
uses: docker/login-action@v3
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -159,7 +159,7 @@ jobs:
- name: Login to Docker Hub
uses: docker/login-action@v3
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -204,7 +204,7 @@ jobs:
- name: Create manifest list and push Dockerhub
id: push-dockerhub
working-directory: /tmp/digests
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)

View File

@@ -27,7 +27,7 @@ on:
env:
DOCKERHUB_REPO: sysadminsmedia/homebox
GHCR_REPO: ghcr.io/${{ github.repository }}
GHCR_REPO: ghcr.io/sysadminsmedia/homebox
permissions:
contents: read # Access to repository contents
@@ -78,7 +78,7 @@ jobs:
- name: Login to Docker Hub
uses: docker/login-action@v3
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -152,7 +152,6 @@ jobs:
- name: Login to Docker Hub
uses: docker/login-action@v3
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -195,7 +194,8 @@ jobs:
- name: Create manifest list and push Dockerhub
id: push-dockerhub
working-directory: /tmp/digests
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)

View File

@@ -27,8 +27,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
cache-dependency-path: backend/go.mod
go-version: "1.21"
- uses: actions/setup-node@v4
with:

View File

@@ -12,8 +12,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
cache-dependency-path: backend/go.mod
go-version: "1.21"
- name: Install Task
uses: arduino/setup-task@v1
@@ -35,3 +34,8 @@ jobs:
- name: Test
run: task go:coverage
- name: Validate OpenAPI definition
uses: swaggerexpert/swagger-editor-validate@v1
with:
definition-file: backend/app/api/static/docs/swagger.json

View File

@@ -60,8 +60,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
cache-dependency-path: backend/go.mod
go-version: "1.21"
- uses: actions/setup-node@v4
with:
@@ -111,8 +110,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
cache-dependency-path: backend/go.mod
go-version: "1.21"
- uses: actions/setup-node@v4
with:

View File

@@ -9,10 +9,7 @@ on:
paths:
- 'backend/**'
- 'frontend/**'
- '.github/workflows/partial-backend.yaml'
- '.github/workflows/partial-frontend.yaml'
- '.github/workflows/e2e-partial.yaml'
- '.github/workflows/pull-requests.yaml'
- '.github/workflows/**'
jobs:
backend-tests:

View File

@@ -5,22 +5,18 @@ on:
branches: [ main ]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-currencies:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: '3.8'
cache: 'pip'
@@ -29,14 +25,15 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r .github/workflows/update-currencies/requirements.txt
pip install requests
- name: Run currency update script
run: python .github/scripts/update_currencies.py
- name: Check for currencies.json changes
- name: Check for file changes
id: changes
run: |
if git diff --quiet -- backend/internal/core/currencies/currencies.json; then
if git diff --quiet; then
echo "changed=false" >> $GITHUB_ENV
else
echo "changed=true" >> $GITHUB_ENV
@@ -44,16 +41,14 @@ jobs:
- name: Create Pull Request
if: env.changed == 'true'
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v7.0.8
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: update-currencies
base: main
title: "Update currencies.json"
commit-message: "chore: update currencies.json"
path: .
add-paths: |
backend/internal/core/currencies/currencies.json
path: backend/internal/core/currencies/currencies.json
- name: No updates needed
if: env.changed == 'false'

2
.gitignore vendored
View File

@@ -60,7 +60,7 @@ backend/app/api/static/public/*
backend/api
docs/.vitepress/cache/
.data/
/.data/
# Playwright
frontend/test-results/

8
.vscode/launch.json vendored
View File

@@ -16,12 +16,14 @@
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/backend/app/api/",
"program": "${workspaceRoot}/backend/app/api/",
"args": [],
"env": {
"HBOX_DEMO": "true",
"HBOX_LOG_LEVEL": "debug",
"HBOX_DEBUG_ENABLED": "true"
"HBOX_DEBUG_ENABLED": "true",
"HBOX_STORAGE_DATA": "${workspaceRoot}/backend/.data",
"HBOX_STORAGE_SQLITE_URL": "${workspaceRoot}/backend/.data/homebox.db?_fk=1&_time_format=sqlite"
},
"console": "integratedTerminal",
},
@@ -44,4 +46,4 @@
"console": "integratedTerminal",
}
]
}
}

View File

@@ -4,7 +4,7 @@
},
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"package.json": "package-lock.json, yarn.lock, eslint.config.mjs, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js",
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js",
"docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml",
"README.md": "LICENSE, SECURITY.md"
},
@@ -22,8 +22,6 @@
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"eslint.format.enable": true,
"eslint.validate": ["javascript", "typescript", "vue"],
"eslint.useFlatConfig": true,
"css.validate": false,
"tailwindCSS.includeLanguages": {
"vue": "html",

View File

@@ -31,8 +31,6 @@ RUN go mod download
# Build API stage
FROM public.ecr.aws/docker/library/golang:alpine AS builder
ARG TARGETOS
ARG TARGETARCH
ARG BUILD_TIME
ARG COMMIT
ARG VERSION
@@ -40,8 +38,7 @@ ARG VERSION
# Install necessary build tools
RUN apk update && \
apk upgrade && \
apk add --no-cache git build-base gcc g++ && \
if [ "$TARGETARCH" != "arm" ] || [ "$TARGETARCH" != "riscv64" ]; then apk --no-cache add libwebp libavif libheif libjxl; fi
apk add --no-cache git build-base gcc g++
WORKDIR /go/src/app
@@ -55,26 +52,19 @@ COPY --from=frontend-builder /app/.output/public ./app/api/static/public
# Use cache for Go build artifacts
RUN --mount=type=cache,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm" ] || [ "$TARGETARCH" = "riscv64" ]; \
then echo "nodynamic" $TARGETOS $TARGETARCH; CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
-tags nodynamic -o /go/bin/api -v ./app/api/*.go; \
else \
echo $TARGETOS $TARGETARCH; CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
-o /go/bin/api -v ./app/api/*.go; \
fi
CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
-o /go/bin/api \
-v ./app/api/*.go
# Production stage
FROM public.ecr.aws/docker/library/alpine:latest
ENV HBOX_MODE=production
ENV HBOX_STORAGE_CONN_STRING=file:///?no_tmp_dir=true
ENV HBOX_STORAGE_PREFIX_PATH=data
ENV HBOX_STORAGE_DATA=/data/
ENV HBOX_DATABASE_SQLITE_PATH=/data/homebox.db?_pragma=busy_timeout=2000&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite
# Install necessary runtime dependencies
RUN apk --no-cache add ca-certificates wget && \
if [ "$TARGETARCH" != "arm" ] || [ "$TARGETARCH" != "riscv64" ]; then apk --no-cache add libwebp libavif libheif libjxl; fi
RUN apk --no-cache add ca-certificates wget
# Create application directory and copy over built Go binary
RUN mkdir /app

View File

@@ -1,136 +0,0 @@
# ---------------------------------------
# Node dependencies stage
# ---------------------------------------
FROM public.ecr.aws/docker/library/node:lts-alpine AS frontend-dependencies
WORKDIR /app
# Install pnpm globally (caching layer)
RUN npm install -g pnpm
# Copy package.json and lockfile to leverage caching
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ---------------------------------------
# Build Nuxt (frontend) stage
# ---------------------------------------
FROM public.ecr.aws/docker/library/node:lts-alpine AS frontend-builder
WORKDIR /app
# Install pnpm globally again (it can reuse the cache if not changed)
RUN npm install -g pnpm
# Copy over source files and node_modules from dependencies stage
COPY frontend .
COPY --from=frontend-dependencies /app/node_modules ./node_modules
RUN pnpm build
# ---------------------------------------
# Go dependencies stage
# ---------------------------------------
FROM public.ecr.aws/docker/library/golang:alpine AS builder-dependencies
WORKDIR /go/src/app
# Copy go.mod and go.sum for better caching
COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download
# ---------------------------------------
# Build API + healthcheck stage
# ---------------------------------------
FROM public.ecr.aws/docker/library/golang:alpine AS builder
ARG TARGETOS
ARG TARGETARCH
ARG BUILD_TIME
ARG COMMIT
ARG VERSION
# Install necessary build tools
RUN apk update && \
apk upgrade && \
apk add --no-cache git build-base gcc g++
WORKDIR /go/src/app
# Copy Go modules (from dependencies stage) and source code
COPY --from=builder-dependencies /go/pkg/mod /go/pkg/mod
COPY ./backend .
# Clear old public files and copy new ones from frontend build
RUN rm -rf ./app/api/public
COPY --from=frontend-builder /app/.output/public ./app/api/static/public
# Use cache for Go build artifacts to build Homebox API
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
-tags nodynamic -o /go/bin/api -v ./app/api/*.go
RUN chmod +x /go/bin/api
RUN mkdir /app
RUN mkdir /data
# ---------- Build static healthcheck helper ----------
# A small Go program that GETs the status URL and exits 0 on 2xx.
RUN cat > /tmp/healthcheck.go <<'EOF'
package main
import (
"fmt"
"net/http"
"os"
"time"
)
func main() {
url := "http://127.0.0.1:7745/api/v1/status"
if len(os.Args) > 1 { url = os.Args[1] }
c := &http.Client{ Timeout: 3 * time.Second }
resp, err := c.Get(url)
if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
resp.Body.Close()
if resp.StatusCode/100 != 2 {
fmt.Fprintln(os.Stderr, "unexpected status:", resp.StatusCode)
os.Exit(1)
}
}
EOF
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -ldflags "-s -w" -o /go/bin/hc /tmp/healthcheck.go
# ---------------------------------------
# Production stage
# ---------------------------------------
FROM gcr.io/distroless/static:nonroot
ENV HBOX_MODE=production
ENV HBOX_STORAGE_CONN_STRING=file:///?no_tmp_dir=true
ENV HBOX_STORAGE_PREFIX_PATH=data
ENV HBOX_DATABASE_SQLITE_PATH=/data/homebox.db?_pragma=busy_timeout=2000&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite
# Create application directory and copy over built Go binary and assets
COPY --from=builder --chown=65532:65532 /app /app
COPY --from=builder --chown=65532:65532 --chmod=755 /go/bin/api /app
COPY --from=builder --chown=65532:65532 /data /data
# Copy the healthcheck helper
COPY --from=builder --chown=65532:65532 --chmod=755 /go/bin/hc /app/healthcheck
# Labels and configuration for the final image
LABEL Name=homebox Version=0.0.1
LABEL org.opencontainers.image.source="https://github.com/sysadminsmedia/homebox"
# Expose necessary ports for Homebox
EXPOSE 7745
WORKDIR /app
# Persist volume for data
VOLUME [ "/data" ]
# Entrypoint and CMD
USER 65532
ENTRYPOINT [ "/app/api" ]
CMD [ "/data/config.yml" ]
# JSON exec-form healthcheck (no shell, no wget)
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/app/healthcheck", "http://127.0.0.1:7745/api/v1/status"]

View File

@@ -31,8 +31,6 @@ RUN go mod download
# Build API stage
FROM public.ecr.aws/docker/library/golang:alpine AS builder
ARG TARGETOS
ARG TARGETARCH
ARG BUILD_TIME
ARG COMMIT
ARG VERSION
@@ -40,8 +38,7 @@ ARG VERSION
# Install necessary build tools
RUN apk update && \
apk upgrade && \
apk add --no-cache git build-base gcc g++ && \
if [ "$TARGETARCH" != "arm" ] || [ "$TARGETARCH" != "riscv64" ]; then apk --no-cache add libwebp libavif libheif libjxl; fi
apk add --no-cache git build-base gcc g++
WORKDIR /go/src/app
@@ -55,29 +52,21 @@ COPY --from=frontend-builder /app/.output/public ./app/api/static/public
# Use cache for Go build artifacts
RUN --mount=type=cache,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm" ] || [ "$TARGETARCH" = "riscv64" ]; \
then echo "nodynamic" $TARGETOS $TARGETARCH; CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
-tags nodynamic -o /go/bin/api -v ./app/api/*.go; \
else \
echo $TARGETOS $TARGETARCH; CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
-o /go/bin/api -v ./app/api/*.go; \
fi
CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
-o /go/bin/api \
-v ./app/api/*.go
RUN mkdir /data
# Production stage
FROM public.ecr.aws/docker/library/alpine:latest
ENV HBOX_MODE=production
ENV HBOX_STORAGE_CONN_STRING=file:///?no_tmp_dir=true
ENV HBOX_STORAGE_PREFIX_PATH=data
ENV HBOX_STORAGE_DATA=/data/
ENV HBOX_DATABASE_SQLITE_PATH=/data/homebox.db?_pragma=busy_timeout=2000&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite
# Install necessary runtime dependencies
RUN apk --no-cache add ca-certificates wget && \
if [ "$TARGETARCH" != "arm" ] || [ "$TARGETARCH" != "riscv64" ]; then apk --no-cache add libwebp libavif libheif libjxl; fi
RUN apk --no-cache add ca-certificates wget
# Create a nonroot user with UID/GID 65532
RUN addgroup -g 65532 nonroot && adduser -u 65532 -G nonroot -S nonroot

View File

@@ -2,59 +2,36 @@
<img src="/docs/public/lilbox.svg" height="200"/>
</div>
<h1 align="center" style="margin-top: -10px;"> HomeBox </h1>
<p align="center" style="width: 100%;">
<h1 align="center" style="margin-top: -10px"> HomeBox </h1>
<p align="center" style="width: 100;">
<a href="https://homebox.software/en/">Docs</a>
|
<a href="https://demo.homebox.software">Demo</a>
|
<a href="https://discord.gg/aY4DCkpNA9">Discord</a>
</p>
<p align="center" style="width: 100%;">
<img src="https://img.shields.io/github/check-runs/sysadminsmedia/homebox/main" alt="Github Checks"/>
<img src="https://img.shields.io/github/license/sysadminsmedia/homebox"/>
<img src="https://img.shields.io/github/v/release/sysadminsmedia/homebox?sort=semver&display_name=release"/>
<img src="https://img.shields.io/weblate/progress/homebox?server=https%3A%2F%2Ftranslate.sysadminsmedia.com"/>
</p>
<p align="center" style="width: 100%;">
<img src="https://img.shields.io/reddit/subreddit-subscribers/homebox"/>
<img src="https://img.shields.io/mastodon/follow/110749314839831923?domain=infosec.exchange"/>
<img src="https://img.shields.io/lemmy/homebox%40lemmy.world?label=lemmy"/>
</p>
## What is HomeBox
HomeBox is the inventory and organization system built for the Home User! With a focus on simplicity and ease of use, Homebox is the perfect solution for your home inventory, organization, and management needs. While developing this project, We've tried to keep the following principles in mind:
HomeBox is the inventory and organization system built for the Home User! With a focus on simplicity and ease of use, Homebox is the perfect solution for your home inventory, organization, and management needs. While developing this project, I've tried to keep the following principles in mind:
- 🧘 _Simple but Expandable_ - Homebox is designed to be simple and easy to use. No complicated setup or configuration required. But expandable to whatever level of infrastructure you want to put into it.
- 🚀 _Blazingly Fast_ - Homebox is written in Go, which makes it extremely fast and requires minimal resources to deploy. In general, idle memory usage is less than 50MB for the whole container.
- 📦 _Portable_ - Homebox is designed to be portable and run on anywhere. We use SQLite and an embedded Web UI to make it easy to deploy, use, and backup.
### Key Features
- 📇 Rich Organization - Organize your items into categories, locations, and tags. You can also create custom fields to store additional information about your items.
- 🔍 Powerful Search - Quickly find items in your inventory using the powerful search feature.
- 📸 Image Upload - Upload images of your items to make it easy to identify them.
- 📄 Document and Warranty Tracking - Keep track of important documents and warranties for your items.
- 💰 Purchase & Maintenance Tracking - Track purchase dates, prices, and maintenance schedules for your items.
- 📱 Responsive Design - Homebox is designed to work on any device, including desktops, tablets, and smartphones.
## Screenshots
![Login Screen](.github/screenshots/1.png)
![Dashboard](.github/screenshots/2.png)
![Item View](.github/screenshots/3.png)
![Create Item](.github/screenshots/9.png)
![Search](.github/screenshots/8.png)
- _Simple_ - Homebox is designed to be simple and easy to use. No complicated setup or configuration required. Use either a single docker container, or deploy yourself by compiling the binary for your platform of choice.
- _Blazingly Fast_ - Homebox is written in Go, which makes it extremely fast and requires minimal resources to deploy. In general, idle memory usage is less than 50MB for the whole container.
- _Portable_ - Homebox is designed to be portable and run on anywhere. We use SQLite and an embedded Web UI to make it easy to deploy, use, and backup.
# Screenshots
Check out screenshots of the project [here](https://imgur.com/a/5gLWt2j).
You can also try the demo instances of Homebox:
- [Demo](https://demo.homebox.software)
- [Nightly](https://nightly.homebox.software)
- [VNext](https://vnext.homebox.software/)
## Quick Start
[Configuration & Docker Compose](https://homebox.software/en/quick-start.html)
```bash
# If using the rootless or hardened image, ensure data
# If using the rootless image, ensure data
# folder has correct permissions
mkdir -p /path/to/data/folder
chown 65532:65532 -R /path/to/data/folder
@@ -66,7 +43,6 @@ docker run -d \
--volume /path/to/data/folder/:/data \
ghcr.io/sysadminsmedia/homebox:latest
# ghcr.io/sysadminsmedia/homebox:latest-rootless
# ghcr.io/sysadminsmedia/homebox:latest-hardened
```
<!-- CONTRIBUTING -->
@@ -75,20 +51,14 @@ docker run -d \
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
To get started with code based contributions, please see our [contributing guide](https://homebox.software/en/contribute/get-started.html).
If you are not a coder and can't help translate, you can still contribute financially. Financial contributions help us maintain the project and keep demos running.
If you are not a coder, you can still contribute financially. Financial contributions help me prioritize working on this project over others and helps me know that there is a real demand for project development.
## Help us Translate
We want to make sure that Homebox is available in as many languages as possible. If you are interested in helping us translate Homebox, please help us via our [Weblate instance](https://translate.sysadminsmedia.com/projects/homebox/).
[![Translation status](https://translate.sysadminsmedia.com/widget/homebox/multi-auto.svg)](https://translate.sysadminsmedia.com/engage/homebox/)
[![Translation status](http://translate.sysadminsmedia.com/widget/homebox/multi-auto.svg)](http://translate.sysadminsmedia.com/engage/homebox/)
## Credits
- Original project by [@hay-kot](https://github.com/hay-kot)
- Logo by [@lakotelman](https://github.com/lakotelman)
### Contributors
<a href="https://github.com/sysadminsmedia/homebox/graphs/contributors">
<img src="https://contrib.rocks/image?repo=sysadminsmedia/homebox" />
</a>

View File

@@ -23,13 +23,8 @@ tasks:
INTERNAL: "../../../internal"
PKGS: "../../../pkgs"
cmds:
- swag fmt --dir={{ .API }}
- swag init --dir={{ .API }},{{ .INTERNAL }}/core/services,{{ .INTERNAL }}/data/repo --parseDependency
- npx -y -p swagger2openapi swagger2openapi --outfile ./docs/openapi-3.json ./docs/swagger.json
- npx -y -p swagger2openapi swagger2openapi --yaml --outfile ./docs/openapi-3.yaml ./docs/swagger.json
- cp -r ./docs/swagger.json ../../../../docs/en/api/swagger-2.0.json
- cp -r ./docs/swagger.yaml ../../../../docs/en/api/swagger-2.0.yaml
- cp -r ./docs/openapi-3.json ../../../../docs/en/api/openapi-3.0.json
- cp -r ./docs/openapi-3.yaml ../../../../docs/en/api/openapi-3.0.yaml
sources:
- "./backend/app/api/**/*"
- "./backend/internal/data/**"
@@ -96,21 +91,11 @@ tasks:
- go run ./app/api/ {{ .CLI_ARGS }} &
silent: true
go:ci:with-frontend:
desc: Run backend with frontend in CI mode
dir: frontend
cmds:
- pnpm install
- pnpm run build
- cp -r ./.output/public ../backend/app/api/static/
- task: go:ci
silent: true
go:test:
desc: Runs all go tests using gotestsum - supports passing gotestsum args
dir: backend
cmds:
- go test {{ .CLI_ARGS }} ./...
- gotestsum {{ .CLI_ARGS }} ./...
go:coverage:
desc: Runs all go tests with -race flag and generates a coverage report
@@ -163,7 +148,7 @@ tasks:
desc: Run frontend development server
dir: frontend
cmds:
- pnpm dev --no-fork
- pnpm dev
ui:ci:
desc: Run frontend build in CI mode
@@ -189,7 +174,7 @@ tasks:
cmds:
- cd backend && go build ./app/api
- backend/api &
- sleep 15
- sleep 10
- cd frontend && pnpm run test:ci
silent: true
@@ -206,7 +191,7 @@ tasks:
cmds:
- cd backend && go build ./app/api
- backend/api &
- sleep 15
- sleep 10
- cd frontend && pnpm run test:ci
silent: true
@@ -214,11 +199,12 @@ tasks:
desc: Runs end-to-end test on a live server
dir: frontend
cmds:
- task: go:ci:with-frontend
- task: go:ci
- task: ui:ci
- pnpm exec playwright install-deps
- pnpm exec playwright install
- sleep 30
- TEST_SHUTDOWN_API_SERVER=true E2E_BASE_URL=http://localhost:7745 pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }}
- TEST_SHUTDOWN_API_SERVER=true pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }}
pr:
desc: Runs all tasks required for a PR

View File

@@ -14,36 +14,16 @@ builds:
- linux
- windows
- darwin
- freebsd
goarch:
- amd64
- "386"
- arm
- arm64
- riscv64
flags:
- -trimpath
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
ignore:
- goos: windows
goarch: arm
- goos: windows
goarch: "386"
- goos: freebsd
goarch: arm
- goos: freebsd
goarch: "386"
tags:
- >-
{{- if eq .Arch "riscv64" }}nodynamic
{{- else if eq .Arch "arm" }}nodynamic
{{- else if eq .Arch "386" }}nodynamic
{{- else if eq .Os "freebsd" }}nodynamic
{{ end }}
signs:
- cmd: cosign
@@ -74,6 +54,7 @@ archives:
release:
extra_files:
- glob: dist/*.sig
checksum:
name_template: 'checksums.txt'
snapshot:

View File

@@ -81,16 +81,3 @@ func (ctrl *V1Controller) HandleItemDateZeroOut() errchain.HandlerFunc {
func (ctrl *V1Controller) HandleSetPrimaryPhotos() errchain.HandlerFunc {
return actionHandlerFactory("ensure asset IDs", ctrl.repo.Items.SetPrimaryPhotos)
}
// HandleCreateMissingThumbnails godoc
//
// @Summary Create Missing Thumbnails
// @Description Creates thumbnails for items that are missing them
// @Tags Actions
// @Produce json
// @Success 200 {object} ActionAmountResult
// @Router /v1/actions/create-missing-thumbnails [Post]
// @Security Bearer
func (ctrl *V1Controller) HandleCreateMissingThumbnails() errchain.HandlerFunc {
return actionHandlerFactory("create missing thumbnails", ctrl.repo.Attachments.CreateMissingThumbnails)
}

View File

@@ -79,15 +79,15 @@ type AuthProvider interface {
// HandleAuthLogin godoc
//
// @Summary User Login
// @Tags Authentication
// @Accept x-www-form-urlencoded
// @Accept application/json
// @Param payload body LoginForm true "Login Data"
// @Param provider query string false "auth provider"
// @Produce json
// @Success 200 {object} TokenResponse
// @Router /v1/users/login [POST]
// @Summary User Login
// @Tags Authentication
// @Accept x-www-form-urlencoded
// @Accept application/json
// @Param payload body LoginForm true "Login Data"
// @Param provider query string false "auth provider"
// @Produce json
// @Success 200 {object} TokenResponse
// @Router /v1/users/login [POST]
func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFunc {
if len(ps) == 0 {
panic("no auth providers provided")

View File

@@ -254,25 +254,6 @@ func (ctrl *V1Controller) HandleItemPatch() errchain.HandlerFunc {
return adapters.ActionID("id", fn, http.StatusOK)
}
// HandleItemDuplicate godocs
//
// @Summary Duplicate Item
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Param payload body repo.DuplicateOptions true "Duplicate Options"
// @Success 201 {object} repo.ItemOut
// @Router /v1/items/{id}/duplicate [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleItemDuplicate() errchain.HandlerFunc {
fn := func(r *http.Request, ID uuid.UUID, options repo.DuplicateOptions) (repo.ItemOut, error) {
ctx := services.NewContext(r.Context())
return ctrl.svc.Items.Duplicate(ctx, ctx.GID, ID, options)
}
return adapters.ActionID("id", fn, http.StatusCreated)
}
// HandleGetAllCustomFieldNames godocs
//
// @Summary Get All Custom Field Names
@@ -315,14 +296,14 @@ func (ctrl *V1Controller) HandleGetAllCustomFieldValues() errchain.HandlerFunc {
// HandleItemsImport godocs
//
// @Summary Import Items
// @Tags Items
// @Accept multipart/form-data
// @Produce json
// @Success 204
// @Param csv formData file true "Image to upload"
// @Router /v1/items/import [Post]
// @Security Bearer
// @Summary Import Items
// @Tags Items
// @Accept multipart/form-data
// @Produce json
// @Success 204
// @Param csv formData file true "Image to upload"
// @Router /v1/items/import [Post]
// @Security Bearer
func (ctrl *V1Controller) HandleItemsImport() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
err := r.ParseMultipartForm(ctrl.maxUploadSize << 20)

View File

@@ -3,7 +3,6 @@ package v1
import (
"errors"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
@@ -15,13 +14,6 @@ import (
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
"gocloud.dev/blob"
_ "gocloud.dev/blob/azureblob"
_ "gocloud.dev/blob/fileblob"
_ "gocloud.dev/blob/gcsblob"
_ "gocloud.dev/blob/memblob"
_ "gocloud.dev/blob/s3blob"
)
type (
@@ -32,19 +24,19 @@ type (
// HandleItemAttachmentCreate godocs
//
// @Summary Create Item Attachment
// @Tags Items Attachments
// @Accept multipart/form-data
// @Produce json
// @Param id path string true "Item ID"
// @Param file formData file true "File attachment"
// @Param type formData string false "Type of file"
// @Param primary formData bool false "Is this the primary attachment"
// @Param name formData string true "name of the file including extension"
// @Success 200 {object} repo.ItemOut
// @Failure 422 {object} validate.ErrorResponse
// @Router /v1/items/{id}/attachments [POST]
// @Security Bearer
// @Summary Create Item Attachment
// @Tags Items Attachments
// @Accept multipart/form-data
// @Produce json
// @Param id path string true "Item ID"
// @Param file formData file true "File attachment"
// @Param type formData string true "Type of file"
// @Param primary formData bool false "Is this the primary attachment"
// @Param name formData string true "name of the file including extension"
// @Success 200 {object} repo.ItemOut
// @Failure 422 {object} validate.ErrorResponse
// @Router /v1/items/{id}/attachments [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
err := r.ParseMultipartForm(ctrl.maxUploadSize << 20)
@@ -83,7 +75,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc {
ext := filepath.Ext(attachmentName)
switch strings.ToLower(ext) {
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".avif", ".ico", ".heic", ".jxl":
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff":
attachmentType = attachment.TypePhoto.String()
default:
attachmentType = attachment.TypeAttachment.String()
@@ -175,39 +167,13 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
ctx := services.NewContext(r.Context())
switch r.Method {
case http.MethodGet:
doc, err := ctrl.svc.Items.AttachmentPath(r.Context(), ctx.GID, attachmentID)
doc, err := ctrl.svc.Items.AttachmentPath(r.Context(), attachmentID)
if err != nil {
log.Err(err).Msg("failed to get attachment path")
return validate.NewRequestError(err, http.StatusInternalServerError)
}
bucket, err := blob.OpenBucket(ctx, ctrl.repo.Attachments.GetConnString())
if err != nil {
log.Err(err).Msg("failed to open bucket")
return validate.NewRequestError(err, http.StatusInternalServerError)
}
file, err := bucket.NewReader(ctx, ctrl.repo.Attachments.GetFullPath(doc.Path), nil)
if err != nil {
log.Err(err).Msg("failed to open file")
return validate.NewRequestError(err, http.StatusInternalServerError)
}
defer func(file *blob.Reader) {
err := file.Close()
if err != nil {
log.Err(err).Msg("failed to close file")
}
}(file)
defer func(bucket *blob.Bucket) {
err := bucket.Close()
if err != nil {
log.Err(err).Msg("failed to close bucket")
}
}(bucket)
// Set the Content-Disposition header for RFC6266 compliance
disposition := "inline; filename*=UTF-8''" + url.QueryEscape(doc.Title)
w.Header().Set("Content-Disposition", disposition)
http.ServeContent(w, r, doc.Title, doc.CreatedAt, file)
// w.Header().Set("Content-Disposition", "attachment; filename="+doc.Title)
http.ServeFile(w, r, doc.Path)
return nil
// Delete Attachment Handler
@@ -230,9 +196,9 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
}
attachment.ID = attachmentID
val, err := ctrl.svc.Items.AttachmentUpdate(ctx, ctx.GID, ID, &attachment)
val, err := ctrl.svc.Items.AttachmentUpdate(ctx, ID, &attachment)
if err != nil {
log.Err(err).Msg("failed to update attachment")
log.Err(err).Msg("failed to delete attachment")
return validate.NewRequestError(err, http.StatusInternalServerError)
}

View File

@@ -29,20 +29,20 @@ func generateOrPrint(ctrl *V1Controller, w http.ResponseWriter, r *http.Request,
_, err = w.Write([]byte("Printed!"))
return err
} else {
return labelmaker.GenerateLabel(w, &params, ctrl.config)
return labelmaker.GenerateLabel(w, &params)
}
}
// HandleGetLocationLabel godoc
//
// @Summary Get Location label
// @Tags Locations
// @Produce json
// @Param id path string true "Location ID"
// @Param print query bool false "Print this label, defaults to false"
// @Success 200 {string} string "image/png"
// @Router /v1/labelmaker/location/{id} [GET]
// @Security Bearer
// @Summary Get Location label
// @Tags Locations
// @Produce json
// @Param id path string true "Location ID"
// @Param print query bool false "Print this label, defaults to false"
// @Success 200 {string} string "image/png"
// @Router /v1/labelmaker/location/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleGetLocationLabel() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ID, err := adapters.RouteUUID(r, "id")
@@ -63,14 +63,14 @@ func (ctrl *V1Controller) HandleGetLocationLabel() errchain.HandlerFunc {
// HandleGetItemLabel godoc
//
// @Summary Get Item label
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Param print query bool false "Print this label, defaults to false"
// @Success 200 {string} string "image/png"
// @Router /v1/labelmaker/item/{id} [GET]
// @Security Bearer
// @Summary Get Item label
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Param print query bool false "Print this label, defaults to false"
// @Success 200 {string} string "image/png"
// @Router /v1/labelmaker/item/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleGetItemLabel() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ID, err := adapters.RouteUUID(r, "id")
@@ -97,14 +97,14 @@ func (ctrl *V1Controller) HandleGetItemLabel() errchain.HandlerFunc {
// HandleGetAssetLabel godoc
//
// @Summary Get Asset label
// @Tags Items
// @Produce json
// @Param id path string true "Asset ID"
// @Param print query bool false "Print this label, defaults to false"
// @Success 200 {string} string "image/png"
// @Router /v1/labelmaker/assets/{id} [GET]
// @Security Bearer
// @Summary Get Asset label
// @Tags Items
// @Produce json
// @Param id path string true "Asset ID"
// @Param print query bool false "Print this label, defaults to false"
// @Success 200 {string} string "image/png"
// @Router /v1/labelmaker/assets/{id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleGetAssetLabel() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
assetIDParam := chi.URLParam(r, "id")

View File

@@ -12,14 +12,14 @@ import (
// HandleMaintenanceLogGet godoc
//
// @Summary Get Maintenance Log
// @Tags Item Maintenance
// @Produce json
// @Param id path string true "Item ID"
// @Param filters query repo.MaintenanceFilters false "which maintenance to retrieve"
// @Success 200 {array} repo.MaintenanceEntryWithDetails[]
// @Router /v1/items/{id}/maintenance [GET]
// @Security Bearer
// @Summary Get Maintenance Log
// @Tags Item Maintenance
// @Produce json
// @Param id path string true "Item ID"
// @Param filters query repo.MaintenanceFilters false "which maintenance to retrieve"
// @Success 200 {array} repo.MaintenanceEntryWithDetails[]
// @Router /v1/items/{id}/maintenance [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleMaintenanceLogGet() errchain.HandlerFunc {
fn := func(r *http.Request, ID uuid.UUID, filters repo.MaintenanceFilters) ([]repo.MaintenanceEntryWithDetails, error) {
auth := services.NewContext(r.Context())
@@ -31,14 +31,14 @@ func (ctrl *V1Controller) HandleMaintenanceLogGet() errchain.HandlerFunc {
// HandleMaintenanceEntryCreate godoc
//
// @Summary Create Maintenance Entry
// @Tags Item Maintenance
// @Produce json
// @Param id path string true "Item ID"
// @Param payload body repo.MaintenanceEntryCreate true "Entry Data"
// @Success 201 {object} repo.MaintenanceEntry
// @Router /v1/items/{id}/maintenance [POST]
// @Security Bearer
// @Summary Create Maintenance Entry
// @Tags Item Maintenance
// @Produce json
// @Param id path string true "Item ID"
// @Param payload body repo.MaintenanceEntryCreate true "Entry Data"
// @Success 201 {object} repo.MaintenanceEntry
// @Router /v1/items/{id}/maintenance [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleMaintenanceEntryCreate() errchain.HandlerFunc {
fn := func(r *http.Request, itemID uuid.UUID, body repo.MaintenanceEntryCreate) (repo.MaintenanceEntry, error) {
auth := services.NewContext(r.Context())

View File

@@ -30,14 +30,14 @@ func (ctrl *V1Controller) HandleMaintenanceGetAll() errchain.HandlerFunc {
// HandleMaintenanceEntryUpdate godoc
//
// @Summary Update Maintenance Entry
// @Tags Maintenance
// @Produce json
// @Param id path string true "Maintenance ID"
// @Param payload body repo.MaintenanceEntryUpdate true "Entry Data"
// @Success 200 {object} repo.MaintenanceEntry
// @Router /v1/maintenance/{id} [PUT]
// @Security Bearer
// @Summary Update Maintenance Entry
// @Tags Maintenance
// @Produce json
// @Param id path string true "Maintenance ID"
// @Param payload body repo.MaintenanceEntryUpdate true "Entry Data"
// @Success 200 {object} repo.MaintenanceEntry
// @Router /v1/maintenance/{id} [PUT]
// @Security Bearer
func (ctrl *V1Controller) HandleMaintenanceEntryUpdate() errchain.HandlerFunc {
fn := func(r *http.Request, entryID uuid.UUID, body repo.MaintenanceEntryUpdate) (repo.MaintenanceEntry, error) {
auth := services.NewContext(r.Context())
@@ -49,13 +49,13 @@ func (ctrl *V1Controller) HandleMaintenanceEntryUpdate() errchain.HandlerFunc {
// HandleMaintenanceEntryDelete godoc
//
// @Summary Delete Maintenance Entry
// @Tags Maintenance
// @Produce json
// @Param id path string true "Maintenance ID"
// @Success 204
// @Router /v1/maintenance/{id} [DELETE]
// @Security Bearer
// @Summary Delete Maintenance Entry
// @Tags Maintenance
// @Produce json
// @Param id path string true "Maintenance ID"
// @Success 204
// @Router /v1/maintenance/{id} [DELETE]
// @Security Bearer
func (ctrl *V1Controller) HandleMaintenanceEntryDelete() errchain.HandlerFunc {
fn := func(r *http.Request, entryID uuid.UUID) (any, error) {
auth := services.NewContext(r.Context())

View File

@@ -83,13 +83,13 @@ func (ctrl *V1Controller) HandleUpdateNotifier() errchain.HandlerFunc {
// HandlerNotifierTest godoc
//
// @Summary Test Notifier
// @Tags Notifiers
// @Produce json
// @Param url query string true "URL"
// @Success 204
// @Router /v1/notifiers/test [POST]
// @Security Bearer
// @Summary Test Notifier
// @Tags Notifiers
// @Produce json
// @Param url query string true "URL"
// @Success 204
// @Router /v1/notifiers/test [POST]
// @Security Bearer
func (ctrl *V1Controller) HandlerNotifierTest() errchain.HandlerFunc {
type body struct {
URL string `json:"url" validate:"required"`

View File

@@ -1,332 +0,0 @@
package v1
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/hay-kot/httpkit/errchain"
"github.com/hay-kot/httpkit/server"
"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
)
type UPCITEMDBResponse struct {
Code string `json:"code"`
Total int `json:"total"`
Offset int `json:"offset"`
Items []struct {
Ean string `json:"ean"`
Title string `json:"title"`
Description string `json:"description"`
Upc string `json:"upc"`
Brand string `json:"brand"`
Model string `json:"model"`
Color string `json:"color"`
Size string `json:"size"`
Dimension string `json:"dimension"`
Weight string `json:"weight"`
Category string `json:"category"`
LowestRecordedPrice float64 `json:"lowest_recorded_price"`
HighestRecordedPrice float64 `json:"highest_recorded_price"`
Images []string `json:"images"`
Offers []struct {
Merchant string `json:"merchant"`
Domain string `json:"domain"`
Title string `json:"title"`
Currency string `json:"currency"`
ListPrice string `json:"list_price"`
Price float64 `json:"price"`
Shipping string `json:"shipping"`
Condition string `json:"condition"`
Availability string `json:"availability"`
Link string `json:"link"`
UpdatedT int `json:"updated_t"`
} `json:"offers"`
Asin string `json:"asin"`
Elid string `json:"elid"`
} `json:"items"`
}
type BARCODESPIDER_COMResponse struct {
ItemResponse struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
} `json:"item_response"`
ItemAttributes struct {
Title string `json:"title"`
Upc string `json:"upc"`
Ean string `json:"ean"`
ParentCategory string `json:"parent_category"`
Category string `json:"category"`
Brand string `json:"brand"`
Model string `json:"model"`
Mpn string `json:"mpn"`
Manufacturer string `json:"manufacturer"`
Publisher string `json:"publisher"`
Asin string `json:"asin"`
Color string `json:"color"`
Size string `json:"size"`
Weight string `json:"weight"`
Image string `json:"image"`
IsAdult string `json:"is_adult"`
Description string `json:"description"`
} `json:"item_attributes"`
Stores []struct {
StoreName string `json:"store_name"`
Title string `json:"title"`
Image string `json:"image"`
Price string `json:"price"`
Currency string `json:"currency"`
Link string `json:"link"`
Updated string `json:"updated"`
} `json:"Stores"`
}
// HandleGenerateQRCode godoc
//
// @Summary Search EAN from Barcode
// @Tags Items
// @Produce json
// @Param data query string false "barcode to be searched"
// @Success 200 {object} []repo.BarcodeProduct
// @Router /v1/products/search-from-barcode [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleProductSearchFromBarcode(conf config.BarcodeAPIConf) errchain.HandlerFunc {
type query struct {
// 80 characters is the longest non-2D barcode length (GS1-128)
EAN string `schema:"productEAN" validate:"required,max=80"`
}
return func(w http.ResponseWriter, r *http.Request) error {
q, err := adapters.DecodeQuery[query](r)
if err != nil {
return err
}
const TIMEOUT_SEC = 10
log.Info().Msg("Processing barcode lookup request on: " + q.EAN)
// Search on UPCITEMDB
var products []repo.BarcodeProduct
// www.ean-search.org/: not free
// Example code: dewalt 5035048748428
upcitemdb := func(iEan string) ([]repo.BarcodeProduct, error) {
client := &http.Client{Timeout: TIMEOUT_SEC * time.Second}
resp, err := client.Get("https://api.upcitemdb.com/prod/trial/lookup?upc=" + iEan)
if err != nil {
return nil, err
}
defer func() {
err = errors.Join(err, resp.Body.Close())
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode)
}
// We Read the response body on the line below.
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Uncomment the following string for debug
// sb := string(body)
// log.Debug().Msg("Response: " + sb)
var result UPCITEMDBResponse
if err := json.Unmarshal(body, &result); err != nil { // Parse []byte to go struct pointer
log.Error().Msg("Can not unmarshal JSON")
}
var res []repo.BarcodeProduct
for _, it := range result.Items {
var p repo.BarcodeProduct
p.SearchEngineName = "upcitemdb.com"
p.Barcode = iEan
p.Item.Description = it.Description
p.Item.Name = it.Title
p.Manufacturer = it.Brand
p.ModelNumber = it.Model
if len(it.Images) != 0 {
p.ImageURL = it.Images[0]
}
res = append(res, p)
}
return res, nil
}
ps, err := upcitemdb(q.EAN)
if err != nil {
log.Error().Msg("Can not retrieve product from upcitemdb.com" + err.Error())
}
// Barcode spider implementation
barcodespider := func(tokenAPI string, iEan string) ([]repo.BarcodeProduct, error) {
if len(tokenAPI) == 0 {
return nil, errors.New("no api token configured for barcodespider. " +
"Please define the api token in environment variable HBOX_BARCODE_TOKEN_BARCODESPIDER")
}
req, err := http.NewRequest(
"GET", "https://api.barcodespider.com/v1/lookup?upc="+iEan, nil)
if err != nil {
return nil, err
}
req.Header.Add("token", tokenAPI)
client := &http.Client{Timeout: TIMEOUT_SEC * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
// defer the call to Body.Close(). We also check the error code, and merge
// it with the other error in this code to avoid error overiding.
defer func() {
err = errors.Join(err, resp.Body.Close())
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("barcodespider API returned status code: %d", resp.StatusCode)
}
// We Read the response body on the line below.
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Uncomment the following string for debug
// sb := string(body)
// log.Debug().Msg("Response: " + sb)
var result BARCODESPIDER_COMResponse
if err := json.Unmarshal(body, &result); err != nil { // Parse []byte to go struct pointer
log.Error().Msg("Can not unmarshal JSON")
}
// TODO: check 200 code on HTTP response.
var p repo.BarcodeProduct
p.Barcode = iEan
p.SearchEngineName = "barcodespider.com"
p.Item.Name = result.ItemAttributes.Title
p.Item.Description = result.ItemAttributes.Description
p.Manufacturer = result.ItemAttributes.Brand
p.ModelNumber = result.ItemAttributes.Model
p.ImageURL = result.ItemAttributes.Image
var res []repo.BarcodeProduct
res = append(res, p)
return res, nil
}
ps2, err := barcodespider(conf.TokenBarcodespider, q.EAN)
if err != nil {
log.Error().Msg("Can not retrieve product from barcodespider.com: " + err.Error())
}
// Merge everything.
products = append(products, ps...)
products = append(products, ps2...)
// Retrieve images if possible
for i := range products {
p := &products[i]
if len(p.ImageURL) == 0 {
continue
}
// Validate URL is HTTPS
u, err := url.Parse(p.ImageURL)
if err != nil || u.Scheme != "https" {
log.Warn().Msg("Skipping non-HTTPS image URL: " + p.ImageURL)
continue
}
client := &http.Client{Timeout: TIMEOUT_SEC * time.Second}
res, err := client.Get(p.ImageURL)
if err != nil {
log.Warn().Msg("Cannot fetch image for URL: " + p.ImageURL + ": " + err.Error())
}
defer func() {
err = errors.Join(err, res.Body.Close())
}()
// Validate response
if res.StatusCode != http.StatusOK {
continue
}
// Check content type
contentType := res.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
continue
}
// Limit image size to 8MB
limitedReader := io.LimitReader(res.Body, 8*1024*1024)
// Read data of image
bytes, err := io.ReadAll(limitedReader)
if err != nil {
log.Warn().Msg(err.Error())
continue
}
// Convert to Base64
var base64Encoding string
// Determine the content type of the image file
mimeType := http.DetectContentType(bytes)
// Prepend the appropriate URI scheme header depending
// on the MIME type
switch mimeType {
case "image/jpeg":
base64Encoding += "data:image/jpeg;base64,"
case "image/png":
base64Encoding += "data:image/png;base64,"
default:
continue
}
// Append the base64 encoded output
base64Encoding += base64.StdEncoding.EncodeToString(bytes)
p.ImageBase64 = base64Encoding
}
if len(products) != 0 {
return server.JSON(w, http.StatusOK, products)
}
return server.JSON(w, http.StatusNoContent, nil)
}
}

View File

@@ -18,10 +18,7 @@ func (a *app) setupLogger() {
}
level, err := zerolog.ParseLevel(a.conf.Log.Level)
if err != nil {
log.Error().Err(err).Str("level", a.conf.Log.Level).Msg("invalid log level, falling back to info")
zerolog.SetGlobalLevel(zerolog.InfoLevel)
} else {
if err == nil {
zerolog.SetGlobalLevel(level)
}
}

View File

@@ -1,17 +1,18 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/pressly/goose/v3"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/pressly/goose/v3"
"github.com/sysadminsmedia/homebox/backend/internal/sys/analytics"
"github.com/hay-kot/httpkit/errchain"
"github.com/hay-kot/httpkit/graceful"
@@ -24,22 +25,14 @@ import (
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
"github.com/sysadminsmedia/homebox/backend/internal/data/migrations"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/internal/sys/analytics"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
"github.com/sysadminsmedia/homebox/backend/internal/web/mid"
"go.balki.me/anyhttp"
_ "github.com/lib/pq"
_ "github.com/sysadminsmedia/homebox/backend/internal/data/migrations/postgres"
_ "github.com/sysadminsmedia/homebox/backend/internal/data/migrations/sqlite3"
_ "github.com/sysadminsmedia/homebox/backend/pkgs/cgofreesqlite"
_ "gocloud.dev/pubsub/awssnssqs"
_ "gocloud.dev/pubsub/azuresb"
_ "gocloud.dev/pubsub/gcppubsub"
_ "gocloud.dev/pubsub/kafkapubsub"
_ "gocloud.dev/pubsub/mempubsub"
_ "gocloud.dev/pubsub/natspubsub"
_ "gocloud.dev/pubsub/rabbitpubsub"
)
var (
@@ -70,19 +63,19 @@ func validatePostgresSSLMode(sslMode string) bool {
return validModes[strings.ToLower(strings.TrimSpace(sslMode))]
}
// @title Homebox API
// @version 1.0
// @description Track, Manage, and Organize your Things.
// @contact.name Homebox Team
// @contact.url https://discord.homebox.software
// @host demo.homebox.software
// @schemes https http
// @BasePath /api
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
// @externalDocs.url https://homebox.software/en/api
// @title Homebox API
// @version 1.0
// @description Track, Manage, and Organize your Things.
// @contact.name Homebox Team
// @contact.url https://discord.homebox.software
// @host demo.homebox.software
// @schemes https http
// @BasePath /api
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
// @externalDocs.url https://homebox.software/en/api
func main() {
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
@@ -101,59 +94,50 @@ func run(cfg *config.Config) error {
app := new(cfg)
app.setupLogger()
if cfg.Options.AllowAnalytics {
analytics.Send(version, build())
}
// =========================================================================
// Initialize Database & Repos
err := setupStorageDir(cfg)
err := os.MkdirAll(cfg.Storage.Data, 0o755)
if err != nil {
return err
log.Fatal().Err(err).Msg("failed to create data directory")
}
if strings.ToLower(cfg.Database.Driver) == "postgres" {
if !validatePostgresSSLMode(cfg.Database.SslMode) {
log.Error().Str("sslmode", cfg.Database.SslMode).Msg("invalid sslmode")
return fmt.Errorf("invalid sslmode: %s", cfg.Database.SslMode)
log.Fatal().Str("sslmode", cfg.Database.SslMode).Msg("invalid sslmode")
}
}
databaseURL, err := setupDatabaseURL(cfg)
if err != nil {
return err
// Set up the database URL based on the driver because for some reason a common URL format is not used
databaseURL := ""
switch strings.ToLower(cfg.Database.Driver) {
case "sqlite3":
databaseURL = cfg.Database.SqlitePath
case "postgres":
databaseURL = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Username, cfg.Database.Password, cfg.Database.Database, cfg.Database.SslMode)
default:
log.Fatal().Str("driver", cfg.Database.Driver).Msg("unsupported database driver")
}
c, err := ent.Open(strings.ToLower(cfg.Database.Driver), databaseURL)
if err != nil {
log.Error().
log.Fatal().
Err(err).
Str("driver", strings.ToLower(cfg.Database.Driver)).
Str("host", cfg.Database.Host).
Str("port", cfg.Database.Port).
Str("database", cfg.Database.Database).
Msg("failed opening connection to {driver} database at {host}:{port}/{database}")
return fmt.Errorf("failed opening connection to %s database at %s:%s/%s: %w",
strings.ToLower(cfg.Database.Driver),
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.Database,
err,
)
}
if strings.ToLower(cfg.Database.Driver) == "sqlite3" {
db := c.Sql()
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
log.Info().Msg("SQLite connection pool configured: max_open=1, max_idle=1")
}
migrationsFs, err := migrations.Migrations(strings.ToLower(cfg.Database.Driver))
if err != nil {
return fmt.Errorf("failed to get migrations for %s: %w", strings.ToLower(cfg.Database.Driver), err)
}
goose.SetBaseFS(migrationsFs)
goose.SetBaseFS(migrations.Migrations(strings.ToLower(cfg.Database.Driver)))
err = goose.SetDialect(strings.ToLower(cfg.Database.Driver))
if err != nil {
log.Error().Str("driver", cfg.Database.Driver).Msg("unsupported database driver")
log.Fatal().Str("driver", cfg.Database.Driver).Msg("unsupported database driver")
return fmt.Errorf("unsupported database driver: %s", cfg.Database.Driver)
}
@@ -163,9 +147,25 @@ func run(cfg *config.Config) error {
return err
}
collectFuncs, err := loadCurrencies(cfg)
if err != nil {
return err
collectFuncs := []currencies.CollectorFunc{
currencies.CollectDefaults(),
}
if cfg.Options.CurrencyConfig != "" {
log.Info().
Str("path", cfg.Options.CurrencyConfig).
Msg("loading currency config file")
content, err := os.ReadFile(cfg.Options.CurrencyConfig)
if err != nil {
log.Error().
Err(err).
Str("path", cfg.Options.CurrencyConfig).
Msg("failed to read currency config file")
return err
}
collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content)))
}
currencies, err := currencies.CollectionCurrencies(collectFuncs...)
@@ -178,7 +178,7 @@ func run(cfg *config.Config) error {
app.bus = eventbus.New()
app.db = c
app.repos = repo.New(c, app.bus, cfg.Storage, cfg.Database.PubSubConnString, cfg.Thumbnail)
app.repos = repo.New(c, app.bus, cfg.Storage.Data)
app.services = services.New(
app.repos,
services.WithAutoIncrementAssetID(cfg.Options.AutoIncrementAssetID),
@@ -219,52 +219,90 @@ func run(cfg *config.Config) error {
_ = httpserver.Shutdown(context.Background())
}()
listener, addrType, addrCfg, err := anyhttp.GetListener(cfg.Web.Host)
if err == nil {
switch addrType {
case anyhttp.SystemdFD:
sysdCfg := addrCfg.(*anyhttp.SysdConfig)
if sysdCfg.IdleTimeout != nil {
log.Error().Msg("idle timeout not yet supported. Please remove and try again")
return errors.New("idle timeout not yet supported. Please remove and try again")
}
fallthrough
case anyhttp.UnixSocket:
log.Info().Msgf("Server is running on %s", cfg.Web.Host)
return httpserver.Serve(listener)
}
} else {
log.Debug().Msgf("anyhttp error: %v", err)
}
log.Info().Msgf("Server is running on %s:%s", cfg.Web.Host, cfg.Web.Port)
return httpserver.ListenAndServe()
})
// =========================================================================
// Start Reoccurring Tasks
registerRecurringTasks(app, cfg, runner)
// Send analytics if enabled at around midnight UTC
if cfg.Options.AllowAnalytics {
analyticsTime := time.Second
runner.AddPlugin(NewTask("send-analytics", analyticsTime, func(ctx context.Context) {
for {
now := time.Now().UTC()
nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
dur := time.Until(nextMidnight)
analyticsTime = dur
select {
case <-ctx.Done():
return
case <-time.After(dur):
log.Debug().Msg("running send analytics")
err := analytics.Send(version, build())
if err != nil {
log.Error().Err(err).Msg("failed to send analytics")
}
}
runner.AddFunc("eventbus", app.bus.Run)
runner.AddFunc("seed_database", func(ctx context.Context) error {
// TODO: Remove through external API that does setup
if cfg.Demo {
log.Info().Msg("Running in demo mode, creating demo data")
err := app.SetupDemo()
if err != nil {
log.Fatal().Msg(err.Error())
}
}
return nil
})
runner.AddPlugin(NewTask("purge-tokens", time.Duration(24)*time.Hour, func(ctx context.Context) {
_, err := app.repos.AuthTokens.PurgeExpiredTokens(ctx)
if err != nil {
log.Error().
Err(err).
Msg("failed to purge expired tokens")
}
}))
runner.AddPlugin(NewTask("purge-invitations", time.Duration(24)*time.Hour, func(ctx context.Context) {
_, err := app.repos.Groups.InvitationPurge(ctx)
if err != nil {
log.Error().
Err(err).
Msg("failed to purge expired invitations")
}
}))
runner.AddPlugin(NewTask("send-notifications", time.Duration(1)*time.Hour, func(ctx context.Context) {
now := time.Now()
if now.Hour() == 8 {
fmt.Println("run notifiers")
err := app.services.BackgroundService.SendNotifiersToday(context.Background())
if err != nil {
log.Error().
Err(err).
Msg("failed to send notifiers")
}
}
}))
if cfg.Options.GithubReleaseCheck {
runner.AddPlugin(NewTask("get-latest-github-release", time.Hour, func(ctx context.Context) {
log.Debug().Msg("running get latest github release")
err := app.services.BackgroundService.GetLatestGithubRelease(context.Background())
if err != nil {
log.Error().
Err(err).
Msg("failed to get latest github release")
}
}))
}
if cfg.Debug.Enabled {
runner.AddFunc("debug", func(ctx context.Context) error {
debugserver := http.Server{
Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port),
Handler: app.debugRouter(),
ReadTimeout: cfg.Web.ReadTimeout,
WriteTimeout: cfg.Web.WriteTimeout,
IdleTimeout: cfg.Web.IdleTimeout,
}
go func() {
<-ctx.Done()
_ = debugserver.Shutdown(context.Background())
}()
log.Info().Msgf("Debug server is running on %s:%s", cfg.Web.Host, cfg.Debug.Port)
return debugserver.ListenAndServe()
})
}
return runner.Start(context.Background())
}

View File

@@ -1,151 +0,0 @@
package main
import (
"context"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/hay-kot/httpkit/graceful"
"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
"github.com/sysadminsmedia/homebox/backend/pkgs/utils"
"gocloud.dev/pubsub"
)
func registerRecurringTasks(app *app, cfg *config.Config, runner *graceful.Runner) {
runner.AddFunc("eventbus", app.bus.Run)
runner.AddFunc("seed_database", func(ctx context.Context) error {
if cfg.Demo {
log.Info().Msg("Running in demo mode, creating demo data")
err := app.SetupDemo()
if err != nil {
log.Error().Err(err).Msg("failed to setup demo data")
return fmt.Errorf("failed to setup demo data: %w", err)
}
}
return nil
})
runner.AddPlugin(NewTask("purge-tokens", 24*time.Hour, func(ctx context.Context) {
_, err := app.repos.AuthTokens.PurgeExpiredTokens(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to purge expired tokens")
}
}))
runner.AddPlugin(NewTask("purge-invitations", 24*time.Hour, func(ctx context.Context) {
_, err := app.repos.Groups.InvitationPurge(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to purge expired invitations")
}
}))
runner.AddPlugin(NewTask("send-notifications", time.Hour, func(ctx context.Context) {
now := time.Now()
if now.Hour() == 8 {
fmt.Println("run notifiers")
err := app.services.BackgroundService.SendNotifiersToday(context.Background())
if err != nil {
log.Error().Err(err).Msg("failed to send notifiers")
}
}
}))
if cfg.Thumbnail.Enabled {
runner.AddFunc("create-thumbnails-subscription", func(ctx context.Context) error {
pubsubString, err := utils.GenerateSubPubConn(cfg.Database.PubSubConnString, "thumbnails")
if err != nil {
log.Error().Err(err).Msg("failed to generate pubsub connection string")
return err
}
topic, err := pubsub.OpenTopic(ctx, pubsubString)
if err != nil {
return err
}
defer func(topic *pubsub.Topic, ctx context.Context) {
err := topic.Shutdown(ctx)
if err != nil {
log.Err(err).Msg("fail to shutdown pubsub topic")
}
}(topic, ctx)
subscription, err := pubsub.OpenSubscription(ctx, pubsubString)
if err != nil {
log.Err(err).Msg("failed to open pubsub topic")
return err
}
defer func(topic *pubsub.Subscription, ctx context.Context) {
err := topic.Shutdown(ctx)
if err != nil {
log.Err(err).Msg("fail to shutdown pubsub topic")
}
}(subscription, ctx)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
msg, err := subscription.Receive(ctx)
log.Debug().Msg("received thumbnail generation request from pubsub topic")
if err != nil {
log.Err(err).Msg("failed to receive message from pubsub topic")
continue
}
if msg == nil {
log.Warn().Msg("received nil message from pubsub topic")
continue
}
groupId, err := uuid.Parse(msg.Metadata["group_id"])
if err != nil {
log.Error().Err(err).Str("group_id", msg.Metadata["group_id"]).Msg("failed to parse group ID from message metadata")
}
attachmentId, err := uuid.Parse(msg.Metadata["attachment_id"])
if err != nil {
log.Error().Err(err).Str("attachment_id", msg.Metadata["attachment_id"]).Msg("failed to parse attachment ID from message metadata")
}
err = app.repos.Attachments.CreateThumbnail(ctx, groupId, attachmentId, msg.Metadata["title"], msg.Metadata["path"])
if err != nil {
log.Err(err).Msg("failed to create thumbnail")
}
msg.Ack()
}
}
})
}
if cfg.Options.GithubReleaseCheck {
runner.AddPlugin(NewTask("get-latest-github-release", time.Hour, func(ctx context.Context) {
log.Debug().Msg("running get latest github release")
err := app.services.BackgroundService.GetLatestGithubRelease(context.Background())
if err != nil {
log.Error().Err(err).Msg("failed to get latest github release")
}
}))
}
if cfg.Debug.Enabled {
runner.AddFunc("debug", func(ctx context.Context) error {
debugserver := http.Server{
Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port),
Handler: app.debugRouter(),
ReadTimeout: cfg.Web.ReadTimeout,
WriteTimeout: cfg.Web.WriteTimeout,
IdleTimeout: cfg.Web.IdleTimeout,
}
go func() {
<-ctx.Done()
_ = debugserver.Shutdown(context.Background())
}()
log.Info().Msgf("Debug server is running on %s:%s", cfg.Web.Host, cfg.Debug.Port)
return debugserver.ListenAndServe()
})
// Print the configuration to the console
cfg.Print()
}
}

View File

@@ -102,7 +102,6 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Post("/actions/zero-item-time-fields", chain.ToHandlerFunc(v1Ctrl.HandleItemDateZeroOut(), userMW...))
r.Post("/actions/ensure-import-refs", chain.ToHandlerFunc(v1Ctrl.HandleEnsureImportRefs(), userMW...))
r.Post("/actions/set-primary-photos", chain.ToHandlerFunc(v1Ctrl.HandleSetPrimaryPhotos(), userMW...))
r.Post("/actions/create-missing-thumbnails", chain.ToHandlerFunc(v1Ctrl.HandleCreateMissingThumbnails(), userMW...))
r.Get("/locations", chain.ToHandlerFunc(v1Ctrl.HandleLocationGetAll(), userMW...))
r.Post("/locations", chain.ToHandlerFunc(v1Ctrl.HandleLocationCreate(), userMW...))
@@ -129,7 +128,6 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Put("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemUpdate(), userMW...))
r.Patch("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemPatch(), userMW...))
r.Delete("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemDelete(), userMW...))
r.Post("/items/{id}/duplicate", chain.ToHandlerFunc(v1Ctrl.HandleItemDuplicate(), userMW...))
r.Post("/items/{id}/attachments", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentCreate(), userMW...))
r.Put("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentUpdate(), userMW...))
@@ -158,8 +156,6 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
a.mwRoles(RoleModeOr, authroles.RoleUser.String(), authroles.RoleAttachments.String()),
}
r.Get("/products/search-from-barcode", chain.ToHandlerFunc(v1Ctrl.HandleProductSearchFromBarcode(a.conf.Barcode), userMW...))
r.Get("/qrcode", chain.ToHandlerFunc(v1Ctrl.HandleGenerateQRCode(), assetMW...))
r.Get(
"/items/{id}/attachments/{attachment_id}",

View File

@@ -1,103 +0,0 @@
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/core/currencies"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
)
// setupStorageDir handles the creation and validation of the storage directory.
func setupStorageDir(cfg *config.Config) error {
if strings.HasPrefix(cfg.Storage.ConnString, "file:///./") {
raw := strings.TrimPrefix(cfg.Storage.ConnString, "file:///./")
clean := filepath.Clean(raw)
absBase, err := filepath.Abs(clean)
if err != nil {
log.Error().Err(err).Msg("failed to get absolute path for storage connection string")
return fmt.Errorf("failed to get absolute path for storage connection string: %w", err)
}
absBase = strings.ReplaceAll(absBase, "\\", "/")
storageDir := filepath.Join(absBase, cfg.Storage.PrefixPath)
storageDir = strings.ReplaceAll(storageDir, "\\", "/")
if !strings.HasPrefix(storageDir, absBase+"/") && storageDir != absBase {
log.Error().Str("path", storageDir).Msg("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path")
return fmt.Errorf("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path")
}
if err := os.MkdirAll(storageDir, 0o750); err != nil {
log.Error().Err(err).Msg("failed to create data directory")
return fmt.Errorf("failed to create data directory: %w", err)
}
}
return nil
}
// setupDatabaseURL returns the database URL and ensures any required directories exist.
func setupDatabaseURL(cfg *config.Config) (string, error) {
databaseURL := ""
switch strings.ToLower(cfg.Database.Driver) {
case "sqlite3":
databaseURL = cfg.Database.SqlitePath
dbFilePath := strings.Split(cfg.Database.SqlitePath, "?")[0]
dbDir := filepath.Dir(dbFilePath)
if err := os.MkdirAll(dbDir, 0o755); err != nil {
log.Error().Err(err).Str("path", dbDir).Msg("failed to create SQLite database directory")
return "", fmt.Errorf("failed to create SQLite database directory: %w", err)
}
case "postgres":
databaseURL = fmt.Sprintf("host=%s port=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Database, cfg.Database.SslMode)
if cfg.Database.Username != "" {
databaseURL += fmt.Sprintf(" user=%s", cfg.Database.Username)
}
if cfg.Database.Password != "" {
databaseURL += fmt.Sprintf(" password=%s", cfg.Database.Password)
}
if cfg.Database.SslRootCert != "" {
if _, err := os.Stat(cfg.Database.SslRootCert); err != nil {
log.Error().Err(err).Str("path", cfg.Database.SslRootCert).Msg("SSL root certificate file is not accessible")
return "", fmt.Errorf("SSL root certificate file is not accessible: %w", err)
}
databaseURL += fmt.Sprintf(" sslrootcert=%s", cfg.Database.SslRootCert)
}
if cfg.Database.SslCert != "" {
if _, err := os.Stat(cfg.Database.SslCert); err != nil {
log.Error().Err(err).Str("path", cfg.Database.SslCert).Msg("SSL certificate file is not accessible")
return "", fmt.Errorf("SSL certificate file is not accessible: %w", err)
}
databaseURL += fmt.Sprintf(" sslcert=%s", cfg.Database.SslCert)
}
if cfg.Database.SslKey != "" {
if _, err := os.Stat(cfg.Database.SslKey); err != nil {
log.Error().Err(err).Str("path", cfg.Database.SslKey).Msg("SSL key file is not accessible")
return "", fmt.Errorf("SSL key file is not accessible: %w", err)
}
databaseURL += fmt.Sprintf(" sslkey=%s", cfg.Database.SslKey)
}
default:
log.Error().Str("driver", cfg.Database.Driver).Msg("unsupported database driver")
return "", fmt.Errorf("unsupported database driver: %s", cfg.Database.Driver)
}
return databaseURL, nil
}
// loadCurrencies loads currency data from config if provided.
func loadCurrencies(cfg *config.Config) ([]currencies.CollectorFunc, error) {
collectFuncs := []currencies.CollectorFunc{
currencies.CollectDefaults(),
}
if cfg.Options.CurrencyConfig != "" {
log.Info().Str("path", cfg.Options.CurrencyConfig).Msg("loading currency config file")
content, err := os.ReadFile(cfg.Options.CurrencyConfig)
if err != nil {
log.Error().Err(err).Str("path", cfg.Options.CurrencyConfig).Msg("failed to read currency config file")
return nil, err
}
collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content)))
}
return collectFuncs, nil
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +1,9 @@
basePath: /api
definitions:
attachment.Type:
enum:
- attachment
- photo
- manual
- warranty
- attachment
- receipt
- thumbnail
type: string
x-enum-varnames:
- DefaultType
- TypePhoto
- TypeManual
- TypeWarranty
- TypeAttachment
- TypeReceipt
- TypeThumbnail
authroles.Role:
enum:
- user
- admin
- user
- attachments
type: string
x-enum-varnames:
- DefaultRole
- RoleAdmin
- RoleUser
- RoleAttachments
currencies.Currency:
properties:
code:
type: string
decimals:
type: integer
local:
type: string
name:
@@ -43,643 +11,6 @@ definitions:
symbol:
type: string
type: object
ent.Attachment:
properties:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.AttachmentEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the AttachmentQuery when eager-loading is set.
id:
description: ID of the ent.
type: string
mime_type:
description: MimeType holds the value of the "mime_type" field.
type: string
path:
description: Path holds the value of the "path" field.
type: string
primary:
description: Primary holds the value of the "primary" field.
type: boolean
title:
description: Title holds the value of the "title" field.
type: string
type:
allOf:
- $ref: '#/definitions/attachment.Type'
description: Type holds the value of the "type" field.
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
type: object
ent.AttachmentEdges:
properties:
item:
allOf:
- $ref: '#/definitions/ent.Item'
description: Item holds the value of the item edge.
thumbnail:
allOf:
- $ref: '#/definitions/ent.Attachment'
description: Thumbnail holds the value of the thumbnail edge.
type: object
ent.AuthRoles:
properties:
edges:
allOf:
- $ref: '#/definitions/ent.AuthRolesEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the AuthRolesQuery when eager-loading is set.
id:
description: ID of the ent.
type: integer
role:
allOf:
- $ref: '#/definitions/authroles.Role'
description: Role holds the value of the "role" field.
type: object
ent.AuthRolesEdges:
properties:
token:
allOf:
- $ref: '#/definitions/ent.AuthTokens'
description: Token holds the value of the token edge.
type: object
ent.AuthTokens:
properties:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.AuthTokensEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the AuthTokensQuery when eager-loading is set.
expires_at:
description: ExpiresAt holds the value of the "expires_at" field.
type: string
id:
description: ID of the ent.
type: string
token:
description: Token holds the value of the "token" field.
items:
type: integer
type: array
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
type: object
ent.AuthTokensEdges:
properties:
roles:
allOf:
- $ref: '#/definitions/ent.AuthRoles'
description: Roles holds the value of the roles edge.
user:
allOf:
- $ref: '#/definitions/ent.User'
description: User holds the value of the user edge.
type: object
ent.Group:
properties:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
currency:
description: Currency holds the value of the "currency" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.GroupEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the GroupQuery when eager-loading is set.
id:
description: ID of the ent.
type: string
name:
description: Name holds the value of the "name" field.
type: string
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
type: object
ent.GroupEdges:
properties:
invitation_tokens:
description: InvitationTokens holds the value of the invitation_tokens edge.
items:
$ref: '#/definitions/ent.GroupInvitationToken'
type: array
items:
description: Items holds the value of the items edge.
items:
$ref: '#/definitions/ent.Item'
type: array
labels:
description: Labels holds the value of the labels edge.
items:
$ref: '#/definitions/ent.Label'
type: array
locations:
description: Locations holds the value of the locations edge.
items:
$ref: '#/definitions/ent.Location'
type: array
notifiers:
description: Notifiers holds the value of the notifiers edge.
items:
$ref: '#/definitions/ent.Notifier'
type: array
users:
description: Users holds the value of the users edge.
items:
$ref: '#/definitions/ent.User'
type: array
type: object
ent.GroupInvitationToken:
properties:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.GroupInvitationTokenEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the GroupInvitationTokenQuery when eager-loading is set.
expires_at:
description: ExpiresAt holds the value of the "expires_at" field.
type: string
id:
description: ID of the ent.
type: string
token:
description: Token holds the value of the "token" field.
items:
type: integer
type: array
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
uses:
description: Uses holds the value of the "uses" field.
type: integer
type: object
ent.GroupInvitationTokenEdges:
properties:
group:
allOf:
- $ref: '#/definitions/ent.Group'
description: Group holds the value of the group edge.
type: object
ent.Item:
properties:
archived:
description: Archived holds the value of the "archived" field.
type: boolean
asset_id:
description: AssetID holds the value of the "asset_id" field.
type: integer
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
description:
description: Description holds the value of the "description" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.ItemEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the ItemQuery when eager-loading is set.
id:
description: ID of the ent.
type: string
import_ref:
description: ImportRef holds the value of the "import_ref" field.
type: string
insured:
description: Insured holds the value of the "insured" field.
type: boolean
lifetime_warranty:
description: LifetimeWarranty holds the value of the "lifetime_warranty" field.
type: boolean
manufacturer:
description: Manufacturer holds the value of the "manufacturer" field.
type: string
model_number:
description: ModelNumber holds the value of the "model_number" field.
type: string
name:
description: Name holds the value of the "name" field.
type: string
notes:
description: Notes holds the value of the "notes" field.
type: string
purchase_from:
description: PurchaseFrom holds the value of the "purchase_from" field.
type: string
purchase_price:
description: PurchasePrice holds the value of the "purchase_price" field.
type: number
purchase_time:
description: PurchaseTime holds the value of the "purchase_time" field.
type: string
quantity:
description: Quantity holds the value of the "quantity" field.
type: integer
serial_number:
description: SerialNumber holds the value of the "serial_number" field.
type: string
sold_notes:
description: SoldNotes holds the value of the "sold_notes" field.
type: string
sold_price:
description: SoldPrice holds the value of the "sold_price" field.
type: number
sold_time:
description: SoldTime holds the value of the "sold_time" field.
type: string
sold_to:
description: SoldTo holds the value of the "sold_to" field.
type: string
sync_child_items_locations:
description: SyncChildItemsLocations holds the value of the "sync_child_items_locations"
field.
type: boolean
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
warranty_details:
description: WarrantyDetails holds the value of the "warranty_details" field.
type: string
warranty_expires:
description: WarrantyExpires holds the value of the "warranty_expires" field.
type: string
type: object
ent.ItemEdges:
properties:
attachments:
description: Attachments holds the value of the attachments edge.
items:
$ref: '#/definitions/ent.Attachment'
type: array
children:
description: Children holds the value of the children edge.
items:
$ref: '#/definitions/ent.Item'
type: array
fields:
description: Fields holds the value of the fields edge.
items:
$ref: '#/definitions/ent.ItemField'
type: array
group:
allOf:
- $ref: '#/definitions/ent.Group'
description: Group holds the value of the group edge.
label:
description: Label holds the value of the label edge.
items:
$ref: '#/definitions/ent.Label'
type: array
location:
allOf:
- $ref: '#/definitions/ent.Location'
description: Location holds the value of the location edge.
maintenance_entries:
description: MaintenanceEntries holds the value of the maintenance_entries
edge.
items:
$ref: '#/definitions/ent.MaintenanceEntry'
type: array
parent:
allOf:
- $ref: '#/definitions/ent.Item'
description: Parent holds the value of the parent edge.
type: object
ent.ItemField:
properties:
boolean_value:
description: BooleanValue holds the value of the "boolean_value" field.
type: boolean
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
description:
description: Description holds the value of the "description" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.ItemFieldEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the ItemFieldQuery when eager-loading is set.
id:
description: ID of the ent.
type: string
name:
description: Name holds the value of the "name" field.
type: string
number_value:
description: NumberValue holds the value of the "number_value" field.
type: integer
text_value:
description: TextValue holds the value of the "text_value" field.
type: string
time_value:
description: TimeValue holds the value of the "time_value" field.
type: string
type:
allOf:
- $ref: '#/definitions/itemfield.Type'
description: Type holds the value of the "type" field.
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
type: object
ent.ItemFieldEdges:
properties:
item:
allOf:
- $ref: '#/definitions/ent.Item'
description: Item holds the value of the item edge.
type: object
ent.Label:
properties:
color:
description: Color holds the value of the "color" field.
type: string
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
description:
description: Description holds the value of the "description" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.LabelEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the LabelQuery when eager-loading is set.
id:
description: ID of the ent.
type: string
name:
description: Name holds the value of the "name" field.
type: string
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
type: object
ent.LabelEdges:
properties:
group:
allOf:
- $ref: '#/definitions/ent.Group'
description: Group holds the value of the group edge.
items:
description: Items holds the value of the items edge.
items:
$ref: '#/definitions/ent.Item'
type: array
type: object
ent.Location:
properties:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
description:
description: Description holds the value of the "description" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.LocationEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the LocationQuery when eager-loading is set.
id:
description: ID of the ent.
type: string
name:
description: Name holds the value of the "name" field.
type: string
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
type: object
ent.LocationEdges:
properties:
children:
description: Children holds the value of the children edge.
items:
$ref: '#/definitions/ent.Location'
type: array
group:
allOf:
- $ref: '#/definitions/ent.Group'
description: Group holds the value of the group edge.
items:
description: Items holds the value of the items edge.
items:
$ref: '#/definitions/ent.Item'
type: array
parent:
allOf:
- $ref: '#/definitions/ent.Location'
description: Parent holds the value of the parent edge.
type: object
ent.MaintenanceEntry:
properties:
cost:
description: Cost holds the value of the "cost" field.
type: number
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
date:
description: Date holds the value of the "date" field.
type: string
description:
description: Description holds the value of the "description" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.MaintenanceEntryEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the MaintenanceEntryQuery when eager-loading is set.
id:
description: ID of the ent.
type: string
item_id:
description: ItemID holds the value of the "item_id" field.
type: string
name:
description: Name holds the value of the "name" field.
type: string
scheduled_date:
description: ScheduledDate holds the value of the "scheduled_date" field.
type: string
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
type: object
ent.MaintenanceEntryEdges:
properties:
item:
allOf:
- $ref: '#/definitions/ent.Item'
description: Item holds the value of the item edge.
type: object
ent.Notifier:
properties:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.NotifierEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the NotifierQuery when eager-loading is set.
group_id:
description: GroupID holds the value of the "group_id" field.
type: string
id:
description: ID of the ent.
type: string
is_active:
description: IsActive holds the value of the "is_active" field.
type: boolean
name:
description: Name holds the value of the "name" field.
type: string
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
user_id:
description: UserID holds the value of the "user_id" field.
type: string
type: object
ent.NotifierEdges:
properties:
group:
allOf:
- $ref: '#/definitions/ent.Group'
description: Group holds the value of the group edge.
user:
allOf:
- $ref: '#/definitions/ent.User'
description: User holds the value of the user edge.
type: object
ent.User:
properties:
activated_on:
description: ActivatedOn holds the value of the "activated_on" field.
type: string
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.UserEdges'
description: |-
Edges holds the relations/edges for other nodes in the graph.
The values are being populated by the UserQuery when eager-loading is set.
email:
description: Email holds the value of the "email" field.
type: string
id:
description: ID of the ent.
type: string
is_superuser:
description: IsSuperuser holds the value of the "is_superuser" field.
type: boolean
name:
description: Name holds the value of the "name" field.
type: string
role:
allOf:
- $ref: '#/definitions/user.Role'
description: Role holds the value of the "role" field.
superuser:
description: Superuser holds the value of the "superuser" field.
type: boolean
updated_at:
description: UpdatedAt holds the value of the "updated_at" field.
type: string
type: object
ent.UserEdges:
properties:
auth_tokens:
description: AuthTokens holds the value of the auth_tokens edge.
items:
$ref: '#/definitions/ent.AuthTokens'
type: array
group:
allOf:
- $ref: '#/definitions/ent.Group'
description: Group holds the value of the group edge.
notifiers:
description: Notifiers holds the value of the notifiers edge.
items:
$ref: '#/definitions/ent.Notifier'
type: array
type: object
itemfield.Type:
enum:
- text
- number
- boolean
- time
type: string
x-enum-varnames:
- TypeText
- TypeNumber
- TypeBoolean
- TypeTime
repo.BarcodeProduct:
properties:
barcode:
type: string
imageBase64:
type: string
imageURL:
type: string
item:
$ref: '#/definitions/repo.ItemCreate'
manufacturer:
type: string
modelNumber:
description: Identifications
type: string
notes:
description: Extras
type: string
search_engine_name:
type: string
type: object
repo.DuplicateOptions:
properties:
copyAttachments:
type: boolean
copyCustomFields:
type: boolean
copyMaintenance:
type: boolean
copyPrefix:
type: string
type: object
repo.Group:
properties:
createdAt:
@@ -721,14 +52,10 @@ definitions:
type: string
id:
type: string
mimeType:
type: string
path:
type: string
primary:
type: boolean
thumbnail:
$ref: '#/definitions/ent.Attachment'
title:
type: string
type:
@@ -807,8 +134,6 @@ definitions:
type: string
imageId:
type: string
x-nullable: true
x-omitempty: true
insured:
type: boolean
labels:
@@ -860,10 +185,6 @@ definitions:
type: string
syncChildItemsLocations:
type: boolean
thumbnailId:
type: string
x-nullable: true
x-omitempty: true
updatedAt:
type: string
warrantyDetails:
@@ -875,16 +196,6 @@ definitions:
properties:
id:
type: string
labelIds:
items:
type: string
type: array
x-nullable: true
x-omitempty: true
locationId:
type: string
x-nullable: true
x-omitempty: true
quantity:
type: integer
x-nullable: true
@@ -914,8 +225,6 @@ definitions:
type: string
imageId:
type: string
x-nullable: true
x-omitempty: true
insured:
type: boolean
labels:
@@ -937,10 +246,6 @@ definitions:
soldTime:
description: Sale details
type: string
thumbnailId:
type: string
x-nullable: true
x-omitempty: true
updatedAt:
type: string
type: object
@@ -1035,7 +340,7 @@ definitions:
color:
type: string
description:
maxLength: 1000
maxLength: 255
type: string
name:
maxLength: 255
@@ -1046,8 +351,6 @@ definitions:
type: object
repo.LabelOut:
properties:
color:
type: string
createdAt:
type: string
description:
@@ -1061,8 +364,6 @@ definitions:
type: object
repo.LabelSummary:
properties:
color:
type: string
createdAt:
type: string
description:
@@ -1369,16 +670,6 @@ definitions:
token:
type: string
type: object
user.Role:
enum:
- user
- user
- owner
type: string
x-enum-varnames:
- DefaultRole
- RoleUser
- RoleOwner
v1.APISummary:
properties:
allowRegistration:
@@ -1488,21 +779,6 @@ info:
title: Homebox API
version: "1.0"
paths:
/v1/actions/create-missing-thumbnails:
post:
description: Creates thumbnails for items that are missing them
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.ActionAmountResult'
security:
- Bearer: []
summary: Create Missing Thumbnails
tags:
- Actions
/v1/actions/ensure-asset-ids:
post:
description: Ensures all items in the database have an asset ID
@@ -1892,6 +1168,7 @@ paths:
- description: Type of file
in: formData
name: type
required: true
type: string
- description: Is this the primary attachment
in: formData
@@ -1991,32 +1268,6 @@ paths:
summary: Update Item Attachment
tags:
- Items Attachments
/v1/items/{id}/duplicate:
post:
parameters:
- description: Item ID
in: path
name: id
required: true
type: string
- description: Duplicate Options
in: body
name: payload
required: true
schema:
$ref: '#/definitions/repo.DuplicateOptions'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/repo.ItemOut'
security:
- Bearer: []
summary: Duplicate Item
tags:
- Items
/v1/items/{id}/maintenance:
get:
parameters:
@@ -2613,27 +1864,6 @@ paths:
summary: Test Notifier
tags:
- Notifiers
/v1/products/search-from-barcode:
get:
parameters:
- description: barcode to be searched
in: query
name: data
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.BarcodeProduct'
type: array
security:
- Bearer: []
summary: Search EAN from Barcode
tags:
- Items
/v1/qrcode:
get:
parameters:

View File

@@ -1,207 +1,94 @@
module github.com/sysadminsmedia/homebox/backend
go 1.24.0
toolchain go1.24.3
go 1.23.0
require (
entgo.io/ent v0.14.5
github.com/ardanlabs/conf/v3 v3.8.0
entgo.io/ent v0.14.4
github.com/ardanlabs/conf/v3 v3.7.2
github.com/containrrr/shoutrrr v0.8.0
github.com/evanoberholster/imagemeta v0.3.1
github.com/gen2brain/avif v0.4.4
github.com/gen2brain/heic v0.4.5
github.com/gen2brain/jpegxl v0.4.5
github.com/gen2brain/webp v0.5.5
github.com/go-chi/chi/v5 v5.2.2
github.com/go-playground/validator/v10 v10.27.0
github.com/go-chi/chi/v5 v5.2.1
github.com/go-playground/validator/v10 v10.26.0
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/google/uuid v1.6.0
github.com/gorilla/schema v1.4.1
github.com/hay-kot/httpkit v0.0.11
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.32
github.com/olahol/melody v1.3.0
github.com/mattn/go-sqlite3 v1.14.28
github.com/olahol/melody v1.2.1
github.com/pkg/errors v0.9.1
github.com/pressly/goose/v3 v3.24.3
github.com/pressly/goose/v3 v3.24.2
github.com/rs/zerolog v1.34.0
github.com/shirou/gopsutil/v4 v4.25.7
github.com/shirou/gopsutil/v4 v4.25.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.10.0
github.com/swaggo/http-swagger/v2 v2.0.2
github.com/swaggo/swag v1.16.6
github.com/swaggo/swag v1.16.4
github.com/yeqown/go-qrcode/v2 v2.2.5
github.com/yeqown/go-qrcode/writer/standard v1.3.0
github.com/yeqown/go-qrcode/writer/standard v1.2.5
github.com/zeebo/blake3 v0.2.4
go.balki.me/anyhttp v0.5.2
gocloud.dev v0.43.0
gocloud.dev/pubsub/kafkapubsub v0.43.0
gocloud.dev/pubsub/natspubsub v0.43.0
gocloud.dev/pubsub/rabbitpubsub v0.43.0
golang.org/x/crypto v0.41.0
golang.org/x/image v0.30.0
golang.org/x/text v0.28.0
modernc.org/sqlite v1.38.2
golang.org/x/crypto v0.37.0
modernc.org/sqlite v1.37.0
)
require (
ariga.io/atlas v0.32.0 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/klauspost/cpuid/v2 v2.0.12 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
)
require (
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go v0.121.4 // indirect
cloud.google.com/go/auth v0.16.4 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.8.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/pubsub v1.49.0 // indirect
cloud.google.com/go/storage v1.55.0 // indirect
github.com/Azure/azure-amqp-common-go/v3 v3.2.3 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.9.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 // indirect
github.com/Azure/go-amqp v1.4.0 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/IBM/sarama v1.45.2 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/aws/aws-sdk-go v1.55.7 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sns v1.34.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sqs v1.38.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect
github.com/aws/smithy-go v1.22.4 // indirect
github.com/bmatcuk/doublestar v1.3.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eapache/go-resiliency v1.7.0 // indirect
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect
github.com/eapache/queue v1.1.0 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/inflect v0.19.0 // indirect
github.com/go-openapi/jsonpointer v0.21.2 // indirect
github.com/go-openapi/inflect v0.21.2 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/wire v0.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/hashicorp/hcl/v2 v2.23.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/nats-io/nats.go v1.44.0 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rabbitmq/amqp091-go v1.10.0 // indirect
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/yeqown/reedsolomon v1.0.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.247.0 // indirect
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect
google.golang.org/grpc v1.74.2 // indirect
google.golang.org/protobuf v1.36.7 // indirect
github.com/zclconf/go-cty v1.16.2 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/image v0.26.0
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.7 // indirect
modernc.org/libc v1.63.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/memory v1.10.0 // indirect
)

View File

@@ -1,200 +1,45 @@
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc=
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w=
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs=
cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s=
cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8=
cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
cloud.google.com/go/pubsub v1.49.0 h1:5054IkbslnrMCgA2MAEPcsN3Ky+AyMpEZcii/DoySPo=
cloud.google.com/go/pubsub v1.49.0/go.mod h1:K1FswTWP+C1tI/nfi3HQecoVeFvL4HUOB1tdaNXKhUY=
cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0=
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=
entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U=
github.com/Azure/azure-amqp-common-go/v3 v3.2.3 h1:uDF62mbd9bypXWi19V1bN5NZEO84JqgmI5G73ibAmrk=
github.com/Azure/azure-amqp-common-go/v3 v3.2.3/go.mod h1:7rPmbSfszeovxGfc5fSAXE4ehlXQZHpMja2OtxC2Tas=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.9.1 h1:CRZwf68N55u7ZZo3Xx2ynuqEA6k5GZfwsEUkU8qsAPk=
github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.9.1/go.mod h1:NydgUaroiShkgOcb+X6OUdS3RalWBrvDNtOyFHJtsZY=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.0 h1:LR0kAX9ykz8G4YgLCaRDVJ3+n43R8MneB5dTy2konZo=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.0/go.mod h1:DWAciXemNf++PQJLeXUB4HHH5OpsAh12HZnu2wXE1jA=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 h1:lhZdRq7TIx0GJQvSyX2Si406vrYsov2FXGp/RnSEtcs=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1/go.mod h1:8cl44BDmi+effbARHMQjgOKA2AYvcohNm7KEt42mSV8=
github.com/Azure/go-amqp v0.17.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg=
github.com/Azure/go-amqp v1.4.0 h1:Xj3caqi4comOF/L1Uc5iuBxR/pB6KumejC01YQOqOR4=
github.com/Azure/go-amqp v1.4.0/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE=
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA=
github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc=
github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M=
github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
ariga.io/atlas v0.32.0 h1:y+77nueMrExLiKlz1CcPKh/nU7VSlWfBbwCShsJyvCw=
ariga.io/atlas v0.32.0/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w=
entgo.io/ent v0.14.4 h1:/DhDraSLXIkBhyiVoJeSshr4ZYi7femzhj6/TckzZuI=
entgo.io/ent v0.14.4/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
github.com/IBM/sarama v1.45.2 h1:8m8LcMCu3REcwpa7fCP6v2fuPuzVwXDAM2DOv3CBrKw=
github.com/IBM/sarama v1.45.2/go.mod h1:ppaoTcVdGv186/z6MEKsMm70A5fwJfRTpstI37kVn3Y=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/ardanlabs/conf/v3 v3.8.0 h1:Mvv2wZJz8tIl705m5BU3ZRCP1V6TKY6qebA8i4sykrY=
github.com/ardanlabs/conf/v3 v3.8.0/go.mod h1:XlL9P0quWP4m1weOVFmlezabinbZLI05niDof/+Ochk=
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0=
github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY=
github.com/aws/aws-sdk-go-v2/config v1.29.17 h1:jSuiQ5jEe4SAMH6lLRMY9OVC+TqJLP5655pBGjmnjr0=
github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8=
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 h1:ONnH5CM16RTXRkS8Z1qg7/s2eDOhHhaXVd72mmyv4/0=
github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 h1:KAXP9JSHO1vKGCr5f4O6WmlVKLFFXgWYAGoJosorxzU=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 h1:SsytQyTMHMDPspp+spo7XwXTP44aJZZAC7fBV2C5+5s=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 h1:i2vNHQiXUvKhs3quBR6aqlgJaiaexz/aNvdCktW/kAM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 h1:t0E6FzREdtCsiLIoLCWsYliNsRBgyGD/MCK571qk4MI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U=
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU=
github.com/aws/aws-sdk-go-v2/service/sns v1.34.7 h1:OBuZE9Wt8h2imuRktu+WfjiTGrnYdCIJg8IX92aalHE=
github.com/aws/aws-sdk-go-v2/service/sns v1.34.7/go.mod h1:4WYoZAhHt+dWYpoOQUgkUKfuQbE6Gg/hW4oXE0pKS9U=
github.com/aws/aws-sdk-go-v2/service/sqs v1.38.8 h1:80dpSqWMwx2dAm30Ib7J6ucz1ZHfiv5OCRwN/EnCOXQ=
github.com/aws/aws-sdk-go-v2/service/sqs v1.38.8/go.mod h1:IzNt/udsXlETCdvBOL0nmyMe2t9cGmXmZgsdoZGYYhI=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 h1:AIRJ3lfb2w/1/8wOOSqYb9fUKGwQbtysJ2H1MofRUPg=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 h1:BpOxT3yhLwSJ77qIY3DoHAQjZsc4HEGfMCE4NGy3uFg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E=
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1ezajK+yQmkwnPV0=
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w=
github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw=
github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/ardanlabs/conf/v3 v3.7.2 h1:s2VBuDJM6OQfR0erDuopiZ+dHUQVqGxZeLrTsls03dw=
github.com/ardanlabs/conf/v3 v3.7.2/go.mod h1:XlL9P0quWP4m1weOVFmlezabinbZLI05niDof/+Ochk=
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA=
github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho=
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4ALJ04o5Qqpdz8XLIpNA3WM/iSIXqxtqo7UGVws=
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0=
github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/evanoberholster/imagemeta v0.3.1 h1:E4GUjXcvlVMjP9joN25+bBNf3Al3MTTfMqCrDOCW+LE=
github.com/evanoberholster/imagemeta v0.3.1/go.mod h1:V0vtDJmjTqvwAYO8r+u33NRVIMXQb0qSqEfImoKEiXM=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gen2brain/avif v0.4.4 h1:Ga/ss7qcWWQm2bxFpnjYjhJsNfZrWs5RsyklgFjKRSE=
github.com/gen2brain/avif v0.4.4/go.mod h1:/XCaJcjZraQwKVhpu9aEd9aLOssYOawLvhMBtmHVGqk=
github.com/gen2brain/heic v0.4.5 h1:Cq3hPu6wwlTJNv2t48ro3oWje54h82Q5pALeCBNgaSk=
github.com/gen2brain/heic v0.4.5/go.mod h1:ECnpqbqLu0qSje4KSNWUUDK47UPXPzl80T27GWGEL5I=
github.com/gen2brain/jpegxl v0.4.5 h1:TWpVEn5xkIfsswzkjHBArd0Cc9AE0tbjBSoa0jDsrbo=
github.com/gen2brain/jpegxl v0.4.5/go.mod h1:4kWYJ18xCEuO2vzocYdGpeqNJ990/Gjy3uLMg5TBN6I=
github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg=
github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA=
github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/inflect v0.21.2 h1:0gClGlGcxifcJR56zwvhaOulnNgnhc4qTAkob5ObnSM=
github.com/go-openapi/inflect v0.21.2/go.mod h1:INezMuUu7SJQc2AyR3WO0DqqYUJSj8Kb4hBd7WtjlAw=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
@@ -207,8 +52,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
@@ -216,98 +61,40 @@ github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3a
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ=
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo=
github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI=
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=
github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
github.com/hay-kot/httpkit v0.0.11 h1:ZdB2uqsFBSDpfUoClGK5c5orjBjQkEVSXh7fZX5FKEk=
github.com/hay-kot/httpkit v0.0.11/go.mod h1:0kZdk5/swzdfqfg2c6pBWimcgeJ9PTyO97EbHnYl2Sw=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -317,281 +104,134 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g=
github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/nats-io/jwt/v2 v2.5.0 h1:WQQ40AAlqqfx+f6ku+i0pOVm+ASirD4fUh+oQsiE9Ak=
github.com/nats-io/jwt/v2 v2.5.0/go.mod h1:24BeQtRwxRV8ruvC4CojXlx/WQ/VjuwlYiH+vu/+ibI=
github.com/nats-io/nats-server/v2 v2.9.23 h1:6Wj6H6QpP9FMlpCyWUaNu2yeZ/qGj+mdRkZ1wbikExU=
github.com/nats-io/nats-server/v2 v2.9.23/go.mod h1:wEjrEy9vnqIGE4Pqz4/c75v9Pmaq7My2IgFmnykc4C0=
github.com/nats-io/nats.go v1.44.0 h1:ECKVrDLdh/kDPV1g0gAQ+2+m2KprqZK5O/eJAyAnH2M=
github.com/nats-io/nats.go v1.44.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olahol/melody v1.3.0 h1:n7UlKiQnxVrgxKoM0d7usZiN+Z0y2lVENtYLgKtXS6s=
github.com/olahol/melody v1.3.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
github.com/olahol/melody v1.2.1 h1:xdwRkzHxf+B0w4TKbGpUSSkV516ZucQZJIWLztOWICQ=
github.com/olahol/melody v1.2.1/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM=
github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
github.com/yeqown/go-qrcode/writer/standard v1.2.5 h1:m+5BUIcbsaG2md76FIqI/oZULrAju8tsk47eOohovQ0=
github.com/yeqown/go-qrcode/writer/standard v1.2.5/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70=
github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
go.balki.me/anyhttp v0.5.2 h1:et4tCDXLeXpWfMNvRKG7ojfrnlr3du7cEaG966MLSpA=
go.balki.me/anyhttp v0.5.2/go.mod h1:JhfekOIjgVODoVqUCficjpIgmB3wwlB7jhN0eN2EZ/s=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA=
go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0 h1:6VjV6Et+1Hd2iLZEPtdV7vie80Yyqf7oikJLjQ/myi0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.37.0/go.mod h1:u8hcp8ji5gaM/RfcOo8z9NMnf1pVLfVY7lBY2VOGuUU=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
gocloud.dev v0.43.0 h1:aW3eq4RMyehbJ54PMsh4hsp7iX8cO/98ZRzJJOzN/5M=
gocloud.dev v0.43.0/go.mod h1:eD8rkg7LhKUHrzkEdLTZ+Ty/vgPHPCd+yMQdfelQVu4=
gocloud.dev/pubsub/kafkapubsub v0.43.0 h1:Kgwi0na69W3RgxEffEkdrMhox6A3Q0gajoJtjHGVr/s=
gocloud.dev/pubsub/kafkapubsub v0.43.0/go.mod h1:uKI0CXuj7HJ/YnnOLQ3VkDnuUnkz+q/d+tRzmfhmOOU=
gocloud.dev/pubsub/natspubsub v0.43.0 h1:k35tFoaorvD9Fa26zVEEzyXiMOEyXNHc0pBOmRYvQI0=
gocloud.dev/pubsub/natspubsub v0.43.0/go.mod h1:xJn8TO8pGYieDn6AsRFsYfhQW8cnC+xGmG9APGNxkpQ=
gocloud.dev/pubsub/rabbitpubsub v0.43.0 h1:6nNZFSlJ1dk2GujL8PFltfLz3vC6IbrpjGS4FTduo1s=
gocloud.dev/pubsub/rabbitpubsub v0.43.0/go.mod h1:sEaueAGat+OASRoB3QDkghCtibKttgg7X6zsPTm1pl0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc=
google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM=
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs=
google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s=
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 h1:iOye66xuaAK0WnkPuhQPUFy8eJcmwUXqGGP3om6IxX8=
google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA=
modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -7,7 +7,6 @@ import (
_ "embed"
"encoding/json"
"io"
"log"
"slices"
"strings"
"sync"
@@ -16,24 +15,6 @@ import (
//go:embed currencies.json
var defaults []byte
const (
MinDecimals = 0
MaxDecimals = 18
)
// clampDecimals ensures the decimals value is within a safe range [0, 18]
func clampDecimals(decimals int, code string) int {
original := decimals
if decimals < MinDecimals {
decimals = MinDecimals
log.Printf("WARNING: Currency %s had negative decimals (%d), normalized to %d", code, original, decimals)
} else if decimals > MaxDecimals {
decimals = MaxDecimals
log.Printf("WARNING: Currency %s had excessive decimals (%d), normalized to %d", code, original, decimals)
}
return decimals
}
type CollectorFunc func() ([]Currency, error)
func CollectJSON(reader io.Reader) CollectorFunc {
@@ -44,11 +25,6 @@ func CollectJSON(reader io.Reader) CollectorFunc {
return nil, err
}
// Clamp decimals during collection to ensure early normalization
for i := range currencies {
currencies[i].Decimals = clampDecimals(currencies[i].Decimals, currencies[i].Code)
}
return currencies, nil
}
}
@@ -72,11 +48,10 @@ func CollectionCurrencies(collectors ...CollectorFunc) ([]Currency, error) {
}
type Currency struct {
Name string `json:"name"`
Code string `json:"code"`
Local string `json:"local"`
Symbol string `json:"symbol"`
Decimals int `json:"decimals"`
Name string `json:"name"`
Code string `json:"code"`
Local string `json:"local"`
Symbol string `json:"symbol"`
}
type CurrencyRegistry struct {
@@ -87,10 +62,7 @@ type CurrencyRegistry struct {
func NewCurrencyService(currencies []Currency) *CurrencyRegistry {
registry := make(map[string]Currency, len(currencies))
for i := range currencies {
// Clamp decimals to safe range before adding to registry
currency := currencies[i]
currency.Decimals = clampDecimals(currency.Decimals, currency.Code)
registry[currency.Code] = currency
registry[currencies[i].Code] = currencies[i]
}
return &CurrencyRegistry{

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@ package services
import (
"context"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
"log"
"os"
"testing"
@@ -62,19 +61,7 @@ func MainNoExit(m *testing.M) int {
}
tClient = client
tRepos = repo.New(tClient, tbus, config.Storage{
PrefixPath: "/",
ConnString: "file://" + os.TempDir(),
}, "mem://{{ .Topic }}", config.Thumbnail{
Enabled: false,
Width: 0,
Height: 0,
})
err = os.MkdirAll(os.TempDir()+"/homebox", 0o755)
if err != nil {
return 0
}
tRepos = repo.New(tClient, tbus, os.TempDir()+"/homebox")
defaults, _ := currencies.CollectionCurrencies(
currencies.CollectDefaults(),

View File

@@ -38,10 +38,6 @@ func (svc *ItemService) Create(ctx Context, item repo.ItemCreate) (repo.ItemOut,
return svc.repo.Items.Create(ctx, ctx.GID, item)
}
func (svc *ItemService) Duplicate(ctx Context, gid, id uuid.UUID, options repo.DuplicateOptions) (repo.ItemOut, error) {
return svc.repo.Items.Duplicate(ctx, gid, id, options)
}
func (svc *ItemService) EnsureAssetID(ctx context.Context, gid uuid.UUID) (int, error) {
items, err := svc.repo.Items.GetAllZeroAssetID(ctx, gid)
if err != nil {

View File

@@ -10,8 +10,8 @@ import (
"io"
)
func (svc *ItemService) AttachmentPath(ctx context.Context, gid uuid.UUID, attachmentID uuid.UUID) (*ent.Attachment, error) {
attachment, err := svc.repo.Attachments.Get(ctx, gid, attachmentID)
func (svc *ItemService) AttachmentPath(ctx context.Context, attachmentID uuid.UUID) (*ent.Attachment, error) {
attachment, err := svc.repo.Attachments.Get(ctx, attachmentID)
if err != nil {
return nil, err
}
@@ -19,16 +19,16 @@ func (svc *ItemService) AttachmentPath(ctx context.Context, gid uuid.UUID, attac
return attachment, nil
}
func (svc *ItemService) AttachmentUpdate(ctx Context, gid uuid.UUID, itemID uuid.UUID, data *repo.ItemAttachmentUpdate) (repo.ItemOut, error) {
func (svc *ItemService) AttachmentUpdate(ctx Context, itemID uuid.UUID, data *repo.ItemAttachmentUpdate) (repo.ItemOut, error) {
// Update Attachment
attachment, err := svc.repo.Attachments.Update(ctx, gid, data.ID, data)
attachment, err := svc.repo.Attachments.Update(ctx, data.ID, data)
if err != nil {
return repo.ItemOut{}, err
}
// Update Document
attDoc := attachment
_, err = svc.repo.Attachments.Rename(ctx, gid, attDoc.ID, data.Title)
_, err = svc.repo.Attachments.Rename(ctx, attDoc.ID, data.Title)
if err != nil {
return repo.ItemOut{}, err
}
@@ -50,15 +50,14 @@ func (svc *ItemService) AttachmentAdd(ctx Context, itemID uuid.UUID, filename st
_, err = svc.repo.Attachments.Create(ctx, itemID, repo.ItemCreateAttachment{Title: filename, Content: file}, attachmentType, primary)
if err != nil {
log.Err(err).Msg("failed to create attachment")
return repo.ItemOut{}, err
}
return svc.repo.Items.GetOneByGroup(ctx, ctx.GID, itemID)
}
func (svc *ItemService) AttachmentDelete(ctx context.Context, gid uuid.UUID, id uuid.UUID, attachmentID uuid.UUID) error {
func (svc *ItemService) AttachmentDelete(ctx context.Context, gid, itemID, attachmentID uuid.UUID) error {
// Delete the attachment
err := svc.repo.Attachments.Delete(ctx, gid, id, attachmentID)
err := svc.repo.Attachments.Delete(ctx, attachmentID)
if err != nil {
return err
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
)
func TestItemService_AddAttachment(t *testing.T) {
@@ -53,61 +52,11 @@ func TestItemService_AddAttachment(t *testing.T) {
// Check that the file exists
storedPath := afterAttachment.Attachments[0].Path
// path should now be relative: {group}/{documents}
assert.Equal(t, path.Join(tGroup.ID.String(), "documents"), path.Dir(storedPath))
// {root}/{group}/{item}/{attachment}
assert.Equal(t, path.Join(temp, "homebox", tGroup.ID.String(), "documents"), path.Dir(storedPath))
// Check that the file contents are correct
bts, err := os.ReadFile(path.Join(os.TempDir(), storedPath))
bts, err := os.ReadFile(storedPath)
require.NoError(t, err)
assert.Equal(t, contents, string(bts))
}
func TestItemService_AddAttachment_InvalidStorage(t *testing.T) {
// Create a service with an invalid storage path to simulate the issue
svc := &ItemService{
repo: tRepos,
filepath: "/nonexistent/path/that/should/not/exist",
}
// Create a temporary repo with invalid storage config
invalidRepos := repo.New(tClient, tbus, config.Storage{
PrefixPath: "/",
ConnString: "file:///nonexistent/directory/that/does/not/exist",
}, "mem://{{ .Topic }}", config.Thumbnail{
Enabled: false,
Width: 0,
Height: 0,
})
svc.repo = invalidRepos
loc, err := invalidRepos.Locations.Create(context.Background(), tGroup.ID, repo.LocationCreate{
Description: "test",
Name: "test-invalid",
})
require.NoError(t, err)
assert.NotNil(t, loc)
itmC := repo.ItemCreate{
Name: fk.Str(10),
Description: fk.Str(10),
LocationID: loc.ID,
}
itm, err := invalidRepos.Items.Create(context.Background(), tGroup.ID, itmC)
require.NoError(t, err)
assert.NotNil(t, itm)
t.Cleanup(func() {
err := invalidRepos.Items.Delete(context.Background(), itm.ID)
require.NoError(t, err)
})
contents := fk.Str(1000)
reader := strings.NewReader(contents)
// Attempt to add attachment with invalid storage - should return an error
_, err = svc.AttachmentAdd(tCtx, itm.ID, "testfile.txt", "attachment", false, reader)
// This should return an error now (after the fix)
assert.Error(t, err, "AttachmentAdd should return an error when storage is invalid")
}

View File

@@ -190,23 +190,10 @@ func (svc *UserService) Login(ctx context.Context, username, password string, ex
return UserAuthTokenDetail{}, ErrorInvalidLogin
}
check, rehash := hasher.CheckPasswordHash(password, usr.PasswordHash)
if !check {
if !hasher.CheckPasswordHash(password, usr.PasswordHash) {
return UserAuthTokenDetail{}, ErrorInvalidLogin
}
if rehash {
hash, err := hasher.HashPassword(password)
if err != nil {
log.Err(err).Msg("Failed to hash password")
return UserAuthTokenDetail{}, err
}
err = svc.repos.Users.ChangePassword(ctx, usr.ID, hash)
if err != nil {
return UserAuthTokenDetail{}, err
}
}
return svc.createSessionToken(ctx, usr.ID, extendedSession)
}
@@ -240,8 +227,7 @@ func (svc *UserService) ChangePassword(ctx Context, current string, new string)
return false
}
match, _ := hasher.CheckPasswordHash(current, usr.PasswordHash)
if !match {
if !hasher.CheckPasswordHash(current, usr.PasswordHash) {
log.Err(errors.New("current password is incorrect")).Msg("Failed to change password")
return false
}

View File

@@ -31,25 +31,20 @@ type Attachment struct {
Title string `json:"title,omitempty"`
// Path holds the value of the "path" field.
Path string `json:"path,omitempty"`
// MimeType holds the value of the "mime_type" field.
MimeType string `json:"mime_type,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the AttachmentQuery when eager-loading is set.
Edges AttachmentEdges `json:"edges"`
attachment_thumbnail *uuid.UUID
item_attachments *uuid.UUID
selectValues sql.SelectValues
Edges AttachmentEdges `json:"edges"`
item_attachments *uuid.UUID
selectValues sql.SelectValues
}
// AttachmentEdges holds the relations/edges for other nodes in the graph.
type AttachmentEdges struct {
// Item holds the value of the item edge.
Item *Item `json:"item,omitempty"`
// Thumbnail holds the value of the thumbnail edge.
Thumbnail *Attachment `json:"thumbnail,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
loadedTypes [2]bool
loadedTypes [1]bool
}
// ItemOrErr returns the Item value or an error if the edge
@@ -63,17 +58,6 @@ func (e AttachmentEdges) ItemOrErr() (*Item, error) {
return nil, &NotLoadedError{edge: "item"}
}
// ThumbnailOrErr returns the Thumbnail value or an error if the edge
// was not loaded in eager-loading, or loaded but was not found.
func (e AttachmentEdges) ThumbnailOrErr() (*Attachment, error) {
if e.Thumbnail != nil {
return e.Thumbnail, nil
} else if e.loadedTypes[1] {
return nil, &NotFoundError{label: attachment.Label}
}
return nil, &NotLoadedError{edge: "thumbnail"}
}
// scanValues returns the types for scanning values from sql.Rows.
func (*Attachment) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
@@ -81,15 +65,13 @@ func (*Attachment) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case attachment.FieldPrimary:
values[i] = new(sql.NullBool)
case attachment.FieldType, attachment.FieldTitle, attachment.FieldPath, attachment.FieldMimeType:
case attachment.FieldType, attachment.FieldTitle, attachment.FieldPath:
values[i] = new(sql.NullString)
case attachment.FieldCreatedAt, attachment.FieldUpdatedAt:
values[i] = new(sql.NullTime)
case attachment.FieldID:
values[i] = new(uuid.UUID)
case attachment.ForeignKeys[0]: // attachment_thumbnail
values[i] = &sql.NullScanner{S: new(uuid.UUID)}
case attachment.ForeignKeys[1]: // item_attachments
case attachment.ForeignKeys[0]: // item_attachments
values[i] = &sql.NullScanner{S: new(uuid.UUID)}
default:
values[i] = new(sql.UnknownType)
@@ -148,20 +130,7 @@ func (a *Attachment) assignValues(columns []string, values []any) error {
} else if value.Valid {
a.Path = value.String
}
case attachment.FieldMimeType:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field mime_type", values[i])
} else if value.Valid {
a.MimeType = value.String
}
case attachment.ForeignKeys[0]:
if value, ok := values[i].(*sql.NullScanner); !ok {
return fmt.Errorf("unexpected type %T for field attachment_thumbnail", values[i])
} else if value.Valid {
a.attachment_thumbnail = new(uuid.UUID)
*a.attachment_thumbnail = *value.S.(*uuid.UUID)
}
case attachment.ForeignKeys[1]:
if value, ok := values[i].(*sql.NullScanner); !ok {
return fmt.Errorf("unexpected type %T for field item_attachments", values[i])
} else if value.Valid {
@@ -186,11 +155,6 @@ func (a *Attachment) QueryItem() *ItemQuery {
return NewAttachmentClient(a.config).QueryItem(a)
}
// QueryThumbnail queries the "thumbnail" edge of the Attachment entity.
func (a *Attachment) QueryThumbnail() *AttachmentQuery {
return NewAttachmentClient(a.config).QueryThumbnail(a)
}
// Update returns a builder for updating this Attachment.
// Note that you need to call Attachment.Unwrap() before calling this method if this Attachment
// was returned from a transaction, and the transaction was committed or rolled back.
@@ -231,9 +195,6 @@ func (a *Attachment) String() string {
builder.WriteString(", ")
builder.WriteString("path=")
builder.WriteString(a.Path)
builder.WriteString(", ")
builder.WriteString("mime_type=")
builder.WriteString(a.MimeType)
builder.WriteByte(')')
return builder.String()
}

View File

@@ -28,12 +28,8 @@ const (
FieldTitle = "title"
// FieldPath holds the string denoting the path field in the database.
FieldPath = "path"
// FieldMimeType holds the string denoting the mime_type field in the database.
FieldMimeType = "mime_type"
// EdgeItem holds the string denoting the item edge name in mutations.
EdgeItem = "item"
// EdgeThumbnail holds the string denoting the thumbnail edge name in mutations.
EdgeThumbnail = "thumbnail"
// Table holds the table name of the attachment in the database.
Table = "attachments"
// ItemTable is the table that holds the item relation/edge.
@@ -43,10 +39,6 @@ const (
ItemInverseTable = "items"
// ItemColumn is the table column denoting the item relation/edge.
ItemColumn = "item_attachments"
// ThumbnailTable is the table that holds the thumbnail relation/edge.
ThumbnailTable = "attachments"
// ThumbnailColumn is the table column denoting the thumbnail relation/edge.
ThumbnailColumn = "attachment_thumbnail"
)
// Columns holds all SQL columns for attachment fields.
@@ -58,13 +50,11 @@ var Columns = []string{
FieldPrimary,
FieldTitle,
FieldPath,
FieldMimeType,
}
// ForeignKeys holds the SQL foreign-keys that are owned by the "attachments"
// table and are not defined as standalone fields in the schema.
var ForeignKeys = []string{
"attachment_thumbnail",
"item_attachments",
}
@@ -96,8 +86,6 @@ var (
DefaultTitle string
// DefaultPath holds the default value on creation for the "path" field.
DefaultPath string
// DefaultMimeType holds the default value on creation for the "mime_type" field.
DefaultMimeType string
// DefaultID holds the default value on creation for the "id" field.
DefaultID func() uuid.UUID
)
@@ -115,7 +103,6 @@ const (
TypeWarranty Type = "warranty"
TypeAttachment Type = "attachment"
TypeReceipt Type = "receipt"
TypeThumbnail Type = "thumbnail"
)
func (_type Type) String() string {
@@ -125,7 +112,7 @@ func (_type Type) String() string {
// TypeValidator is a validator for the "type" field enum values. It is called by the builders before save.
func TypeValidator(_type Type) error {
switch _type {
case TypePhoto, TypeManual, TypeWarranty, TypeAttachment, TypeReceipt, TypeThumbnail:
case TypePhoto, TypeManual, TypeWarranty, TypeAttachment, TypeReceipt:
return nil
default:
return fmt.Errorf("attachment: invalid enum value for type field: %q", _type)
@@ -170,24 +157,12 @@ func ByPath(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPath, opts...).ToFunc()
}
// ByMimeType orders the results by the mime_type field.
func ByMimeType(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMimeType, opts...).ToFunc()
}
// ByItemField orders the results by item field.
func ByItemField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newItemStep(), sql.OrderByField(field, opts...))
}
}
// ByThumbnailField orders the results by thumbnail field.
func ByThumbnailField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newThumbnailStep(), sql.OrderByField(field, opts...))
}
}
func newItemStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
@@ -195,10 +170,3 @@ func newItemStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.M2O, true, ItemTable, ItemColumn),
)
}
func newThumbnailStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, ThumbnailTable, ThumbnailColumn),
)
}

View File

@@ -81,11 +81,6 @@ func Path(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldEQ(FieldPath, v))
}
// MimeType applies equality check predicate on the "mime_type" field. It's identical to MimeTypeEQ.
func MimeType(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldEQ(FieldMimeType, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.Attachment {
return predicate.Attachment(sql.FieldEQ(FieldCreatedAt, v))
@@ -326,71 +321,6 @@ func PathContainsFold(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldContainsFold(FieldPath, v))
}
// MimeTypeEQ applies the EQ predicate on the "mime_type" field.
func MimeTypeEQ(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldEQ(FieldMimeType, v))
}
// MimeTypeNEQ applies the NEQ predicate on the "mime_type" field.
func MimeTypeNEQ(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldNEQ(FieldMimeType, v))
}
// MimeTypeIn applies the In predicate on the "mime_type" field.
func MimeTypeIn(vs ...string) predicate.Attachment {
return predicate.Attachment(sql.FieldIn(FieldMimeType, vs...))
}
// MimeTypeNotIn applies the NotIn predicate on the "mime_type" field.
func MimeTypeNotIn(vs ...string) predicate.Attachment {
return predicate.Attachment(sql.FieldNotIn(FieldMimeType, vs...))
}
// MimeTypeGT applies the GT predicate on the "mime_type" field.
func MimeTypeGT(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldGT(FieldMimeType, v))
}
// MimeTypeGTE applies the GTE predicate on the "mime_type" field.
func MimeTypeGTE(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldGTE(FieldMimeType, v))
}
// MimeTypeLT applies the LT predicate on the "mime_type" field.
func MimeTypeLT(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldLT(FieldMimeType, v))
}
// MimeTypeLTE applies the LTE predicate on the "mime_type" field.
func MimeTypeLTE(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldLTE(FieldMimeType, v))
}
// MimeTypeContains applies the Contains predicate on the "mime_type" field.
func MimeTypeContains(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldContains(FieldMimeType, v))
}
// MimeTypeHasPrefix applies the HasPrefix predicate on the "mime_type" field.
func MimeTypeHasPrefix(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldHasPrefix(FieldMimeType, v))
}
// MimeTypeHasSuffix applies the HasSuffix predicate on the "mime_type" field.
func MimeTypeHasSuffix(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldHasSuffix(FieldMimeType, v))
}
// MimeTypeEqualFold applies the EqualFold predicate on the "mime_type" field.
func MimeTypeEqualFold(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldEqualFold(FieldMimeType, v))
}
// MimeTypeContainsFold applies the ContainsFold predicate on the "mime_type" field.
func MimeTypeContainsFold(v string) predicate.Attachment {
return predicate.Attachment(sql.FieldContainsFold(FieldMimeType, v))
}
// HasItem applies the HasEdge predicate on the "item" edge.
func HasItem() predicate.Attachment {
return predicate.Attachment(func(s *sql.Selector) {
@@ -414,29 +344,6 @@ func HasItemWith(preds ...predicate.Item) predicate.Attachment {
})
}
// HasThumbnail applies the HasEdge predicate on the "thumbnail" edge.
func HasThumbnail() predicate.Attachment {
return predicate.Attachment(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, ThumbnailTable, ThumbnailColumn),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasThumbnailWith applies the HasEdge predicate on the "thumbnail" edge with a given conditions (other predicates).
func HasThumbnailWith(preds ...predicate.Attachment) predicate.Attachment {
return predicate.Attachment(func(s *sql.Selector) {
step := newThumbnailStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// And groups predicates with the AND operator between them.
func And(predicates ...predicate.Attachment) predicate.Attachment {
return predicate.Attachment(sql.AndPredicates(predicates...))

View File

@@ -106,20 +106,6 @@ func (ac *AttachmentCreate) SetNillablePath(s *string) *AttachmentCreate {
return ac
}
// SetMimeType sets the "mime_type" field.
func (ac *AttachmentCreate) SetMimeType(s string) *AttachmentCreate {
ac.mutation.SetMimeType(s)
return ac
}
// SetNillableMimeType sets the "mime_type" field if the given value is not nil.
func (ac *AttachmentCreate) SetNillableMimeType(s *string) *AttachmentCreate {
if s != nil {
ac.SetMimeType(*s)
}
return ac
}
// SetID sets the "id" field.
func (ac *AttachmentCreate) SetID(u uuid.UUID) *AttachmentCreate {
ac.mutation.SetID(u)
@@ -140,38 +126,11 @@ func (ac *AttachmentCreate) SetItemID(id uuid.UUID) *AttachmentCreate {
return ac
}
// SetNillableItemID sets the "item" edge to the Item entity by ID if the given value is not nil.
func (ac *AttachmentCreate) SetNillableItemID(id *uuid.UUID) *AttachmentCreate {
if id != nil {
ac = ac.SetItemID(*id)
}
return ac
}
// SetItem sets the "item" edge to the Item entity.
func (ac *AttachmentCreate) SetItem(i *Item) *AttachmentCreate {
return ac.SetItemID(i.ID)
}
// SetThumbnailID sets the "thumbnail" edge to the Attachment entity by ID.
func (ac *AttachmentCreate) SetThumbnailID(id uuid.UUID) *AttachmentCreate {
ac.mutation.SetThumbnailID(id)
return ac
}
// SetNillableThumbnailID sets the "thumbnail" edge to the Attachment entity by ID if the given value is not nil.
func (ac *AttachmentCreate) SetNillableThumbnailID(id *uuid.UUID) *AttachmentCreate {
if id != nil {
ac = ac.SetThumbnailID(*id)
}
return ac
}
// SetThumbnail sets the "thumbnail" edge to the Attachment entity.
func (ac *AttachmentCreate) SetThumbnail(a *Attachment) *AttachmentCreate {
return ac.SetThumbnailID(a.ID)
}
// Mutation returns the AttachmentMutation object of the builder.
func (ac *AttachmentCreate) Mutation() *AttachmentMutation {
return ac.mutation
@@ -231,10 +190,6 @@ func (ac *AttachmentCreate) defaults() {
v := attachment.DefaultPath
ac.mutation.SetPath(v)
}
if _, ok := ac.mutation.MimeType(); !ok {
v := attachment.DefaultMimeType
ac.mutation.SetMimeType(v)
}
if _, ok := ac.mutation.ID(); !ok {
v := attachment.DefaultID()
ac.mutation.SetID(v)
@@ -266,8 +221,8 @@ func (ac *AttachmentCreate) check() error {
if _, ok := ac.mutation.Path(); !ok {
return &ValidationError{Name: "path", err: errors.New(`ent: missing required field "Attachment.path"`)}
}
if _, ok := ac.mutation.MimeType(); !ok {
return &ValidationError{Name: "mime_type", err: errors.New(`ent: missing required field "Attachment.mime_type"`)}
if len(ac.mutation.ItemIDs()) == 0 {
return &ValidationError{Name: "item", err: errors.New(`ent: missing required edge "Attachment.item"`)}
}
return nil
}
@@ -328,10 +283,6 @@ func (ac *AttachmentCreate) createSpec() (*Attachment, *sqlgraph.CreateSpec) {
_spec.SetField(attachment.FieldPath, field.TypeString, value)
_node.Path = value
}
if value, ok := ac.mutation.MimeType(); ok {
_spec.SetField(attachment.FieldMimeType, field.TypeString, value)
_node.MimeType = value
}
if nodes := ac.mutation.ItemIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -349,23 +300,6 @@ func (ac *AttachmentCreate) createSpec() (*Attachment, *sqlgraph.CreateSpec) {
_node.item_attachments = &nodes[0]
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := ac.mutation.ThumbnailIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Inverse: false,
Table: attachment.ThumbnailTable,
Columns: []string{attachment.ThumbnailColumn},
Bidi: true,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(attachment.FieldID, field.TypeUUID),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_node.attachment_thumbnail = &nodes[0]
_spec.Edges = append(_spec.Edges, edge)
}
return _node, _spec
}

View File

@@ -20,13 +20,12 @@ import (
// AttachmentQuery is the builder for querying Attachment entities.
type AttachmentQuery struct {
config
ctx *QueryContext
order []attachment.OrderOption
inters []Interceptor
predicates []predicate.Attachment
withItem *ItemQuery
withThumbnail *AttachmentQuery
withFKs bool
ctx *QueryContext
order []attachment.OrderOption
inters []Interceptor
predicates []predicate.Attachment
withItem *ItemQuery
withFKs bool
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@@ -85,28 +84,6 @@ func (aq *AttachmentQuery) QueryItem() *ItemQuery {
return query
}
// QueryThumbnail chains the current query on the "thumbnail" edge.
func (aq *AttachmentQuery) QueryThumbnail() *AttachmentQuery {
query := (&AttachmentClient{config: aq.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := aq.prepareQuery(ctx); err != nil {
return nil, err
}
selector := aq.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(attachment.Table, attachment.FieldID, selector),
sqlgraph.To(attachment.Table, attachment.FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, attachment.ThumbnailTable, attachment.ThumbnailColumn),
)
fromU = sqlgraph.SetNeighbors(aq.driver.Dialect(), step)
return fromU, nil
}
return query
}
// First returns the first Attachment entity from the query.
// Returns a *NotFoundError when no Attachment was found.
func (aq *AttachmentQuery) First(ctx context.Context) (*Attachment, error) {
@@ -294,13 +271,12 @@ func (aq *AttachmentQuery) Clone() *AttachmentQuery {
return nil
}
return &AttachmentQuery{
config: aq.config,
ctx: aq.ctx.Clone(),
order: append([]attachment.OrderOption{}, aq.order...),
inters: append([]Interceptor{}, aq.inters...),
predicates: append([]predicate.Attachment{}, aq.predicates...),
withItem: aq.withItem.Clone(),
withThumbnail: aq.withThumbnail.Clone(),
config: aq.config,
ctx: aq.ctx.Clone(),
order: append([]attachment.OrderOption{}, aq.order...),
inters: append([]Interceptor{}, aq.inters...),
predicates: append([]predicate.Attachment{}, aq.predicates...),
withItem: aq.withItem.Clone(),
// clone intermediate query.
sql: aq.sql.Clone(),
path: aq.path,
@@ -318,17 +294,6 @@ func (aq *AttachmentQuery) WithItem(opts ...func(*ItemQuery)) *AttachmentQuery {
return aq
}
// WithThumbnail tells the query-builder to eager-load the nodes that are connected to
// the "thumbnail" edge. The optional arguments are used to configure the query builder of the edge.
func (aq *AttachmentQuery) WithThumbnail(opts ...func(*AttachmentQuery)) *AttachmentQuery {
query := (&AttachmentClient{config: aq.config}).Query()
for _, opt := range opts {
opt(query)
}
aq.withThumbnail = query
return aq
}
// GroupBy is used to group vertices by one or more fields/columns.
// It is often used with aggregate functions, like: count, max, mean, min, sum.
//
@@ -408,12 +373,11 @@ func (aq *AttachmentQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*A
nodes = []*Attachment{}
withFKs = aq.withFKs
_spec = aq.querySpec()
loadedTypes = [2]bool{
loadedTypes = [1]bool{
aq.withItem != nil,
aq.withThumbnail != nil,
}
)
if aq.withItem != nil || aq.withThumbnail != nil {
if aq.withItem != nil {
withFKs = true
}
if withFKs {
@@ -443,12 +407,6 @@ func (aq *AttachmentQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*A
return nil, err
}
}
if query := aq.withThumbnail; query != nil {
if err := aq.loadThumbnail(ctx, query, nodes, nil,
func(n *Attachment, e *Attachment) { n.Edges.Thumbnail = e }); err != nil {
return nil, err
}
}
return nodes, nil
}
@@ -484,38 +442,6 @@ func (aq *AttachmentQuery) loadItem(ctx context.Context, query *ItemQuery, nodes
}
return nil
}
func (aq *AttachmentQuery) loadThumbnail(ctx context.Context, query *AttachmentQuery, nodes []*Attachment, init func(*Attachment), assign func(*Attachment, *Attachment)) error {
ids := make([]uuid.UUID, 0, len(nodes))
nodeids := make(map[uuid.UUID][]*Attachment)
for i := range nodes {
if nodes[i].attachment_thumbnail == nil {
continue
}
fk := *nodes[i].attachment_thumbnail
if _, ok := nodeids[fk]; !ok {
ids = append(ids, fk)
}
nodeids[fk] = append(nodeids[fk], nodes[i])
}
if len(ids) == 0 {
return nil
}
query.Where(attachment.IDIn(ids...))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
nodes, ok := nodeids[n.ID]
if !ok {
return fmt.Errorf(`unexpected foreign-key "attachment_thumbnail" returned %v`, n.ID)
}
for i := range nodes {
assign(nodes[i], n)
}
}
return nil
}
func (aq *AttachmentQuery) sqlCount(ctx context.Context) (int, error) {
_spec := aq.querySpec()

View File

@@ -92,58 +92,17 @@ func (au *AttachmentUpdate) SetNillablePath(s *string) *AttachmentUpdate {
return au
}
// SetMimeType sets the "mime_type" field.
func (au *AttachmentUpdate) SetMimeType(s string) *AttachmentUpdate {
au.mutation.SetMimeType(s)
return au
}
// SetNillableMimeType sets the "mime_type" field if the given value is not nil.
func (au *AttachmentUpdate) SetNillableMimeType(s *string) *AttachmentUpdate {
if s != nil {
au.SetMimeType(*s)
}
return au
}
// SetItemID sets the "item" edge to the Item entity by ID.
func (au *AttachmentUpdate) SetItemID(id uuid.UUID) *AttachmentUpdate {
au.mutation.SetItemID(id)
return au
}
// SetNillableItemID sets the "item" edge to the Item entity by ID if the given value is not nil.
func (au *AttachmentUpdate) SetNillableItemID(id *uuid.UUID) *AttachmentUpdate {
if id != nil {
au = au.SetItemID(*id)
}
return au
}
// SetItem sets the "item" edge to the Item entity.
func (au *AttachmentUpdate) SetItem(i *Item) *AttachmentUpdate {
return au.SetItemID(i.ID)
}
// SetThumbnailID sets the "thumbnail" edge to the Attachment entity by ID.
func (au *AttachmentUpdate) SetThumbnailID(id uuid.UUID) *AttachmentUpdate {
au.mutation.SetThumbnailID(id)
return au
}
// SetNillableThumbnailID sets the "thumbnail" edge to the Attachment entity by ID if the given value is not nil.
func (au *AttachmentUpdate) SetNillableThumbnailID(id *uuid.UUID) *AttachmentUpdate {
if id != nil {
au = au.SetThumbnailID(*id)
}
return au
}
// SetThumbnail sets the "thumbnail" edge to the Attachment entity.
func (au *AttachmentUpdate) SetThumbnail(a *Attachment) *AttachmentUpdate {
return au.SetThumbnailID(a.ID)
}
// Mutation returns the AttachmentMutation object of the builder.
func (au *AttachmentUpdate) Mutation() *AttachmentMutation {
return au.mutation
@@ -155,12 +114,6 @@ func (au *AttachmentUpdate) ClearItem() *AttachmentUpdate {
return au
}
// ClearThumbnail clears the "thumbnail" edge to the Attachment entity.
func (au *AttachmentUpdate) ClearThumbnail() *AttachmentUpdate {
au.mutation.ClearThumbnail()
return au
}
// Save executes the query and returns the number of nodes affected by the update operation.
func (au *AttachmentUpdate) Save(ctx context.Context) (int, error) {
au.defaults()
@@ -204,6 +157,9 @@ func (au *AttachmentUpdate) check() error {
return &ValidationError{Name: "type", err: fmt.Errorf(`ent: validator failed for field "Attachment.type": %w`, err)}
}
}
if au.mutation.ItemCleared() && len(au.mutation.ItemIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "Attachment.item"`)
}
return nil
}
@@ -234,9 +190,6 @@ func (au *AttachmentUpdate) sqlSave(ctx context.Context) (n int, err error) {
if value, ok := au.mutation.Path(); ok {
_spec.SetField(attachment.FieldPath, field.TypeString, value)
}
if value, ok := au.mutation.MimeType(); ok {
_spec.SetField(attachment.FieldMimeType, field.TypeString, value)
}
if au.mutation.ItemCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -266,35 +219,6 @@ func (au *AttachmentUpdate) sqlSave(ctx context.Context) (n int, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if au.mutation.ThumbnailCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Inverse: false,
Table: attachment.ThumbnailTable,
Columns: []string{attachment.ThumbnailColumn},
Bidi: true,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(attachment.FieldID, field.TypeUUID),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := au.mutation.ThumbnailIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Inverse: false,
Table: attachment.ThumbnailTable,
Columns: []string{attachment.ThumbnailColumn},
Bidi: true,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(attachment.FieldID, field.TypeUUID),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if n, err = sqlgraph.UpdateNodes(ctx, au.driver, _spec); err != nil {
if _, ok := err.(*sqlgraph.NotFoundError); ok {
err = &NotFoundError{attachment.Label}
@@ -377,58 +301,17 @@ func (auo *AttachmentUpdateOne) SetNillablePath(s *string) *AttachmentUpdateOne
return auo
}
// SetMimeType sets the "mime_type" field.
func (auo *AttachmentUpdateOne) SetMimeType(s string) *AttachmentUpdateOne {
auo.mutation.SetMimeType(s)
return auo
}
// SetNillableMimeType sets the "mime_type" field if the given value is not nil.
func (auo *AttachmentUpdateOne) SetNillableMimeType(s *string) *AttachmentUpdateOne {
if s != nil {
auo.SetMimeType(*s)
}
return auo
}
// SetItemID sets the "item" edge to the Item entity by ID.
func (auo *AttachmentUpdateOne) SetItemID(id uuid.UUID) *AttachmentUpdateOne {
auo.mutation.SetItemID(id)
return auo
}
// SetNillableItemID sets the "item" edge to the Item entity by ID if the given value is not nil.
func (auo *AttachmentUpdateOne) SetNillableItemID(id *uuid.UUID) *AttachmentUpdateOne {
if id != nil {
auo = auo.SetItemID(*id)
}
return auo
}
// SetItem sets the "item" edge to the Item entity.
func (auo *AttachmentUpdateOne) SetItem(i *Item) *AttachmentUpdateOne {
return auo.SetItemID(i.ID)
}
// SetThumbnailID sets the "thumbnail" edge to the Attachment entity by ID.
func (auo *AttachmentUpdateOne) SetThumbnailID(id uuid.UUID) *AttachmentUpdateOne {
auo.mutation.SetThumbnailID(id)
return auo
}
// SetNillableThumbnailID sets the "thumbnail" edge to the Attachment entity by ID if the given value is not nil.
func (auo *AttachmentUpdateOne) SetNillableThumbnailID(id *uuid.UUID) *AttachmentUpdateOne {
if id != nil {
auo = auo.SetThumbnailID(*id)
}
return auo
}
// SetThumbnail sets the "thumbnail" edge to the Attachment entity.
func (auo *AttachmentUpdateOne) SetThumbnail(a *Attachment) *AttachmentUpdateOne {
return auo.SetThumbnailID(a.ID)
}
// Mutation returns the AttachmentMutation object of the builder.
func (auo *AttachmentUpdateOne) Mutation() *AttachmentMutation {
return auo.mutation
@@ -440,12 +323,6 @@ func (auo *AttachmentUpdateOne) ClearItem() *AttachmentUpdateOne {
return auo
}
// ClearThumbnail clears the "thumbnail" edge to the Attachment entity.
func (auo *AttachmentUpdateOne) ClearThumbnail() *AttachmentUpdateOne {
auo.mutation.ClearThumbnail()
return auo
}
// Where appends a list predicates to the AttachmentUpdate builder.
func (auo *AttachmentUpdateOne) Where(ps ...predicate.Attachment) *AttachmentUpdateOne {
auo.mutation.Where(ps...)
@@ -502,6 +379,9 @@ func (auo *AttachmentUpdateOne) check() error {
return &ValidationError{Name: "type", err: fmt.Errorf(`ent: validator failed for field "Attachment.type": %w`, err)}
}
}
if auo.mutation.ItemCleared() && len(auo.mutation.ItemIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "Attachment.item"`)
}
return nil
}
@@ -549,9 +429,6 @@ func (auo *AttachmentUpdateOne) sqlSave(ctx context.Context) (_node *Attachment,
if value, ok := auo.mutation.Path(); ok {
_spec.SetField(attachment.FieldPath, field.TypeString, value)
}
if value, ok := auo.mutation.MimeType(); ok {
_spec.SetField(attachment.FieldMimeType, field.TypeString, value)
}
if auo.mutation.ItemCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -581,35 +458,6 @@ func (auo *AttachmentUpdateOne) sqlSave(ctx context.Context) (_node *Attachment,
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if auo.mutation.ThumbnailCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Inverse: false,
Table: attachment.ThumbnailTable,
Columns: []string{attachment.ThumbnailColumn},
Bidi: true,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(attachment.FieldID, field.TypeUUID),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := auo.mutation.ThumbnailIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Inverse: false,
Table: attachment.ThumbnailTable,
Columns: []string{attachment.ThumbnailColumn},
Bidi: true,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(attachment.FieldID, field.TypeUUID),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
_node = &Attachment{config: auo.config}
_spec.Assign = _node.assignValues
_spec.ScanValues = _node.scanValues

View File

@@ -422,22 +422,6 @@ func (c *AttachmentClient) QueryItem(a *Attachment) *ItemQuery {
return query
}
// QueryThumbnail queries the thumbnail edge of a Attachment.
func (c *AttachmentClient) QueryThumbnail(a *Attachment) *AttachmentQuery {
query := (&AttachmentClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := a.ID
step := sqlgraph.NewStep(
sqlgraph.From(attachment.Table, attachment.FieldID, id),
sqlgraph.To(attachment.Table, attachment.FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, attachment.ThumbnailTable, attachment.ThumbnailColumn),
)
fromV = sqlgraph.Neighbors(a.driver.Dialect(), step)
return fromV, nil
}
return query
}
// Hooks returns the client hooks.
func (c *AttachmentClient) Hooks() []Hook {
return c.hooks.Attachment

View File

@@ -1,120 +0,0 @@
package ent
import (
"entgo.io/ent/dialect/sql"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/item"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/predicate"
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
)
// AccentInsensitiveContains creates a predicate that performs accent-insensitive text search.
// It normalizes both the database field value and the search value for comparison.
func AccentInsensitiveContains(field string, searchValue string) predicate.Item {
if searchValue == "" {
return predicate.Item(func(s *sql.Selector) {
// Return a predicate that never matches if search is empty
s.Where(sql.False())
})
}
// Normalize the search value
normalizedSearch := textutils.NormalizeSearchQuery(searchValue)
return predicate.Item(func(s *sql.Selector) {
dialect := s.Dialect()
switch dialect {
case "sqlite3":
// For SQLite, we'll create a custom normalization function using REPLACE
// to handle common accented characters
normalizeFunc := buildSQLiteNormalizeExpression(s.C(field))
s.Where(sql.ExprP(
"LOWER("+normalizeFunc+") LIKE ?",
"%"+normalizedSearch+"%",
))
case "postgres":
// For PostgreSQL, use REPLACE-based normalization to avoid unaccent dependency
normalizeFunc := buildGenericNormalizeExpression(s.C(field))
// Use sql.P() for proper PostgreSQL parameter binding ($1, $2, etc.)
s.Where(sql.P(func(b *sql.Builder) {
b.WriteString("LOWER(")
b.WriteString(normalizeFunc)
b.WriteString(") LIKE ")
b.Arg("%" + normalizedSearch + "%")
}))
default:
// Default fallback using REPLACE for common accented characters
normalizeFunc := buildGenericNormalizeExpression(s.C(field))
s.Where(sql.ExprP(
"LOWER("+normalizeFunc+") LIKE ?",
"%"+normalizedSearch+"%",
))
}
})
}
// buildSQLiteNormalizeExpression creates a SQLite expression to normalize accented characters
func buildSQLiteNormalizeExpression(fieldExpr string) string {
return buildGenericNormalizeExpression(fieldExpr)
}
// buildGenericNormalizeExpression creates a database-agnostic expression to normalize common accented characters
func buildGenericNormalizeExpression(fieldExpr string) string {
// Chain REPLACE functions to handle the most common accented characters
// Focused on the most frequently used accents in Spanish, French, and Portuguese
// Ordered by frequency of use for better performance
normalized := fieldExpr
// Most common accented characters ordered by frequency
commonAccents := []struct {
from, to string
}{
// Spanish - most common
{"á", "a"}, {"é", "e"}, {"í", "i"}, {"ó", "o"}, {"ú", "u"}, {"ñ", "n"},
{"Á", "A"}, {"É", "E"}, {"Í", "I"}, {"Ó", "O"}, {"Ú", "U"}, {"Ñ", "N"},
// French - most common
{"è", "e"}, {"ê", "e"}, {"à", "a"}, {"ç", "c"},
{"È", "E"}, {"Ê", "E"}, {"À", "A"}, {"Ç", "C"},
// German umlauts and Portuguese - common
{"ä", "a"}, {"ö", "o"}, {"ü", "u"}, {"ã", "a"}, {"õ", "o"},
{"Ä", "A"}, {"Ö", "O"}, {"Ü", "U"}, {"Ã", "A"}, {"Õ", "O"},
}
for _, accent := range commonAccents {
normalized = "REPLACE(" + normalized + ", '" + accent.from + "', '" + accent.to + "')"
}
return normalized
}
// ItemNameAccentInsensitiveContains creates an accent-insensitive search predicate for the item name field.
func ItemNameAccentInsensitiveContains(value string) predicate.Item {
return AccentInsensitiveContains(item.FieldName, value)
}
// ItemDescriptionAccentInsensitiveContains creates an accent-insensitive search predicate for the item description field.
func ItemDescriptionAccentInsensitiveContains(value string) predicate.Item {
return AccentInsensitiveContains(item.FieldDescription, value)
}
// ItemSerialNumberAccentInsensitiveContains creates an accent-insensitive search predicate for the item serial number field.
func ItemSerialNumberAccentInsensitiveContains(value string) predicate.Item {
return AccentInsensitiveContains(item.FieldSerialNumber, value)
}
// ItemModelNumberAccentInsensitiveContains creates an accent-insensitive search predicate for the item model number field.
func ItemModelNumberAccentInsensitiveContains(value string) predicate.Item {
return AccentInsensitiveContains(item.FieldModelNumber, value)
}
// ItemManufacturerAccentInsensitiveContains creates an accent-insensitive search predicate for the item manufacturer field.
func ItemManufacturerAccentInsensitiveContains(value string) predicate.Item {
return AccentInsensitiveContains(item.FieldManufacturer, value)
}
// ItemNotesAccentInsensitiveContains creates an accent-insensitive search predicate for the item notes field.
func ItemNotesAccentInsensitiveContains(value string) predicate.Item {
return AccentInsensitiveContains(item.FieldNotes, value)
}

View File

@@ -1,147 +0,0 @@
package ent
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildGenericNormalizeExpression(t *testing.T) {
tests := []struct {
name string
field string
expected string
}{
{
name: "Simple field name",
field: "name",
expected: "name", // Should be wrapped in many REPLACE functions
},
{
name: "Complex field name",
field: "description",
expected: "description",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildGenericNormalizeExpression(tt.field)
// Should contain the original field
assert.Contains(t, result, tt.field)
// Should contain REPLACE functions for accent normalization
assert.Contains(t, result, "REPLACE(")
// Should handle common accented characters
assert.Contains(t, result, "'á'", "Should handle Spanish á")
assert.Contains(t, result, "'é'", "Should handle Spanish é")
assert.Contains(t, result, "'ñ'", "Should handle Spanish ñ")
assert.Contains(t, result, "'ü'", "Should handle German ü")
// Should handle uppercase accents too
assert.Contains(t, result, "'Á'", "Should handle uppercase Spanish Á")
assert.Contains(t, result, "'É'", "Should handle uppercase Spanish É")
})
}
}
func TestSQLiteNormalizeExpression(t *testing.T) {
result := buildSQLiteNormalizeExpression("test_field")
// Should contain the field name and REPLACE functions
assert.Contains(t, result, "test_field")
assert.Contains(t, result, "REPLACE(")
// Check for some specific accent replacements (order doesn't matter)
assert.Contains(t, result, "'á'", "Should handle Spanish á")
assert.Contains(t, result, "'ó'", "Should handle Spanish ó")
}
func TestAccentInsensitivePredicateCreation(t *testing.T) {
tests := []struct {
name string
field string
searchValue string
description string
}{
{
name: "Normal search value",
field: "name",
searchValue: "electronica",
description: "Should create predicate for normal search",
},
{
name: "Accented search value",
field: "description",
searchValue: "electrónica",
description: "Should create predicate for accented search",
},
{
name: "Empty search value",
field: "name",
searchValue: "",
description: "Should handle empty search gracefully",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
predicate := AccentInsensitiveContains(tt.field, tt.searchValue)
assert.NotNil(t, predicate, tt.description)
})
}
}
func TestSpecificItemPredicates(t *testing.T) {
tests := []struct {
name string
predicateFunc func(string) interface{}
searchValue string
description string
}{
{
name: "ItemNameAccentInsensitiveContains",
predicateFunc: func(val string) interface{} { return ItemNameAccentInsensitiveContains(val) },
searchValue: "electronica",
description: "Should create accent-insensitive name search predicate",
},
{
name: "ItemDescriptionAccentInsensitiveContains",
predicateFunc: func(val string) interface{} { return ItemDescriptionAccentInsensitiveContains(val) },
searchValue: "descripcion",
description: "Should create accent-insensitive description search predicate",
},
{
name: "ItemManufacturerAccentInsensitiveContains",
predicateFunc: func(val string) interface{} { return ItemManufacturerAccentInsensitiveContains(val) },
searchValue: "compañia",
description: "Should create accent-insensitive manufacturer search predicate",
},
{
name: "ItemSerialNumberAccentInsensitiveContains",
predicateFunc: func(val string) interface{} { return ItemSerialNumberAccentInsensitiveContains(val) },
searchValue: "sn123",
description: "Should create accent-insensitive serial number search predicate",
},
{
name: "ItemModelNumberAccentInsensitiveContains",
predicateFunc: func(val string) interface{} { return ItemModelNumberAccentInsensitiveContains(val) },
searchValue: "model456",
description: "Should create accent-insensitive model number search predicate",
},
{
name: "ItemNotesAccentInsensitiveContains",
predicateFunc: func(val string) interface{} { return ItemNotesAccentInsensitiveContains(val) },
searchValue: "notas importantes",
description: "Should create accent-insensitive notes search predicate",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
predicate := tt.predicateFunc(tt.searchValue)
assert.NotNil(t, predicate, tt.description)
})
}
}

View File

@@ -13,13 +13,11 @@ var (
{Name: "id", Type: field.TypeUUID},
{Name: "created_at", Type: field.TypeTime},
{Name: "updated_at", Type: field.TypeTime},
{Name: "type", Type: field.TypeEnum, Enums: []string{"photo", "manual", "warranty", "attachment", "receipt", "thumbnail"}, Default: "attachment"},
{Name: "type", Type: field.TypeEnum, Enums: []string{"photo", "manual", "warranty", "attachment", "receipt"}, Default: "attachment"},
{Name: "primary", Type: field.TypeBool, Default: false},
{Name: "title", Type: field.TypeString, Default: ""},
{Name: "path", Type: field.TypeString, Default: ""},
{Name: "mime_type", Type: field.TypeString, Default: "application/octet-stream"},
{Name: "attachment_thumbnail", Type: field.TypeUUID, Unique: true, Nullable: true},
{Name: "item_attachments", Type: field.TypeUUID, Nullable: true},
{Name: "item_attachments", Type: field.TypeUUID},
}
// AttachmentsTable holds the schema information for the "attachments" table.
AttachmentsTable = &schema.Table{
@@ -27,15 +25,9 @@ var (
Columns: AttachmentsColumns,
PrimaryKey: []*schema.Column{AttachmentsColumns[0]},
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "attachments_attachments_thumbnail",
Columns: []*schema.Column{AttachmentsColumns[8]},
RefColumns: []*schema.Column{AttachmentsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "attachments_items_attachments",
Columns: []*schema.Column{AttachmentsColumns[9]},
Columns: []*schema.Column{AttachmentsColumns[7]},
RefColumns: []*schema.Column{ItemsColumns[0]},
OnDelete: schema.Cascade,
},
@@ -451,8 +443,7 @@ var (
)
func init() {
AttachmentsTable.ForeignKeys[0].RefTable = AttachmentsTable
AttachmentsTable.ForeignKeys[1].RefTable = ItemsTable
AttachmentsTable.ForeignKeys[0].RefTable = ItemsTable
AuthRolesTable.ForeignKeys[0].RefTable = AuthTokensTable
AuthTokensTable.ForeignKeys[0].RefTable = UsersTable
GroupInvitationTokensTable.ForeignKeys[0].RefTable = GroupsTable

View File

@@ -53,24 +53,21 @@ const (
// AttachmentMutation represents an operation that mutates the Attachment nodes in the graph.
type AttachmentMutation struct {
config
op Op
typ string
id *uuid.UUID
created_at *time.Time
updated_at *time.Time
_type *attachment.Type
primary *bool
title *string
_path *string
mime_type *string
clearedFields map[string]struct{}
item *uuid.UUID
cleareditem bool
thumbnail *uuid.UUID
clearedthumbnail bool
done bool
oldValue func(context.Context) (*Attachment, error)
predicates []predicate.Attachment
op Op
typ string
id *uuid.UUID
created_at *time.Time
updated_at *time.Time
_type *attachment.Type
primary *bool
title *string
_path *string
clearedFields map[string]struct{}
item *uuid.UUID
cleareditem bool
done bool
oldValue func(context.Context) (*Attachment, error)
predicates []predicate.Attachment
}
var _ ent.Mutation = (*AttachmentMutation)(nil)
@@ -393,42 +390,6 @@ func (m *AttachmentMutation) ResetPath() {
m._path = nil
}
// SetMimeType sets the "mime_type" field.
func (m *AttachmentMutation) SetMimeType(s string) {
m.mime_type = &s
}
// MimeType returns the value of the "mime_type" field in the mutation.
func (m *AttachmentMutation) MimeType() (r string, exists bool) {
v := m.mime_type
if v == nil {
return
}
return *v, true
}
// OldMimeType returns the old "mime_type" field's value of the Attachment entity.
// If the Attachment object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *AttachmentMutation) OldMimeType(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldMimeType is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldMimeType requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldMimeType: %w", err)
}
return oldValue.MimeType, nil
}
// ResetMimeType resets all changes to the "mime_type" field.
func (m *AttachmentMutation) ResetMimeType() {
m.mime_type = nil
}
// SetItemID sets the "item" edge to the Item entity by id.
func (m *AttachmentMutation) SetItemID(id uuid.UUID) {
m.item = &id
@@ -468,45 +429,6 @@ func (m *AttachmentMutation) ResetItem() {
m.cleareditem = false
}
// SetThumbnailID sets the "thumbnail" edge to the Attachment entity by id.
func (m *AttachmentMutation) SetThumbnailID(id uuid.UUID) {
m.thumbnail = &id
}
// ClearThumbnail clears the "thumbnail" edge to the Attachment entity.
func (m *AttachmentMutation) ClearThumbnail() {
m.clearedthumbnail = true
}
// ThumbnailCleared reports if the "thumbnail" edge to the Attachment entity was cleared.
func (m *AttachmentMutation) ThumbnailCleared() bool {
return m.clearedthumbnail
}
// ThumbnailID returns the "thumbnail" edge ID in the mutation.
func (m *AttachmentMutation) ThumbnailID() (id uuid.UUID, exists bool) {
if m.thumbnail != nil {
return *m.thumbnail, true
}
return
}
// ThumbnailIDs returns the "thumbnail" edge IDs in the mutation.
// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
// ThumbnailID instead. It exists only for internal usage by the builders.
func (m *AttachmentMutation) ThumbnailIDs() (ids []uuid.UUID) {
if id := m.thumbnail; id != nil {
ids = append(ids, *id)
}
return
}
// ResetThumbnail resets all changes to the "thumbnail" edge.
func (m *AttachmentMutation) ResetThumbnail() {
m.thumbnail = nil
m.clearedthumbnail = false
}
// Where appends a list predicates to the AttachmentMutation builder.
func (m *AttachmentMutation) Where(ps ...predicate.Attachment) {
m.predicates = append(m.predicates, ps...)
@@ -541,7 +463,7 @@ func (m *AttachmentMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *AttachmentMutation) Fields() []string {
fields := make([]string, 0, 7)
fields := make([]string, 0, 6)
if m.created_at != nil {
fields = append(fields, attachment.FieldCreatedAt)
}
@@ -560,9 +482,6 @@ func (m *AttachmentMutation) Fields() []string {
if m._path != nil {
fields = append(fields, attachment.FieldPath)
}
if m.mime_type != nil {
fields = append(fields, attachment.FieldMimeType)
}
return fields
}
@@ -583,8 +502,6 @@ func (m *AttachmentMutation) Field(name string) (ent.Value, bool) {
return m.Title()
case attachment.FieldPath:
return m.Path()
case attachment.FieldMimeType:
return m.MimeType()
}
return nil, false
}
@@ -606,8 +523,6 @@ func (m *AttachmentMutation) OldField(ctx context.Context, name string) (ent.Val
return m.OldTitle(ctx)
case attachment.FieldPath:
return m.OldPath(ctx)
case attachment.FieldMimeType:
return m.OldMimeType(ctx)
}
return nil, fmt.Errorf("unknown Attachment field %s", name)
}
@@ -659,13 +574,6 @@ func (m *AttachmentMutation) SetField(name string, value ent.Value) error {
}
m.SetPath(v)
return nil
case attachment.FieldMimeType:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetMimeType(v)
return nil
}
return fmt.Errorf("unknown Attachment field %s", name)
}
@@ -733,22 +641,16 @@ func (m *AttachmentMutation) ResetField(name string) error {
case attachment.FieldPath:
m.ResetPath()
return nil
case attachment.FieldMimeType:
m.ResetMimeType()
return nil
}
return fmt.Errorf("unknown Attachment field %s", name)
}
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *AttachmentMutation) AddedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 1)
if m.item != nil {
edges = append(edges, attachment.EdgeItem)
}
if m.thumbnail != nil {
edges = append(edges, attachment.EdgeThumbnail)
}
return edges
}
@@ -760,17 +662,13 @@ func (m *AttachmentMutation) AddedIDs(name string) []ent.Value {
if id := m.item; id != nil {
return []ent.Value{*id}
}
case attachment.EdgeThumbnail:
if id := m.thumbnail; id != nil {
return []ent.Value{*id}
}
}
return nil
}
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *AttachmentMutation) RemovedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 1)
return edges
}
@@ -782,13 +680,10 @@ func (m *AttachmentMutation) RemovedIDs(name string) []ent.Value {
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *AttachmentMutation) ClearedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 1)
if m.cleareditem {
edges = append(edges, attachment.EdgeItem)
}
if m.clearedthumbnail {
edges = append(edges, attachment.EdgeThumbnail)
}
return edges
}
@@ -798,8 +693,6 @@ func (m *AttachmentMutation) EdgeCleared(name string) bool {
switch name {
case attachment.EdgeItem:
return m.cleareditem
case attachment.EdgeThumbnail:
return m.clearedthumbnail
}
return false
}
@@ -811,9 +704,6 @@ func (m *AttachmentMutation) ClearEdge(name string) error {
case attachment.EdgeItem:
m.ClearItem()
return nil
case attachment.EdgeThumbnail:
m.ClearThumbnail()
return nil
}
return fmt.Errorf("unknown Attachment unique edge %s", name)
}
@@ -825,9 +715,6 @@ func (m *AttachmentMutation) ResetEdge(name string) error {
case attachment.EdgeItem:
m.ResetItem()
return nil
case attachment.EdgeThumbnail:
m.ResetThumbnail()
return nil
}
return fmt.Errorf("unknown Attachment edge %s", name)
}

View File

@@ -51,10 +51,6 @@ func init() {
attachmentDescPath := attachmentFields[3].Descriptor()
// attachment.DefaultPath holds the default value on creation for the path field.
attachment.DefaultPath = attachmentDescPath.Default.(string)
// attachmentDescMimeType is the schema descriptor for mime_type field.
attachmentDescMimeType := attachmentFields[4].Descriptor()
// attachment.DefaultMimeType holds the default value on creation for the mime_type field.
attachment.DefaultMimeType = attachmentDescMimeType.Default.(string)
// attachmentDescID is the schema descriptor for id field.
attachmentDescID := attachmentMixinFields0[0].Descriptor()
// attachment.DefaultID holds the default value on creation for the id field.

View File

@@ -21,11 +21,10 @@ func (Attachment) Mixin() []ent.Mixin {
// Fields of the Attachment.
func (Attachment) Fields() []ent.Field {
return []ent.Field{
field.Enum("type").Values("photo", "manual", "warranty", "attachment", "receipt", "thumbnail").Default("attachment"),
field.Enum("type").Values("photo", "manual", "warranty", "attachment", "receipt").Default("attachment"),
field.Bool("primary").Default(false),
field.String("title").Default(""),
field.String("path").Default(""),
field.String("mime_type").Default("application/octet-stream"),
}
}
@@ -34,8 +33,7 @@ func (Attachment) Edges() []ent.Edge {
return []ent.Edge{
edge.From("item", Item.Type).
Ref("attachments").
Unique(),
edge.To("thumbnail", Attachment.Type).
Required().
Unique(),
}
}

View File

@@ -3,8 +3,6 @@ package migrations
import (
"embed"
"fmt"
"github.com/rs/zerolog/log"
)
@@ -19,16 +17,15 @@ var sqliteFiles embed.FS
// migration files in the binary at build time. The function takes a string
// parameter "dialect" which specifies the SQL dialect to use. It returns an
// embedded file system containing the migration files for the specified dialect.
func Migrations(dialect string) (embed.FS, error) {
func Migrations(dialect string) embed.FS {
switch dialect {
case "postgres":
return postgresFiles, nil
return postgresFiles
case "sqlite3":
return sqliteFiles, nil
return sqliteFiles
default:
log.Error().Str("dialect", dialect).Msg("unknown sql dialect")
return embed.FS{}, fmt.Errorf("unknown sql dialect: %s", dialect)
log.Fatal().Str("dialect", dialect).Msg("unknown sql dialect")
}
// This should never get hit, but just in case
return sqliteFiles, nil
return sqliteFiles
}

View File

@@ -1,14 +0,0 @@
-- +goose Up
alter table public.attachments
alter column item_attachments drop not null;
alter table public.attachments
add attachment_thumbnail uuid;
alter table public.attachments
add constraint attachments_attachments_thumbnail
foreign key (attachment_thumbnail) references public.attachments (id);
alter table public.attachments
add constraint attachments_no_self_reference
check (id != attachment_thumbnail);

View File

@@ -1,3 +0,0 @@
-- +goose Up
ALTER TABLE public.attachments ADD COLUMN mime_type VARCHAR DEFAULT 'application/octet-stream';

View File

@@ -1,8 +0,0 @@
-- +goose Up
alter table public.attachments
drop constraint attachments_attachments_thumbnail;
alter table public.attachments
add constraint attachments_attachments_thumbnail
foreign key (attachment_thumbnail) references public.attachments
on delete set null;

View File

@@ -1,25 +0,0 @@
-- +goose Up
-- Make attachment paths relative by removing the prefix path
-- This migration converts absolute paths to relative paths by finding the UUID/documents pattern
-- Update Unix-style paths that contain "/documents/" by extracting the part starting from the UUID
-- The approach: find the "/documents/" substring, go back 37 characters (UUID + slash),
-- and extract from there to get "uuid/documents/hash"
UPDATE attachments
SET path = SUBSTRING(path FROM POSITION('/documents/' IN path) - 36)
WHERE path LIKE '%/documents/%'
AND POSITION('/documents/' IN path) > 36;
-- Update Windows-style paths that contain "\documents\" by extracting the part starting from the UUID
-- Convert backslashes to forward slashes in the process for consistency
UPDATE attachments
SET path = REPLACE(SUBSTRING(path FROM POSITION('\documents\' IN path) - 36), '\', '/')
WHERE path LIKE '%\documents\%'
AND POSITION('\documents\' IN path) > 36;
-- For paths that already look like relative paths (start with UUID), leave them unchanged
-- This handles cases where the migration might be run multiple times
-- +goose Down
-- Note: This down migration cannot be safely implemented because we don't know
-- what the original prefix paths were. This is a one-way migration.

View File

@@ -1,35 +0,0 @@
-- +goose Up
create table attachments_dg_tmp
(
id uuid not null
primary key,
created_at datetime not null,
updated_at datetime not null,
type text default 'attachment' not null,
"primary" bool default false not null,
path text not null,
title text not null,
item_attachments uuid
constraint attachments_items_attachments
references items
on delete cascade,
attachment_thumbnail uuid
constraint attachments_attachments_thumbnail
references attachments
);
insert into attachments_dg_tmp(id, created_at, updated_at, type, "primary", path, title, item_attachments)
select id,
created_at,
updated_at,
type,
"primary",
path,
title,
item_attachments
from attachments;
drop table attachments;
alter table attachments_dg_tmp
rename to attachments;

View File

@@ -1,3 +0,0 @@
-- +goose Up
ALTER TABLE attachments ADD COLUMN mime_type TEXT DEFAULT 'application/octet-stream';

View File

@@ -1,45 +0,0 @@
-- +goose Up
create table attachments_dg_tmp
(
id uuid not null
primary key,
created_at datetime not null,
updated_at datetime not null,
type text default 'attachment' not null,
"primary" bool default false not null,
path text not null,
title text not null,
mime_type text default 'application/octet-stream' not null,
item_attachments uuid
constraint attachments_items_attachments
references items
on delete cascade,
attachment_thumbnail uuid
constraint attachments_attachments_thumbnail
references attachments
on delete set null
);
insert into attachments_dg_tmp(id, created_at, updated_at, type, "primary", path, title, mime_type, item_attachments,
attachment_thumbnail)
select id,
created_at,
updated_at,
type,
"primary",
path,
title,
mime_type,
item_attachments,
attachment_thumbnail
from attachments;
drop table attachments;
alter table attachments_dg_tmp
rename to attachments;
CREATE INDEX IF NOT EXISTS idx_attachments_item_id ON attachments(item_attachments);
CREATE INDEX IF NOT EXISTS idx_attachments_path ON attachments(path);
CREATE INDEX IF NOT EXISTS idx_attachments_type ON attachments(type);
CREATE INDEX IF NOT EXISTS idx_attachments_thumbnail ON attachments(attachment_thumbnail);

View File

@@ -1,126 +0,0 @@
-- +goose Up
-- GENERATED with 20250706190000_generate_migration.py
-- Migrating auth_tokens/created_at
update auth_tokens set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update auth_tokens set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating auth_tokens/updated_at
update auth_tokens set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update auth_tokens set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
-- Migrating auth_tokens/expires_at
update auth_tokens set expires_at = substr(expires_at,1, instr(expires_at, ' +')-1) || substr(expires_at, instr(expires_at, ' +')+1,3) || ':' || substr(expires_at, instr(expires_at, ' +')+4,2) where expires_at like '% +%';
update auth_tokens set expires_at = substr(expires_at,1, instr(expires_at, ' -')-1) || substr(expires_at, instr(expires_at, ' -')+1,3) || ':' || substr(expires_at, instr(expires_at, ' -')+4,2) where expires_at like '% -%';
-- Migrating groups/created_at
update groups set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update groups set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating groups/updated_at
update groups set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update groups set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
-- Migrating group_invitation_tokens/created_at
update group_invitation_tokens set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update group_invitation_tokens set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating group_invitation_tokens/updated_at
update group_invitation_tokens set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update group_invitation_tokens set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
-- Migrating group_invitation_tokens/expires_at
update group_invitation_tokens set expires_at = substr(expires_at,1, instr(expires_at, ' +')-1) || substr(expires_at, instr(expires_at, ' +')+1,3) || ':' || substr(expires_at, instr(expires_at, ' +')+4,2) where expires_at like '% +%';
update group_invitation_tokens set expires_at = substr(expires_at,1, instr(expires_at, ' -')-1) || substr(expires_at, instr(expires_at, ' -')+1,3) || ':' || substr(expires_at, instr(expires_at, ' -')+4,2) where expires_at like '% -%';
-- Migrating item_fields/created_at
update item_fields set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update item_fields set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating item_fields/updated_at
update item_fields set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update item_fields set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
-- Migrating item_fields/time_value
update item_fields set time_value = substr(time_value,1, instr(time_value, ' +')-1) || substr(time_value, instr(time_value, ' +')+1,3) || ':' || substr(time_value, instr(time_value, ' +')+4,2) where time_value like '% +%';
update item_fields set time_value = substr(time_value,1, instr(time_value, ' -')-1) || substr(time_value, instr(time_value, ' -')+1,3) || ':' || substr(time_value, instr(time_value, ' -')+4,2) where time_value like '% -%';
-- Migrating labels/created_at
update labels set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update labels set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating labels/updated_at
update labels set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update labels set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
-- Migrating locations/created_at
update locations set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update locations set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating locations/updated_at
update locations set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update locations set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
-- Migrating maintenance_entries/created_at
update maintenance_entries set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update maintenance_entries set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating maintenance_entries/updated_at
update maintenance_entries set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update maintenance_entries set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
-- Migrating maintenance_entries/date
update maintenance_entries set date = substr(date,1, instr(date, ' +')-1) || substr(date, instr(date, ' +')+1,3) || ':' || substr(date, instr(date, ' +')+4,2) where date like '% +%';
update maintenance_entries set date = substr(date,1, instr(date, ' -')-1) || substr(date, instr(date, ' -')+1,3) || ':' || substr(date, instr(date, ' -')+4,2) where date like '% -%';
-- Migrating maintenance_entries/scheduled_date
update maintenance_entries set scheduled_date = substr(scheduled_date,1, instr(scheduled_date, ' +')-1) || substr(scheduled_date, instr(scheduled_date, ' +')+1,3) || ':' || substr(scheduled_date, instr(scheduled_date, ' +')+4,2) where scheduled_date like '% +%';
update maintenance_entries set scheduled_date = substr(scheduled_date,1, instr(scheduled_date, ' -')-1) || substr(scheduled_date, instr(scheduled_date, ' -')+1,3) || ':' || substr(scheduled_date, instr(scheduled_date, ' -')+4,2) where scheduled_date like '% -%';
-- Migrating notifiers/created_at
update notifiers set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update notifiers set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating notifiers/updated_at
update notifiers set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update notifiers set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
-- Migrating users/created_at
update users set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update users set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating users/updated_at
update users set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update users set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
-- Migrating users/activated_on
update users set activated_on = substr(activated_on,1, instr(activated_on, ' +')-1) || substr(activated_on, instr(activated_on, ' +')+1,3) || ':' || substr(activated_on, instr(activated_on, ' +')+4,2) where activated_on like '% +%';
update users set activated_on = substr(activated_on,1, instr(activated_on, ' -')-1) || substr(activated_on, instr(activated_on, ' -')+1,3) || ':' || substr(activated_on, instr(activated_on, ' -')+4,2) where activated_on like '% -%';
-- Migrating items/created_at
update items set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update items set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating items/updated_at
update items set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update items set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
-- Migrating items/warranty_expires
update items set warranty_expires = substr(warranty_expires,1, instr(warranty_expires, ' +')-1) || substr(warranty_expires, instr(warranty_expires, ' +')+1,3) || ':' || substr(warranty_expires, instr(warranty_expires, ' +')+4,2) where warranty_expires like '% +%';
update items set warranty_expires = substr(warranty_expires,1, instr(warranty_expires, ' -')-1) || substr(warranty_expires, instr(warranty_expires, ' -')+1,3) || ':' || substr(warranty_expires, instr(warranty_expires, ' -')+4,2) where warranty_expires like '% -%';
-- Migrating items/purchase_time
update items set purchase_time = substr(purchase_time,1, instr(purchase_time, ' +')-1) || substr(purchase_time, instr(purchase_time, ' +')+1,3) || ':' || substr(purchase_time, instr(purchase_time, ' +')+4,2) where purchase_time like '% +%';
update items set purchase_time = substr(purchase_time,1, instr(purchase_time, ' -')-1) || substr(purchase_time, instr(purchase_time, ' -')+1,3) || ':' || substr(purchase_time, instr(purchase_time, ' -')+4,2) where purchase_time like '% -%';
-- Migrating items/sold_time
update items set sold_time = substr(sold_time,1, instr(sold_time, ' +')-1) || substr(sold_time, instr(sold_time, ' +')+1,3) || ':' || substr(sold_time, instr(sold_time, ' +')+4,2) where sold_time like '% +%';
update items set sold_time = substr(sold_time,1, instr(sold_time, ' -')-1) || substr(sold_time, instr(sold_time, ' -')+1,3) || ':' || substr(sold_time, instr(sold_time, ' -')+4,2) where sold_time like '% -%';
-- Migrating attachments/created_at
update attachments set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
update attachments set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
-- Migrating attachments/updated_at
update attachments set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
update attachments set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';

View File

@@ -1,61 +0,0 @@
#!/usr/bin/env python
import os
# Extract fields with
""" WITH tables AS (
SELECT name AS table_name
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
)
SELECT
'["' || t.table_name || '", "' || c.name || '"],' AS table_column
FROM tables t
JOIN pragma_table_info(t.table_name) c
WHERE c.name like'%date%'; """
fields = [["auth_tokens", "created_at"],
["auth_tokens", "updated_at"],
["auth_tokens", "expires_at"],
["groups", "created_at"],
["groups", "updated_at"],
["group_invitation_tokens", "created_at"],
["group_invitation_tokens", "updated_at"],
["group_invitation_tokens", "expires_at"],
["item_fields", "created_at"],
["item_fields", "updated_at"],
["item_fields", "time_value"],
["labels", "created_at"],
["labels", "updated_at"],
["locations", "created_at"],
["locations", "updated_at"],
["maintenance_entries", "created_at"],
["maintenance_entries", "updated_at"],
["maintenance_entries", "date"],
["maintenance_entries", "scheduled_date"],
["notifiers", "created_at"],
["notifiers", "updated_at"],
["users", "created_at"],
["users", "updated_at"],
["users", "activated_on"],
["items", "created_at"],
["items", "updated_at"],
["items", "warranty_expires"],
["items", "purchase_time"],
["items", "sold_time"],
["attachments", "created_at"],
["attachments", "updated_at"]]
def generate_migration(table_name, field_name):
return f"""update {table_name} set {field_name} = substr({field_name},1, instr({field_name}, ' +')-1) || substr({field_name}, instr({field_name}, ' +')+1,3) || ':' || substr({field_name}, instr({field_name}, ' +')+4,2) where {field_name} like '% +%';\n""" + \
f"""update {table_name} set {field_name} = substr({field_name},1, instr({field_name}, ' -')-1) || substr({field_name}, instr({field_name}, ' -')+1,3) || ':' || substr({field_name}, instr({field_name}, ' -')+4,2) where {field_name} like '% -%';"""
print("-- +goose Up")
print(f"-- GENERATED with {os.path.basename(__file__)}")
for table, column in fields:
print(f"-- Migrating {table}/{column}")
print(generate_migration(table, column))
print()

View File

@@ -1,25 +0,0 @@
-- +goose Up
-- Make attachment paths relative by removing the prefix path
-- This migration converts absolute paths to relative paths by finding the UUID/documents pattern
-- Update Unix-style paths that contain "/documents/" by extracting the part starting from the UUID
-- The approach: find the "/documents/" substring, go back 37 characters (UUID + slash),
-- and extract from there to get "uuid/documents/hash"
UPDATE attachments
SET path = SUBSTR(path, INSTR(path, '/documents/') - 36)
WHERE path LIKE '%/documents/%'
AND INSTR(path, '/documents/') > 36;
-- Update Windows-style paths that contain "\documents\" by extracting the part starting from the UUID
-- Convert backslashes to forward slashes in the process for consistency
UPDATE attachments
SET path = REPLACE(SUBSTR(path, INSTR(path, '\documents\') - 36), '\', '/')
WHERE path LIKE '%\documents\%'
AND INSTR(path, '\documents\') > 36;
-- For paths that already look like relative paths (start with UUID), leave them unchanged
-- This handles cases where the migration might be run multiple times
-- +goose Down
-- Note: This down migration cannot be safely implemented because we don't know
-- what the original prefix paths were. This is a one-way migration.

View File

@@ -2,7 +2,6 @@ package repo
import (
"context"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
"log"
"os"
"testing"
@@ -56,19 +55,7 @@ func MainNoExit(m *testing.M) int {
}
tClient = client
tRepos = New(tClient, tbus, config.Storage{
PrefixPath: "/",
ConnString: "file://" + os.TempDir(),
}, "mem://{{ .Topic }}", config.Thumbnail{
Enabled: false,
Width: 0,
Height: 0,
})
err = os.MkdirAll(os.TempDir()+"/homebox", 0o755)
if err != nil {
return 0
}
tRepos = New(tClient, tbus, os.TempDir())
defer func() { _ = client.Close() }()
bootstrap()

View File

@@ -8,10 +8,5 @@ type PaginationResult[T any] struct {
}
func calculateOffset(page, pageSize int) int {
offset := (page - 1) * pageSize
if offset < 0 {
return 0
} else {
return offset
}
return (page - 1) * pageSize
}

View File

@@ -3,71 +3,37 @@ package repo
import (
"bytes"
"context"
"crypto/md5"
"fmt"
"image"
"io"
"io/fs"
"net/http"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/evanoberholster/imagemeta"
"github.com/gen2brain/avif"
"github.com/gen2brain/heic"
"github.com/gen2brain/jpegxl"
"github.com/gen2brain/webp"
"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/group"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
"github.com/sysadminsmedia/homebox/backend/pkgs/utils"
"github.com/zeebo/blake3"
"golang.org/x/image/draw"
"io"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/item"
"gocloud.dev/blob"
_ "gocloud.dev/blob/azureblob"
_ "gocloud.dev/blob/fileblob"
_ "gocloud.dev/blob/gcsblob"
_ "gocloud.dev/blob/memblob"
_ "gocloud.dev/blob/s3blob"
"gocloud.dev/pubsub"
_ "gocloud.dev/pubsub/awssnssqs"
_ "gocloud.dev/pubsub/azuresb"
_ "gocloud.dev/pubsub/gcppubsub"
_ "gocloud.dev/pubsub/kafkapubsub"
_ "gocloud.dev/pubsub/mempubsub"
_ "gocloud.dev/pubsub/natspubsub"
_ "gocloud.dev/pubsub/rabbitpubsub"
)
// AttachmentRepo is a repository for Attachments table that links Items to their
// associated files while also specifying the type of the attachment.
type AttachmentRepo struct {
db *ent.Client
storage config.Storage
pubSubConn string
thumbnail config.Thumbnail
db *ent.Client
dir string
}
type (
ItemAttachment struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Type string `json:"type"`
Primary bool `json:"primary"`
Path string `json:"path"`
Title string `json:"title"`
MimeType string `json:"mimeType,omitempty"`
Thumbnail *ent.Attachment `json:"thumbnail,omitempty"`
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Type string `json:"type"`
Primary bool `json:"primary"`
Path string `json:"path"`
Title string `json:"title"`
}
ItemAttachmentUpdate struct {
@@ -92,50 +58,11 @@ func ToItemAttachment(attachment *ent.Attachment) ItemAttachment {
Primary: attachment.Primary,
Path: attachment.Path,
Title: attachment.Title,
MimeType: attachment.MimeType,
Thumbnail: attachment.QueryThumbnail().FirstX(context.Background()),
}
}
func (r *AttachmentRepo) path(gid uuid.UUID, hash string) string {
return filepath.Join(gid.String(), "documents", hash)
}
func (r *AttachmentRepo) fullPath(relativePath string) string {
return filepath.Join(r.storage.PrefixPath, relativePath)
}
func (r *AttachmentRepo) GetFullPath(relativePath string) string {
return r.fullPath(relativePath)
}
func (r *AttachmentRepo) GetConnString() string {
// Handle the default case for file storage
// which is file:///./ meaning relative to the current working directory
if strings.HasPrefix(r.storage.ConnString, "file:///./") {
dir, err := filepath.Abs(strings.TrimPrefix(r.storage.ConnString, "file:///./"))
if runtime.GOOS == "windows" {
dir = fmt.Sprintf("/%s", dir)
}
if err != nil {
log.Err(err).Msg("failed to get absolute path for attachment directory")
return r.storage.ConnString
}
return strings.ReplaceAll(fmt.Sprintf("file://%s?no_tmp_dir=true", dir), "\\", "/")
} else if strings.HasPrefix(r.storage.ConnString, "file://") {
// Handle the case for file storage with an absolute path
// Convert Windows paths to a format compatible with fileblob
// e.g. file:///C:/path/to/file becomes file:///C/path
dir := strings.TrimPrefix(strings.ReplaceAll(r.storage.ConnString, "\\", "/"), "file://")
if runtime.GOOS == "windows" {
// Remove the colon from the drive letter (in case the user adds it)
dir = strings.ReplaceAll(dir, ":", "")
// Ensure the path starts with a slash for Windows compatibility
dir = fmt.Sprintf("/%s", dir)
}
return fmt.Sprintf("file://%s", dir)
}
return r.storage.ConnString
return filepath.Join(r.dir, gid.String(), "documents", hash)
}
func (r *AttachmentRepo) Create(ctx context.Context, itemID uuid.UUID, doc ItemCreateAttachment, typ attachment.Type, primary bool) (*ent.Attachment, error) {
@@ -215,26 +142,73 @@ func (r *AttachmentRepo) Create(ctx context.Context, itemID uuid.UUID, doc ItemC
return nil, err
}
// Upload the file to the storage bucket
path, err := r.UploadFile(ctx, itemGroup, doc)
// Prepare for the hashing of the file contents
hashOut := make([]byte, 32)
// Read all content into a buffer
buf := new(bytes.Buffer)
_, err = io.Copy(buf, doc.Content)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return nil, rollbackErr
log.Err(err).Msg("failed to read file content")
if rbErr := tx.Rollback(); rbErr != nil {
return nil, rbErr
}
return nil, err
}
// Now the buffer contains all the data, use it for hashing
contentBytes := buf.Bytes()
limitedReader := io.LimitReader(doc.Content, 1024*128)
file, err := io.ReadAll(limitedReader)
// We use blake3 to generate a hash of the file contents, the group ID is used as context to ensure unique hashes
// for the same file across different groups to reduce the chance of collisions
// additionally, the hash can be used to validate the file contents if needed
blake3.DeriveKey(itemGroup.ID.String(), contentBytes, hashOut)
// Create the file itself
path := r.path(itemGroup.ID, fmt.Sprintf("%x", hashOut))
parent := filepath.Dir(path)
err = os.MkdirAll(parent, 0755)
if err != nil {
log.Err(err).Msg("failed to read file content")
err = tx.Rollback()
log.Err(err).Msg("failed to create parent directory")
err := tx.Rollback()
if err != nil {
return nil, err
}
return nil, err
}
bldr = bldr.SetMimeType(http.DetectContentType(file[:min(512, len(file))]))
if _, err := os.Stat(path); os.IsNotExist(err) {
file, err := os.Create(path)
if err != nil {
log.Err(err).Msg("failed to create file")
err := tx.Rollback()
if err != nil {
return nil, err
}
return nil, err
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
log.Err(err).Msg("failed to close file")
err := tx.Rollback()
if err != nil {
return
}
return
}
}(file)
_, err = file.Write(contentBytes)
if err != nil {
log.Err(err).Msg("failed to copy file contents")
err := tx.Rollback()
if err != nil {
return nil, err
}
return nil, err
}
}
bldr = bldr.SetPath(path)
attachmentDb, err := bldr.Save(ctx)
@@ -251,77 +225,18 @@ func (r *AttachmentRepo) Create(ctx context.Context, itemID uuid.UUID, doc ItemC
log.Err(err).Msg("failed to commit transaction")
return nil, err
}
if r.thumbnail.Enabled {
pubsubString, err := utils.GenerateSubPubConn(r.pubSubConn, "thumbnails")
if err != nil {
log.Err(err).Msg("failed to generate pubsub connection string")
return nil, err
}
topic, err := pubsub.OpenTopic(ctx, pubsubString)
if err != nil {
log.Err(err).Msg("failed to open pubsub topic")
return nil, err
}
err = topic.Send(ctx, &pubsub.Message{
Body: []byte(fmt.Sprintf("attachment_created:%s", attachmentDb.ID.String())),
Metadata: map[string]string{
"group_id": itemGroup.ID.String(),
"attachment_id": attachmentDb.ID.String(),
"title": doc.Title,
"path": attachmentDb.Path,
},
})
if err != nil {
log.Err(err).Msg("failed to send message to topic")
return nil, err
}
}
return attachmentDb, nil
}
func (r *AttachmentRepo) Get(ctx context.Context, gid uuid.UUID, id uuid.UUID) (*ent.Attachment, error) {
first, err := r.db.Attachment.Query().Where(attachment.ID(id)).Only(ctx)
if err != nil {
return nil, err
}
if first.Type == attachment.TypeThumbnail {
// If the attachment is a thumbnail, get the parent attachment and check if it belongs to the specified group
return r.db.Attachment.
Query().
Where(attachment.ID(id),
attachment.HasThumbnailWith(attachment.HasItemWith(item.HasGroupWith(group.ID(gid)))),
).
WithItem().
WithThumbnail().
Only(ctx)
} else {
// For regular attachments, check if the attachment's item belongs to the specified group
return r.db.Attachment.
Query().
Where(attachment.ID(id),
attachment.HasItemWith(item.HasGroupWith(group.ID(gid))),
).
WithItem().
WithThumbnail().
Only(ctx)
}
func (r *AttachmentRepo) Get(ctx context.Context, id uuid.UUID) (*ent.Attachment, error) {
return r.db.Attachment.
Query().
Where(attachment.ID(id)).
WithItem().
Only(ctx)
}
func (r *AttachmentRepo) Update(ctx context.Context, gid uuid.UUID, id uuid.UUID, data *ItemAttachmentUpdate) (*ent.Attachment, error) {
// Validate that the attachment belongs to the specified group
_, err := r.db.Attachment.Query().
Where(
attachment.ID(id),
attachment.HasItemWith(item.HasGroupWith(group.ID(gid))),
).
Only(ctx)
if err != nil {
return nil, err
}
func (r *AttachmentRepo) Update(ctx context.Context, id uuid.UUID, data *ItemAttachmentUpdate) (*ent.Attachment, error) {
// TODO: execute within Tx
typ := attachment.Type(data.Type)
@@ -345,76 +260,35 @@ func (r *AttachmentRepo) Update(ctx context.Context, gid uuid.UUID, id uuid.UUID
return nil, err
}
// Only remove primary status from other photo attachments when setting a new photo as primary
if typ == attachment.TypePhoto && data.Primary {
err = r.db.Attachment.Update().
Where(
attachment.HasItemWith(item.ID(attachmentItem.ID)),
attachment.IDNEQ(updatedAttachment.ID),
attachment.TypeEQ(attachment.TypePhoto),
).
SetPrimary(false).
Exec(ctx)
if err != nil {
return nil, err
}
// Ensure all other attachments are not primary
err = r.db.Attachment.Update().
Where(
attachment.HasItemWith(item.ID(attachmentItem.ID)),
attachment.IDNEQ(updatedAttachment.ID),
).
SetPrimary(false).
Exec(ctx)
if err != nil {
return nil, err
}
return r.Get(ctx, gid, updatedAttachment.ID)
return r.Get(ctx, updatedAttachment.ID)
}
func (r *AttachmentRepo) Delete(ctx context.Context, gid uuid.UUID, itemId uuid.UUID, id uuid.UUID) error {
// Validate that the attachment belongs to the specified group
doc, err := r.db.Attachment.Query().
Where(
attachment.ID(id),
attachment.HasItemWith(item.HasGroupWith(group.ID(gid))),
).
Only(ctx)
if err != nil {
return err
func (r *AttachmentRepo) Delete(ctx context.Context, id uuid.UUID) error {
doc, error := r.db.Attachment.Get(ctx, id)
if error != nil {
return error
}
all, err := r.db.Attachment.Query().Where(attachment.Path(doc.Path)).All(ctx)
if err != nil {
return err
}
// If this is the last attachment for this path, delete the file
if len(all) == 1 {
thumb, err := doc.QueryThumbnail().First(ctx)
if err != nil && !ent.IsNotFound(err) {
log.Err(err).Msg("failed to query thumbnail for attachment")
return err
}
if thumb != nil {
thumbBucket, err := blob.OpenBucket(ctx, r.GetConnString())
if err != nil {
log.Err(err).Msg("failed to open bucket for thumbnail deletion")
return err
}
err = thumbBucket.Delete(ctx, r.fullPath(thumb.Path))
if err != nil {
return err
}
_ = doc.Update().SetNillableThumbnailID(nil).SaveX(ctx)
_ = thumb.Update().SetNillableThumbnailID(nil).SaveX(ctx)
err = r.db.Attachment.DeleteOneID(thumb.ID).Exec(ctx)
if err != nil {
return err
}
}
bucket, err := blob.OpenBucket(ctx, r.GetConnString())
if err != nil {
log.Err(err).Msg("failed to open bucket")
return err
}
defer func(bucket *blob.Bucket) {
err := bucket.Close()
if err != nil {
log.Err(err).Msg("failed to close bucket")
}
}(bucket)
err = bucket.Delete(ctx, r.fullPath(doc.Path))
err := os.Remove(doc.Path)
if err != nil {
return err
}
@@ -423,457 +297,6 @@ func (r *AttachmentRepo) Delete(ctx context.Context, gid uuid.UUID, itemId uuid.
return r.db.Attachment.DeleteOneID(id).Exec(ctx)
}
func (r *AttachmentRepo) Rename(ctx context.Context, gid uuid.UUID, id uuid.UUID, title string) (*ent.Attachment, error) {
// Validate that the attachment belongs to the specified group
_, err := r.db.Attachment.Query().
Where(
attachment.ID(id),
attachment.HasItemWith(item.HasGroupWith(group.ID(gid))),
).
Only(ctx)
if err != nil {
return nil, err
}
func (r *AttachmentRepo) Rename(ctx context.Context, id uuid.UUID, title string) (*ent.Attachment, error) {
return r.db.Attachment.UpdateOneID(id).SetTitle(title).Save(ctx)
}
//nolint:gocyclo
func (r *AttachmentRepo) CreateThumbnail(ctx context.Context, groupId, attachmentId uuid.UUID, title string, path string) error {
log.Debug().Msg("starting thumbnail creation")
tx, err := r.db.Tx(ctx)
if err != nil {
return nil
}
// If there is an error during file creation rollback the database
defer func() {
if v := recover(); v != nil {
err := tx.Rollback()
if err != nil {
return
}
}
}()
log.Debug().Msg("set initial database transaction")
att := tx.Attachment.Create().
SetID(uuid.New()).
SetTitle(fmt.Sprintf("%s-thumb", title)).
SetType("thumbnail")
log.Debug().Msg("opening original file")
bucket, err := blob.OpenBucket(ctx, r.GetConnString())
if err != nil {
log.Err(err).Msg("failed to open bucket")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
defer func(bucket *blob.Bucket) {
err := bucket.Close()
if err != nil {
err := tx.Rollback()
if err != nil {
return
}
log.Err(err).Msg("failed to close bucket")
}
}(bucket)
origFile, err := bucket.Open(r.fullPath(path))
if err != nil {
err := tx.Rollback()
if err != nil {
return err
}
return err
}
defer func(file fs.File) {
err := file.Close()
if err != nil {
err := tx.Rollback()
if err != nil {
return
}
log.Err(err).Msg("failed to close file")
}
}(origFile)
log.Debug().Msg("stat original file for file size")
stats, err := origFile.Stat()
if err != nil {
err := tx.Rollback()
if err != nil {
return err
}
log.Err(err).Msg("failed to stat original file")
return err
}
if stats.Size() > 100*1024*1024 {
return fmt.Errorf("original file %s is too large to create a thumbnail", title)
}
log.Debug().Msg("reading original file content")
contentBytes, err := io.ReadAll(origFile)
if err != nil {
err := tx.Rollback()
if err != nil {
return err
}
log.Err(err).Msg("failed to read original file content")
return err
}
log.Debug().Msg("detecting content type of original file")
contentType := http.DetectContentType(contentBytes[:min(512, len(contentBytes))])
if contentType == "application/octet-stream" {
switch {
case strings.HasSuffix(title, ".heic") || strings.HasSuffix(title, ".heif"):
contentType = "image/heic"
case strings.HasSuffix(title, ".avif"):
contentType = "image/avif"
case strings.HasSuffix(title, ".jxl"):
contentType = "image/jxl"
}
}
switch {
case isImageFile(contentType):
log.Debug().Msg("creating thumbnail for image file")
img, _, err := image.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode image file")
err := tx.Rollback()
if err != nil {
log.Err(err).Msg("failed to rollback transaction")
return err
}
return err
}
log.Debug().Msg("reading original file orientation")
imageMeta, err := imagemeta.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode original file content")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
orientation := uint16(imageMeta.Orientation)
thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, orientation)
if err != nil {
err := tx.Rollback()
if err != nil {
return err
}
return err
}
att.SetPath(thumbnailPath)
case contentType == "image/webp":
log.Debug().Msg("creating thumbnail for webp file")
img, err := webp.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode webp image")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
log.Debug().Msg("reading original file orientation")
imageMeta, err := imagemeta.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode original file content")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
orientation := uint16(imageMeta.Orientation)
thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, orientation)
if err != nil {
err := tx.Rollback()
if err != nil {
return err
}
return err
}
att.SetPath(thumbnailPath)
case contentType == "image/avif":
log.Debug().Msg("creating thumbnail for avif file")
img, err := avif.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode avif image")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, uint16(1))
if err != nil {
err := tx.Rollback()
if err != nil {
return err
}
return err
}
att.SetPath(thumbnailPath)
case contentType == "image/heic" || contentType == "image/heif":
log.Debug().Msg("creating thumbnail for heic file")
img, err := heic.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode avif image")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
log.Debug().Msg("reading original file orientation")
imageMeta, err := imagemeta.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode original file content")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
orientation := uint16(imageMeta.Orientation)
thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, orientation)
if err != nil {
err := tx.Rollback()
if err != nil {
return err
}
return err
}
att.SetPath(thumbnailPath)
case contentType == "image/jxl":
log.Debug().Msg("creating thumbnail for jpegxl file")
img, err := jpegxl.Decode(bytes.NewReader(contentBytes))
if err != nil {
log.Err(err).Msg("failed to decode avif image")
err := tx.Rollback()
if err != nil {
return err
}
return err
}
thumbnailPath, err := r.processThumbnailFromImage(ctx, groupId, img, title, uint16(1))
if err != nil {
err := tx.Rollback()
if err != nil {
return err
}
return err
}
att.SetPath(thumbnailPath)
default:
return fmt.Errorf("file type %s is not supported for thumbnail creation or document thumnails disabled", title)
}
att.SetMimeType("image/webp")
log.Debug().Msg("saving thumbnail attachment to database")
thumbnail, err := att.Save(ctx)
if err != nil {
return err
}
_, err = tx.Attachment.UpdateOneID(attachmentId).SetThumbnail(thumbnail).Save(ctx)
if err != nil {
return err
}
log.Debug().Msg("finishing thumbnail creation transaction")
if err := tx.Commit(); err != nil {
log.Err(err).Msg("failed to commit transaction")
return nil
}
return nil
}
func (r *AttachmentRepo) CreateMissingThumbnails(ctx context.Context, groupId uuid.UUID) (int, error) {
attachments, err := r.db.Attachment.Query().
Where(
attachment.HasItemWith(item.HasGroupWith(group.ID(groupId))),
attachment.TypeNEQ("thumbnail"),
).
All(ctx)
if err != nil {
return 0, err
}
pubsubString, err := utils.GenerateSubPubConn(r.pubSubConn, "thumbnails")
if err != nil {
log.Err(err).Msg("failed to generate pubsub connection string")
}
topic, err := pubsub.OpenTopic(ctx, pubsubString)
if err != nil {
log.Err(err).Msg("failed to open pubsub topic")
}
count := 0
for _, attachment := range attachments {
if r.thumbnail.Enabled {
if !attachment.QueryThumbnail().ExistX(ctx) {
if count > 0 && count%100 == 0 {
time.Sleep(2 * time.Second)
}
err = topic.Send(ctx, &pubsub.Message{
Body: []byte(fmt.Sprintf("attachment_created:%s", attachment.ID.String())),
Metadata: map[string]string{
"group_id": groupId.String(),
"attachment_id": attachment.ID.String(),
"title": attachment.Title,
"path": attachment.Path,
},
})
if err != nil {
log.Err(err).Msg("failed to send message to topic")
continue
} else {
count++
}
}
}
}
return count, nil
}
func (r *AttachmentRepo) UploadFile(ctx context.Context, itemGroup *ent.Group, doc ItemCreateAttachment) (string, error) {
// Prepare for the hashing of the file contents
hashOut := make([]byte, 32)
// Read all content into a buffer
buf := new(bytes.Buffer)
_, err := io.Copy(buf, doc.Content)
if err != nil {
log.Err(err).Msg("failed to read file content")
return "", err
}
// Now the buffer contains all the data, use it for hashing
contentBytes := buf.Bytes()
// We use blake3 to generate a hash of the file contents, the group ID is used as context to ensure unique hashes
// for the same file across different groups to reduce the chance of collisions
// additionally, the hash can be used to validate the file contents if needed
blake3.DeriveKey(itemGroup.ID.String(), contentBytes, hashOut)
// Write the file to the blob storage bucket which might be a local file system or cloud storage
bucket, err := blob.OpenBucket(ctx, r.GetConnString())
if err != nil {
log.Err(err).Msg("failed to open bucket")
return "", err
}
defer func(bucket *blob.Bucket) {
err := bucket.Close()
if err != nil {
log.Err(err).Msg("failed to close bucket")
}
}(bucket)
md5hash := md5.New()
_, err = md5hash.Write(contentBytes)
if err != nil {
log.Err(err).Msg("failed to generate MD5 hash for storage")
return "", err
}
contentType := http.DetectContentType(contentBytes[:min(512, len(contentBytes))])
options := &blob.WriterOptions{
ContentType: contentType,
ContentMD5: md5hash.Sum(nil),
}
relativePath := r.path(itemGroup.ID, fmt.Sprintf("%x", hashOut))
fullPath := r.fullPath(relativePath)
err = bucket.WriteAll(ctx, fullPath, contentBytes, options)
if err != nil {
log.Err(err).Msg("failed to write file to bucket")
return "", err
}
return relativePath, nil
}
func isImageFile(mimetype string) bool {
// Check file extension for image types
return strings.Contains(mimetype, "image/jpeg") || strings.Contains(mimetype, "image/png") || strings.Contains(mimetype, "image/gif")
}
// calculateThumbnailDimensions calculates new dimensions that preserve aspect ratio
// while fitting within the configured maximum width and height
func calculateThumbnailDimensions(origWidth, origHeight, maxWidth, maxHeight int) (int, int) {
if origWidth <= maxWidth && origHeight <= maxHeight {
return origWidth, origHeight
}
// Calculate scaling factors for both dimensions
scaleX := float64(maxWidth) / float64(origWidth)
scaleY := float64(maxHeight) / float64(origHeight)
// Use the smaller scaling factor to ensure both dimensions fit
scale := scaleX
if scaleY < scaleX {
scale = scaleY
}
newWidth := int(float64(origWidth) * scale)
newHeight := int(float64(origHeight) * scale)
// Ensure we don't get zero dimensions
if newWidth < 1 {
newWidth = 1
}
if newHeight < 1 {
newHeight = 1
}
return newWidth, newHeight
}
// processThumbnailFromImage handles the common thumbnail processing logic after image decoding
// Returns the thumbnail file path or an error
func (r *AttachmentRepo) processThumbnailFromImage(ctx context.Context, groupId uuid.UUID, img image.Image, title string, orientation uint16) (string, error) {
bounds := img.Bounds()
// Apply EXIF orientation if needed
if orientation > 1 {
img = utils.ApplyOrientation(img, orientation)
bounds = img.Bounds()
}
newWidth, newHeight := calculateThumbnailDimensions(bounds.Dx(), bounds.Dy(), r.thumbnail.Width, r.thumbnail.Height)
dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
draw.CatmullRom.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil)
buf := new(bytes.Buffer)
err := webp.Encode(buf, dst, webp.Options{Quality: 80, Lossless: false})
if err != nil {
return "", err
}
contentBytes := buf.Bytes()
log.Debug().Msg("uploading thumbnail file")
// Get the group for uploading the thumbnail
group, err := r.db.Group.Get(ctx, groupId)
if err != nil {
return "", err
}
thumbnailFile, err := r.UploadFile(ctx, group, ItemCreateAttachment{
Title: fmt.Sprintf("%s-thumb", title),
Content: bytes.NewReader(contentBytes),
})
if err != nil {
log.Err(err).Msg("failed to upload thumbnail file")
return "", err
}
return thumbnailFile, nil
}

View File

@@ -18,7 +18,7 @@ func TestAttachmentRepo_Create(t *testing.T) {
ids := []uuid.UUID{item.ID}
t.Cleanup(func() {
for _, id := range ids {
_ = tRepos.Attachments.Delete(context.Background(), tGroup.ID, item.ID, id)
_ = tRepos.Attachments.Delete(context.Background(), id)
}
})
@@ -69,7 +69,7 @@ func TestAttachmentRepo_Create(t *testing.T) {
assert.Equal(t, tt.want.Type, got.Type)
withItems, err := tRepos.Attachments.Get(tt.args.ctx, tGroup.ID, got.ID)
withItems, err := tRepos.Attachments.Get(tt.args.ctx, got.ID)
require.NoError(t, err)
assert.Equal(t, tt.args.itemID, withItems.Edges.Item.ID)
@@ -86,17 +86,17 @@ func useAttachments(t *testing.T, n int) []*ent.Attachment {
ids := make([]uuid.UUID, 0, n)
t.Cleanup(func() {
for _, id := range ids {
_ = tRepos.Attachments.Delete(context.Background(), tGroup.ID, item.ID, id)
_ = tRepos.Attachments.Delete(context.Background(), id)
}
})
attachments := make([]*ent.Attachment, n)
for i := 0; i < n; i++ {
attach, err := tRepos.Attachments.Create(context.Background(), item.ID, ItemCreateAttachment{Title: "Test", Content: strings.NewReader("Test String")}, attachment.TypePhoto, true)
attachment, err := tRepos.Attachments.Create(context.Background(), item.ID, ItemCreateAttachment{Title: "Test", Content: strings.NewReader("Test String")}, attachment.TypePhoto, true)
require.NoError(t, err)
attachments[i] = attach
attachments[i] = attachment
ids = append(ids, attach.ID)
ids = append(ids, attachment.ID)
}
return attachments
@@ -107,13 +107,13 @@ func TestAttachmentRepo_Update(t *testing.T) {
for _, typ := range []attachment.Type{"photo", "manual", "warranty", "attachment"} {
t.Run(string(typ), func(t *testing.T) {
_, err := tRepos.Attachments.Update(context.Background(), tGroup.ID, entity.ID, &ItemAttachmentUpdate{
_, err := tRepos.Attachments.Update(context.Background(), entity.ID, &ItemAttachmentUpdate{
Type: string(typ),
})
require.NoError(t, err)
updated, err := tRepos.Attachments.Get(context.Background(), tGroup.ID, entity.ID)
updated, err := tRepos.Attachments.Get(context.Background(), entity.ID)
require.NoError(t, err)
assert.Equal(t, typ, updated.Type)
})
@@ -122,12 +122,11 @@ func TestAttachmentRepo_Update(t *testing.T) {
func TestAttachmentRepo_Delete(t *testing.T) {
entity := useAttachments(t, 1)[0]
item := useItems(t, 1)[0]
err := tRepos.Attachments.Delete(context.Background(), tGroup.ID, item.ID, entity.ID)
err := tRepos.Attachments.Delete(context.Background(), entity.ID)
require.NoError(t, err)
_, err = tRepos.Attachments.Get(context.Background(), tGroup.ID, entity.ID)
_, err = tRepos.Attachments.Get(context.Background(), entity.ID)
require.Error(t, err)
}
@@ -136,13 +135,13 @@ func TestAttachmentRepo_EnsureSinglePrimaryAttachment(t *testing.T) {
attachments := useAttachments(t, 2)
setAndVerifyPrimary := func(primaryAttachmentID, nonPrimaryAttachmentID uuid.UUID) {
primaryAttachment, err := tRepos.Attachments.Update(ctx, tGroup.ID, primaryAttachmentID, &ItemAttachmentUpdate{
primaryAttachment, err := tRepos.Attachments.Update(ctx, primaryAttachmentID, &ItemAttachmentUpdate{
Type: attachment.TypePhoto.String(),
Primary: true,
})
require.NoError(t, err)
nonPrimaryAttachment, err := tRepos.Attachments.Get(ctx, tGroup.ID, nonPrimaryAttachmentID)
nonPrimaryAttachment, err := tRepos.Attachments.Get(ctx, nonPrimaryAttachmentID)
require.NoError(t, err)
assert.True(t, primaryAttachment.Primary)
@@ -152,132 +151,3 @@ func TestAttachmentRepo_EnsureSinglePrimaryAttachment(t *testing.T) {
setAndVerifyPrimary(attachments[0].ID, attachments[1].ID)
setAndVerifyPrimary(attachments[1].ID, attachments[0].ID)
}
func TestAttachmentRepo_UpdateNonPhotoDoesNotAffectPrimaryPhoto(t *testing.T) {
ctx := context.Background()
item := useItems(t, 1)[0]
// Create a photo attachment that will be primary
photoAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Test Photo", Content: strings.NewReader("Photo content")}, attachment.TypePhoto, true)
require.NoError(t, err)
// Create a manual attachment (non-photo)
manualAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Test Manual", Content: strings.NewReader("Manual content")}, attachment.TypeManual, false)
require.NoError(t, err)
// Cleanup
t.Cleanup(func() {
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photoAttachment.ID)
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, manualAttachment.ID)
})
// Verify photo is primary initially
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
require.NoError(t, err)
assert.True(t, photoAttachment.Primary)
// Update the manual attachment (this should NOT affect the photo's primary status)
_, err = tRepos.Attachments.Update(ctx, tGroup.ID, manualAttachment.ID, &ItemAttachmentUpdate{
Type: attachment.TypeManual.String(),
Title: "Updated Manual",
Primary: false, // This should have no effect since it's not a photo
})
require.NoError(t, err)
// Verify photo is still primary after updating the manual
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
require.NoError(t, err)
assert.True(t, photoAttachment.Primary, "Photo attachment should remain primary after updating non-photo attachment")
// Verify manual attachment is not primary
manualAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, manualAttachment.ID)
require.NoError(t, err)
assert.False(t, manualAttachment.Primary)
}
func TestAttachmentRepo_AddingPDFAfterPhotoKeepsPhotoAsPrimary(t *testing.T) {
ctx := context.Background()
item := useItems(t, 1)[0]
// Step 1: Upload a photo first (this should become primary since it's the first photo)
photoAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Item Photo", Content: strings.NewReader("Photo content")}, attachment.TypePhoto, false)
require.NoError(t, err)
// Cleanup
t.Cleanup(func() {
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photoAttachment.ID)
})
// Verify photo becomes primary automatically (since it's the first photo)
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
require.NoError(t, err)
assert.True(t, photoAttachment.Primary, "First photo should automatically become primary")
// Step 2: Add a PDF receipt (this should NOT affect the photo's primary status)
pdfAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Receipt PDF", Content: strings.NewReader("PDF content")}, attachment.TypeReceipt, false)
require.NoError(t, err)
// Add to cleanup
t.Cleanup(func() {
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, pdfAttachment.ID)
})
// Step 3: Verify photo is still primary after adding PDF
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
require.NoError(t, err)
assert.True(t, photoAttachment.Primary, "Photo should remain primary after adding PDF attachment")
// Verify PDF is not primary
pdfAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, pdfAttachment.ID)
require.NoError(t, err)
assert.False(t, pdfAttachment.Primary)
// Step 4: Test the actual item summary mapping (this is what determines the card display)
updatedItem, err := tRepos.Items.GetOne(ctx, item.ID)
require.NoError(t, err)
// The item should have the photo's ID as the imageId
assert.NotNil(t, updatedItem.ImageID, "Item should have an imageId")
assert.Equal(t, photoAttachment.ID, *updatedItem.ImageID, "Item's imageId should match the photo attachment ID")
}
func TestAttachmentRepo_SettingPhotoPrimaryStillWorks(t *testing.T) {
ctx := context.Background()
item := useItems(t, 1)[0]
// Create two photo attachments
photo1, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Photo 1", Content: strings.NewReader("Photo 1 content")}, attachment.TypePhoto, false)
require.NoError(t, err)
photo2, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Photo 2", Content: strings.NewReader("Photo 2 content")}, attachment.TypePhoto, false)
require.NoError(t, err)
// Cleanup
t.Cleanup(func() {
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photo1.ID)
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photo2.ID)
})
// First photo should be primary (since it was created first)
photo1, err = tRepos.Attachments.Get(ctx, tGroup.ID, photo1.ID)
require.NoError(t, err)
assert.True(t, photo1.Primary)
photo2, err = tRepos.Attachments.Get(ctx, tGroup.ID, photo2.ID)
require.NoError(t, err)
assert.False(t, photo2.Primary)
// Now set photo2 as primary (this should work and remove primary from photo1)
photo2, err = tRepos.Attachments.Update(ctx, tGroup.ID, photo2.ID, &ItemAttachmentUpdate{
Type: attachment.TypePhoto.String(),
Title: "Photo 2",
Primary: true,
})
require.NoError(t, err)
assert.True(t, photo2.Primary)
// Verify photo1 is no longer primary
photo1, err = tRepos.Attachments.Get(ctx, tGroup.ID, photo1.ID)
require.NoError(t, err)
assert.False(t, photo1.Primary, "Photo 1 should no longer be primary after setting Photo 2 as primary")
}

View File

@@ -6,7 +6,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
@@ -15,7 +14,6 @@ import (
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/itemfield"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/label"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/location"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/maintenanceentry"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/predicate"
"github.com/sysadminsmedia/homebox/backend/internal/data/types"
)
@@ -48,13 +46,6 @@ type (
OrderBy string `json:"orderBy"`
}
DuplicateOptions struct {
CopyMaintenance bool `json:"copyMaintenance"`
CopyAttachments bool `json:"copyAttachments"`
CopyCustomFields bool `json:"copyCustomFields"`
CopyPrefix string `json:"copyPrefix"`
}
ItemField struct {
ID uuid.UUID `json:"id,omitempty"`
Type string `json:"type"`
@@ -120,11 +111,9 @@ type (
}
ItemPatch struct {
ID uuid.UUID `json:"id"`
Quantity *int `json:"quantity,omitempty" extensions:"x-nullable,x-omitempty"`
ImportRef *string `json:"-,omitempty" extensions:"x-nullable,x-omitempty"`
LocationID uuid.UUID `json:"locationId" extensions:"x-nullable,x-omitempty"`
LabelIDs []uuid.UUID `json:"labelIds" extensions:"x-nullable,x-omitempty"`
ID uuid.UUID `json:"id"`
Quantity *int `json:"quantity,omitempty" extensions:"x-nullable,x-omitempty"`
ImportRef *string `json:"-,omitempty" extensions:"x-nullable,x-omitempty"`
}
ItemSummary struct {
@@ -145,8 +134,7 @@ type (
Location *LocationSummary `json:"location,omitempty" extensions:"x-nullable,x-omitempty"`
Labels []LabelSummary `json:"labels"`
ImageID *uuid.UUID `json:"imageId,omitempty" extensions:"x-nullable,x-omitempty"`
ThumbnailId *uuid.UUID `json:"thumbnailId,omitempty" extensions:"x-nullable,x-omitempty"`
ImageID *uuid.UUID `json:"imageId,omitempty"`
// Sale details
SoldTime time.Time `json:"soldTime"`
@@ -201,20 +189,10 @@ func mapItemSummary(item *ent.Item) ItemSummary {
}
var imageID *uuid.UUID
var thumbnailID *uuid.UUID
if item.Edges.Attachments != nil {
for _, a := range item.Edges.Attachments {
if a.Primary && a.Type == attachment.TypePhoto {
imageID = &a.ID
if a.Edges.Thumbnail != nil {
if a.Edges.Thumbnail.ID != uuid.Nil {
thumbnailID = &a.Edges.Thumbnail.ID
} else {
thumbnailID = nil
}
} else {
thumbnailID = nil
}
break
}
}
@@ -237,9 +215,8 @@ func mapItemSummary(item *ent.Item) ItemSummary {
Labels: labels,
// Warranty
Insured: item.Insured,
ImageID: imageID,
ThumbnailId: thumbnailID,
Insured: item.Insured,
ImageID: imageID,
}
}
@@ -371,25 +348,14 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
}
if q.Search != "" {
// Use accent-insensitive search predicates that normalize both
// the search query and database field values during comparison.
// For queries without accents, the traditional search is more efficient.
qb.Where(
item.Or(
// Regular case-insensitive search (fastest)
item.NameContainsFold(q.Search),
item.DescriptionContainsFold(q.Search),
item.SerialNumberContainsFold(q.Search),
item.ModelNumberContainsFold(q.Search),
item.ManufacturerContainsFold(q.Search),
item.NotesContainsFold(q.Search),
// Accent-insensitive search using custom predicates
ent.ItemNameAccentInsensitiveContains(q.Search),
ent.ItemDescriptionAccentInsensitiveContains(q.Search),
ent.ItemSerialNumberAccentInsensitiveContains(q.Search),
ent.ItemModelNumberAccentInsensitiveContains(q.Search),
ent.ItemManufacturerAccentInsensitiveContains(q.Search),
ent.ItemNotesAccentInsensitiveContains(q.Search),
),
)
}
@@ -500,7 +466,6 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
aq.Where(
attachment.Primary(true),
)
aq.WithThumbnail()
})
if q.Page != -1 || q.PageSize != -1 {
@@ -816,20 +781,7 @@ func (e *ItemsRepository) GetAllZeroImportRef(ctx context.Context, gid uuid.UUID
}
func (e *ItemsRepository) Patch(ctx context.Context, gid, id uuid.UUID, data ItemPatch) error {
tx, err := e.db.Tx(ctx)
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
if err := tx.Rollback(); err != nil {
log.Warn().Err(err).Msg("failed to rollback transaction during item patch")
}
}
}()
q := tx.Item.Update().
q := e.db.Item.Update().
Where(
item.ID(id),
item.HasGroupWith(group.ID(gid)),
@@ -843,81 +795,8 @@ func (e *ItemsRepository) Patch(ctx context.Context, gid, id uuid.UUID, data Ite
q.SetQuantity(*data.Quantity)
}
if data.LocationID != uuid.Nil {
q.SetLocationID(data.LocationID)
}
err = q.Exec(ctx)
if err != nil {
return err
}
if data.LabelIDs != nil {
currentLabels, err := tx.Item.Query().Where(item.ID(id), item.HasGroupWith(group.ID(gid))).QueryLabel().All(ctx)
if err != nil {
return err
}
set := newIDSet(currentLabels)
addLabels := []uuid.UUID{}
for _, l := range data.LabelIDs {
if set.Contains(l) {
set.Remove(l)
} else {
addLabels = append(addLabels, l)
}
}
if len(addLabels) > 0 {
if err := tx.Item.Update().
Where(item.ID(id), item.HasGroupWith(group.ID(gid))).
AddLabelIDs(addLabels...).
Exec(ctx); err != nil {
return err
}
}
if set.Len() > 0 {
if err := tx.Item.Update().
Where(item.ID(id), item.HasGroupWith(group.ID(gid))).
RemoveLabelIDs(set.Slice()...).
Exec(ctx); err != nil {
return err
}
}
}
if data.LocationID != uuid.Nil {
itemEnt, err := tx.Item.Query().Where(item.ID(id), item.HasGroupWith(group.ID(gid))).Only(ctx)
if err != nil {
return err
}
if itemEnt.SyncChildItemsLocations {
children, err := tx.Item.Query().Where(item.ID(id), item.HasGroupWith(group.ID(gid))).QueryChildren().All(ctx)
if err != nil {
return err
}
for _, child := range children {
childLocation, err := child.QueryLocation().First(ctx)
if err != nil {
return err
}
if data.LocationID != childLocation.ID {
err = child.Update().SetLocationID(data.LocationID).Exec(ctx)
if err != nil {
return err
}
}
}
}
}
if err := tx.Commit(); err != nil {
return err
}
committed = true
e.publishMutationEvent(gid)
return nil
return q.Exec(ctx)
}
func (e *ItemsRepository) GetAllCustomFieldValues(ctx context.Context, gid uuid.UUID, name string) ([]string, error) {
@@ -1101,164 +980,3 @@ func (e *ItemsRepository) SetPrimaryPhotos(ctx context.Context, gid uuid.UUID) (
return updated, nil
}
// Duplicate creates a copy of an item with configurable options for what data to copy.
// The new item will have the next available asset ID and a customizable prefix in the name.
func (e *ItemsRepository) Duplicate(ctx context.Context, gid, id uuid.UUID, options DuplicateOptions) (ItemOut, error) {
tx, err := e.db.Tx(ctx)
if err != nil {
return ItemOut{}, err
}
committed := false
defer func() {
if !committed {
if err := tx.Rollback(); err != nil {
log.Warn().Err(err).Msg("failed to rollback transaction during item duplication")
}
}
}()
// Get the original item with all its data
originalItem, err := e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid)))
if err != nil {
return ItemOut{}, err
}
nextAssetID, err := e.GetHighestAssetID(ctx, gid)
if err != nil {
return ItemOut{}, err
}
nextAssetID++
// Set default copy prefix if not provided
if options.CopyPrefix == "" {
options.CopyPrefix = "Copy of "
}
// Create the new item directly in the transaction
newItemID := uuid.New()
itemBuilder := tx.Item.Create().
SetID(newItemID).
SetName(options.CopyPrefix + originalItem.Name).
SetDescription(originalItem.Description).
SetQuantity(originalItem.Quantity).
SetLocationID(originalItem.Location.ID).
SetGroupID(gid).
SetAssetID(int(nextAssetID)).
SetSerialNumber(originalItem.SerialNumber).
SetModelNumber(originalItem.ModelNumber).
SetManufacturer(originalItem.Manufacturer).
SetLifetimeWarranty(originalItem.LifetimeWarranty).
SetWarrantyExpires(originalItem.WarrantyExpires.Time()).
SetWarrantyDetails(originalItem.WarrantyDetails).
SetPurchaseTime(originalItem.PurchaseTime.Time()).
SetPurchaseFrom(originalItem.PurchaseFrom).
SetPurchasePrice(originalItem.PurchasePrice).
SetSoldTime(originalItem.SoldTime.Time()).
SetSoldTo(originalItem.SoldTo).
SetSoldPrice(originalItem.SoldPrice).
SetSoldNotes(originalItem.SoldNotes).
SetNotes(originalItem.Notes).
SetInsured(originalItem.Insured).
SetArchived(originalItem.Archived).
SetSyncChildItemsLocations(originalItem.SyncChildItemsLocations)
if originalItem.Parent != nil {
itemBuilder.SetParentID(originalItem.Parent.ID)
}
// Add labels
if len(originalItem.Labels) > 0 {
labelIDs := make([]uuid.UUID, len(originalItem.Labels))
for i, label := range originalItem.Labels {
labelIDs[i] = label.ID
}
itemBuilder.AddLabelIDs(labelIDs...)
}
_, err = itemBuilder.Save(ctx)
if err != nil {
return ItemOut{}, err
}
// Copy custom fields if requested
if options.CopyCustomFields {
for _, field := range originalItem.Fields {
_, err = tx.ItemField.Create().
SetItemID(newItemID).
SetType(itemfield.Type(field.Type)).
SetName(field.Name).
SetTextValue(field.TextValue).
SetNumberValue(field.NumberValue).
SetBooleanValue(field.BooleanValue).
Save(ctx)
if err != nil {
log.Warn().Err(err).Str("field_name", field.Name).Msg("failed to copy custom field during duplication")
continue
}
}
}
// Copy attachments if requested
if options.CopyAttachments {
for _, att := range originalItem.Attachments {
// Get the original attachment file
originalAttachment, err := tx.Attachment.Query().
Where(attachment.ID(att.ID)).
Only(ctx)
if err != nil {
// Log error but continue to copy other attachments
log.Warn().Err(err).Str("attachment_id", att.ID.String()).Msg("failed to find attachment during duplication")
continue
}
// Create a copy of the attachment with the same file path
// Since files are stored with hash-based paths, this is safe
_, err = tx.Attachment.Create().
SetItemID(newItemID).
SetType(originalAttachment.Type).
SetTitle(originalAttachment.Title).
SetPath(originalAttachment.Path).
SetMimeType(originalAttachment.MimeType).
SetPrimary(originalAttachment.Primary).
Save(ctx)
if err != nil {
log.Warn().Err(err).Str("original_attachment_id", att.ID.String()).Msg("failed to copy attachment during duplication")
continue
}
}
}
// Copy maintenance entries if requested
if options.CopyMaintenance {
maintenanceEntries, err := tx.MaintenanceEntry.Query().
Where(maintenanceentry.HasItemWith(item.ID(id))).
All(ctx)
if err == nil {
for _, entry := range maintenanceEntries {
_, err = tx.MaintenanceEntry.Create().
SetItemID(newItemID).
SetDate(entry.Date).
SetScheduledDate(entry.ScheduledDate).
SetName(entry.Name).
SetDescription(entry.Description).
SetCost(entry.Cost).
Save(ctx)
if err != nil {
log.Warn().Err(err).Str("maintenance_entry_id", entry.ID.String()).Msg("failed to copy maintenance entry during duplication")
continue
}
}
}
}
if err := tx.Commit(); err != nil {
return ItemOut{}, err
}
committed = true
e.publishMutationEvent(gid)
// Get the final item with all copied data
return e.GetOne(ctx, newItemID)
}

View File

@@ -1,213 +0,0 @@
package repo
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
)
func TestItemsRepository_AccentInsensitiveSearch(t *testing.T) {
// Test cases for accent-insensitive search
testCases := []struct {
name string
itemName string
searchQuery string
shouldMatch bool
description string
}{
{
name: "Spanish accented item, search without accents",
itemName: "electrónica",
searchQuery: "electronica",
shouldMatch: true,
description: "Should find 'electrónica' when searching for 'electronica'",
},
{
name: "Spanish accented item, search with accents",
itemName: "electrónica",
searchQuery: "electrónica",
shouldMatch: true,
description: "Should find 'electrónica' when searching for 'electrónica'",
},
{
name: "Non-accented item, search with accents",
itemName: "electronica",
searchQuery: "electrónica",
shouldMatch: true,
description: "Should find 'electronica' when searching for 'electrónica' (bidirectional search)",
},
{
name: "Spanish item with tilde, search without accents",
itemName: "café",
searchQuery: "cafe",
shouldMatch: true,
description: "Should find 'café' when searching for 'cafe'",
},
{
name: "Spanish item without tilde, search with accents",
itemName: "cafe",
searchQuery: "café",
shouldMatch: true,
description: "Should find 'cafe' when searching for 'café' (bidirectional)",
},
{
name: "French accented item, search without accents",
itemName: "pére",
searchQuery: "pere",
shouldMatch: true,
description: "Should find 'pére' when searching for 'pere'",
},
{
name: "French: père without accent, search with accents",
itemName: "pere",
searchQuery: "père",
shouldMatch: true,
description: "Should find 'pere' when searching for 'père' (bidirectional)",
},
{
name: "Mixed case with accents",
itemName: "Electrónica",
searchQuery: "ELECTRONICA",
shouldMatch: true,
description: "Should find 'Electrónica' when searching for 'ELECTRONICA' (case insensitive)",
},
{
name: "Bidirectional: Non-accented item, search with different accents",
itemName: "cafe",
searchQuery: "café",
shouldMatch: true,
description: "Should find 'cafe' when searching for 'café' (bidirectional)",
},
{
name: "Bidirectional: Item with accent, search with different accent",
itemName: "résumé",
searchQuery: "resume",
shouldMatch: true,
description: "Should find 'résumé' when searching for 'resume' (bidirectional)",
},
{
name: "Bidirectional: Spanish ñ to n",
itemName: "espanol",
searchQuery: "español",
shouldMatch: true,
description: "Should find 'espanol' when searching for 'español' (bidirectional ñ)",
},
{
name: "French: français with accent, search without",
itemName: "français",
searchQuery: "francais",
shouldMatch: true,
description: "Should find 'français' when searching for 'francais'",
},
{
name: "French: français without accent, search with",
itemName: "francais",
searchQuery: "français",
shouldMatch: true,
description: "Should find 'francais' when searching for 'français' (bidirectional)",
},
{
name: "French: été with accent, search without",
itemName: "été",
searchQuery: "ete",
shouldMatch: true,
description: "Should find 'été' when searching for 'ete'",
},
{
name: "French: été without accent, search with",
itemName: "ete",
searchQuery: "été",
shouldMatch: true,
description: "Should find 'ete' when searching for 'été' (bidirectional)",
},
{
name: "French: hôtel with accent, search without",
itemName: "hôtel",
searchQuery: "hotel",
shouldMatch: true,
description: "Should find 'hôtel' when searching for 'hotel'",
},
{
name: "French: hôtel without accent, search with",
itemName: "hotel",
searchQuery: "hôtel",
shouldMatch: true,
description: "Should find 'hotel' when searching for 'hôtel' (bidirectional)",
},
{
name: "French: naïve with accent, search without",
itemName: "naïve",
searchQuery: "naive",
shouldMatch: true,
description: "Should find 'naïve' when searching for 'naive'",
},
{
name: "French: naïve without accent, search with",
itemName: "naive",
searchQuery: "naïve",
shouldMatch: true,
description: "Should find 'naive' when searching for 'naïve' (bidirectional)",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Test the normalization logic used in the repository
normalizedSearch := textutils.NormalizeSearchQuery(tc.searchQuery)
// This simulates what happens in the repository
// The original search would find exact matches (case-insensitive)
// The normalized search would find accent-insensitive matches
// Test that our normalization works as expected
if tc.shouldMatch {
// If it should match, then either the original query should match
// or the normalized query should match when applied to the stored data
assert.NotEqual(t, "", normalizedSearch, "Normalized search should not be empty")
// The key insight is that we're searching with both the original and normalized queries
// So "electrónica" will be found when searching for "electronica" because:
// 1. Original search: "electronica" doesn't match "electrónica"
// 2. Normalized search: "electronica" matches the normalized version
t.Logf("✓ %s: Item '%s' should be found with search '%s' (normalized: '%s')",
tc.description, tc.itemName, tc.searchQuery, normalizedSearch)
} else {
t.Logf("✗ %s: Item '%s' should NOT be found with search '%s' (normalized: '%s')",
tc.description, tc.itemName, tc.searchQuery, normalizedSearch)
}
})
}
}
func TestNormalizeSearchQueryIntegration(t *testing.T) {
// Test that the normalization function works correctly
testCases := []struct {
input string
expected string
}{
{"electrónica", "electronica"},
{"café", "cafe"},
{"ELECTRÓNICA", "electronica"},
{"Café París", "cafe paris"},
{"hello world", "hello world"},
// French accented words
{"père", "pere"},
{"français", "francais"},
{"été", "ete"},
{"hôtel", "hotel"},
{"naïve", "naive"},
{"PÈRE", "pere"},
{"FRANÇAIS", "francais"},
{"ÉTÉ", "ete"},
{"HÔTEL", "hotel"},
{"NAÏVE", "naive"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
result := textutils.NormalizeSearchQuery(tc.input)
assert.Equal(t, tc.expected, result, "Normalization should work correctly")
})
}
}

View File

@@ -20,14 +20,14 @@ type LabelRepository struct {
type (
LabelCreate struct {
Name string `json:"name" validate:"required,min=1,max=255"`
Description string `json:"description" validate:"max=1000"`
Description string `json:"description" validate:"max=255"`
Color string `json:"color"`
}
LabelUpdate struct {
ID uuid.UUID `json:"id"`
Name string `json:"name" validate:"required,min=1,max=255"`
Description string `json:"description" validate:"max=1000"`
Description string `json:"description" validate:"max=255"`
Color string `json:"color"`
}
@@ -35,7 +35,6 @@ type (
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -50,7 +49,6 @@ func mapLabelSummary(label *ent.Label) LabelSummary {
ID: label.ID,
Name: label.Name,
Description: label.Description,
Color: label.Color,
CreatedAt: label.CreatedAt,
UpdatedAt: label.UpdatedAt,
}

View File

@@ -1,18 +0,0 @@
package repo
type BarcodeProduct struct {
SearchEngineName string `json:"search_engine_name"`
// Identifications
ModelNumber string `json:"modelNumber"`
Manufacturer string `json:"manufacturer"`
// Extras
Country string `json:"notes"`
Barcode string `json:"barcode"`
ImageURL string `json:"imageURL"`
ImageBase64 string `json:"imageBase64"`
Item ItemCreate `json:"item"`
}

View File

@@ -4,7 +4,6 @@ package repo
import (
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
)
// AllRepos is a container for all the repository interfaces
@@ -20,7 +19,7 @@ type AllRepos struct {
Notifiers *NotifierRepository
}
func New(db *ent.Client, bus *eventbus.EventBus, storage config.Storage, pubSubConn string, thumbnail config.Thumbnail) *AllRepos {
func New(db *ent.Client, bus *eventbus.EventBus, root string) *AllRepos {
return &AllRepos{
Users: &UserRepository{db},
AuthTokens: &TokenRepository{db},
@@ -28,7 +27,7 @@ func New(db *ent.Client, bus *eventbus.EventBus, storage config.Storage, pubSubC
Locations: &LocationRepository{db, bus},
Labels: &LabelRepository{db, bus},
Items: &ItemsRepository{db, bus},
Attachments: &AttachmentRepo{db, storage, pubSubConn, thumbnail},
Attachments: &AttachmentRepo{db, root},
MaintEntry: &MaintenanceEntryRepository{db},
Notifiers: NewNotifierRepository(db),
}

View File

@@ -11,8 +11,6 @@ import (
"github.com/rs/zerolog/log"
)
var startTime = time.Now()
type Data struct {
Domain string `json:"domain"`
Name string `json:"name"`
@@ -20,7 +18,7 @@ type Data struct {
Props map[string]interface{} `json:"props"`
}
func Send(version, buildInfo string) error {
func Send(version, buildInfo string) {
hostData, _ := host.Info()
analytics := Data{
Domain: "homebox.software",
@@ -34,23 +32,22 @@ func Send(version, buildInfo string) error {
"platform_version": hostData.PlatformVersion,
"kernel_arch": hostData.KernelArch,
"virt_type": hostData.VirtualizationSystem,
"uptime_min": time.Since(startTime).Minutes(),
},
}
jsonBody, err := json.Marshal(analytics)
if err != nil {
log.Error().Err(err).Msg("failed to marshal analytics data")
return err
return
}
bodyReader := bytes.NewReader(jsonBody)
req, err := http.NewRequest("POST", "https://a.sysadmins.zone/api/event", bodyReader)
if err != nil {
log.Error().Err(err).Msg("failed to create analytics request")
return err
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Homebox/"+version+"/(https://homebox.software)")
req.Header.Set("User-Agent", "Homebox/"+version+"/"+buildInfo+" (https://homebox.software)")
client := &http.Client{
Timeout: 10 * time.Second,
@@ -59,7 +56,7 @@ func Send(version, buildInfo string) error {
res, err := client.Do(req)
if err != nil {
log.Error().Err(err).Msg("failed to send analytics request")
return err
return
}
defer func() {
@@ -68,5 +65,4 @@ func Send(version, buildInfo string) error {
log.Error().Err(err).Msg("failed to close response body")
}
}()
return nil
}

View File

@@ -28,8 +28,6 @@ type Config struct {
Debug DebugConf `yaml:"debug"`
Options Options `yaml:"options"`
LabelMaker LabelMakerConf `yaml:"labelmaker"`
Thumbnail Thumbnail `yaml:"thumbnail"`
Barcode BarcodeAPIConf `yaml:"barcode"`
}
type Options struct {
@@ -40,12 +38,6 @@ type Options struct {
AllowAnalytics bool `yaml:"allow_analytics" conf:"default:false"`
}
type Thumbnail struct {
Enabled bool `yaml:"enabled" conf:"default:true"`
Width int `yaml:"width" conf:"default:500"`
Height int `yaml:"height" conf:"default:500"`
}
type DebugConf struct {
Enabled bool `yaml:"enabled" conf:"default:false"`
Port string `yaml:"port" conf:"default:4000"`
@@ -61,22 +53,14 @@ type WebConfig struct {
}
type LabelMakerConf struct {
Width int64 `yaml:"width" conf:"default:526"`
Height int64 `yaml:"height" conf:"default:200"`
Padding int64 `yaml:"padding" conf:"default:32"`
Margin int64 `yaml:"margin" conf:"default:32"`
FontSize float64 `yaml:"font_size" conf:"default:32.0"`
PrintCommand *string `yaml:"string"`
AdditionalInformation *string `yaml:"string"`
DynamicLength bool `yaml:"bool" conf:"default:true"`
LabelServiceUrl *string `yaml:"label_service_url"`
LabelServiceTimeout *time.Duration `yaml:"label_service_timeout"`
RegularFontPath *string `yaml:"regular_font_path"`
BoldFontPath *string `yaml:"bold_font_path"`
}
type BarcodeAPIConf struct {
TokenBarcodespider string `yaml:"token_barcodespider"`
Width int64 `yaml:"width" conf:"default:526"`
Height int64 `yaml:"height" conf:"default:200"`
Padding int64 `yaml:"padding" conf:"default:32"`
Margin int64 `yaml:"margin" conf:"default:32"`
FontSize float64 `yaml:"font_size" conf:"default:32.0"`
PrintCommand *string `yaml:"string"`
AdditionalInformation *string `yaml:"string"`
DynamicLength bool `yaml:"bool" conf:"default:true"`
}
// New parses the CLI/Config file and returns a Config struct. If the file argument is an empty string, the

View File

@@ -6,21 +6,16 @@ const (
type Storage struct {
// Data is the path to the root directory
PrefixPath string `yaml:"prefix_path" conf:"default:.data"`
ConnString string `yaml:"conn_string" conf:"default:file:///./"`
Data string `yaml:"data" conf:"default:./.data"`
}
type Database struct {
Driver string `yaml:"driver" conf:"default:sqlite3"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Host string `yaml:"host"`
Port string `yaml:"port"`
Database string `yaml:"database"`
SslMode string `yaml:"ssl_mode" conf:"default:require"`
SslRootCert string `yaml:"ssl_rootcert"`
SslCert string `yaml:"ssl_cert"`
SslKey string `yaml:"ssl_key"`
SqlitePath string `yaml:"sqlite_path" conf:"default:./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite"`
PubSubConnString string `yaml:"pubsub_conn_string" conf:"default:mem://{{ .Topic }}"`
Driver string `yaml:"driver" conf:"default:sqlite3"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Host string `yaml:"host"`
Port string `yaml:"port"`
Database string `yaml:"database"`
SslMode string `yaml:"ssl_mode"`
SqlitePath string `yaml:"sqlite_path" conf:"default:./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite"`
}

View File

@@ -1,35 +1,14 @@
package hasher
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"os"
"strings"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)
var enabled = true
type params struct {
memory uint32
iterations uint32
parallelism uint8
saltLength uint32
keyLength uint32
}
var p = &params{
memory: 64 * 1024,
iterations: 3,
parallelism: 2,
saltLength: 16,
keyLength: 32,
}
func init() { // nolint: gochecknoinits
disableHas := os.Getenv("UNSAFE_DISABLE_PASSWORD_PROJECTION") == "yes_i_am_sure"
@@ -39,108 +18,20 @@ func init() { // nolint: gochecknoinits
}
}
func GenerateRandomBytes(n uint32) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return nil, err
}
return b, nil
}
func HashPassword(password string) (string, error) {
if !enabled {
return password, nil
}
salt, err := GenerateRandomBytes(p.saltLength)
if err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, 64*1024, 3, 2, b64Salt, b64Hash)
return encodedHash, err
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
// CheckPasswordHash checks if the provided password matches the hash.
// Additionally, it returns a boolean indicating whether the password should be rehashed.
func CheckPasswordHash(password, hash string) (bool, bool) {
func CheckPasswordHash(password, hash string) bool {
if !enabled {
return password == hash, false
return password == hash
}
// Compare Argon2id hash first
match, err := comparePasswordAndHash(password, hash)
if err != nil || !match {
// If argon2id hash fails or doesn't match, try bcrypt
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
if err == nil {
// If bcrypt hash matches, return true and indicate rehashing
return true, true
} else {
// If both fail, return false and indicate no rehashing
return false, false
}
}
return match, false
}
func comparePasswordAndHash(password, encodedHash string) (match bool, err error) {
// Extract the parameters, salt and derived key from the encoded password
// hash.
p, salt, hash, err := decodeHash(encodedHash)
if err != nil {
return false, err
}
// Derive the key from the other password using the same parameters.
otherHash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)
// Check that the contents of the hashed passwords are identical. Note
// that we are using the subtle.ConstantTimeCompare() function for this
// to help prevent timing attacks.
if subtle.ConstantTimeCompare(hash, otherHash) == 1 {
return true, nil
}
return false, nil
}
func decodeHash(encodedHash string) (p *params, salt, hash []byte, err error) {
vals := strings.Split(encodedHash, "$")
if len(vals) != 6 {
return nil, nil, nil, fmt.Errorf("invalid hash format")
}
var version int
_, err = fmt.Sscanf(vals[2], "v=%d", &version)
if err != nil {
return nil, nil, nil, err
}
if version != argon2.Version {
return nil, nil, nil, fmt.Errorf("unsupported argon2 version: %d", version)
}
p = &params{}
_, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism)
if err != nil {
return nil, nil, nil, err
}
salt, err = base64.RawStdEncoding.Strict().DecodeString(vals[4])
if err != nil {
return nil, nil, nil, err
}
p.saltLength = uint32(len(salt))
hash, err = base64.RawStdEncoding.Strict().DecodeString(vals[5])
if err != nil {
return nil, nil, nil, err
}
p.keyLength = uint32(len(hash))
return p, salt, hash, nil
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

Some files were not shown because too many files have changed in this diff Show More