mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 21:33:02 +01:00
Compare commits
1 Commits
v0.19.0
...
fix/label-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d182d07b0 |
@@ -1,3 +1,5 @@
|
|||||||
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:22-bullseye
|
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
||||||
|
ARG VARIANT=16-bullseye
|
||||||
|
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
|
||||||
|
|
||||||
RUN sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
|
RUN sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
|
||||||
@@ -3,7 +3,13 @@
|
|||||||
{
|
{
|
||||||
"name": "Node.js & TypeScript",
|
"name": "Node.js & TypeScript",
|
||||||
"build": {
|
"build": {
|
||||||
"dockerfile": "Dockerfile"
|
"dockerfile": "Dockerfile",
|
||||||
|
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||||
|
// Append -bullseye or -buster to pin to an OS version.
|
||||||
|
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||||
|
"args": {
|
||||||
|
"VARIANT": "18-bullseye"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Configure tool-specific properties.
|
// Configure tool-specific properties.
|
||||||
@@ -29,6 +35,6 @@
|
|||||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||||
"remoteUser": "node",
|
"remoteUser": "node",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/go:1": "1.21"
|
"golang": "1.19"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,3 @@
|
|||||||
**/secrets.dev.yaml
|
**/secrets.dev.yaml
|
||||||
**/values.dev.yaml
|
**/values.dev.yaml
|
||||||
README.md
|
README.md
|
||||||
!Dockerfile.rootless
|
|
||||||
|
|||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1 +1 @@
|
|||||||
github: [tankerkiller125,katosdev,tonyaellie]
|
github: [hay-kot]
|
||||||
|
|||||||
18
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
18
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,9 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: "Bug Report"
|
name: "Bug Report"
|
||||||
description: "Submit a bug report for the current release"
|
description: "Submit a bug report for the current release"
|
||||||
labels: ["🕷️ bug"]
|
labels: ["bug"]
|
||||||
projects: ["sysadminsmedia/2"]
|
|
||||||
type: "Bug"
|
|
||||||
body:
|
body:
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: checks
|
id: checks
|
||||||
@@ -21,8 +19,6 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I already read the docs and didn't find an answer.
|
- label: I already read the docs and didn't find an answer.
|
||||||
required: true
|
required: true
|
||||||
- label: I can replicate the issue inside the Demo install.
|
|
||||||
required: true
|
|
||||||
- type: input
|
- type: input
|
||||||
id: homebox-version
|
id: homebox-version
|
||||||
attributes:
|
attributes:
|
||||||
@@ -58,18 +54,6 @@ body:
|
|||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
id: arch
|
|
||||||
attributes:
|
|
||||||
label: OS Architechture
|
|
||||||
description: What type of processor are you running on.
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- x86_64 (AMD, Intel)
|
|
||||||
- ARM64
|
|
||||||
- ARM/v7
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: os-details
|
id: os-details
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
4
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,9 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: "Feature Request"
|
name: "Feature Request"
|
||||||
description: "Submit a feature request for the current release"
|
description: "Submit a feature request for the current release"
|
||||||
labels: ["⬆️ enhancement"]
|
labels: ["feature-request"]
|
||||||
projects: ["sysadminsmedia/2"]
|
|
||||||
type: "Enhancement"
|
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem-statement
|
id: problem-statement
|
||||||
|
|||||||
16
.github/pull_request_template.md
vendored
16
.github/pull_request_template.md
vendored
@@ -55,4 +55,18 @@ _(fill-in or delete this section)_
|
|||||||
|
|
||||||
<!--
|
<!--
|
||||||
Describe how you tested this change.
|
Describe how you tested this change.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
## Release Notes
|
||||||
|
|
||||||
|
_(REQUIRED)_
|
||||||
|
<!--
|
||||||
|
If this PR makes user facing changes, please describe them here. This
|
||||||
|
description will be copied into the release notes/changelog, whenever the
|
||||||
|
next version is released. Keep this section short, and focus on high level
|
||||||
|
changes.
|
||||||
|
Put your text between the block. To omit notes, use NONE within the block.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```release-note
|
||||||
|
```
|
||||||
106
.github/scripts/update_currencies.py
vendored
106
.github/scripts/update_currencies.py
vendored
@@ -1,106 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from requests.adapters import HTTPAdapter, Retry
|
|
||||||
|
|
||||||
API_URL = 'https://restcountries.com/v3.1/all?fields=name,common,currencies'
|
|
||||||
SAVE_PATH = Path('backend/internal/core/currencies/currencies.json')
|
|
||||||
TIMEOUT = 10 # seconds
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s %(levelname)s: %(message)s'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_currencies():
|
|
||||||
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:
|
|
||||||
resp = session.get(API_URL, timeout=TIMEOUT)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logging.error("API request failed: %s", e)
|
|
||||||
return None # signal fatal
|
|
||||||
|
|
||||||
try:
|
|
||||||
countries = resp.json()
|
|
||||||
except ValueError as e:
|
|
||||||
logging.error("Failed to parse JSON response: %s", e)
|
|
||||||
return None # signal fatal
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for country in countries:
|
|
||||||
country_name = country.get('name', {}).get('common') or "Unknown"
|
|
||||||
for code, info in country.get('currencies', {}).items():
|
|
||||||
results.append({
|
|
||||||
'code': code,
|
|
||||||
'local': country_name,
|
|
||||||
'symbol': info.get('symbol', ''),
|
|
||||||
'name': info.get('name', '')
|
|
||||||
})
|
|
||||||
|
|
||||||
# sort by country name for consistency
|
|
||||||
return sorted(results, key=lambda x: x['local'].lower())
|
|
||||||
|
|
||||||
|
|
||||||
def load_existing(path: Path):
|
|
||||||
if not path.exists():
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
with path.open('r', encoding='utf-8') as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (IOError, ValueError) as e:
|
|
||||||
logging.warning("Could not load existing file (%s): %s", path, e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def save_currencies(data, path: Path):
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with path.open('w', encoding='utf-8') as f:
|
|
||||||
json.dump(data, f, ensure_ascii=False, indent=4)
|
|
||||||
logging.info("Wrote %d entries to %s", len(data), path)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
setup_logging()
|
|
||||||
logging.info("🔄 Starting currency update")
|
|
||||||
|
|
||||||
existing = load_existing(SAVE_PATH)
|
|
||||||
new = fetch_currencies()
|
|
||||||
|
|
||||||
# Fatal API / parse error → exit non-zero so the workflow will fail
|
|
||||||
if new is None:
|
|
||||||
logging.error("Aborting: failed to fetch or parse API data.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Empty array → log & exit zero (no file change)
|
|
||||||
if not new:
|
|
||||||
logging.warning("API returned empty list; skipping write.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Identical to existing → nothing to do
|
|
||||||
if new == existing:
|
|
||||||
logging.info("Up-to-date; skipping write.")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Otherwise actually overwrite
|
|
||||||
save_currencies(new, SAVE_PATH)
|
|
||||||
logging.info("✅ Currency file updated.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
44
.github/workflows/binaries-publish.yaml
vendored
44
.github/workflows/binaries-publish.yaml
vendored
@@ -1,44 +0,0 @@
|
|||||||
name: Publish Release Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: [ 'v*.*.*' ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
goreleaser:
|
|
||||||
name: goreleaser
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 9.15.3
|
|
||||||
|
|
||||||
- name: Build Frontend and Copy to Backend
|
|
||||||
working-directory: frontend
|
|
||||||
run: |
|
|
||||||
pnpm install
|
|
||||||
pnpm run build
|
|
||||||
cp -r ./.output/public ../backend/app/api/static/
|
|
||||||
|
|
||||||
- name: Install CoSign
|
|
||||||
working-directory: backend
|
|
||||||
run: |
|
|
||||||
go install github.com/sigstore/cosign/cmd/cosign@latest
|
|
||||||
|
|
||||||
- name: Run GoReleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v5
|
|
||||||
with:
|
|
||||||
workdir: "backend"
|
|
||||||
distribution: goreleaser
|
|
||||||
version: latest
|
|
||||||
args: release --clean
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
52
.github/workflows/clear-stale-docker-images.yml
vendored
52
.github/workflows/clear-stale-docker-images.yml
vendored
@@ -1,52 +0,0 @@
|
|||||||
name: Docker Cleanup
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '00 0 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
delete-old-images-main:
|
|
||||||
name: Delete Untagged Images
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- name: Fetch multi-platform package version SHAs
|
|
||||||
id: multi-arch-digests
|
|
||||||
run: |
|
|
||||||
package1=$(docker manifest inspect ghcr.io/sysadminsmedia/homebox | jq -r '.manifests.[] | .digest' | paste -s -d ' ' -)
|
|
||||||
echo "multi-arch-digests=$package1" >> $GITHUB_OUTPUT
|
|
||||||
- uses: snok/container-retention-policy@v3.0.0
|
|
||||||
with:
|
|
||||||
skip-shas: ${{ steps.multi-arch-digests.outputs.multi-arch-digests }}
|
|
||||||
# The type of account. Can be either 'org' or 'personal'.
|
|
||||||
account: sysadminsmedia
|
|
||||||
# Image name to delete. Supports passing several names as a comma-separated list.
|
|
||||||
image-names: homebox
|
|
||||||
# The cut-off for which to delete images older than. For example '2 days ago UTC'. Timezone is required.
|
|
||||||
cut-off: 90d
|
|
||||||
# Personal access token with read and delete scopes.
|
|
||||||
token: ${{ secrets.CLEANUP_PAT }}
|
|
||||||
# Restrict deletions to images without specific tags. Supports Unix-shell style wildcards
|
|
||||||
skip-tags: "!latest,!latest-rootless,!0.*,!0.*-rootless,!main,!main-rootless,!vnext,!vnext-rootless,!0,!0-rootless" # optional
|
|
||||||
# Do not actually delete images. Print output showing what would have been deleted.
|
|
||||||
dry-run: true # optional, default is false
|
|
||||||
|
|
||||||
delete-old-images-devcache:
|
|
||||||
name: Delete Cache Old Images
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: snok/container-retention-policy@v3.0.0
|
|
||||||
with:
|
|
||||||
# The type of account. Can be either 'org' or 'personal'.
|
|
||||||
account: sysadminsmedia
|
|
||||||
image-names: devcache
|
|
||||||
# The cut-off for which to delete images older than. For example '2 days ago UTC'. Timezone is required.
|
|
||||||
cut-off: 90d
|
|
||||||
# Personal access token with read and delete scopes.
|
|
||||||
token: ${{ secrets.CLEANUP_PAT }}
|
|
||||||
# Do not actually delete images. Print output showing what would have been deleted.
|
|
||||||
dry-run: true # optional, default is false
|
|
||||||
210
.github/workflows/docker-publish-rootless.yaml
vendored
210
.github/workflows/docker-publish-rootless.yaml
vendored
@@ -1,210 +0,0 @@
|
|||||||
name: Docker publish rootless
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '00 0 * * *'
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- '.dockerignore'
|
|
||||||
- '.github/workflows/docker-publish-rootless.yaml'
|
|
||||||
ignore:
|
|
||||||
- 'docs/**'
|
|
||||||
tags: [ 'v*.*.*' ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- '.dockerignore'
|
|
||||||
- '.github/workflows/docker-publish-rootless.yaml'
|
|
||||||
ignore:
|
|
||||||
- 'docs/**'
|
|
||||||
|
|
||||||
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/sysadminsmedia/homebox
|
|
||||||
|
|
||||||
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: |
|
|
||||||
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@v5
|
|
||||||
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@v3
|
|
||||||
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@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
with:
|
|
||||||
image: ghcr.io/amitie10g/binfmt:latest
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=ghcr.io/amitie10g/buildkit:master
|
|
||||||
|
|
||||||
- name: Build and push by digest
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: . # Explicitly specify the build context
|
|
||||||
file: ./Dockerfile.rootless # 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 }}-rootless
|
|
||||||
cache-to: type=registry,ref=ghcr.io/sysadminsmedia/devcache:${{ env.PLATFORM_PAIR }}-${{ env.BRANCH }}-rootless,mode=max,ignore-error=true
|
|
||||||
build-args: |
|
|
||||||
VERSION=${{ github.ref_name }}
|
|
||||||
COMMIT=${{ github.sha }}
|
|
||||||
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@v4
|
|
||||||
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@v4
|
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- 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 }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=ghcr.io/amitie10g/buildkit:master
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
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=-rootless,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 ' *)
|
|
||||||
201
.github/workflows/docker-publish.yaml
vendored
201
.github/workflows/docker-publish.yaml
vendored
@@ -1,201 +0,0 @@
|
|||||||
name: Docker publish
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '00 0 * * *'
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- '.dockerignore'
|
|
||||||
- '.github/workflows/docker-publish.yaml'
|
|
||||||
ignore:
|
|
||||||
- 'docs/**'
|
|
||||||
tags: [ 'v*.*.*' ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- '.dockerignore'
|
|
||||||
- '.github/workflows/docker-publish.yaml'
|
|
||||||
ignore:
|
|
||||||
- 'docs/**'
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOCKERHUB_REPO: sysadminsmedia/homebox
|
|
||||||
GHCR_REPO: ghcr.io/sysadminsmedia/homebox
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read # Allows access to repository contents (read-only)
|
|
||||||
packages: write # Allows pushing to GHCR
|
|
||||||
id-token: write # Allows identity token write access for authentication
|
|
||||||
attestations: write # Needed for signing and attestation (if required)
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
platform:
|
|
||||||
- linux/amd64
|
|
||||||
- linux/arm64
|
|
||||||
- linux/arm/v7
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Prepare
|
|
||||||
run: |
|
|
||||||
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@v5
|
|
||||||
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@v3
|
|
||||||
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@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
with:
|
|
||||||
image: ghcr.io/amitie10g/binfmt:latest
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=ghcr.io/amitie10g/buildkit:master
|
|
||||||
|
|
||||||
- name: Build and push by digest
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
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 }}
|
|
||||||
cache-to: type=registry,ref=ghcr.io/sysadminsmedia/devcache:${{ env.PLATFORM_PAIR}}-${{ env.BRANCH }},mode=max,ignore-error=true
|
|
||||||
build-args: |
|
|
||||||
VERSION=${{ github.ref_name }}
|
|
||||||
COMMIT=${{ github.sha }}
|
|
||||||
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@v4
|
|
||||||
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 # Allows access to repository contents (read-only)
|
|
||||||
packages: write # Allows pushing to GHCR
|
|
||||||
id-token: write # Allows identity token write access for authentication
|
|
||||||
attestations: write # Needed for signing and attestation (if required)
|
|
||||||
needs:
|
|
||||||
- build
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download digests
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=ghcr.io/amitie10g/buildkit:master
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
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
|
|
||||||
|
|
||||||
- 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 ' *)
|
|
||||||
|
|
||||||
94
.github/workflows/e2e-partial.yaml
vendored
94
.github/workflows/e2e-partial.yaml
vendored
@@ -1,94 +0,0 @@
|
|||||||
name: E2E (Playwright)
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
playwright-tests:
|
|
||||||
timeout-minutes: 60
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
shardIndex: [1,2,3,4]
|
|
||||||
shardTotal: [4]
|
|
||||||
name: E2E Playwright Testing ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install Task
|
|
||||||
uses: arduino/setup-task@v1
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.21"
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v3.0.0
|
|
||||||
with:
|
|
||||||
version: 9.12.2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
working-directory: frontend
|
|
||||||
|
|
||||||
- name: Install Go Dependencies
|
|
||||||
run: go mod download
|
|
||||||
working-directory: backend
|
|
||||||
|
|
||||||
- name: Run E2E Tests
|
|
||||||
run: task test:e2e -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
name: Upload partial Playwright report
|
|
||||||
if: ${{ !cancelled() }}
|
|
||||||
with:
|
|
||||||
name: blob-report-${{ matrix.shardIndex }}
|
|
||||||
path: frontend/blob-report/
|
|
||||||
retention-days: 2
|
|
||||||
|
|
||||||
merge-reports:
|
|
||||||
# Merge reports after playwright-tests, even if some shards have failed
|
|
||||||
if: ${{ !cancelled() }}
|
|
||||||
needs: [playwright-tests]
|
|
||||||
name: Merge Playwright Reports
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
- uses: pnpm/action-setup@v3.0.0
|
|
||||||
with:
|
|
||||||
version: 9.12.2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
working-directory: frontend
|
|
||||||
|
|
||||||
- name: Download blob reports from GitHub Actions Artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: frontend/all-blob-reports
|
|
||||||
pattern: blob-report-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Merge into HTML Report
|
|
||||||
run: pnpm exec playwright merge-reports --reporter html,github ./all-blob-reports
|
|
||||||
working-directory: frontend
|
|
||||||
|
|
||||||
- name: Upload HTML report
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: html-report--attempt-${{ github.run_attempt }}
|
|
||||||
path: frontend/playwright-report
|
|
||||||
retention-days: 30
|
|
||||||
13
.github/workflows/partial-backend.yaml
vendored
13
.github/workflows/partial-backend.yaml
vendored
@@ -7,12 +7,12 @@ jobs:
|
|||||||
Go:
|
Go:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "1.21"
|
go-version: 1.19
|
||||||
|
|
||||||
- name: Install Task
|
- name: Install Task
|
||||||
uses: arduino/setup-task@v1
|
uses: arduino/setup-task@v1
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v7
|
uses: golangci/golangci-lint-action@v3
|
||||||
with:
|
with:
|
||||||
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
||||||
version: latest
|
version: latest
|
||||||
@@ -34,8 +34,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: task go:coverage
|
run: task go:coverage
|
||||||
|
|
||||||
- name: Validate OpenAPI definition
|
|
||||||
uses: swaggerexpert/swagger-editor-validate@v1
|
|
||||||
with:
|
|
||||||
definition-file: backend/app/api/static/docs/swagger.json
|
|
||||||
|
|||||||
86
.github/workflows/partial-frontend.yaml
vendored
86
.github/workflows/partial-frontend.yaml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Frontend
|
name: Frontend / E2E
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
@@ -9,16 +9,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v3.0.0
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 9.12.2
|
version: 6.0.2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install --shamefully-hoist
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Run Lint
|
- name: Run Lint
|
||||||
@@ -32,23 +32,9 @@ jobs:
|
|||||||
integration-tests:
|
integration-tests:
|
||||||
name: Integration Tests
|
name: Integration Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:17
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: homebox
|
|
||||||
POSTGRES_PASSWORD: homebox
|
|
||||||
POSTGRES_DB: homebox
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -58,17 +44,17 @@ jobs:
|
|||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "1.21"
|
go-version: 1.19
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v3.0.0
|
- uses: pnpm/action-setup@v2.2.4
|
||||||
with:
|
with:
|
||||||
version: 9.12.2
|
version: 6.0.2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -76,53 +62,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Integration Tests
|
- name: Run Integration Tests
|
||||||
run: task test:ci
|
run: task test:ci
|
||||||
integration-tests-pgsql:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
database_version: [17,16,15]
|
|
||||||
name: Integration Tests PGSQL ${{ matrix.database_version }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:${{ matrix.database_version }}
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: homebox
|
|
||||||
POSTGRES_PASSWORD: homebox
|
|
||||||
POSTGRES_DB: homebox
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install Task
|
|
||||||
uses: arduino/setup-task@v1
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.21"
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v3.0.0
|
|
||||||
with:
|
|
||||||
version: 9.12.2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
working-directory: frontend
|
|
||||||
|
|
||||||
- name: Run Integration Tests
|
|
||||||
run: task test:ci:postgresql
|
|
||||||
66
.github/workflows/partial-publish.yaml
vendored
Normal file
66
.github/workflows/partial-publish.yaml
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
name: Frontend / E2E
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
release:
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
GH_TOKEN:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: "Publish Homebox"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: 1.19
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
id: qemu
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
with:
|
||||||
|
image: tonistiigi/binfmt:latest
|
||||||
|
platforms: all
|
||||||
|
|
||||||
|
- name: install buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with:
|
||||||
|
install: true
|
||||||
|
|
||||||
|
- name: login to container registry
|
||||||
|
run: docker login ghcr.io --username hay-kot --password $CR_PAT
|
||||||
|
env:
|
||||||
|
CR_PAT: ${{ secrets.GH_TOKEN }}
|
||||||
|
|
||||||
|
- name: build nightly the image
|
||||||
|
if: ${{ inputs.release == false }}
|
||||||
|
run: |
|
||||||
|
docker build --push --no-cache \
|
||||||
|
--tag=ghcr.io/hay-kot/homebox:${{ inputs.tag }} \
|
||||||
|
--build-arg=COMMIT=$(git rev-parse HEAD) \
|
||||||
|
--build-arg=BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
|
||||||
|
--platform=linux/amd64,linux/arm64,linux/arm/v7 .
|
||||||
|
|
||||||
|
- name: build release tagged the image
|
||||||
|
if: ${{ inputs.release == true }}
|
||||||
|
run: |
|
||||||
|
docker build --push --no-cache \
|
||||||
|
--tag ghcr.io/hay-kot/homebox:nightly \
|
||||||
|
--tag ghcr.io/hay-kot/homebox:latest \
|
||||||
|
--tag ghcr.io/hay-kot/homebox:${{ inputs.tag }} \
|
||||||
|
--build-arg VERSION=${{ inputs.tag }} \
|
||||||
|
--build-arg COMMIT=$(git rev-parse HEAD) \
|
||||||
|
--build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
|
||||||
|
--platform linux/amd64,linux/arm64,linux/arm/v7 .
|
||||||
73
.github/workflows/publish.yaml
vendored
Normal file
73
.github/workflows/publish.yaml
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
name: Build Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-tests:
|
||||||
|
name: "Backend Server Tests"
|
||||||
|
uses: hay-kot/homebox/.github/workflows/partial-backend.yaml@main
|
||||||
|
|
||||||
|
frontend-tests:
|
||||||
|
name: "Frontend and End-to-End Tests"
|
||||||
|
uses: hay-kot/homebox/.github/workflows/partial-frontend.yaml@main
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: "Deploy Nightly to Fly.io"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- backend-tests
|
||||||
|
- frontend-tests
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||||
|
- run: flyctl deploy --remote-only
|
||||||
|
|
||||||
|
publish-nightly:
|
||||||
|
name: "Publish Nightly"
|
||||||
|
if: github.event_name != 'release'
|
||||||
|
needs:
|
||||||
|
- backend-tests
|
||||||
|
- frontend-tests
|
||||||
|
uses: hay-kot/homebox/.github/workflows/partial-publish.yaml@main
|
||||||
|
with:
|
||||||
|
tag: nightly
|
||||||
|
secrets:
|
||||||
|
GH_TOKEN: ${{ secrets.CR_PAT }}
|
||||||
|
|
||||||
|
publish-tag:
|
||||||
|
name: "Publish Tag"
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
needs:
|
||||||
|
- backend-tests
|
||||||
|
- frontend-tests
|
||||||
|
uses: hay-kot/homebox/.github/workflows/partial-publish.yaml@main
|
||||||
|
with:
|
||||||
|
release: true
|
||||||
|
tag: ${{ github.event.release.tag_name }}
|
||||||
|
secrets:
|
||||||
|
GH_TOKEN: ${{ secrets.CR_PAT }}
|
||||||
|
|
||||||
|
deploy-docs:
|
||||||
|
name: Deploy docs
|
||||||
|
needs:
|
||||||
|
- publish-tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout main
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Deploy docs
|
||||||
|
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CONFIG_FILE: docs/mkdocs.yml
|
||||||
|
EXTRA_PACKAGES: build-base
|
||||||
12
.github/workflows/pull-requests.yaml
vendored
12
.github/workflows/pull-requests.yaml
vendored
@@ -4,12 +4,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- vnext
|
|
||||||
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'frontend/**'
|
|
||||||
- '.github/workflows/**'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-tests:
|
backend-tests:
|
||||||
@@ -17,9 +11,5 @@ jobs:
|
|||||||
uses: ./.github/workflows/partial-backend.yaml
|
uses: ./.github/workflows/partial-backend.yaml
|
||||||
|
|
||||||
frontend-tests:
|
frontend-tests:
|
||||||
name: "Frontend Tests"
|
name: "Frontend and End-to-End Tests"
|
||||||
uses: ./.github/workflows/partial-frontend.yaml
|
uses: ./.github/workflows/partial-frontend.yaml
|
||||||
|
|
||||||
e2e-tests:
|
|
||||||
name: "End-to-End Playwright Tests"
|
|
||||||
uses: ./.github/workflows/e2e-partial.yaml
|
|
||||||
55
.github/workflows/update-currencies.yml
vendored
55
.github/workflows/update-currencies.yml
vendored
@@ -1,55 +0,0 @@
|
|||||||
name: Update Currencies
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-currencies:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: '3.8'
|
|
||||||
cache: 'pip'
|
|
||||||
cache-dependency-path: .github/workflows/update-currencies/requirements.txt
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install requests
|
|
||||||
|
|
||||||
- name: Run currency update script
|
|
||||||
run: python .github/scripts/update_currencies.py
|
|
||||||
|
|
||||||
- name: Check for file changes
|
|
||||||
id: changes
|
|
||||||
run: |
|
|
||||||
if git diff --quiet; then
|
|
||||||
echo "changed=false" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "changed=true" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create Pull Request
|
|
||||||
if: env.changed == 'true'
|
|
||||||
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: backend/internal/core/currencies/currencies.json
|
|
||||||
|
|
||||||
- name: No updates needed
|
|
||||||
if: env.changed == 'false'
|
|
||||||
run: echo "✅ currencies.json is already up-to-date"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
requests
|
|
||||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -2,9 +2,6 @@
|
|||||||
backend/.data/*
|
backend/.data/*
|
||||||
config.yml
|
config.yml
|
||||||
homebox.db
|
homebox.db
|
||||||
homebox.db-journal
|
|
||||||
homebox.db-shm
|
|
||||||
homebox.db-wal
|
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
test-mailer.json
|
test-mailer.json
|
||||||
@@ -36,7 +33,7 @@ go.work
|
|||||||
backend/.env
|
backend/.env
|
||||||
build/*
|
build/*
|
||||||
|
|
||||||
# Output Directory for Nuxt/Frontend during build steps
|
# Output Directory for Nuxt/Frontend during build step
|
||||||
backend/app/api/public/*
|
backend/app/api/public/*
|
||||||
!backend/app/api/public/.gitkeep
|
!backend/app/api/public/.gitkeep
|
||||||
|
|
||||||
@@ -51,19 +48,4 @@ dist
|
|||||||
|
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
backend/app/api/app
|
backend/app/api/app
|
||||||
backend/app/api/__debug_bin*
|
backend/app/api/__debug_bin
|
||||||
dist/
|
|
||||||
|
|
||||||
# Nuxt Publish Dir
|
|
||||||
backend/app/api/static/public/*
|
|
||||||
!backend/app/api/static/public/.gitkeep
|
|
||||||
backend/api
|
|
||||||
|
|
||||||
docs/.vitepress/cache/
|
|
||||||
/.data/
|
|
||||||
|
|
||||||
# Playwright
|
|
||||||
frontend/test-results/
|
|
||||||
frontend/playwright-report/
|
|
||||||
frontend/blob-report/
|
|
||||||
frontend/playwright/.cache/
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
# yaml-language-server: $schema=https://hay-kot.github.io/scaffold/schema.json
|
|
||||||
messages:
|
|
||||||
pre: |
|
|
||||||
# Ent Model Generation
|
|
||||||
|
|
||||||
With Boilerplate!
|
|
||||||
post: |
|
|
||||||
Complete!
|
|
||||||
|
|
||||||
questions:
|
|
||||||
- name: "model"
|
|
||||||
prompt:
|
|
||||||
message: "What is the name of the model? (PascalCase)"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- name: "by_group"
|
|
||||||
prompt:
|
|
||||||
confirm: "Include a Group Edge? (group_id -> id)"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
rewrites:
|
|
||||||
- from: 'templates/model.go'
|
|
||||||
to: 'backend/internal/data/ent/schema/{{ lower .Scaffold.model }}.go'
|
|
||||||
|
|
||||||
inject:
|
|
||||||
- name: "Insert Groups Edge"
|
|
||||||
path: 'backend/internal/data/ent/schema/group.go'
|
|
||||||
at: // $scaffold_edge
|
|
||||||
template: |
|
|
||||||
{{- if .Scaffold.by_group -}}
|
|
||||||
owned("{{ lower .Scaffold.model }}s", {{ .Scaffold.model }}.Type),
|
|
||||||
{{- end -}}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package schema
|
|
||||||
|
|
||||||
import (
|
|
||||||
"entgo.io/ent"
|
|
||||||
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/schema/mixins"
|
|
||||||
)
|
|
||||||
|
|
||||||
type {{ .Scaffold.model }} struct {
|
|
||||||
ent.Schema
|
|
||||||
}
|
|
||||||
|
|
||||||
func ({{ .Scaffold.model }}) Mixin() []ent.Mixin {
|
|
||||||
return []ent.Mixin{
|
|
||||||
mixins.BaseMixin{},
|
|
||||||
{{- if .Scaffold.by_group }}
|
|
||||||
GroupMixin{ref: "{{ snakecase .Scaffold.model }}s"},
|
|
||||||
{{- end }}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fields of the {{ .Scaffold.model }}.
|
|
||||||
func ({{ .Scaffold.model }}) Fields() []ent.Field {
|
|
||||||
return []ent.Field{
|
|
||||||
// field.String("name").
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edges of the {{ .Scaffold.model }}.
|
|
||||||
func ({{ .Scaffold.model }}) Edges() []ent.Edge {
|
|
||||||
return []ent.Edge{
|
|
||||||
// edge.From("group", Group.Type).
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ({{ .Scaffold.model }}) Indexes() []ent.Index {
|
|
||||||
return []ent.Index{
|
|
||||||
// index.Fields("token"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@@ -23,9 +23,8 @@
|
|||||||
"HBOX_LOG_LEVEL": "debug",
|
"HBOX_LOG_LEVEL": "debug",
|
||||||
"HBOX_DEBUG_ENABLED": "true",
|
"HBOX_DEBUG_ENABLED": "true",
|
||||||
"HBOX_STORAGE_DATA": "${workspaceRoot}/backend/.data",
|
"HBOX_STORAGE_DATA": "${workspaceRoot}/backend/.data",
|
||||||
"HBOX_STORAGE_SQLITE_URL": "${workspaceRoot}/backend/.data/homebox.db?_fk=1&_time_format=sqlite"
|
"HBOX_STORAGE_SQLITE_URL": "${workspaceRoot}/backend/.data/homebox.db?_fk=1"
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Launch Frontend",
|
"name": "Launch Frontend",
|
||||||
@@ -39,11 +38,10 @@
|
|||||||
"cwd": "${workspaceFolder}/frontend",
|
"cwd": "${workspaceFolder}/frontend",
|
||||||
"serverReadyAction": {
|
"serverReadyAction": {
|
||||||
"action": "debugWithChrome",
|
"action": "debugWithChrome",
|
||||||
"pattern": "Local: +http://localhost:([0-9]+)",
|
"pattern": "Local: http://localhost:([0-9]+)",
|
||||||
"uriFormat": "http://localhost:%s",
|
"uriFormat": "http://localhost:%s",
|
||||||
"webRoot": "${workspaceFolder}/frontend"
|
"webRoot": "${workspaceFolder}/frontend"
|
||||||
},
|
}
|
||||||
"console": "integratedTerminal",
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -16,7 +16,7 @@
|
|||||||
"editor.formatOnSave": false,
|
"editor.formatOnSave": false,
|
||||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": "explicit"
|
"source.fixAll.eslint": true
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
@@ -30,8 +30,5 @@
|
|||||||
"editor.quickSuggestions": {
|
"editor.quickSuggestions": {
|
||||||
"strings": true
|
"strings": true
|
||||||
},
|
},
|
||||||
"tailwindCSS.experimental.configFile": "./frontend/tailwind.config.js",
|
"tailwindCSS.experimental.configFile": "./frontend/tailwind.config.js"
|
||||||
"[go]": {
|
|
||||||
"editor.defaultFormatter": "golang.go"
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
# Contributor Covenant Code of Conduct
|
|
||||||
|
|
||||||
## Our Pledge
|
|
||||||
|
|
||||||
We as members, contributors, and leaders pledge to make participation in our
|
|
||||||
community a harassment-free experience for everyone, regardless of age, body
|
|
||||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
|
||||||
identity and expression, level of experience, education, socio-economic status,
|
|
||||||
nationality, personal appearance, race, religion, or sexual identity
|
|
||||||
and orientation.
|
|
||||||
|
|
||||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
|
||||||
diverse, inclusive, and healthy community.
|
|
||||||
|
|
||||||
## Our Standards
|
|
||||||
|
|
||||||
Examples of behavior that contributes to a positive environment for our
|
|
||||||
community include:
|
|
||||||
|
|
||||||
* Demonstrating empathy and kindness toward other people
|
|
||||||
* Being respectful of differing opinions, viewpoints, and experiences
|
|
||||||
* Giving and gracefully accepting constructive feedback
|
|
||||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
|
||||||
and learning from the experience
|
|
||||||
* Focusing on what is best not just for us as individuals, but for the
|
|
||||||
overall community
|
|
||||||
|
|
||||||
Examples of unacceptable behavior include:
|
|
||||||
|
|
||||||
* The use of sexualized language or imagery, and sexual attention or
|
|
||||||
advances of any kind
|
|
||||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
|
||||||
* Public or private harassment
|
|
||||||
* Publishing others' private information, such as a physical or email
|
|
||||||
address, without their explicit permission
|
|
||||||
* Other conduct which could reasonably be considered inappropriate in a
|
|
||||||
professional setting
|
|
||||||
|
|
||||||
## Enforcement Responsibilities
|
|
||||||
|
|
||||||
Community leaders are responsible for clarifying and enforcing our standards of
|
|
||||||
acceptable behavior and will take appropriate and fair corrective action in
|
|
||||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
|
||||||
or harmful.
|
|
||||||
|
|
||||||
Community leaders have the right and responsibility to remove, edit, or reject
|
|
||||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
|
||||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
|
||||||
decisions when appropriate.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This Code of Conduct applies within all community spaces, and also applies when
|
|
||||||
an individual is officially representing the community in public spaces.
|
|
||||||
Examples of representing our community include using an official e-mail address,
|
|
||||||
posting via an official social media account, or acting as an appointed
|
|
||||||
representative at an online or offline event.
|
|
||||||
|
|
||||||
## Enforcement
|
|
||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
||||||
reported to the community leaders responsible for enforcement at
|
|
||||||
[support@sysadminsmedia.com](mailto:support@sysadminsmedia.com).
|
|
||||||
All complaints will be reviewed and investigated promptly and fairly.
|
|
||||||
|
|
||||||
All community leaders are obligated to respect the privacy and security of the
|
|
||||||
reporter of any incident.
|
|
||||||
|
|
||||||
## Enforcement Guidelines
|
|
||||||
|
|
||||||
Community leaders will follow these Community Impact Guidelines in determining
|
|
||||||
the consequences for any action they deem in violation of this Code of Conduct:
|
|
||||||
|
|
||||||
### 1. Correction
|
|
||||||
|
|
||||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
|
||||||
unprofessional or unwelcome in the community.
|
|
||||||
|
|
||||||
**Consequence**: A private, written warning from community leaders, providing
|
|
||||||
clarity around the nature of the violation and an explanation of why the
|
|
||||||
behavior was inappropriate. A public apology may be requested.
|
|
||||||
|
|
||||||
### 2. Warning
|
|
||||||
|
|
||||||
**Community Impact**: A violation through a single incident or series
|
|
||||||
of actions.
|
|
||||||
|
|
||||||
**Consequence**: A warning with consequences for continued behavior. No
|
|
||||||
interaction with the people involved, including unsolicited interaction with
|
|
||||||
those enforcing the Code of Conduct, for a specified period of time. This
|
|
||||||
includes avoiding interactions in community spaces as well as external channels
|
|
||||||
like social media. Violating these terms may lead to a temporary or
|
|
||||||
permanent ban.
|
|
||||||
|
|
||||||
### 3. Temporary Ban
|
|
||||||
|
|
||||||
**Community Impact**: A serious violation of community standards, including
|
|
||||||
sustained inappropriate behavior.
|
|
||||||
|
|
||||||
**Consequence**: A temporary ban from any sort of interaction or public
|
|
||||||
communication with the community for a specified period of time. No public or
|
|
||||||
private interaction with the people involved, including unsolicited interaction
|
|
||||||
with those enforcing the Code of Conduct, is allowed during this period.
|
|
||||||
Violating these terms may lead to a permanent ban.
|
|
||||||
|
|
||||||
### 4. Permanent Ban
|
|
||||||
|
|
||||||
**Community Impact**: Demonstrating a pattern of violation of community
|
|
||||||
standards, including sustained inappropriate behavior, harassment of an
|
|
||||||
individual, or aggression toward or disparagement of classes of individuals.
|
|
||||||
|
|
||||||
**Consequence**: A permanent ban from any sort of public interaction within
|
|
||||||
the community.
|
|
||||||
|
|
||||||
## Attribution
|
|
||||||
|
|
||||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
|
||||||
version 2.0, available at
|
|
||||||
[Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html).
|
|
||||||
|
|
||||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
|
||||||
enforcement ladder](https://github.com/mozilla/diversity).
|
|
||||||
|
|
||||||
[homepage]: https://www.contributor-covenant.org
|
|
||||||
|
|
||||||
For answers to common questions about this code of conduct, see the FAQ at
|
|
||||||
[FAQ](https://www.contributor-covenant.org/faq). Translations are available at
|
|
||||||
[Translations](https://www.contributor-covenant.org/translations).
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
## We Develop with GitHub
|
## We Develop with Github
|
||||||
|
|
||||||
We use GitHub to host code, to track issues and feature requests, as well as accept pull requests.
|
We use github to host code, to track issues and feature requests, as well as accept pull requests.
|
||||||
|
|
||||||
## Branch Flow
|
## Branch Flow
|
||||||
|
|
||||||
We use the `main` branch as the development branch. All PRs should be made to the `main` branch from a feature branch. To create a pull request, you can use the following steps:
|
We use the `main` branch as the development branch. All PRs should be made to the `main` branch from a feature branch. To create a pull request you can use the following steps:
|
||||||
|
|
||||||
1. Fork the repository and create a new branch from `main`.
|
1. Fork the repository and create a new branch from `main`.
|
||||||
2. If you've added code that should be tested, add tests.
|
2. If you've added code that should be tested, add tests.
|
||||||
3. If you've changed APIs, update the documentation.
|
3. If you've changed API's, update the documentation.
|
||||||
4. Ensure that the test suite and linters pass
|
4. Ensure that the test suite and linters pass
|
||||||
5. Issue your pull request
|
5. Issue your pull request
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ We use the `main` branch as the development branch. All PRs should be made to th
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
There is a devcontainer available for this project. If you are using VSCode, you can use the devcontainer to get started. If you are not using VSCode, you need to ensure that you have the following tools installed:
|
There is a devcontainer available for this project. If you are using VSCode, you can use the devcontainer to get started. If you are not using VSCode, you can need to ensure that you have the following tools installed:
|
||||||
|
|
||||||
- [Go 1.19+](https://golang.org/doc/install)
|
- [Go 1.19+](https://golang.org/doc/install)
|
||||||
- [Swaggo](https://github.com/swaggo/swag)
|
- [Swaggo](https://github.com/swaggo/swag)
|
||||||
@@ -31,25 +31,21 @@ If you're using `taskfile` you can run `task --list-all` for a list of all comma
|
|||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
If you're using the taskfile, you can use the `task setup` command to run the required setup commands. Otherwise, you can review the commands required in the `Taskfile.yml` file.
|
If you're using the taskfile you can use the `task setup` command to run the required setup commands. Otherwise you can review the commands required in the `Taskfile.yml` file.
|
||||||
|
|
||||||
|
Note that when installing dependencies with pnpm you must use the `--shamefully-hoist` flag. If you don't use this flag you will get an error when running the the frontend server.
|
||||||
|
|
||||||
### API Development Notes
|
### API Development Notes
|
||||||
|
|
||||||
start command `task go:run`
|
start command `task go:run`
|
||||||
|
|
||||||
1. API Server does not auto reload. You'll need to restart the server after making changes.
|
1. API Server does not auto reload. You'll need to restart the server after making changes.
|
||||||
2. Unit tests should be written in Go, however, end-to-end or user story tests should be written in TypeScript using the client library in the frontend directory.
|
2. Unit tests should be written in Go, however end-to-end or user story tests should be written in TypeScript using the client library in the frontend directory.
|
||||||
|
|
||||||
### Frontend Development Notes
|
### Frontend Development Notes
|
||||||
|
|
||||||
start command `task ui:dev`
|
start command `task: ui:dev`
|
||||||
|
|
||||||
1. The frontend is a Vue 3 app with Nuxt.js that uses Tailwind and Shadcn-vue for styling.
|
1. The frontend is a Vue 3 app with Nuxt.js that uses Tailwind and DaisyUI for styling.
|
||||||
2. We're using Vitest for our automated testing. You can run these with `task ui:watch`.
|
2. We're using Vitest for our automated testing. you can run these with `task ui:watch`.
|
||||||
3. Tests require the API server to be running, and in some cases the first run will fail due to a race condition. If this happens, just run the tests again and they should pass.
|
3. Tests require the API server to be running and in some cases the first run will fail due to a race condition. If this happens just run the tests again and they should pass.
|
||||||
|
|
||||||
## Publishing Release
|
|
||||||
|
|
||||||
Create a new tag in GitHub with the version number vX.X.X. This will trigger a new release to be created.
|
|
||||||
|
|
||||||
Test -> Goreleaser -> Publish Release -> Trigger Docker Builds -> Deploy Docs + Fly.io Demo
|
|
||||||
77
Dockerfile
77
Dockerfile
@@ -1,91 +1,50 @@
|
|||||||
# Node dependencies stage
|
|
||||||
FROM public.ecr.aws/docker/library/node:lts-alpine AS frontend-dependencies
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install pnpm globally (caching layer)
|
# Build Nuxt
|
||||||
|
FROM node:17-alpine as frontend-builder
|
||||||
|
WORKDIR /app
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
# Copy package.json and lockfile to leverage caching
|
|
||||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile --shamefully-hoist
|
||||||
|
COPY frontend .
|
||||||
# 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
|
RUN pnpm build
|
||||||
|
|
||||||
# Go dependencies stage
|
# Build API
|
||||||
FROM public.ecr.aws/docker/library/golang:alpine AS builder-dependencies
|
FROM golang:alpine AS builder
|
||||||
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 stage
|
|
||||||
FROM public.ecr.aws/docker/library/golang:alpine AS builder
|
|
||||||
ARG BUILD_TIME
|
ARG BUILD_TIME
|
||||||
ARG COMMIT
|
ARG COMMIT
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
|
|
||||||
# Install necessary build tools
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk upgrade && \
|
apk upgrade && \
|
||||||
apk add --no-cache git build-base gcc g++
|
apk add --update git build-base gcc g++
|
||||||
|
|
||||||
WORKDIR /go/src/app
|
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 .
|
COPY ./backend .
|
||||||
|
RUN go get -d -v ./...
|
||||||
# Clear old public files and copy new ones from frontend build
|
|
||||||
RUN rm -rf ./app/api/public
|
RUN rm -rf ./app/api/public
|
||||||
COPY --from=frontend-builder /app/.output/public ./app/api/static/public
|
COPY --from=frontend-builder /app/.output/public ./app/api/static/public
|
||||||
|
RUN CGO_ENABLED=1 GOOS=linux go build \
|
||||||
# Use cache for Go build artifacts
|
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
|
||||||
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 \
|
-o /go/bin/api \
|
||||||
-v ./app/api/*.go
|
-v ./app/api/*.go
|
||||||
|
|
||||||
# Production stage
|
# Production Stage
|
||||||
FROM public.ecr.aws/docker/library/alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
ENV HBOX_MODE=production
|
ENV HBOX_MODE=production
|
||||||
ENV HBOX_STORAGE_DATA=/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
|
ENV HBOX_STORAGE_SQLITE_URL=/data/homebox.db?_fk=1
|
||||||
|
|
||||||
# Install necessary runtime dependencies
|
RUN apk --no-cache add ca-certificates
|
||||||
RUN apk --no-cache add ca-certificates wget
|
|
||||||
|
|
||||||
# Create application directory and copy over built Go binary
|
|
||||||
RUN mkdir /app
|
RUN mkdir /app
|
||||||
COPY --from=builder /go/bin/api /app
|
COPY --from=builder /go/bin/api /app
|
||||||
|
|
||||||
RUN chmod +x /app/api
|
RUN chmod +x /app/api
|
||||||
|
|
||||||
# Labels and configuration for the final image
|
|
||||||
LABEL Name=homebox Version=0.0.1
|
LABEL Name=homebox Version=0.0.1
|
||||||
LABEL org.opencontainers.image.source="https://github.com/sysadminsmedia/homebox"
|
LABEL org.opencontainers.image.source="https://github.com/hay-kot/homebox"
|
||||||
|
|
||||||
# Expose necessary ports for Homebox
|
|
||||||
EXPOSE 7745
|
EXPOSE 7745
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Healthcheck configuration
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
||||||
CMD [ "wget", "--no-verbose", "--tries=1", "-O", "-", "http://localhost:7745/api/v1/status" ]
|
|
||||||
|
|
||||||
# Persist volume
|
|
||||||
VOLUME [ "/data" ]
|
VOLUME [ "/data" ]
|
||||||
|
|
||||||
# Entrypoint and CMD
|
|
||||||
ENTRYPOINT [ "/app/api" ]
|
ENTRYPOINT [ "/app/api" ]
|
||||||
CMD [ "/data/config.yml" ]
|
CMD [ "/data/config.yml" ]
|
||||||
|
|||||||
@@ -1,97 +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 stage
|
|
||||||
FROM public.ecr.aws/docker/library/golang:alpine AS builder
|
|
||||||
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
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
|
||||||
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_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
|
|
||||||
# Create a nonroot user with UID/GID 65532
|
|
||||||
RUN addgroup -g 65532 nonroot && adduser -u 65532 -G nonroot -S nonroot
|
|
||||||
|
|
||||||
# Create application directory and copy over built Go binary
|
|
||||||
RUN mkdir /app
|
|
||||||
COPY --from=builder --chown=nonroot /go/bin/api /app
|
|
||||||
COPY --from=builder --chown=nonroot /data /data
|
|
||||||
RUN chmod +x /app/api
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Healthcheck configuration
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
||||||
CMD [ "wget", "--no-verbose", "--tries=1", "-O", "-", "http://localhost:7745/api/v1/status" ]
|
|
||||||
|
|
||||||
# Persist volume for data
|
|
||||||
VOLUME [ "/data" ]
|
|
||||||
|
|
||||||
# Entrypoint and CMD
|
|
||||||
USER nonroot
|
|
||||||
ENTRYPOINT [ "/app/api" ]
|
|
||||||
CMD [ "/data/config.yml" ]
|
|
||||||
55
README.md
55
README.md
@@ -1,64 +1,27 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="/docs/public/lilbox.svg" height="200"/>
|
<img src="/docs/docs/assets/img/lilbox.svg" height="200"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 align="center" style="margin-top: -10px"> HomeBox </h1>
|
<h1 align="center" style="margin-top: -10px"> HomeBox </h1>
|
||||||
<p align="center" style="width: 100;">
|
<p align="center" style="width: 100;">
|
||||||
<a href="https://homebox.software/en/">Docs</a>
|
<a href="https://hay-kot.github.io/homebox/">Docs</a>
|
||||||
|
|
|
|
||||||
<a href="https://demo.homebox.software">Demo</a>
|
<a href="https://homebox.fly.dev">Demo</a>
|
||||||
|
|
|
|
||||||
<a href="https://discord.gg/aY4DCkpNA9">Discord</a>
|
<a href="https://discord.gg/tuncmNrE4z">Discord</a>
|
||||||
</p>
|
</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, I've tried to keep the following principles in mind:
|
|
||||||
|
|
||||||
- _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
|
## Quick Start
|
||||||
|
|
||||||
[Configuration & Docker Compose](https://homebox.software/en/quick-start.html)
|
[Configuration & Docker Compose](https://hay-kot.github.io/homebox/quick-start)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# If using the rootless image, ensure data
|
docker run --name=homebox \
|
||||||
# folder has correct permissions
|
--restart=always \
|
||||||
mkdir -p /path/to/data/folder
|
--publish=3100:7745 \
|
||||||
chown 65532:65532 -R /path/to/data/folder
|
ghcr.io/hay-kot/homebox:latest
|
||||||
docker run -d \
|
|
||||||
--name homebox \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--publish 3100:7745 \
|
|
||||||
--env TZ=Europe/Bucharest \
|
|
||||||
--volume /path/to/data/folder/:/data \
|
|
||||||
ghcr.io/sysadminsmedia/homebox:latest
|
|
||||||
# ghcr.io/sysadminsmedia/homebox:latest-rootless
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- CONTRIBUTING -->
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
|
||||||
|
|
||||||
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/).
|
|
||||||
|
|
||||||
[](http://translate.sysadminsmedia.com/engage/homebox/)
|
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
- Original project by [@hay-kot](https://github.com/hay-kot)
|
|
||||||
- Logo by [@lakotelman](https://github.com/lakotelman)
|
- Logo by [@lakotelman](https://github.com/lakotelman)
|
||||||
|
|||||||
@@ -6,6 +6,4 @@ Since this software is still considered beta/WIP support is always only given fo
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Please open a normal public issue for minor security issues or general security inquires.
|
Please open a normal public issue if you have any security related concerns.
|
||||||
|
|
||||||
For major or critical security issues, please open a private github security issue.
|
|
||||||
167
Taskfile.yml
167
Taskfile.yml
@@ -1,9 +1,7 @@
|
|||||||
version: "3"
|
version: "3"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
HBOX_LOG_LEVEL: debug
|
HBOX_STORAGE_SQLITE_URL: .data/homebox.db?_fk=1
|
||||||
HBOX_DATABASE_DRIVER: sqlite3
|
|
||||||
HBOX_DATABASE_SQLITE_PATH: .data/homebox.db?_pragma=busy_timeout=1000&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite
|
|
||||||
HBOX_OPTIONS_ALLOW_REGISTRATION: true
|
HBOX_OPTIONS_ALLOW_REGISTRATION: true
|
||||||
UNSAFE_DISABLE_PASSWORD_PROJECTION: "yes_i_am_sure"
|
UNSAFE_DISABLE_PASSWORD_PROJECTION: "yes_i_am_sure"
|
||||||
tasks:
|
tasks:
|
||||||
@@ -11,110 +9,64 @@ tasks:
|
|||||||
desc: Install development dependencies
|
desc: Install development dependencies
|
||||||
cmds:
|
cmds:
|
||||||
- go install github.com/swaggo/swag/cmd/swag@latest
|
- go install github.com/swaggo/swag/cmd/swag@latest
|
||||||
- go install github.com/pressly/goose/v3/cmd/goose@v3.8.0
|
|
||||||
- cd backend && go mod tidy
|
- cd backend && go mod tidy
|
||||||
- cd frontend && pnpm install
|
- cd frontend && pnpm install --shamefully-hoist
|
||||||
|
|
||||||
swag:
|
generate:
|
||||||
desc: Generate swagger docs
|
desc: |
|
||||||
dir: backend/app/api/static/
|
Generates collateral files from the backend project
|
||||||
vars:
|
including swagger docs and typescripts type for the frontend
|
||||||
API: "../"
|
deps:
|
||||||
INTERNAL: "../../../internal"
|
- db:generate
|
||||||
PKGS: "../../../pkgs"
|
|
||||||
cmds:
|
|
||||||
- swag fmt --dir={{ .API }}
|
|
||||||
- swag init --dir={{ .API }},{{ .INTERNAL }}/core/services,{{ .INTERNAL }}/data/repo --parseDependency
|
|
||||||
sources:
|
|
||||||
- "./backend/app/api/**/*"
|
|
||||||
- "./backend/internal/data/**"
|
|
||||||
- "./backend/internal/core/services/**/*"
|
|
||||||
- "./backend/app/tools/typegen/main.go"
|
|
||||||
typescript-types:
|
|
||||||
desc: Generates typescript types from swagger definition
|
|
||||||
cmds:
|
cmds:
|
||||||
|
- cd backend/app/api/static && swag fmt --dir=../
|
||||||
|
- cd backend/app/api/static && swag init --dir=../,../../../internal,../../../pkgs
|
||||||
- |
|
- |
|
||||||
pnpm dlx swagger-typescript-api generate \
|
npx swagger-typescript-api \
|
||||||
--no-client \
|
--no-client \
|
||||||
--modular \
|
--modular \
|
||||||
--path ./backend/app/api/static/docs/swagger.json \
|
--path ./backend/app/api/static/docs/swagger.json \
|
||||||
--output ./frontend/lib/api/types
|
--output ./frontend/lib/api/types
|
||||||
- go run ./backend/app/tools/typegen/main.go ./frontend/lib/api/types/data-contracts.ts
|
- go run ./scripts/process-types/*.go ./frontend/lib/api/types/data-contracts.ts
|
||||||
sources:
|
sources:
|
||||||
- ./backend/app/tools/typegen/main.go
|
- "./backend/app/api/**/*"
|
||||||
- ./backend/app/api/static/docs/swagger.json
|
- "./backend/internal/data/**"
|
||||||
|
- "./backend/internal/services/**/*"
|
||||||
generate:
|
- "./scripts/process-types.py"
|
||||||
deps:
|
generates:
|
||||||
- db:generate
|
- "./frontend/lib/api/types/data-contracts.ts"
|
||||||
cmds:
|
- "./backend/internal/data/ent/schema"
|
||||||
- task: swag
|
- "./backend/app/api/static/docs/swagger.json"
|
||||||
- task: typescript-types
|
- "./backend/app/api/static/docs/swagger.yaml"
|
||||||
- cp ./backend/app/api/static/docs/swagger.json docs/en/api/openapi-2.0.json
|
|
||||||
- cp ./backend/app/api/static/docs/swagger.yaml docs/en/api/openapi-2.0.yaml
|
|
||||||
|
|
||||||
go:run:
|
go:run:
|
||||||
env:
|
|
||||||
HBOX_DEMO: true
|
|
||||||
desc: Starts the backend api server (depends on generate task)
|
desc: Starts the backend api server (depends on generate task)
|
||||||
dir: backend
|
|
||||||
deps:
|
deps:
|
||||||
- generate
|
- generate
|
||||||
cmds:
|
cmds:
|
||||||
- go run ./app/api/ {{ .CLI_ARGS }}
|
- cd backend && go run ./app/api/ {{ .CLI_ARGS }}
|
||||||
silent: false
|
silent: false
|
||||||
|
|
||||||
go:run:postgresql:
|
|
||||||
env:
|
|
||||||
HBOX_DEMO: true
|
|
||||||
HBOX_DATABASE_DRIVER: postgres
|
|
||||||
HBOX_DATABASE_USERNAME: homebox
|
|
||||||
HBOX_DATABASE_PASSWORD: homebox
|
|
||||||
HBOX_DATABASE_DATABASE: homebox
|
|
||||||
HBOX_DATABASE_HOST: localhost
|
|
||||||
HBOX_DATABASE_PORT: 5432
|
|
||||||
HBOX_DATABASE_SSL_MODE: disable
|
|
||||||
desc: Starts the backend api server with postgresql (depends on generate task)
|
|
||||||
dir: backend
|
|
||||||
deps:
|
|
||||||
- generate
|
|
||||||
cmds:
|
|
||||||
- go run ./app/api/ {{ .CLI_ARGS }}
|
|
||||||
silent: false
|
|
||||||
|
|
||||||
go:ci:
|
|
||||||
env:
|
|
||||||
HBOX_DEMO: true
|
|
||||||
desc: Runs all go test and lint related tasks
|
|
||||||
dir: backend
|
|
||||||
cmds:
|
|
||||||
- go run ./app/api/ {{ .CLI_ARGS }} &
|
|
||||||
silent: true
|
|
||||||
|
|
||||||
go:test:
|
go:test:
|
||||||
desc: Runs all go tests using gotestsum - supports passing gotestsum args
|
desc: Runs all go tests using gotestsum - supports passing gotestsum args
|
||||||
dir: backend
|
|
||||||
cmds:
|
cmds:
|
||||||
- gotestsum {{ .CLI_ARGS }} ./...
|
- cd backend && gotestsum {{ .CLI_ARGS }} ./...
|
||||||
|
|
||||||
go:coverage:
|
go:coverage:
|
||||||
desc: Runs all go tests with -race flag and generates a coverage report
|
desc: Runs all go tests with -race flag and generates a coverage report
|
||||||
dir: backend
|
|
||||||
cmds:
|
cmds:
|
||||||
- go test -race -coverprofile=coverage.out -covermode=atomic ./app/... ./internal/... ./pkgs/... -v -cover
|
- cd backend && go test -race -coverprofile=coverage.out -covermode=atomic ./app/... ./internal/... ./pkgs/... -v -cover
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
go:tidy:
|
go:tidy:
|
||||||
desc: Runs go mod tidy on the backend
|
desc: Runs go mod tidy on the backend
|
||||||
dir: backend
|
|
||||||
cmds:
|
cmds:
|
||||||
- go mod tidy
|
- cd backend && go mod tidy
|
||||||
|
|
||||||
go:lint:
|
go:lint:
|
||||||
desc: Runs golangci-lint
|
desc: Runs golangci-lint
|
||||||
dir: backend
|
|
||||||
cmds:
|
cmds:
|
||||||
- golangci-lint run ./...
|
- cd backend && golangci-lint run ./...
|
||||||
|
|
||||||
go:all:
|
go:all:
|
||||||
desc: Runs all go test and lint related tasks
|
desc: Runs all go test and lint related tasks
|
||||||
@@ -125,87 +77,56 @@ tasks:
|
|||||||
|
|
||||||
go:build:
|
go:build:
|
||||||
desc: Builds the backend binary
|
desc: Builds the backend binary
|
||||||
dir: backend
|
|
||||||
cmds:
|
cmds:
|
||||||
- go build -o ../build/backend ./app/api
|
- cd backend && go build -o ../build/backend ./app/api
|
||||||
|
|
||||||
db:generate:
|
db:generate:
|
||||||
desc: Run Entgo.io Code Generation
|
desc: Run Entgo.io Code Generation
|
||||||
dir: backend/internal/
|
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
go generate ./...
|
cd backend/internal/ && go generate ./... \
|
||||||
|
--template=./data/ent/schema/templates/has_id.tmpl
|
||||||
sources:
|
sources:
|
||||||
- "./backend/internal/data/ent/schema/**/*"
|
- "./backend/internal/data/ent/schema/**/*"
|
||||||
|
generates:
|
||||||
|
- "./backend/internal/ent/"
|
||||||
|
|
||||||
|
db:migration:
|
||||||
|
desc: Runs the database diff engine to generate a SQL migration files
|
||||||
|
deps:
|
||||||
|
- db:generate
|
||||||
|
cmds:
|
||||||
|
- cd backend && go run app/tools/migrations/main.go {{ .CLI_ARGS }}
|
||||||
|
|
||||||
ui:watch:
|
ui:watch:
|
||||||
desc: Starts the vitest test runner in watch mode
|
desc: Starts the vitest test runner in watch mode
|
||||||
dir: frontend
|
|
||||||
cmds:
|
cmds:
|
||||||
- pnpm run test:watch
|
- cd frontend && pnpm run test:watch
|
||||||
|
|
||||||
ui:dev:
|
ui:dev:
|
||||||
desc: Run frontend development server
|
desc: Run frontend development server
|
||||||
dir: frontend
|
|
||||||
cmds:
|
cmds:
|
||||||
- pnpm dev
|
- cd frontend && pnpm dev
|
||||||
|
|
||||||
ui:ci:
|
|
||||||
desc: Run frontend build in CI mode
|
|
||||||
dir: frontend
|
|
||||||
cmds:
|
|
||||||
- pnpm dev &
|
|
||||||
silent: true
|
|
||||||
|
|
||||||
ui:fix:
|
ui:fix:
|
||||||
desc: Runs prettier and eslint on the frontend
|
desc: Runs prettier and eslint on the frontend
|
||||||
dir: frontend
|
|
||||||
cmds:
|
cmds:
|
||||||
- pnpm run lint:fix
|
- cd frontend && pnpm run lint:fix
|
||||||
|
|
||||||
ui:check:
|
ui:check:
|
||||||
desc: Runs type checking
|
desc: Runs type checking
|
||||||
dir: frontend
|
|
||||||
cmds:
|
cmds:
|
||||||
- pnpm run typecheck
|
- cd frontend && pnpm run typecheck
|
||||||
|
|
||||||
test:ci:
|
test:ci:
|
||||||
desc: Runs end-to-end test on a live server (only for use in CI)
|
desc: Runs end-to-end test on a live server (only for use in CI)
|
||||||
cmds:
|
cmds:
|
||||||
- cd backend && go build ./app/api
|
- cd backend && go build ./app/api
|
||||||
- backend/api &
|
- backend/api &
|
||||||
- sleep 10
|
- sleep 5
|
||||||
- cd frontend && pnpm run test:ci
|
- cd frontend && pnpm run test:ci
|
||||||
silent: true
|
silent: true
|
||||||
|
|
||||||
test:ci:postgresql:
|
|
||||||
env:
|
|
||||||
HBOX_DATABASE_DRIVER: postgres
|
|
||||||
HBOX_DATABASE_USERNAME: homebox
|
|
||||||
HBOX_DATABASE_PASSWORD: homebox
|
|
||||||
HBOX_DATABASE_DATABASE: homebox
|
|
||||||
HBOX_DATABASE_HOST: 127.0.0.1
|
|
||||||
HBOX_DATABASE_PORT: 5432
|
|
||||||
HBOX_DATABASE_SSL_MODE: disable
|
|
||||||
desc: Runs end-to-end test on a live server with postgresql (only for use in CI)
|
|
||||||
cmds:
|
|
||||||
- cd backend && go build ./app/api
|
|
||||||
- backend/api &
|
|
||||||
- sleep 10
|
|
||||||
- cd frontend && pnpm run test:ci
|
|
||||||
silent: true
|
|
||||||
|
|
||||||
test:e2e:
|
|
||||||
desc: Runs end-to-end test on a live server
|
|
||||||
dir: frontend
|
|
||||||
cmds:
|
|
||||||
- task: go:ci
|
|
||||||
- task: ui:ci
|
|
||||||
- pnpm exec playwright install-deps
|
|
||||||
- pnpm exec playwright install
|
|
||||||
- sleep 30
|
|
||||||
- TEST_SHUTDOWN_API_SERVER=true pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }}
|
|
||||||
|
|
||||||
pr:
|
pr:
|
||||||
desc: Runs all tasks required for a PR
|
desc: Runs all tasks required for a PR
|
||||||
cmds:
|
cmds:
|
||||||
@@ -213,4 +134,4 @@ tasks:
|
|||||||
- task: go:all
|
- task: go:all
|
||||||
- task: ui:check
|
- task: ui:check
|
||||||
- task: ui:fix
|
- task: ui:fix
|
||||||
- task: test:ci
|
- task: test:ci
|
||||||
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
dist/
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
version: "2"
|
|
||||||
linters:
|
|
||||||
default: none
|
|
||||||
enable:
|
|
||||||
- asciicheck
|
|
||||||
- bodyclose
|
|
||||||
- copyloopvar
|
|
||||||
- depguard
|
|
||||||
- dogsled
|
|
||||||
- errcheck
|
|
||||||
- errorlint
|
|
||||||
- exhaustive
|
|
||||||
- gochecknoinits
|
|
||||||
- goconst
|
|
||||||
- gocritic
|
|
||||||
- gocyclo
|
|
||||||
- goprintffuncname
|
|
||||||
- govet
|
|
||||||
- ineffassign
|
|
||||||
- misspell
|
|
||||||
- nakedret
|
|
||||||
- revive
|
|
||||||
- sqlclosecheck
|
|
||||||
- staticcheck
|
|
||||||
- tagalign
|
|
||||||
- testifylint
|
|
||||||
- unconvert
|
|
||||||
- unused
|
|
||||||
- whitespace
|
|
||||||
- zerologlint
|
|
||||||
settings:
|
|
||||||
depguard:
|
|
||||||
rules:
|
|
||||||
main:
|
|
||||||
deny:
|
|
||||||
- pkg: io/util
|
|
||||||
desc: |
|
|
||||||
Deprecated: As of Go 1.16, the same functionality is now provided by
|
|
||||||
package io or package os, and those implementations should be
|
|
||||||
preferred in new code. See the specific function documentation for
|
|
||||||
details.
|
|
||||||
exhaustive:
|
|
||||||
default-signifies-exhaustive: true
|
|
||||||
goconst:
|
|
||||||
min-len: 5
|
|
||||||
min-occurrences: 5
|
|
||||||
gocritic:
|
|
||||||
enabled-checks:
|
|
||||||
- ruleguard
|
|
||||||
revive:
|
|
||||||
confidence: 3
|
|
||||||
severity: warning
|
|
||||||
tagalign:
|
|
||||||
order:
|
|
||||||
- json
|
|
||||||
- schema
|
|
||||||
- yaml
|
|
||||||
- yml
|
|
||||||
- toml
|
|
||||||
- validate
|
|
||||||
testifylint:
|
|
||||||
enable-all: true
|
|
||||||
exclusions:
|
|
||||||
generated: lax
|
|
||||||
paths:
|
|
||||||
- internal/data/ent.*
|
|
||||||
- third_party$
|
|
||||||
- builtin$
|
|
||||||
- examples$
|
|
||||||
issues:
|
|
||||||
fix: true
|
|
||||||
formatters:
|
|
||||||
enable:
|
|
||||||
- gofmt
|
|
||||||
exclusions:
|
|
||||||
generated: lax
|
|
||||||
paths:
|
|
||||||
- internal/data/ent.*
|
|
||||||
- third_party$
|
|
||||||
- builtin$
|
|
||||||
- examples$
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
version: 2
|
|
||||||
|
|
||||||
# This is an example .goreleaser.yml file with some sensible defaults.
|
|
||||||
# Make sure to check the documentation at https://goreleaser.com
|
|
||||||
before:
|
|
||||||
hooks:
|
|
||||||
# you may remove this if you don't need go generate
|
|
||||||
- go generate ./...
|
|
||||||
builds:
|
|
||||||
- main: ./app/api
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
- windows
|
|
||||||
- darwin
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
- "386"
|
|
||||||
- arm
|
|
||||||
- arm64
|
|
||||||
ignore:
|
|
||||||
- goos: windows
|
|
||||||
goarch: arm
|
|
||||||
- goos: windows
|
|
||||||
goarch: "386"
|
|
||||||
|
|
||||||
signs:
|
|
||||||
- cmd: cosign
|
|
||||||
stdin: "{{ .Env.COSIGN_PWD }}"
|
|
||||||
args:
|
|
||||||
- "sign-blob"
|
|
||||||
- "--key=cosign.key"
|
|
||||||
- "--output-signature=${signature}"
|
|
||||||
- "${artifact}"
|
|
||||||
- "--yes" # needed on cosign 2.0.0+
|
|
||||||
artifacts: all
|
|
||||||
|
|
||||||
archives:
|
|
||||||
- formats: [ 'tar.gz' ]
|
|
||||||
# this name template makes the OS and Arch compatible with the results of uname.
|
|
||||||
name_template: >-
|
|
||||||
{{ .ProjectName }}_
|
|
||||||
{{- title .Os }}_
|
|
||||||
{{- if eq .Arch "amd64" }}x86_64
|
|
||||||
{{- else if eq .Arch "386" }}i386
|
|
||||||
{{- else }}{{ .Arch }}{{ end }}
|
|
||||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
|
||||||
# use zip for windows archives
|
|
||||||
format_overrides:
|
|
||||||
- goos: windows
|
|
||||||
formats: [ 'zip' ]
|
|
||||||
|
|
||||||
release:
|
|
||||||
extra_files:
|
|
||||||
- glob: dist/*.sig
|
|
||||||
|
|
||||||
checksum:
|
|
||||||
name_template: 'checksums.txt'
|
|
||||||
snapshot:
|
|
||||||
version_template: "{{ incpatch .Version }}-next"
|
|
||||||
changelog:
|
|
||||||
sort: asc
|
|
||||||
filters:
|
|
||||||
exclude:
|
|
||||||
- '^docs:'
|
|
||||||
- '^test:'
|
|
||||||
|
|
||||||
# The lines beneath this are called `modelines`. See `:help modeline`
|
|
||||||
# Feel free to remove those if you don't want/use them.
|
|
||||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
|
||||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
"time"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/sysadminsmedia/homebox/backend/pkgs/mailer"
|
"github.com/hay-kot/homebox/backend/internal/sys/config"
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/mailer"
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
conf *config.Config
|
conf *config.Config
|
||||||
mailer mailer.Mailer
|
mailer mailer.Mailer
|
||||||
db *ent.Client
|
db *ent.Client
|
||||||
|
server *server.Server
|
||||||
repos *repo.AllRepos
|
repos *repo.AllRepos
|
||||||
services *services.AllServices
|
services *services.AllServices
|
||||||
bus *eventbus.EventBus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func new(conf *config.Config) *app {
|
func new(conf *config.Config) *app {
|
||||||
@@ -33,3 +35,10 @@ func new(conf *config.Config) *app {
|
|||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *app) startBgTask(t time.Duration, fn func()) {
|
||||||
|
for {
|
||||||
|
a.server.Background(fn)
|
||||||
|
time.Sleep(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BackgroundTask struct {
|
|
||||||
name string
|
|
||||||
Interval time.Duration
|
|
||||||
Fn func(context.Context)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tsk *BackgroundTask) Name() string {
|
|
||||||
return tsk.name
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTask(name string, interval time.Duration, fn func(context.Context)) *BackgroundTask {
|
|
||||||
return &BackgroundTask{
|
|
||||||
Interval: interval,
|
|
||||||
Fn: fn,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tsk *BackgroundTask) Start(ctx context.Context) error {
|
|
||||||
tsk.Fn(ctx)
|
|
||||||
|
|
||||||
timer := time.NewTimer(tsk.Interval)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil
|
|
||||||
case <-timer.C:
|
|
||||||
timer.Reset(tsk.Interval)
|
|
||||||
tsk.Fn(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,66 +2,61 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"encoding/csv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *app) SetupDemo() error {
|
func (a *app) SetupDemo() {
|
||||||
csvText := `HB.import_ref,HB.location,HB.labels,HB.quantity,HB.name,HB.description,HB.insured,HB.serial_number,HB.model_number,HB.manufacturer,HB.notes,HB.purchase_from,HB.purchase_price,HB.purchase_time,HB.lifetime_warranty,HB.warranty_expires,HB.warranty_details,HB.sold_to,HB.sold_price,HB.sold_time,HB.sold_notes
|
csvText := `Import Ref,Location,Labels,Quantity,Name,Description,Insured,Serial Number,Model Number,Manufacturer,Notes,Purchase From,Purchased Price,Purchased Time,Lifetime Warranty,Warranty Expires,Warranty Details,Sold To,Sold Price,Sold Time,Sold Notes
|
||||||
,Garage,IOT;Home Assistant; Z-Wave,1,Zooz Universal Relay ZEN17,"Zooz 700 Series Z-Wave Universal Relay ZEN17 for Awnings, Garage Doors, Sprinklers, and More | 2 NO-C-NC Relays (20A, 10A) | Signal Repeater | Hub Required (Compatible with SmartThings and Hubitat)",,,ZEN17,Zooz,,Amazon,39.95,10/13/2021,,,,,,,
|
,Garage,IOT;Home Assistant; Z-Wave,1,Zooz Universal Relay ZEN17,"Zooz 700 Series Z-Wave Universal Relay ZEN17 for Awnings, Garage Doors, Sprinklers, and More | 2 NO-C-NC Relays (20A, 10A) | Signal Repeater | Hub Required (Compatible with SmartThings and Hubitat)",,,ZEN17,Zooz,,Amazon,39.95,10/13/2021,,,,,,,
|
||||||
,Living Room,IOT;Home Assistant; Z-Wave,1,Zooz Motion Sensor,"Zooz Z-Wave Plus S2 Motion Sensor ZSE18 with Magnetic Mount, Works with Vera and SmartThings",,,ZSE18,Zooz,,Amazon,29.95,10/15/2021,,,,,,,
|
,Living Room,IOT;Home Assistant; Z-Wave,1,Zooz Motion Sensor,"Zooz Z-Wave Plus S2 Motion Sensor ZSE18 with Magnetic Mount, Works with Vera and SmartThings",,,ZSE18,Zooz,,Amazon,29.95,10/15/2021,,,,,,,
|
||||||
,Office,IOT;Home Assistant; Z-Wave,1,Zooz 110v Power Switch,"Zooz Z-Wave Plus Power Switch ZEN15 for 110V AC Units, Sump Pumps, Humidifiers, and More",,,ZEN15,Zooz,,Amazon,39.95,10/13/2021,,,,,,,
|
,Office,IOT;Home Assistant; Z-Wave,1,Zooz 110v Power Switch,"Zooz Z-Wave Plus Power Switch ZEN15 for 110V AC Units, Sump Pumps, Humidifiers, and More",,,ZEN15,Zooz,,Amazon,39.95,10/13/2021,,,,,,,
|
||||||
,Downstairs,IOT;Home Assistant; Z-Wave,1,Ecolink Z-Wave PIR Motion Sensor,"Ecolink Z-Wave PIR Motion Detector Pet Immune, White (PIRZWAVE2.5-ECO)",,,PIRZWAVE2.5-ECO,Ecolink,,Amazon,35.58,10/21/2020,,,,,,,
|
,Downstairs,IOT;Home Assistant; Z-Wave,1,Ecolink Z-Wave PIR Motion Sensor,"Ecolink Z-Wave PIR Motion Detector Pet Immune, White (PIRZWAVE2.5-ECO)",,,PIRZWAVE2.5-ECO,Ecolink,,Amazon,35.58,10/21/2020,,,,,,,
|
||||||
,Entry,IOT;Home Assistant; Z-Wave,1,Yale Security Touchscreen Deadbolt,"Yale Security YRD226-ZW2-619 YRD226ZW2619 Touchscreen Deadbolt, Satin Nickel",,,YRD226ZW2619,Yale,,Amazon,120.39,10/14/2020,,,,,,,
|
,Entry,IOT;Home Assistant; Z-Wave,1,Yale Security Touchscreen Deadbolt,"Yale Security YRD226-ZW2-619 YRD226ZW2619 Touchscreen Deadbolt, Satin Nickel",,,YRD226ZW2619,Yale,,Amazon,120.39,10/14/2020,,,,,,,
|
||||||
,Kitchen,IOT;Home Assistant; Z-Wave,1,Smart Rocker Light Dimmer,"UltraPro Z-Wave Smart Rocker Light Dimmer with QuickFit and SimpleWire, 3-Way Ready, Compatible with Alexa, Google Assistant, ZWave Hub Required, Repeater/Range Extender, White Paddle Only, 39351",,,39351,Honeywell,,Amazon,65.98,09/30/0202,,,,,,,
|
,Kitchen,IOT;Home Assistant; Z-Wave,1,Smart Rocker Light Dimmer,"UltraPro Z-Wave Smart Rocker Light Dimmer with QuickFit and SimpleWire, 3-Way Ready, Compatible with Alexa, Google Assistant, ZWave Hub Required, Repeater/Range Extender, White Paddle Only, 39351",,,39351,Honeywell,,Amazon,65.98,09/30/0202,,,,,,,
|
||||||
`
|
`
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
var (
|
||||||
defer cancel()
|
registration = services.UserRegistration{
|
||||||
|
Email: "demo@example.com",
|
||||||
registration := services.UserRegistration{
|
Name: "Demo",
|
||||||
Email: "demo@example.com",
|
Password: "demo",
|
||||||
Name: "Demo",
|
}
|
||||||
Password: "demo",
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// First check if we've already setup a demo user and skip if so
|
// First check if we've already setup a demo user and skip if so
|
||||||
log.Debug().Msg("Checking if demo user already exists")
|
_, err := a.services.User.Login(context.Background(), registration.Email, registration.Password)
|
||||||
_, err := a.services.User.Login(ctx, registration.Email, registration.Password, false)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
log.Info().Msg("Demo user already exists, skipping setup")
|
return
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug().Msg("Demo user does not exist, setting up demo")
|
_, err = a.services.User.RegisterUser(context.Background(), registration)
|
||||||
_, err = a.services.User.RegisterUser(ctx, registration)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to register demo user")
|
log.Err(err).Msg("Failed to register demo user")
|
||||||
return errors.New("failed to setup demo")
|
log.Fatal().Msg("Failed to setup demo")
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := a.services.User.Login(ctx, registration.Email, registration.Password, false)
|
token, _ := a.services.User.Login(context.Background(), registration.Email, registration.Password)
|
||||||
|
self, _ := a.services.User.GetSelf(context.Background(), token.Raw)
|
||||||
|
|
||||||
|
// Read CSV Text
|
||||||
|
reader := csv.NewReader(strings.NewReader(csvText))
|
||||||
|
reader.Comma = ','
|
||||||
|
|
||||||
|
records, err := reader.ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to login demo user")
|
log.Err(err).Msg("Failed to read CSV")
|
||||||
return errors.New("failed to setup demo")
|
log.Fatal().Msg("Failed to setup demo")
|
||||||
}
|
|
||||||
self, err := a.services.User.GetSelf(ctx, token.Raw)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("Failed to get self")
|
|
||||||
return errors.New("failed to setup demo")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = a.services.Items.CsvImport(ctx, self.GroupID, strings.NewReader(csvText))
|
_, err = a.services.Items.CsvImport(context.Background(), self.GroupID, records)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to import CSV")
|
log.Err(err).Msg("Failed to import CSV")
|
||||||
return errors.New("failed to setup demo")
|
log.Fatal().Msg("Failed to setup demo")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Msg("Demo setup complete")
|
log.Info().Msg("Demo setup complete")
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package debughandlers provides handlers for debugging.
|
|
||||||
package debughandlers
|
package debughandlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,39 +1,13 @@
|
|||||||
// Package v1 provides the API handlers for version 1 of the API.
|
|
||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
|
||||||
|
|
||||||
"github.com/olahol/melody"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Results[T any] struct {
|
|
||||||
Items []T `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func WrapResults[T any](items []T) Results[T] {
|
|
||||||
return Results[T]{Items: items}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Wrapped struct {
|
|
||||||
Item interface{} `json:"item"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Wrap(v any) Wrapped {
|
|
||||||
return Wrapped{Item: v}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithMaxUploadSize(maxUploadSize int64) func(*V1Controller) {
|
func WithMaxUploadSize(maxUploadSize int64) func(*V1Controller) {
|
||||||
return func(ctrl *V1Controller) {
|
return func(ctrl *V1Controller) {
|
||||||
ctrl.maxUploadSize = maxUploadSize
|
ctrl.maxUploadSize = maxUploadSize
|
||||||
@@ -52,28 +26,12 @@ func WithRegistration(allowRegistration bool) func(*V1Controller) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithSecureCookies(secure bool) func(*V1Controller) {
|
|
||||||
return func(ctrl *V1Controller) {
|
|
||||||
ctrl.cookieSecure = secure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func WithURL(url string) func(*V1Controller) {
|
|
||||||
return func(ctrl *V1Controller) {
|
|
||||||
ctrl.url = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type V1Controller struct {
|
type V1Controller struct {
|
||||||
cookieSecure bool
|
|
||||||
repo *repo.AllRepos
|
repo *repo.AllRepos
|
||||||
svc *services.AllServices
|
svc *services.AllServices
|
||||||
maxUploadSize int64
|
maxUploadSize int64
|
||||||
isDemo bool
|
isDemo bool
|
||||||
allowRegistration bool
|
allowRegistration bool
|
||||||
bus *eventbus.EventBus
|
|
||||||
url string
|
|
||||||
config *config.Config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -85,26 +43,28 @@ type (
|
|||||||
BuildTime string `json:"buildTime"`
|
BuildTime string `json:"buildTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
APISummary struct {
|
ApiSummary struct {
|
||||||
Healthy bool `json:"health"`
|
Healthy bool `json:"health"`
|
||||||
Versions []string `json:"versions"`
|
Versions []string `json:"versions"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Build Build `json:"build"`
|
Build Build `json:"build"`
|
||||||
Latest services.Latest `json:"latest"`
|
Demo bool `json:"demo"`
|
||||||
Demo bool `json:"demo"`
|
AllowRegistration bool `json:"allowRegistration"`
|
||||||
AllowRegistration bool `json:"allowRegistration"`
|
|
||||||
LabelPrinting bool `json:"labelPrinting"`
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewControllerV1(svc *services.AllServices, repos *repo.AllRepos, bus *eventbus.EventBus, config *config.Config, options ...func(*V1Controller)) *V1Controller {
|
func BaseUrlFunc(prefix string) func(s string) string {
|
||||||
|
return func(s string) string {
|
||||||
|
return prefix + "/v1" + s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewControllerV1(svc *services.AllServices, repos *repo.AllRepos, options ...func(*V1Controller)) *V1Controller {
|
||||||
ctrl := &V1Controller{
|
ctrl := &V1Controller{
|
||||||
repo: repos,
|
repo: repos,
|
||||||
svc: svc,
|
svc: svc,
|
||||||
allowRegistration: true,
|
allowRegistration: true,
|
||||||
bus: bus,
|
|
||||||
config: config,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
@@ -115,107 +75,20 @@ func NewControllerV1(svc *services.AllServices, repos *repo.AllRepos, bus *event
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleBase godoc
|
// HandleBase godoc
|
||||||
//
|
// @Summary Retrieves the basic information about the API
|
||||||
// @Summary Application Info
|
// @Tags Base
|
||||||
// @Tags Base
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200 {object} ApiSummary
|
||||||
// @Success 200 {object} APISummary
|
// @Router /v1/status [GET]
|
||||||
// @Router /v1/status [GET]
|
func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.JSON(w, http.StatusOK, APISummary{
|
return server.Respond(w, http.StatusOK, ApiSummary{
|
||||||
Healthy: ready(),
|
Healthy: ready(),
|
||||||
Title: "Homebox",
|
Title: "Go API Template",
|
||||||
Message: "Track, Manage, and Organize your Things",
|
Message: "Welcome to the Go API Template Application!",
|
||||||
Build: build,
|
Build: build,
|
||||||
Latest: ctrl.svc.BackgroundService.GetLatestVersion(),
|
|
||||||
Demo: ctrl.isDemo,
|
Demo: ctrl.isDemo,
|
||||||
AllowRegistration: ctrl.allowRegistration,
|
AllowRegistration: ctrl.allowRegistration,
|
||||||
LabelPrinting: ctrl.config.LabelMaker.PrintCommand != nil,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleCurrency godoc
|
|
||||||
//
|
|
||||||
// @Summary Currency
|
|
||||||
// @Tags Base
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} currencies.Currency
|
|
||||||
// @Router /v1/currency [GET]
|
|
||||||
func (ctrl *V1Controller) HandleCurrency() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
// Set Cache for 10 Minutes
|
|
||||||
w.Header().Set("Cache-Control", "max-age=600")
|
|
||||||
|
|
||||||
return server.JSON(w, http.StatusOK, ctrl.svc.Currencies.Slice())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctrl *V1Controller) HandleCacheWS() errchain.HandlerFunc {
|
|
||||||
type eventMsg struct {
|
|
||||||
Event string `json:"event"`
|
|
||||||
}
|
|
||||||
|
|
||||||
m := melody.New()
|
|
||||||
|
|
||||||
m.HandleConnect(func(s *melody.Session) {
|
|
||||||
auth := services.NewContext(s.Request.Context())
|
|
||||||
s.Set("gid", auth.GID)
|
|
||||||
})
|
|
||||||
|
|
||||||
factory := func(e string) func(data any) {
|
|
||||||
return func(data any) {
|
|
||||||
eventData, ok := data.(eventbus.GroupMutationEvent)
|
|
||||||
if !ok {
|
|
||||||
log.Log().Msgf("invalid event data: %v", data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := &eventMsg{Event: e}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(msg)
|
|
||||||
if err != nil {
|
|
||||||
log.Log().Msgf("error marshling event data %v: %v", data, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = m.BroadcastFilter(jsonBytes, func(s *melody.Session) bool {
|
|
||||||
groupIDStr, ok := s.Get("gid")
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
GID := groupIDStr.(uuid.UUID)
|
|
||||||
return GID == eventData.GID
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctrl.bus.Subscribe(eventbus.EventLabelMutation, factory("label.mutation"))
|
|
||||||
ctrl.bus.Subscribe(eventbus.EventLocationMutation, factory("location.mutation"))
|
|
||||||
ctrl.bus.Subscribe(eventbus.EventItemMutation, factory("item.mutation"))
|
|
||||||
|
|
||||||
// Persistent asynchronous ticker that keeps all websocket connections alive with periodic pings.
|
|
||||||
go func() {
|
|
||||||
const interval = 10 * time.Second
|
|
||||||
|
|
||||||
ping := time.NewTicker(interval)
|
|
||||||
defer ping.Stop()
|
|
||||||
|
|
||||||
for range ping.C {
|
|
||||||
msg := &eventMsg{Event: "ping"}
|
|
||||||
|
|
||||||
pingBytes, err := json.Marshal(msg)
|
|
||||||
if err != nil {
|
|
||||||
log.Log().Msgf("error marshaling ping: %v", err)
|
|
||||||
} else {
|
|
||||||
_ = m.Broadcast(pingBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
return m.HandleRequest(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetHBURL(refererHeader, fallback string) (hbURL string) {
|
|
||||||
hbURL = refererHeader
|
|
||||||
if hbURL == "" {
|
|
||||||
hbURL = fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
return stripPathFromURL(hbURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripPathFromURL removes the path from a URL.
|
|
||||||
// ex. https://example.com/tools -> https://example.com
|
|
||||||
func stripPathFromURL(rawURL string) string {
|
|
||||||
parsedURL, err := url.Parse(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to parse URL")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
strippedURL := url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host}
|
|
||||||
|
|
||||||
return strippedURL.String()
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
)
|
)
|
||||||
|
|
||||||
// routeID extracts the ID from the request URL. If the ID is not in a valid
|
// routeID extracts the ID from the request URL. If the ID is not in a valid
|
||||||
@@ -21,7 +21,7 @@ func (ctrl *V1Controller) routeID(r *http.Request) (uuid.UUID, error) {
|
|||||||
func (ctrl *V1Controller) routeUUID(r *http.Request, key string) (uuid.UUID, error) {
|
func (ctrl *V1Controller) routeUUID(r *http.Request, key string) (uuid.UUID, error) {
|
||||||
ID, err := uuid.Parse(chi.URLParam(r, key))
|
ID, err := uuid.Parse(chi.URLParam(r, key))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, validate.NewRouteKeyError(key)
|
return uuid.Nil, validate.NewInvalidRouteKeyError(key)
|
||||||
}
|
}
|
||||||
return ID, nil
|
return ID, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1,56 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ActionAmountResult struct {
|
type ActionAmountResult struct {
|
||||||
Completed int `json:"completed"`
|
Completed int `json:"completed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionHandlerFactory(ref string, fn func(context.Context, uuid.UUID) (int, error)) errchain.HandlerFunc {
|
// HandleGroupInvitationsCreate godoc
|
||||||
|
// @Summary Ensures all items in the database have an asset id
|
||||||
|
// @Tags Group
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} ActionAmountResult
|
||||||
|
// @Router /v1/actions/ensure-asset-ids [Post]
|
||||||
|
// @Security Bearer
|
||||||
|
func (ctrl *V1Controller) HandleEnsureAssetID() server.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := services.NewContext(r.Context())
|
ctx := services.NewContext(r.Context())
|
||||||
|
|
||||||
totalCompleted, err := fn(ctx, ctx.GID)
|
totalCompleted, err := ctrl.svc.Items.EnsureAssetID(ctx, ctx.GID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Str("action_ref", ref).Msg("failed to run action")
|
log.Err(err).Msg("failed to ensure asset id")
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusOK, ActionAmountResult{Completed: totalCompleted})
|
return server.Respond(w, http.StatusOK, ActionAmountResult{Completed: totalCompleted})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleEnsureAssetID godoc
|
|
||||||
//
|
|
||||||
// @Summary Ensure Asset IDs
|
|
||||||
// @Description Ensures all items in the database have an asset ID
|
|
||||||
// @Tags Actions
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} ActionAmountResult
|
|
||||||
// @Router /v1/actions/ensure-asset-ids [Post]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleEnsureAssetID() errchain.HandlerFunc {
|
|
||||||
return actionHandlerFactory("ensure asset IDs", ctrl.svc.Items.EnsureAssetID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleEnsureImportRefs godoc
|
|
||||||
//
|
|
||||||
// @Summary Ensures Import Refs
|
|
||||||
// @Description Ensures all items in the database have an import ref
|
|
||||||
// @Tags Actions
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} ActionAmountResult
|
|
||||||
// @Router /v1/actions/ensure-import-refs [Post]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleEnsureImportRefs() errchain.HandlerFunc {
|
|
||||||
return actionHandlerFactory("ensure import refs", ctrl.svc.Items.EnsureImportRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleItemDateZeroOut godoc
|
// HandleItemDateZeroOut godoc
|
||||||
//
|
// @Summary Resets all item date fields to the beginning of the day
|
||||||
// @Summary Zero Out Time Fields
|
// @Tags Group
|
||||||
// @Description Resets all item date fields to the beginning of the day
|
// @Produce json
|
||||||
// @Tags Actions
|
// @Success 200 {object} ActionAmountResult
|
||||||
// @Produce json
|
// @Router /v1/actions/zero-item-time-fields [Post]
|
||||||
// @Success 200 {object} ActionAmountResult
|
// @Security Bearer
|
||||||
// @Router /v1/actions/zero-item-time-fields [Post]
|
func (ctrl *V1Controller) HandleItemDateZeroOut() server.HandlerFunc {
|
||||||
// @Security Bearer
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
func (ctrl *V1Controller) HandleItemDateZeroOut() errchain.HandlerFunc {
|
ctx := services.NewContext(r.Context())
|
||||||
return actionHandlerFactory("zero out date time", ctrl.repo.Items.ZeroOutTimeFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleSetPrimaryPhotos godoc
|
totalCompleted, err := ctrl.repo.Items.ZeroOutTimeFields(ctx, ctx.GID)
|
||||||
//
|
if err != nil {
|
||||||
// @Summary Set Primary Photos
|
log.Err(err).Msg("failed to ensure asset id")
|
||||||
// @Description Sets the first photo of each item as the primary photo
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
// @Tags Actions
|
}
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} ActionAmountResult
|
return server.Respond(w, http.StatusOK, ActionAmountResult{Completed: totalCompleted})
|
||||||
// @Router /v1/actions/set-primary-photos [Post]
|
}
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleSetPrimaryPhotos() errchain.HandlerFunc {
|
|
||||||
return actionHandlerFactory("ensure asset IDs", ctrl.repo.Items.SetPrimaryPhotos)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,57 +6,55 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleAssetGet godocs
|
// HandleItemGet godocs
|
||||||
//
|
// @Summary Gets an item by Asset ID
|
||||||
// @Summary Get Item by Asset ID
|
// @Tags Assets
|
||||||
// @Tags Items
|
// @Produce json
|
||||||
// @Produce json
|
// @Param id path string true "Asset ID"
|
||||||
// @Param id path string true "Asset ID"
|
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
|
||||||
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
|
// @Router /v1/assets/{id} [GET]
|
||||||
// @Router /v1/assets/{id} [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleAssetGet() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleAssetGet() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := services.NewContext(r.Context())
|
ctx := services.NewContext(r.Context())
|
||||||
assetIDParam := chi.URLParam(r, "id")
|
assetIdParam := chi.URLParam(r, "id")
|
||||||
assetIDParam = strings.ReplaceAll(assetIDParam, "-", "") // Remove dashes
|
assetIdParam = strings.ReplaceAll(assetIdParam, "-", "") // Remove dashes
|
||||||
// Convert the asset ID to an int64
|
// Convert the asset ID to an int64
|
||||||
assetID, err := strconv.ParseInt(assetIDParam, 10, 64)
|
assetId, err := strconv.ParseInt(assetIdParam, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pageParam := r.URL.Query().Get("page")
|
pageParam := r.URL.Query().Get("page")
|
||||||
var page int64 = -1
|
var page int64 = -1
|
||||||
if pageParam != "" {
|
if pageParam != "" {
|
||||||
page, err = strconv.ParseInt(pageParam, 10, 32)
|
page, err = strconv.ParseInt(pageParam, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return server.JSON(w, http.StatusBadRequest, "Invalid page number")
|
return server.Respond(w, http.StatusBadRequest, "Invalid page number")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pageSizeParam := r.URL.Query().Get("pageSize")
|
pageSizeParam := r.URL.Query().Get("pageSize")
|
||||||
var pageSize int64 = -1
|
var pageSize int64 = -1
|
||||||
if pageSizeParam != "" {
|
if pageSizeParam != "" {
|
||||||
pageSize, err = strconv.ParseInt(pageSizeParam, 10, 32)
|
pageSize, err = strconv.ParseInt(pageSizeParam, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return server.JSON(w, http.StatusBadRequest, "Invalid page size")
|
return server.Respond(w, http.StatusBadRequest, "Invalid page size")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := ctrl.repo.Items.QueryByAssetID(r.Context(), ctx.GID, repo.AssetID(assetID), int(page), int(pageSize))
|
items, err := ctrl.repo.Items.QueryByAssetID(r.Context(), ctx.GID, repo.AssetID(assetId), int(page), int(pageSize))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to get item")
|
log.Err(err).Msg("failed to get item")
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return server.JSON(w, http.StatusOK, items)
|
return server.Respond(w, http.StatusOK, items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,13 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
cookieNameToken = "hb.auth.token"
|
|
||||||
cookieNameRemember = "hb.auth.remember"
|
|
||||||
cookieNameSession = "hb.auth.session"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -28,98 +20,62 @@ type (
|
|||||||
}
|
}
|
||||||
|
|
||||||
LoginForm struct {
|
LoginForm struct {
|
||||||
Username string `json:"username" example:"admin@admin.com"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password" example:"admin"`
|
Password string `json:"password"`
|
||||||
StayLoggedIn bool `json:"stayLoggedIn"`
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type CookieContents struct {
|
|
||||||
Token string
|
|
||||||
ExpiresAt time.Time
|
|
||||||
Remember bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetCookies(r *http.Request) (*CookieContents, error) {
|
|
||||||
cookie, err := r.Cookie(cookieNameToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("authorization cookie is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
rememberCookie, err := r.Cookie(cookieNameRemember)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("remember cookie is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CookieContents{
|
|
||||||
Token: cookie.Value,
|
|
||||||
ExpiresAt: cookie.Expires,
|
|
||||||
Remember: rememberCookie.Value == "true",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthProvider is an interface that can be implemented by any authentication provider.
|
|
||||||
// to extend authentication methods for the API.
|
|
||||||
type AuthProvider interface {
|
|
||||||
// Name returns the name of the authentication provider. This should be a unique name.
|
|
||||||
// that is URL friendly.
|
|
||||||
//
|
|
||||||
// Example: "local", "ldap"
|
|
||||||
Name() string
|
|
||||||
// Authenticate is called when a user attempts to login to the API. The implementation
|
|
||||||
// should return an error if the user cannot be authenticated. If an error is returned
|
|
||||||
// the API controller will return a vague error message to the user.
|
|
||||||
//
|
|
||||||
// Authenticate should do the following:
|
|
||||||
//
|
|
||||||
// 1. Ensure that the user exists within the database (either create, or get)
|
|
||||||
// 2. On successful authentication, they must set the user cookies.
|
|
||||||
Authenticate(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleAuthLogin godoc
|
// HandleAuthLogin godoc
|
||||||
//
|
// @Summary User Login
|
||||||
// @Summary User Login
|
// @Tags Authentication
|
||||||
// @Tags Authentication
|
// @Accept x-www-form-urlencoded
|
||||||
// @Accept x-www-form-urlencoded
|
// @Accept application/json
|
||||||
// @Accept application/json
|
// @Param username formData string false "string" example(admin@admin.com)
|
||||||
// @Param payload body LoginForm true "Login Data"
|
// @Param password formData string false "string" example(admin)
|
||||||
// @Param provider query string false "auth provider"
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200 {object} TokenResponse
|
||||||
// @Success 200 {object} TokenResponse
|
// @Router /v1/users/login [POST]
|
||||||
// @Router /v1/users/login [POST]
|
func (ctrl *V1Controller) HandleAuthLogin() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFunc {
|
|
||||||
if len(ps) == 0 {
|
|
||||||
panic("no auth providers provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
providers := make(map[string]AuthProvider)
|
|
||||||
for _, p := range ps {
|
|
||||||
log.Info().Str("name", p.Name()).Msg("registering auth provider")
|
|
||||||
providers[p.Name()] = p
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
// Extract provider query
|
loginForm := &LoginForm{}
|
||||||
provider := r.URL.Query().Get("provider")
|
|
||||||
if provider == "" {
|
switch r.Header.Get("Content-Type") {
|
||||||
provider = "local"
|
case server.ContentFormUrlEncoded:
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
return server.Respond(w, http.StatusBadRequest, server.Wrap(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
loginForm.Username = r.PostFormValue("username")
|
||||||
|
loginForm.Password = r.PostFormValue("password")
|
||||||
|
case server.ContentJSON:
|
||||||
|
err := server.Decode(r, loginForm)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to decode login form")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return server.Respond(w, http.StatusBadRequest, errors.New("invalid content type"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the provider
|
if loginForm.Username == "" || loginForm.Password == "" {
|
||||||
p, ok := providers[provider]
|
return validate.NewFieldErrors(
|
||||||
if !ok {
|
validate.FieldError{
|
||||||
return validate.NewRequestError(errors.New("invalid auth provider"), http.StatusBadRequest)
|
Field: "username",
|
||||||
|
Error: "username or password is empty",
|
||||||
|
},
|
||||||
|
validate.FieldError{
|
||||||
|
Field: "password",
|
||||||
|
Error: "username or password is empty",
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
newToken, err := p.Authenticate(w, r)
|
newToken, err := ctrl.svc.User.Login(r.Context(), strings.ToLower(loginForm.Username), loginForm.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to authenticate")
|
return validate.NewRequestError(errors.New("authentication failed"), http.StatusInternalServerError)
|
||||||
return server.JSON(w, http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true)
|
return server.Respond(w, http.StatusOK, TokenResponse{
|
||||||
return server.JSON(w, http.StatusOK, TokenResponse{
|
|
||||||
Token: "Bearer " + newToken.Raw,
|
Token: "Bearer " + newToken.Raw,
|
||||||
ExpiresAt: newToken.ExpiresAt,
|
ExpiresAt: newToken.ExpiresAt,
|
||||||
AttachmentToken: newToken.AttachmentToken,
|
AttachmentToken: newToken.AttachmentToken,
|
||||||
@@ -128,13 +84,12 @@ func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleAuthLogout godoc
|
// HandleAuthLogout godoc
|
||||||
//
|
// @Summary User Logout
|
||||||
// @Summary User Logout
|
// @Tags Authentication
|
||||||
// @Tags Authentication
|
// @Success 204
|
||||||
// @Success 204
|
// @Router /v1/users/logout [POST]
|
||||||
// @Router /v1/users/logout [POST]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleAuthLogout() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleAuthLogout() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
token := services.UseTokenCtx(r.Context())
|
token := services.UseTokenCtx(r.Context())
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -146,21 +101,19 @@ func (ctrl *V1Controller) HandleAuthLogout() errchain.HandlerFunc {
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrl.unsetCookies(w, noPort(r.Host))
|
return server.Respond(w, http.StatusNoContent, nil)
|
||||||
return server.JSON(w, http.StatusNoContent, nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleAuthRefresh godoc
|
// HandleAuthLogout godoc
|
||||||
//
|
// @Summary User Token Refresh
|
||||||
// @Summary User Token Refresh
|
// @Description handleAuthRefresh returns a handler that will issue a new token from an existing token.
|
||||||
// @Description handleAuthRefresh returns a handler that will issue a new token from an existing token.
|
// @Description This does not validate that the user still exists within the database.
|
||||||
// @Description This does not validate that the user still exists within the database.
|
// @Tags Authentication
|
||||||
// @Tags Authentication
|
// @Success 200
|
||||||
// @Success 200
|
// @Router /v1/users/refresh [GET]
|
||||||
// @Router /v1/users/refresh [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleAuthRefresh() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleAuthRefresh() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
requestToken := services.UseTokenCtx(r.Context())
|
requestToken := services.UseTokenCtx(r.Context())
|
||||||
if requestToken == "" {
|
if requestToken == "" {
|
||||||
@@ -172,78 +125,6 @@ func (ctrl *V1Controller) HandleAuthRefresh() errchain.HandlerFunc {
|
|||||||
return validate.NewUnauthorizedError()
|
return validate.NewUnauthorizedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false)
|
return server.Respond(w, http.StatusOK, newToken)
|
||||||
return server.JSON(w, http.StatusOK, newToken)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func noPort(host string) string {
|
|
||||||
return strings.Split(host, ":")[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: cookieNameRemember,
|
|
||||||
Value: strconv.FormatBool(remember),
|
|
||||||
Expires: expires,
|
|
||||||
Domain: domain,
|
|
||||||
Secure: ctrl.cookieSecure,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set HTTP only cookie
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: cookieNameToken,
|
|
||||||
Value: token,
|
|
||||||
Expires: expires,
|
|
||||||
Domain: domain,
|
|
||||||
Secure: ctrl.cookieSecure,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set Fake Session cookie
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: cookieNameSession,
|
|
||||||
Value: "true",
|
|
||||||
Expires: expires,
|
|
||||||
Domain: domain,
|
|
||||||
Secure: ctrl.cookieSecure,
|
|
||||||
HttpOnly: false,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: cookieNameToken,
|
|
||||||
Value: "",
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
Domain: domain,
|
|
||||||
Secure: ctrl.cookieSecure,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: cookieNameRemember,
|
|
||||||
Value: "false",
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
Domain: domain,
|
|
||||||
Secure: ctrl.cookieSecure,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set Fake Session cookie
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: cookieNameSession,
|
|
||||||
Value: "false",
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
Domain: domain,
|
|
||||||
Secure: ctrl.cookieSecure,
|
|
||||||
HttpOnly: false,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
GroupInvitationCreate struct {
|
GroupInvitationCreate struct {
|
||||||
Uses int `json:"uses" validate:"required,min=1,max=100"`
|
Uses int `json:"uses"`
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,73 +25,93 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HandleGroupGet godoc
|
// HandleGroupGet godoc
|
||||||
//
|
// @Summary Get the current user's group
|
||||||
// @Summary Get Group
|
// @Tags Group
|
||||||
// @Tags Group
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200 {object} repo.Group
|
||||||
// @Success 200 {object} repo.Group
|
// @Router /v1/groups [Get]
|
||||||
// @Router /v1/groups [Get]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleGroupGet() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleGroupGet() errchain.HandlerFunc {
|
return ctrl.handleGroupGeneral()
|
||||||
fn := func(r *http.Request) (repo.Group, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Groups.GroupByID(auth, auth.GID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGroupUpdate godoc
|
// HandleGroupUpdate godoc
|
||||||
//
|
// @Summary Updates some fields of the current users group
|
||||||
// @Summary Update Group
|
// @Tags Group
|
||||||
// @Tags Group
|
// @Produce json
|
||||||
// @Produce json
|
// @Param payload body repo.GroupUpdate true "User Data"
|
||||||
// @Param payload body repo.GroupUpdate true "User Data"
|
// @Success 200 {object} repo.Group
|
||||||
// @Success 200 {object} repo.Group
|
// @Router /v1/groups [Put]
|
||||||
// @Router /v1/groups [Put]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleGroupUpdate() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleGroupUpdate() errchain.HandlerFunc {
|
return ctrl.handleGroupGeneral()
|
||||||
fn := func(r *http.Request, body repo.GroupUpdate) (repo.Group, error) {
|
}
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
|
|
||||||
ok := ctrl.svc.Currencies.IsSupported(body.Currency)
|
func (ctrl *V1Controller) handleGroupGeneral() server.HandlerFunc {
|
||||||
if !ok {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
return repo.Group{}, validate.NewFieldErrors(
|
ctx := services.NewContext(r.Context())
|
||||||
validate.NewFieldError("currency", "currency '"+body.Currency+"' is not supported"),
|
|
||||||
)
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
group, err := ctrl.repo.Groups.GroupByID(ctx, ctx.GID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to get group")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusOK, group)
|
||||||
|
|
||||||
|
case http.MethodPut:
|
||||||
|
data := repo.GroupUpdate{}
|
||||||
|
if err := server.Decode(r, &data); err != nil {
|
||||||
|
return validate.NewRequestError(err, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := ctrl.svc.Group.UpdateGroup(ctx, data)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to update group")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusOK, group)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctrl.svc.Group.UpdateGroup(auth, body)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGroupInvitationsCreate godoc
|
// HandleGroupInvitationsCreate godoc
|
||||||
//
|
// @Summary Get the current user
|
||||||
// @Summary Create Group Invitation
|
// @Tags Group
|
||||||
// @Tags Group
|
// @Produce json
|
||||||
// @Produce json
|
// @Param payload body GroupInvitationCreate true "User Data"
|
||||||
// @Param payload body GroupInvitationCreate true "User Data"
|
// @Success 200 {object} GroupInvitation
|
||||||
// @Success 200 {object} GroupInvitation
|
// @Router /v1/groups/invitations [Post]
|
||||||
// @Router /v1/groups/invitations [Post]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleGroupInvitationsCreate() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleGroupInvitationsCreate() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request, body GroupInvitationCreate) (GroupInvitation, error) {
|
data := GroupInvitationCreate{}
|
||||||
if body.ExpiresAt.IsZero() {
|
if err := server.Decode(r, &data); err != nil {
|
||||||
body.ExpiresAt = time.Now().Add(time.Hour * 24)
|
log.Err(err).Msg("failed to decode user registration data")
|
||||||
|
return validate.NewRequestError(err, http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
auth := services.NewContext(r.Context())
|
if data.ExpiresAt.IsZero() {
|
||||||
|
data.ExpiresAt = time.Now().Add(time.Hour * 24)
|
||||||
|
}
|
||||||
|
|
||||||
token, err := ctrl.svc.Group.NewInvitation(auth, body.Uses, body.ExpiresAt)
|
ctx := services.NewContext(r.Context())
|
||||||
|
|
||||||
return GroupInvitation{
|
token, err := ctrl.svc.Group.NewInvitation(ctx, data.Uses, data.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to create new token")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusCreated, GroupInvitation{
|
||||||
Token: token,
|
Token: token,
|
||||||
ExpiresAt: body.ExpiresAt,
|
ExpiresAt: data.ExpiresAt,
|
||||||
Uses: body.Uses,
|
Uses: data.Uses,
|
||||||
}, err
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,30 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/csv"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleItemsGetAll godoc
|
// HandleItemsGetAll godoc
|
||||||
//
|
// @Summary Get All Items
|
||||||
// @Summary Query All Items
|
// @Tags Items
|
||||||
// @Tags Items
|
// @Produce json
|
||||||
// @Produce json
|
// @Param q query string false "search string"
|
||||||
// @Param q query string false "search string"
|
// @Param page query int false "page number"
|
||||||
// @Param page query int false "page number"
|
// @Param pageSize query int false "items per page"
|
||||||
// @Param pageSize query int false "items per page"
|
// @Param labels query []string false "label Ids" collectionFormat(multi)
|
||||||
// @Param labels query []string false "label Ids" collectionFormat(multi)
|
// @Param locations query []string false "location Ids" collectionFormat(multi)
|
||||||
// @Param locations query []string false "location Ids" collectionFormat(multi)
|
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
|
||||||
// @Param parentIds query []string false "parent Ids" collectionFormat(multi)
|
// @Router /v1/items [GET]
|
||||||
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
|
// @Security Bearer
|
||||||
// @Router /v1/items [GET]
|
func (ctrl *V1Controller) HandleItemsGetAll() server.HandlerFunc {
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc {
|
|
||||||
extractQuery := func(r *http.Request) repo.ItemQuery {
|
extractQuery := func(r *http.Request) repo.ItemQuery {
|
||||||
params := r.URL.Query()
|
params := r.URL.Query()
|
||||||
|
|
||||||
@@ -55,18 +46,13 @@ func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
v := repo.ItemQuery{
|
v := repo.ItemQuery{
|
||||||
Page: queryIntOrNegativeOne(params.Get("page")),
|
Page: queryIntOrNegativeOne(params.Get("page")),
|
||||||
PageSize: queryIntOrNegativeOne(params.Get("pageSize")),
|
PageSize: queryIntOrNegativeOne(params.Get("pageSize")),
|
||||||
Search: params.Get("q"),
|
Search: params.Get("q"),
|
||||||
LocationIDs: queryUUIDList(params, "locations"),
|
LocationIDs: queryUUIDList(params, "locations"),
|
||||||
LabelIDs: queryUUIDList(params, "labels"),
|
LabelIDs: queryUUIDList(params, "labels"),
|
||||||
NegateLabels: queryBool(params.Get("negateLabels")),
|
IncludeArchived: queryBool(params.Get("includeArchived")),
|
||||||
OnlyWithoutPhoto: queryBool(params.Get("onlyWithoutPhoto")),
|
Fields: filterFieldItems(params["fields"]),
|
||||||
OnlyWithPhoto: queryBool(params.Get("onlyWithPhoto")),
|
|
||||||
ParentItemIDs: queryUUIDList(params, "parentIds"),
|
|
||||||
IncludeArchived: queryBool(params.Get("includeArchived")),
|
|
||||||
Fields: filterFieldItems(params["fields"]),
|
|
||||||
OrderBy: params.Get("orderBy"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(v.Search, "#") {
|
if strings.HasPrefix(v.Search, "#") {
|
||||||
@@ -86,225 +72,176 @@ func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc {
|
|||||||
ctx := services.NewContext(r.Context())
|
ctx := services.NewContext(r.Context())
|
||||||
|
|
||||||
items, err := ctrl.repo.Items.QueryByGroup(ctx, ctx.GID, extractQuery(r))
|
items, err := ctrl.repo.Items.QueryByGroup(ctx, ctx.GID, extractQuery(r))
|
||||||
totalPrice := new(big.Int)
|
|
||||||
for _, item := range items.Items {
|
|
||||||
if !item.SoldTime.IsZero() { // Skip items with a non-null SoldDate
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalPrice.Add(totalPrice, big.NewInt(int64(item.PurchasePrice*100)))
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPriceFloat := new(big.Float).SetInt(totalPrice)
|
|
||||||
totalPriceFloat.Quo(totalPriceFloat, big.NewFloat(100))
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return server.JSON(w, http.StatusOK, repo.PaginationResult[repo.ItemSummary]{
|
return server.Respond(w, http.StatusOK, repo.PaginationResult[repo.ItemSummary]{
|
||||||
Items: []repo.ItemSummary{},
|
Items: []repo.ItemSummary{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
log.Err(err).Msg("failed to get items")
|
log.Err(err).Msg("failed to get items")
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
return server.JSON(w, http.StatusOK, items)
|
return server.Respond(w, http.StatusOK, items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemFullPath godoc
|
|
||||||
//
|
|
||||||
// @Summary Get the full path of an item
|
|
||||||
// @Tags Items
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Item ID"
|
|
||||||
// @Success 200 {object} []repo.ItemPath
|
|
||||||
// @Router /v1/items/{id}/path [GET]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemFullPath() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, ID uuid.UUID) ([]repo.ItemPath, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
item, err := ctrl.repo.Items.GetOneByGroup(auth, auth.GID, ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
paths, err := ctrl.repo.Locations.PathForLoc(auth, auth.GID, item.Location.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.Parent != nil {
|
|
||||||
paths = append(paths, repo.ItemPath{
|
|
||||||
Type: repo.ItemTypeItem,
|
|
||||||
ID: item.Parent.ID,
|
|
||||||
Name: item.Parent.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
paths = append(paths, repo.ItemPath{
|
|
||||||
Type: repo.ItemTypeItem,
|
|
||||||
ID: item.ID,
|
|
||||||
Name: item.Name,
|
|
||||||
})
|
|
||||||
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleItemsCreate godoc
|
// HandleItemsCreate godoc
|
||||||
//
|
// @Summary Create a new item
|
||||||
// @Summary Create Item
|
// @Tags Items
|
||||||
// @Tags Items
|
// @Produce json
|
||||||
// @Produce json
|
// @Param payload body repo.ItemCreate true "Item Data"
|
||||||
// @Param payload body repo.ItemCreate true "Item Data"
|
// @Success 200 {object} repo.ItemSummary
|
||||||
// @Success 201 {object} repo.ItemSummary
|
// @Router /v1/items [POST]
|
||||||
// @Router /v1/items [POST]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleItemsCreate() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleItemsCreate() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request, body repo.ItemCreate) (repo.ItemOut, error) {
|
createData := repo.ItemCreate{}
|
||||||
return ctrl.svc.Items.Create(services.NewContext(r.Context()), body)
|
if err := server.Decode(r, &createData); err != nil {
|
||||||
}
|
log.Err(err).Msg("failed to decode request body")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
ctx := services.NewContext(r.Context())
|
||||||
|
item, err := ctrl.svc.Items.Create(ctx, createData)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to create item")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusCreated, item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemGet godocs
|
// HandleItemGet godocs
|
||||||
//
|
// @Summary Gets a item and fields
|
||||||
// @Summary Get Item
|
// @Tags Items
|
||||||
// @Tags Items
|
// @Produce json
|
||||||
// @Produce json
|
// @Param id path string true "Item ID"
|
||||||
// @Param id path string true "Item ID"
|
// @Success 200 {object} repo.ItemOut
|
||||||
// @Success 200 {object} repo.ItemOut
|
// @Router /v1/items/{id} [GET]
|
||||||
// @Router /v1/items/{id} [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleItemGet() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleItemGet() errchain.HandlerFunc {
|
return ctrl.handleItemsGeneral()
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (repo.ItemOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
|
|
||||||
return ctrl.repo.Items.GetOneByGroup(auth, auth.GID, ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemDelete godocs
|
// HandleItemDelete godocs
|
||||||
//
|
// @Summary deletes a item
|
||||||
// @Summary Delete Item
|
// @Tags Items
|
||||||
// @Tags Items
|
// @Produce json
|
||||||
// @Produce json
|
// @Param id path string true "Item ID"
|
||||||
// @Param id path string true "Item ID"
|
// @Success 204
|
||||||
// @Success 204
|
// @Router /v1/items/{id} [DELETE]
|
||||||
// @Router /v1/items/{id} [DELETE]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleItemDelete() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleItemDelete() errchain.HandlerFunc {
|
return ctrl.handleItemsGeneral()
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
err := ctrl.repo.Items.DeleteByGroup(auth, auth.GID, ID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusNoContent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemUpdate godocs
|
// HandleItemUpdate godocs
|
||||||
//
|
// @Summary updates a item
|
||||||
// @Summary Update Item
|
// @Tags Items
|
||||||
// @Tags Items
|
// @Produce json
|
||||||
// @Produce json
|
// @Param id path string true "Item ID"
|
||||||
// @Param id path string true "Item ID"
|
// @Param payload body repo.ItemUpdate true "Item Data"
|
||||||
// @Param payload body repo.ItemUpdate true "Item Data"
|
// @Success 200 {object} repo.ItemOut
|
||||||
// @Success 200 {object} repo.ItemOut
|
// @Router /v1/items/{id} [PUT]
|
||||||
// @Router /v1/items/{id} [PUT]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleItemUpdate() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleItemUpdate() errchain.HandlerFunc {
|
return ctrl.handleItemsGeneral()
|
||||||
fn := func(r *http.Request, ID uuid.UUID, body repo.ItemUpdate) (repo.ItemOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
|
|
||||||
body.ID = ID
|
|
||||||
return ctrl.repo.Items.UpdateByGroup(auth, auth.GID, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemPatch godocs
|
func (ctrl *V1Controller) handleItemsGeneral() server.HandlerFunc {
|
||||||
//
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
// @Summary Update Item
|
ctx := services.NewContext(r.Context())
|
||||||
// @Tags Items
|
ID, err := ctrl.routeID(r)
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Item ID"
|
|
||||||
// @Param payload body repo.ItemPatch true "Item Data"
|
|
||||||
// @Success 200 {object} repo.ItemOut
|
|
||||||
// @Router /v1/items/{id} [Patch]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemPatch() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, ID uuid.UUID, body repo.ItemPatch) (repo.ItemOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
|
|
||||||
body.ID = ID
|
|
||||||
err := ctrl.repo.Items.Patch(auth, auth.GID, ID, body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return repo.ItemOut{}, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctrl.repo.Items.GetOneByGroup(auth, auth.GID, ID)
|
switch r.Method {
|
||||||
}
|
case http.MethodGet:
|
||||||
|
items, err := ctrl.repo.Items.GetOneByGroup(r.Context(), ctx.GID, ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to get item")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusOK, items)
|
||||||
|
case http.MethodDelete:
|
||||||
|
err = ctrl.repo.Items.DeleteByGroup(r.Context(), ctx.GID, ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to delete item")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusNoContent, nil)
|
||||||
|
case http.MethodPut:
|
||||||
|
body := repo.ItemUpdate{}
|
||||||
|
if err := server.Decode(r, &body); err != nil {
|
||||||
|
log.Err(err).Msg("failed to decode request body")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
body.ID = ID
|
||||||
|
result, err := ctrl.repo.Items.UpdateByGroup(r.Context(), ctx.GID, body)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to update item")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGetAllCustomFieldNames godocs
|
// HandleGetAllCustomFieldNames godocs
|
||||||
//
|
// @Summary imports items into the database
|
||||||
// @Summary Get All Custom Field Names
|
// @Tags Items
|
||||||
// @Tags Items
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200
|
||||||
// @Success 200
|
// @Router /v1/items/fields [GET]
|
||||||
// @Router /v1/items/fields [GET]
|
// @Success 200 {object} []string
|
||||||
// @Success 200 {object} []string
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleGetAllCustomFieldNames() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleGetAllCustomFieldNames() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request) ([]string, error) {
|
ctx := services.NewContext(r.Context())
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Items.GetAllCustomFieldNames(auth, auth.GID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
v, err := ctrl.repo.Items.GetAllCustomFieldNames(r.Context(), ctx.GID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGetAllCustomFieldValues godocs
|
// HandleGetAllCustomFieldValues godocs
|
||||||
//
|
// @Summary imports items into the database
|
||||||
// @Summary Get All Custom Field Values
|
// @Tags Items
|
||||||
// @Tags Items
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200
|
||||||
// @Success 200
|
// @Router /v1/items/fields/values [GET]
|
||||||
// @Router /v1/items/fields/values [GET]
|
// @Success 200 {object} []string
|
||||||
// @Success 200 {object} []string
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleGetAllCustomFieldValues() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleGetAllCustomFieldValues() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
type query struct {
|
ctx := services.NewContext(r.Context())
|
||||||
Field string `schema:"field" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
fn := func(r *http.Request, q query) ([]string, error) {
|
v, err := ctrl.repo.Items.GetAllCustomFieldValues(r.Context(), ctx.GID, r.URL.Query().Get("field"))
|
||||||
auth := services.NewContext(r.Context())
|
if err != nil {
|
||||||
return ctrl.repo.Items.GetAllCustomFieldValues(auth, auth.GID, q.Field)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Query(fn, http.StatusOK)
|
return server.Respond(w, http.StatusOK, v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemsImport godocs
|
// HandleItemsImport godocs
|
||||||
//
|
// @Summary imports items into the database
|
||||||
// @Summary Import Items
|
// @Tags Items
|
||||||
// @Tags Items
|
// @Produce json
|
||||||
// @Accept multipart/form-data
|
// @Success 204
|
||||||
// @Produce json
|
// @Param csv formData file true "Image to upload"
|
||||||
// @Success 204
|
// @Router /v1/items/import [Post]
|
||||||
// @Param csv formData file true "Image to upload"
|
// @Security Bearer
|
||||||
// @Router /v1/items/import [Post]
|
func (ctrl *V1Controller) HandleItemsImport() server.HandlerFunc {
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemsImport() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
err := r.ParseMultipartForm(ctrl.maxUploadSize << 20)
|
err := r.ParseMultipartForm(ctrl.maxUploadSize << 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -318,43 +255,20 @@ func (ctrl *V1Controller) HandleItemsImport() errchain.HandlerFunc {
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data, err := services.ReadCsv(file)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to read csv")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
user := services.UseUserCtx(r.Context())
|
user := services.UseUserCtx(r.Context())
|
||||||
|
|
||||||
_, err = ctrl.svc.Items.CsvImport(r.Context(), user.GroupID, file)
|
_, err = ctrl.svc.Items.CsvImport(r.Context(), user.GroupID, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to import items")
|
log.Err(err).Msg("failed to import items")
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusNoContent, nil)
|
return server.Respond(w, http.StatusNoContent, nil)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleItemsExport godocs
|
|
||||||
//
|
|
||||||
// @Summary Export Items
|
|
||||||
// @Tags Items
|
|
||||||
// @Success 200 {string} string "text/csv"
|
|
||||||
// @Router /v1/items/export [GET]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemsExport() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := services.NewContext(r.Context())
|
|
||||||
|
|
||||||
csvData, err := ctrl.svc.Items.ExportCSV(r.Context(), ctx.GID, GetHBURL(r.Header.Get("Referer"), ctrl.url))
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to export items")
|
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp := time.Now().Format("2006-01-02_15-04-05") // YYYY-MM-DD_HH-MM-SS format
|
|
||||||
filename := fmt.Sprintf("homebox-items_%s.csv", timestamp) // add timestamp to filename
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/csv")
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment;filename=%s", filename))
|
|
||||||
|
|
||||||
writer := csv.NewWriter(w)
|
|
||||||
writer.Comma = ','
|
|
||||||
return writer.WriteAll(csvData)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,13 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/homebox/backend/internal/data/ent/attachment"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -22,27 +18,25 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleItemAttachmentCreate godocs
|
// HandleItemsImport godocs
|
||||||
//
|
// @Summary imports items into the database
|
||||||
// @Summary Create Item Attachment
|
// @Tags Items Attachments
|
||||||
// @Tags Items Attachments
|
// @Produce json
|
||||||
// @Accept multipart/form-data
|
// @Param id path string true "Item ID"
|
||||||
// @Produce json
|
// @Param file formData file true "File attachment"
|
||||||
// @Param id path string true "Item ID"
|
// @Param type formData string true "Type of file"
|
||||||
// @Param file formData file true "File attachment"
|
// @Param name formData string true "name of the file including extension"
|
||||||
// @Param type formData string true "Type of file"
|
// @Success 200 {object} repo.ItemOut
|
||||||
// @Param primary formData bool false "Is this the primary attachment"
|
// @Failure 422 {object} server.ErrorResponse
|
||||||
// @Param name formData string true "name of the file including extension"
|
// @Router /v1/items/{id}/attachments [POST]
|
||||||
// @Success 200 {object} repo.ItemOut
|
// @Security Bearer
|
||||||
// @Failure 422 {object} validate.ErrorResponse
|
func (ctrl *V1Controller) HandleItemAttachmentCreate() server.HandlerFunc {
|
||||||
// @Router /v1/items/{id}/attachments [POST]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
err := r.ParseMultipartForm(ctrl.maxUploadSize << 20)
|
err := r.ParseMultipartForm(ctrl.maxUploadSize << 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to parse multipart form")
|
log.Err(err).Msg("failed to parse multipart form")
|
||||||
return validate.NewRequestError(errors.New("failed to parse multipart form"), http.StatusBadRequest)
|
return validate.NewRequestError(errors.New("failed to parse multipart form"), http.StatusBadRequest)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
errs := validate.NewFieldErrors()
|
errs := validate.NewFieldErrors()
|
||||||
@@ -66,26 +60,12 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !errs.Nil() {
|
if !errs.Nil() {
|
||||||
return server.JSON(w, http.StatusUnprocessableEntity, errs)
|
return server.Respond(w, http.StatusUnprocessableEntity, errs)
|
||||||
}
|
}
|
||||||
|
|
||||||
attachmentType := r.FormValue("type")
|
attachmentType := r.FormValue("type")
|
||||||
if attachmentType == "" {
|
if attachmentType == "" {
|
||||||
// Attempt to auto-detect the type of the file
|
attachmentType = attachment.TypeAttachment.String()
|
||||||
ext := filepath.Ext(attachmentName)
|
|
||||||
|
|
||||||
switch strings.ToLower(ext) {
|
|
||||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff":
|
|
||||||
attachmentType = attachment.TypePhoto.String()
|
|
||||||
default:
|
|
||||||
attachmentType = attachment.TypeAttachment.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
primary, err := strconv.ParseBool(r.FormValue("primary"))
|
|
||||||
if err != nil {
|
|
||||||
log.Debug().Msg("failed to parse primary from form")
|
|
||||||
primary = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := ctrl.routeID(r)
|
id, err := ctrl.routeID(r)
|
||||||
@@ -100,7 +80,6 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc {
|
|||||||
id,
|
id,
|
||||||
attachmentName,
|
attachmentName,
|
||||||
attachment.Type(attachmentType),
|
attachment.Type(attachmentType),
|
||||||
primary,
|
|
||||||
file,
|
file,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,48 +87,45 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc {
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusCreated, item)
|
return server.Respond(w, http.StatusCreated, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemAttachmentGet godocs
|
// HandleItemAttachmentGet godocs
|
||||||
//
|
// @Summary retrieves an attachment for an item
|
||||||
// @Summary Get Item Attachment
|
// @Tags Items Attachments
|
||||||
// @Tags Items Attachments
|
// @Produce application/octet-stream
|
||||||
// @Produce application/octet-stream
|
// @Param id path string true "Item ID"
|
||||||
// @Param id path string true "Item ID"
|
// @Param attachment_id path string true "Attachment ID"
|
||||||
// @Param attachment_id path string true "Attachment ID"
|
// @Success 200 {object} ItemAttachmentToken
|
||||||
// @Success 200 {object} ItemAttachmentToken
|
// @Router /v1/items/{id}/attachments/{attachment_id} [GET]
|
||||||
// @Router /v1/items/{id}/attachments/{attachment_id} [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleItemAttachmentGet() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleItemAttachmentGet() errchain.HandlerFunc {
|
|
||||||
return ctrl.handleItemAttachmentsHandler
|
return ctrl.handleItemAttachmentsHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemAttachmentDelete godocs
|
// HandleItemAttachmentDelete godocs
|
||||||
//
|
// @Summary retrieves an attachment for an item
|
||||||
// @Summary Delete Item Attachment
|
// @Tags Items Attachments
|
||||||
// @Tags Items Attachments
|
// @Param id path string true "Item ID"
|
||||||
// @Param id path string true "Item ID"
|
// @Param attachment_id path string true "Attachment ID"
|
||||||
// @Param attachment_id path string true "Attachment ID"
|
// @Success 204
|
||||||
// @Success 204
|
// @Router /v1/items/{id}/attachments/{attachment_id} [DELETE]
|
||||||
// @Router /v1/items/{id}/attachments/{attachment_id} [DELETE]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleItemAttachmentDelete() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleItemAttachmentDelete() errchain.HandlerFunc {
|
|
||||||
return ctrl.handleItemAttachmentsHandler
|
return ctrl.handleItemAttachmentsHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemAttachmentUpdate godocs
|
// HandleItemAttachmentUpdate godocs
|
||||||
//
|
// @Summary retrieves an attachment for an item
|
||||||
// @Summary Update Item Attachment
|
// @Tags Items Attachments
|
||||||
// @Tags Items Attachments
|
// @Param id path string true "Item ID"
|
||||||
// @Param id path string true "Item ID"
|
// @Param attachment_id path string true "Attachment ID"
|
||||||
// @Param attachment_id path string true "Attachment ID"
|
// @Param payload body repo.ItemAttachmentUpdate true "Attachment Update"
|
||||||
// @Param payload body repo.ItemAttachmentUpdate true "Attachment Update"
|
// @Success 200 {object} repo.ItemOut
|
||||||
// @Success 200 {object} repo.ItemOut
|
// @Router /v1/items/{id}/attachments/{attachment_id} [PUT]
|
||||||
// @Router /v1/items/{id}/attachments/{attachment_id} [PUT]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleItemAttachmentUpdate() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleItemAttachmentUpdate() errchain.HandlerFunc {
|
|
||||||
return ctrl.handleItemAttachmentsHandler
|
return ctrl.handleItemAttachmentsHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +148,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
|||||||
log.Err(err).Msg("failed to get attachment path")
|
log.Err(err).Msg("failed to get attachment path")
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
// w.Header().Set("Content-Disposition", "attachment; filename="+doc.Title)
|
|
||||||
http.ServeFile(w, r, doc.Path)
|
http.ServeFile(w, r, doc.Path)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
@@ -184,7 +160,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusNoContent, nil)
|
return server.Respond(w, http.StatusNoContent, nil)
|
||||||
|
|
||||||
// Update Attachment Handler
|
// Update Attachment Handler
|
||||||
case http.MethodPut:
|
case http.MethodPut:
|
||||||
@@ -202,7 +178,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusOK, val)
|
return server.Respond(w, http.StatusOK, val)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/pkgs/labelmaker"
|
|
||||||
)
|
|
||||||
|
|
||||||
func generateOrPrint(ctrl *V1Controller, w http.ResponseWriter, r *http.Request, title string, description string, url string) error {
|
|
||||||
params := labelmaker.NewGenerateParams(int(ctrl.config.LabelMaker.Width), int(ctrl.config.LabelMaker.Height), int(ctrl.config.LabelMaker.Margin), int(ctrl.config.LabelMaker.Padding), ctrl.config.LabelMaker.FontSize, title, description, url, ctrl.config.LabelMaker.DynamicLength, ctrl.config.LabelMaker.AdditionalInformation)
|
|
||||||
|
|
||||||
print := queryBool(r.URL.Query().Get("print"))
|
|
||||||
|
|
||||||
if print {
|
|
||||||
err := labelmaker.PrintLabel(ctrl.config, ¶ms)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = w.Write([]byte("Printed!"))
|
|
||||||
return err
|
|
||||||
} else {
|
|
||||||
return labelmaker.GenerateLabel(w, ¶ms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
func (ctrl *V1Controller) HandleGetLocationLabel() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ID, err := adapters.RouteUUID(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
location, err := ctrl.repo.Locations.GetOneByGroup(auth, auth.GID, ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
hbURL := GetHBURL(r.Header.Get("Referer"), ctrl.url)
|
|
||||||
return generateOrPrint(ctrl, w, r, location.Name, "Homebox Location", fmt.Sprintf("%s/location/%s", hbURL, location.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
func (ctrl *V1Controller) HandleGetItemLabel() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ID, err := adapters.RouteUUID(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
item, err := ctrl.repo.Items.GetOneByGroup(auth, auth.GID, ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
description := ""
|
|
||||||
|
|
||||||
if item.Location != nil {
|
|
||||||
description += fmt.Sprintf("\nLocation: %s", item.Location.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
hbURL := GetHBURL(r.Header.Get("Referer"), ctrl.url)
|
|
||||||
return generateOrPrint(ctrl, w, r, item.Name, description, fmt.Sprintf("%s/item/%s", hbURL, item.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
func (ctrl *V1Controller) HandleGetAssetLabel() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
assetIDParam := chi.URLParam(r, "id")
|
|
||||||
assetIDParam = strings.ReplaceAll(assetIDParam, "-", "")
|
|
||||||
assetID, err := strconv.ParseInt(assetIDParam, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
item, err := ctrl.repo.Items.QueryByAssetID(auth, auth.GID, repo.AssetID(assetID), 0, 1)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(item.Items) == 0 {
|
|
||||||
return validate.NewRequestError(fmt.Errorf("failed to find asset id"), http.StatusNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
description := item.Items[0].Name
|
|
||||||
|
|
||||||
if item.Items[0].Location != nil {
|
|
||||||
description += fmt.Sprintf("\nLocation: %s", item.Items[0].Location.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
hbURL := GetHBURL(r.Header.Get("Referer"), ctrl.url)
|
|
||||||
return generateOrPrint(ctrl, w, r, item.Items[0].AssetID.String(), description, fmt.Sprintf("%s/a/%s", hbURL, item.Items[0].AssetID.String()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,100 +3,141 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleLabelsGetAll godoc
|
// HandleLabelsGetAll godoc
|
||||||
//
|
// @Summary Get All Labels
|
||||||
// @Summary Get All Labels
|
// @Tags Labels
|
||||||
// @Tags Labels
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200 {object} server.Results{items=[]repo.LabelOut}
|
||||||
// @Success 200 {object} []repo.LabelOut
|
// @Router /v1/labels [GET]
|
||||||
// @Router /v1/labels [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleLabelsGetAll() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleLabelsGetAll() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request) ([]repo.LabelSummary, error) {
|
user := services.UseUserCtx(r.Context())
|
||||||
auth := services.NewContext(r.Context())
|
labels, err := ctrl.repo.Labels.GetAll(r.Context(), user.GroupID)
|
||||||
return ctrl.repo.Labels.GetAll(auth, auth.GID)
|
if err != nil {
|
||||||
|
log.Err(err).Msg("error getting labels")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusOK, server.Results{Items: labels})
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLabelsCreate godoc
|
// HandleLabelsCreate godoc
|
||||||
//
|
// @Summary Create a new label
|
||||||
// @Summary Create Label
|
// @Tags Labels
|
||||||
// @Tags Labels
|
// @Produce json
|
||||||
// @Produce json
|
// @Param payload body repo.LabelCreate true "Label Data"
|
||||||
// @Param payload body repo.LabelCreate true "Label Data"
|
// @Success 200 {object} repo.LabelSummary
|
||||||
// @Success 200 {object} repo.LabelSummary
|
// @Router /v1/labels [POST]
|
||||||
// @Router /v1/labels [POST]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleLabelsCreate() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleLabelsCreate() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request, data repo.LabelCreate) (repo.LabelOut, error) {
|
createData := repo.LabelCreate{}
|
||||||
auth := services.NewContext(r.Context())
|
if err := server.Decode(r, &createData); err != nil {
|
||||||
return ctrl.repo.Labels.Create(auth, auth.GID, data)
|
log.Err(err).Msg("error decoding label create data")
|
||||||
}
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
user := services.UseUserCtx(r.Context())
|
||||||
|
label, err := ctrl.repo.Labels.Create(r.Context(), user.GroupID, createData)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("error creating label")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusCreated, label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLabelDelete godocs
|
// HandleLabelDelete godocs
|
||||||
//
|
// @Summary deletes a label
|
||||||
// @Summary Delete Label
|
// @Tags Labels
|
||||||
// @Tags Labels
|
// @Produce json
|
||||||
// @Produce json
|
// @Param id path string true "Label ID"
|
||||||
// @Param id path string true "Label ID"
|
// @Success 204
|
||||||
// @Success 204
|
// @Router /v1/labels/{id} [DELETE]
|
||||||
// @Router /v1/labels/{id} [DELETE]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleLabelDelete() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleLabelDelete() errchain.HandlerFunc {
|
return ctrl.handleLabelsGeneral()
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
err := ctrl.repo.Labels.DeleteByGroup(auth, auth.GID, ID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusNoContent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLabelGet godocs
|
// HandleLabelGet godocs
|
||||||
//
|
// @Summary Gets a label and fields
|
||||||
// @Summary Get Label
|
// @Tags Labels
|
||||||
// @Tags Labels
|
// @Produce json
|
||||||
// @Produce json
|
// @Param id path string true "Label ID"
|
||||||
// @Param id path string true "Label ID"
|
// @Success 200 {object} repo.LabelOut
|
||||||
// @Success 200 {object} repo.LabelOut
|
// @Router /v1/labels/{id} [GET]
|
||||||
// @Router /v1/labels/{id} [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleLabelGet() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleLabelGet() errchain.HandlerFunc {
|
return ctrl.handleLabelsGeneral()
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (repo.LabelOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Labels.GetOneByGroup(auth, auth.GID, ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLabelUpdate godocs
|
// HandleLabelUpdate godocs
|
||||||
//
|
// @Summary updates a label
|
||||||
// @Summary Update Label
|
// @Tags Labels
|
||||||
// @Tags Labels
|
// @Produce json
|
||||||
// @Produce json
|
// @Param id path string true "Label ID"
|
||||||
// @Param id path string true "Label ID"
|
// @Success 200 {object} repo.LabelOut
|
||||||
// @Success 200 {object} repo.LabelOut
|
// @Router /v1/labels/{id} [PUT]
|
||||||
// @Router /v1/labels/{id} [PUT]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleLabelUpdate() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleLabelUpdate() errchain.HandlerFunc {
|
return ctrl.handleLabelsGeneral()
|
||||||
fn := func(r *http.Request, ID uuid.UUID, data repo.LabelUpdate) (repo.LabelOut, error) {
|
}
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
data.ID = ID
|
func (ctrl *V1Controller) handleLabelsGeneral() server.HandlerFunc {
|
||||||
return ctrl.repo.Labels.UpdateByGroup(auth, auth.GID, data)
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
ctx := services.NewContext(r.Context())
|
||||||
|
ID, err := ctrl.routeID(r)
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
labels, err := ctrl.repo.Labels.GetOneByGroup(r.Context(), ctx.GID, ID)
|
||||||
|
if err != nil {
|
||||||
|
if ent.IsNotFound(err) {
|
||||||
|
log.Err(err).
|
||||||
|
Str("id", ID.String()).
|
||||||
|
Msg("label not found")
|
||||||
|
return validate.NewRequestError(err, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
log.Err(err).Msg("error getting label")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusOK, labels)
|
||||||
|
|
||||||
|
case http.MethodDelete:
|
||||||
|
err = ctrl.repo.Labels.DeleteByGroup(ctx, ctx.GID, ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("error deleting label")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusNoContent, nil)
|
||||||
|
|
||||||
|
case http.MethodPut:
|
||||||
|
body := repo.LabelUpdate{}
|
||||||
|
if err := server.Decode(r, &body); err != nil {
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
body.ID = ID
|
||||||
|
result, err := ctrl.repo.Labels.UpdateByGroup(ctx, ctx.GID, body)
|
||||||
|
if err != nil {
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,168 +1,188 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleLocationTreeQuery godoc
|
// HandleLocationTreeQuery godoc
|
||||||
//
|
// @Summary Get All Locations
|
||||||
// @Summary Get Locations Tree
|
// @Tags Locations
|
||||||
// @Tags Locations
|
// @Produce json
|
||||||
// @Produce json
|
// @Param withItems query bool false "include items in response tree"
|
||||||
// @Param withItems query bool false "include items in response tree"
|
// @Success 200 {object} server.Results{items=[]repo.TreeItem}
|
||||||
// @Success 200 {object} []repo.TreeItem
|
// @Router /v1/locations/tree [GET]
|
||||||
// @Router /v1/locations/tree [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleLocationTreeQuery() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleLocationTreeQuery() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request, query repo.TreeQuery) ([]repo.TreeItem, error) {
|
user := services.UseUserCtx(r.Context())
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Locations.Tree(auth, auth.GID, query)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Query(fn, http.StatusOK)
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
withItems := queryBool(q.Get("withItems"))
|
||||||
|
|
||||||
|
locTree, err := ctrl.repo.Locations.Tree(
|
||||||
|
r.Context(),
|
||||||
|
user.GroupID,
|
||||||
|
repo.TreeQuery{
|
||||||
|
WithItems: withItems,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to get locations tree")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusOK, server.Results{Items: locTree})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLocationGetAll godoc
|
// HandleLocationGetAll godoc
|
||||||
//
|
// @Summary Get All Locations
|
||||||
// @Summary Get All Locations
|
// @Tags Locations
|
||||||
// @Tags Locations
|
// @Produce json
|
||||||
// @Produce json
|
// @Param filterChildren query bool false "Filter locations with parents"
|
||||||
// @Param filterChildren query bool false "Filter locations with parents"
|
// @Success 200 {object} server.Results{items=[]repo.LocationOutCount}
|
||||||
// @Success 200 {object} []repo.LocationOutCount
|
// @Router /v1/locations [GET]
|
||||||
// @Router /v1/locations [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleLocationGetAll() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleLocationGetAll() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request, q repo.LocationQuery) ([]repo.LocationOutCount, error) {
|
user := services.UseUserCtx(r.Context())
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Locations.GetAll(auth, auth.GID, q)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Query(fn, http.StatusOK)
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
filter := repo.LocationQuery{
|
||||||
|
FilterChildren: queryBool(q.Get("filterChildren")),
|
||||||
|
}
|
||||||
|
|
||||||
|
locations, err := ctrl.repo.Locations.GetAll(r.Context(), user.GroupID, filter)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to get locations")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusOK, server.Results{Items: locations})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLocationCreate godoc
|
// HandleLocationCreate godoc
|
||||||
//
|
// @Summary Create a new location
|
||||||
// @Summary Create Location
|
// @Tags Locations
|
||||||
// @Tags Locations
|
// @Produce json
|
||||||
// @Produce json
|
// @Param payload body repo.LocationCreate true "Location Data"
|
||||||
// @Param payload body repo.LocationCreate true "Location Data"
|
// @Success 200 {object} repo.LocationSummary
|
||||||
// @Success 200 {object} repo.LocationSummary
|
// @Router /v1/locations [POST]
|
||||||
// @Router /v1/locations [POST]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleLocationCreate() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleLocationCreate() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request, createData repo.LocationCreate) (repo.LocationOut, error) {
|
createData := repo.LocationCreate{}
|
||||||
auth := services.NewContext(r.Context())
|
if err := server.Decode(r, &createData); err != nil {
|
||||||
return ctrl.repo.Locations.Create(auth, auth.GID, createData)
|
log.Err(err).Msg("failed to decode location create data")
|
||||||
}
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleLocationDelete godoc
|
|
||||||
//
|
|
||||||
// @Summary Delete Location
|
|
||||||
// @Tags Locations
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Location ID"
|
|
||||||
// @Success 204
|
|
||||||
// @Router /v1/locations/{id} [DELETE]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleLocationDelete() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
err := ctrl.repo.Locations.DeleteByGroup(auth, auth.GID, ID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctrl *V1Controller) GetLocationWithPrice(auth context.Context, gid uuid.UUID, id uuid.UUID) (repo.LocationOut, error) {
|
|
||||||
var location, err = ctrl.repo.Locations.GetOneByGroup(auth, gid, id)
|
|
||||||
if err != nil {
|
|
||||||
return repo.LocationOut{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add direct child items price
|
|
||||||
totalPrice := new(big.Int)
|
|
||||||
items, err := ctrl.repo.Items.QueryByGroup(auth, gid, repo.ItemQuery{LocationIDs: []uuid.UUID{id}})
|
|
||||||
if err != nil {
|
|
||||||
return repo.LocationOut{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range items.Items {
|
|
||||||
// Skip items with a non-zero SoldTime
|
|
||||||
if !item.SoldTime.IsZero() {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert item.Quantity to float64 for multiplication
|
user := services.UseUserCtx(r.Context())
|
||||||
quantity := float64(item.Quantity)
|
location, err := ctrl.repo.Locations.Create(r.Context(), user.GroupID, createData)
|
||||||
itemTotal := big.NewInt(int64(item.PurchasePrice * quantity * 100))
|
|
||||||
totalPrice.Add(totalPrice, itemTotal)
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPriceFloat := new(big.Float).SetInt(totalPrice)
|
|
||||||
totalPriceFloat.Quo(totalPriceFloat, big.NewFloat(100))
|
|
||||||
location.TotalPrice, _ = totalPriceFloat.Float64()
|
|
||||||
|
|
||||||
// Add price from child locations
|
|
||||||
for _, childLocation := range location.Children {
|
|
||||||
var childLocationWithPrice repo.LocationOut
|
|
||||||
childLocationWithPrice, err = ctrl.GetLocationWithPrice(auth, gid, childLocation.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return repo.LocationOut{}, err
|
log.Err(err).Msg("failed to create location")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
location.TotalPrice += childLocationWithPrice.TotalPrice
|
|
||||||
}
|
|
||||||
|
|
||||||
return location, nil
|
return server.Respond(w, http.StatusCreated, location)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLocationGet godoc
|
// HandleLocationDelete godocs
|
||||||
//
|
// @Summary deletes a location
|
||||||
// @Summary Get Location
|
// @Tags Locations
|
||||||
// @Tags Locations
|
// @Produce json
|
||||||
// @Produce json
|
// @Param id path string true "Location ID"
|
||||||
// @Param id path string true "Location ID"
|
// @Success 204
|
||||||
// @Success 200 {object} repo.LocationOut
|
// @Router /v1/locations/{id} [DELETE]
|
||||||
// @Router /v1/locations/{id} [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleLocationDelete() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleLocationGet() errchain.HandlerFunc {
|
return ctrl.handleLocationGeneral()
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (repo.LocationOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
var location, err = ctrl.GetLocationWithPrice(auth, auth.GID, ID)
|
|
||||||
|
|
||||||
return location, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLocationUpdate godoc
|
// HandleLocationGet godocs
|
||||||
//
|
// @Summary Gets a location and fields
|
||||||
// @Summary Update Location
|
// @Tags Locations
|
||||||
// @Tags Locations
|
// @Produce json
|
||||||
// @Produce json
|
// @Param id path string true "Location ID"
|
||||||
// @Param id path string true "Location ID"
|
// @Success 200 {object} repo.LocationOut
|
||||||
// @Param payload body repo.LocationUpdate true "Location Data"
|
// @Router /v1/locations/{id} [GET]
|
||||||
// @Success 200 {object} repo.LocationOut
|
// @Security Bearer
|
||||||
// @Router /v1/locations/{id} [PUT]
|
func (ctrl *V1Controller) HandleLocationGet() server.HandlerFunc {
|
||||||
// @Security Bearer
|
return ctrl.handleLocationGeneral()
|
||||||
func (ctrl *V1Controller) HandleLocationUpdate() errchain.HandlerFunc {
|
}
|
||||||
fn := func(r *http.Request, ID uuid.UUID, body repo.LocationUpdate) (repo.LocationOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
// HandleLocationUpdate godocs
|
||||||
body.ID = ID
|
// @Summary updates a location
|
||||||
return ctrl.repo.Locations.UpdateByGroup(auth, auth.GID, ID, body)
|
// @Tags Locations
|
||||||
}
|
// @Produce json
|
||||||
|
// @Param id path string true "Location ID"
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
// @Param payload body repo.LocationUpdate true "Location Data"
|
||||||
|
// @Success 200 {object} repo.LocationOut
|
||||||
|
// @Router /v1/locations/{id} [PUT]
|
||||||
|
// @Security Bearer
|
||||||
|
func (ctrl *V1Controller) HandleLocationUpdate() server.HandlerFunc {
|
||||||
|
return ctrl.handleLocationGeneral()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctrl *V1Controller) handleLocationGeneral() server.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := services.NewContext(r.Context())
|
||||||
|
ID, err := ctrl.routeID(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
location, err := ctrl.repo.Locations.GetOneByGroup(r.Context(), ctx.GID, ID)
|
||||||
|
if err != nil {
|
||||||
|
l := log.Err(err).
|
||||||
|
Str("ID", ID.String()).
|
||||||
|
Str("GID", ctx.GID.String())
|
||||||
|
|
||||||
|
if ent.IsNotFound(err) {
|
||||||
|
l.Msg("location not found")
|
||||||
|
return validate.NewRequestError(err, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Msg("failed to get location")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusOK, location)
|
||||||
|
case http.MethodPut:
|
||||||
|
body := repo.LocationUpdate{}
|
||||||
|
if err := server.Decode(r, &body); err != nil {
|
||||||
|
log.Err(err).Msg("failed to decode location update data")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
body.ID = ID
|
||||||
|
|
||||||
|
result, err := ctrl.repo.Locations.UpdateOneByGroup(r.Context(), ctx.GID, ID, body)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to update location")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusOK, result)
|
||||||
|
case http.MethodDelete:
|
||||||
|
err = ctrl.repo.Locations.DeleteByGroup(r.Context(), ctx.GID, ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to delete location")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,47 +3,123 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleMaintenanceLogGet godoc
|
// HandleMaintenanceGetLog godoc
|
||||||
//
|
// @Summary Get Maintenance Log
|
||||||
// @Summary Get Maintenance Log
|
// @Tags Maintenance
|
||||||
// @Tags Item Maintenance
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200 {object} repo.MaintenanceLog
|
||||||
// @Param id path string true "Item ID"
|
// @Router /v1/items/{id}/maintenance [GET]
|
||||||
// @Param filters query repo.MaintenanceFilters false "which maintenance to retrieve"
|
// @Security Bearer
|
||||||
// @Success 200 {array} repo.MaintenanceEntryWithDetails[]
|
func (ctrl *V1Controller) HandleMaintenanceLogGet() server.HandlerFunc {
|
||||||
// @Router /v1/items/{id}/maintenance [GET]
|
return ctrl.handleMaintenanceLog()
|
||||||
// @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())
|
|
||||||
return ctrl.repo.MaintEntry.GetMaintenanceByItemID(auth, auth.GID, ID, filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.QueryID("id", fn, http.StatusOK)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleMaintenanceEntryCreate godoc
|
// HandleMaintenanceEntryCreate godoc
|
||||||
//
|
// @Summary Create Maintenance Entry
|
||||||
// @Summary Create Maintenance Entry
|
// @Tags Maintenance
|
||||||
// @Tags Item Maintenance
|
// @Produce json
|
||||||
// @Produce json
|
// @Param payload body repo.MaintenanceEntryCreate true "Entry Data"
|
||||||
// @Param id path string true "Item ID"
|
// @Success 200 {object} repo.MaintenanceEntry
|
||||||
// @Param payload body repo.MaintenanceEntryCreate true "Entry Data"
|
// @Router /v1/items/{id}/maintenance [POST]
|
||||||
// @Success 201 {object} repo.MaintenanceEntry
|
// @Security Bearer
|
||||||
// @Router /v1/items/{id}/maintenance [POST]
|
func (ctrl *V1Controller) HandleMaintenanceEntryCreate() server.HandlerFunc {
|
||||||
// @Security Bearer
|
return ctrl.handleMaintenanceLog()
|
||||||
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())
|
// HandleMaintenanceEntryDelete godoc
|
||||||
return ctrl.repo.MaintEntry.Create(auth, itemID, body)
|
// @Summary Delete Maintenance Entry
|
||||||
}
|
// @Tags Maintenance
|
||||||
|
// @Produce json
|
||||||
return adapters.ActionID("id", fn, http.StatusCreated)
|
// @Success 204
|
||||||
|
// @Router /v1/items/{id}/maintenance/{entry_id} [DELETE]
|
||||||
|
// @Security Bearer
|
||||||
|
func (ctrl *V1Controller) HandleMaintenanceEntryDelete() server.HandlerFunc {
|
||||||
|
return ctrl.handleMaintenanceLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMaintenanceEntryUpdate godoc
|
||||||
|
// @Summary Update Maintenance Entry
|
||||||
|
// @Tags Maintenance
|
||||||
|
// @Produce json
|
||||||
|
// @Param payload body repo.MaintenanceEntryUpdate true "Entry Data"
|
||||||
|
// @Success 200 {object} repo.MaintenanceEntry
|
||||||
|
// @Router /v1/items/{id}/maintenance/{entry_id} [PUT]
|
||||||
|
// @Security Bearer
|
||||||
|
func (ctrl *V1Controller) HandleMaintenanceEntryUpdate() server.HandlerFunc {
|
||||||
|
return ctrl.handleMaintenanceLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctrl *V1Controller) handleMaintenanceLog() server.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := services.NewContext(r.Context())
|
||||||
|
itemID, err := ctrl.routeID(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
mlog, err := ctrl.repo.MaintEntry.GetLog(ctx, itemID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to get items")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return server.Respond(w, http.StatusOK, mlog)
|
||||||
|
case http.MethodPost:
|
||||||
|
var create repo.MaintenanceEntryCreate
|
||||||
|
err := server.Decode(r, &create)
|
||||||
|
if err != nil {
|
||||||
|
return validate.NewRequestError(err, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := ctrl.repo.MaintEntry.Create(ctx, itemID, create)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to create item")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusCreated, entry)
|
||||||
|
case http.MethodPut:
|
||||||
|
entryID, err := ctrl.routeUUID(r, "entry_id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var update repo.MaintenanceEntryUpdate
|
||||||
|
err = server.Decode(r, &update)
|
||||||
|
if err != nil {
|
||||||
|
return validate.NewRequestError(err, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := ctrl.repo.MaintEntry.Update(ctx, entryID, update)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to update item")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusOK, entry)
|
||||||
|
case http.MethodDelete:
|
||||||
|
entryID, err := ctrl.routeUUID(r, "entry_id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ctrl.repo.MaintEntry.Delete(ctx, entryID)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to delete item")
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleMaintenanceGetAll godoc
|
|
||||||
//
|
|
||||||
// @Summary Query All Maintenance
|
|
||||||
// @Tags Maintenance
|
|
||||||
// @Produce json
|
|
||||||
// @Param filters query repo.MaintenanceFilters false "which maintenance to retrieve"
|
|
||||||
// @Success 200 {array} repo.MaintenanceEntryWithDetails[]
|
|
||||||
// @Router /v1/maintenance [GET]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleMaintenanceGetAll() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, filters repo.MaintenanceFilters) ([]repo.MaintenanceEntryWithDetails, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.MaintEntry.GetAllMaintenance(auth, auth.GID, filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Query(fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
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())
|
|
||||||
return ctrl.repo.MaintEntry.Update(auth, entryID, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
func (ctrl *V1Controller) HandleMaintenanceEntryDelete() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, entryID uuid.UUID) (any, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
err := ctrl.repo.MaintEntry.Delete(auth, entryID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/containrrr/shoutrrr"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleGetUserNotifiers godoc
|
|
||||||
//
|
|
||||||
// @Summary Get Notifiers
|
|
||||||
// @Tags Notifiers
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} []repo.NotifierOut
|
|
||||||
// @Router /v1/notifiers [GET]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleGetUserNotifiers() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, _ struct{}) ([]repo.NotifierOut, error) {
|
|
||||||
user := services.UseUserCtx(r.Context())
|
|
||||||
return ctrl.repo.Notifiers.GetByUser(r.Context(), user.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Query(fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleCreateNotifier godoc
|
|
||||||
//
|
|
||||||
// @Summary Create Notifier
|
|
||||||
// @Tags Notifiers
|
|
||||||
// @Produce json
|
|
||||||
// @Param payload body repo.NotifierCreate true "Notifier Data"
|
|
||||||
// @Success 200 {object} repo.NotifierOut
|
|
||||||
// @Router /v1/notifiers [POST]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleCreateNotifier() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, in repo.NotifierCreate) (repo.NotifierOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Notifiers.Create(auth, auth.GID, auth.UID, in)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleDeleteNotifier godocs
|
|
||||||
//
|
|
||||||
// @Summary Delete a Notifier
|
|
||||||
// @Tags Notifiers
|
|
||||||
// @Param id path string true "Notifier ID"
|
|
||||||
// @Success 204
|
|
||||||
// @Router /v1/notifiers/{id} [DELETE]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleDeleteNotifier() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return nil, ctrl.repo.Notifiers.Delete(auth, auth.UID, ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleUpdateNotifier godocs
|
|
||||||
//
|
|
||||||
// @Summary Update Notifier
|
|
||||||
// @Tags Notifiers
|
|
||||||
// @Param id path string true "Notifier ID"
|
|
||||||
// @Param payload body repo.NotifierUpdate true "Notifier Data"
|
|
||||||
// @Success 200 {object} repo.NotifierOut
|
|
||||||
// @Router /v1/notifiers/{id} [PUT]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleUpdateNotifier() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, ID uuid.UUID, in repo.NotifierUpdate) (repo.NotifierOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Notifiers.Update(auth, auth.UID, ID, in)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandlerNotifierTest godoc
|
|
||||||
//
|
|
||||||
// @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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
fn := func(r *http.Request, q body) (any, error) {
|
|
||||||
err := shoutrrr.Send(q.URL, "Test message from Homebox")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,9 @@ import (
|
|||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/yeqown/go-qrcode/v2"
|
"github.com/yeqown/go-qrcode/v2"
|
||||||
"github.com/yeqown/go-qrcode/writer/standard"
|
"github.com/yeqown/go-qrcode/writer/standard"
|
||||||
|
|
||||||
@@ -20,36 +19,32 @@ var qrcodeLogo []byte
|
|||||||
|
|
||||||
// HandleGenerateQRCode godoc
|
// HandleGenerateQRCode godoc
|
||||||
//
|
//
|
||||||
// @Summary Create QR Code
|
// @Summary Encode data into QRCode
|
||||||
// @Tags Items
|
// @Tags Items
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param data query string false "data to be encoded into qrcode"
|
// @Param data query string false "data to be encoded into qrcode"
|
||||||
// @Success 200 {string} string "image/jpeg"
|
// @Success 200 {string} string "image/jpeg"
|
||||||
// @Router /v1/qrcode [GET]
|
// @Router /v1/qrcode [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleGenerateQRCode() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleGenerateQRCode() server.HandlerFunc {
|
||||||
type query struct {
|
const MaxLength = 4_296 // assume alphanumeric characters only
|
||||||
// 4,296 characters is the maximum length of a QR code
|
|
||||||
Data string `schema:"data" validate:"required,max=4296"`
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
q, err := adapters.DecodeQuery[query](r)
|
data := r.URL.Query().Get("data")
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
image, err := png.Decode(bytes.NewReader(qrcodeLogo))
|
image, err := png.Decode(bytes.NewReader(qrcodeLogo))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
decodedStr, err := url.QueryUnescape(q.Data)
|
if len(data) > MaxLength {
|
||||||
if err != nil {
|
return validate.NewFieldErrors(validate.FieldError{
|
||||||
return err
|
Field: "data",
|
||||||
|
Error: "max length is 4,296 characters exceeded",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
qrc, err := qrcode.New(decodedStr)
|
qrc, err := qrcode.New(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleBillOfMaterialsExport godoc
|
// HandleBillOfMaterialsExport godoc
|
||||||
//
|
//
|
||||||
// @Summary Export Bill of Materials
|
// @Summary Generates a Bill of Materials CSV
|
||||||
// @Tags Reporting
|
// @Tags Reporting
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {string} string "text/csv"
|
// @Success 200 {string} string "text/csv"
|
||||||
// @Router /v1/reporting/bill-of-materials [GET]
|
// @Router /v1/reporting/bill-of-materials [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleBillOfMaterialsExport() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleBillOfMaterialsExport() server.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
actor := services.UseUserCtx(r.Context())
|
actor := services.UseUserCtx(r.Context())
|
||||||
|
|
||||||
csv, err := ctrl.svc.Items.ExportBillOfMaterialsCSV(r.Context(), actor.GroupID)
|
csv, err := ctrl.svc.Reporting.BillOfMaterialsTSV(r.Context(), actor.GroupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/csv")
|
w.Header().Set("Content-Type", "text/csv")
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename=bill-of-materials.csv")
|
w.Header().Set("Content-Disposition", "attachment; filename=bom.csv")
|
||||||
_, err = w.Write(csv)
|
_, err = w.Write(csv)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,76 +4,81 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleGroupStatisticsLocations godoc
|
// HandleGroupGet godoc
|
||||||
//
|
// @Summary Get the current user's group statistics
|
||||||
// @Summary Get Location Statistics
|
// @Tags Statistics
|
||||||
// @Tags Statistics
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200 {object} []repo.TotalsByOrganizer
|
||||||
// @Success 200 {object} []repo.TotalsByOrganizer
|
// @Router /v1/groups/statistics/locations [GET]
|
||||||
// @Router /v1/groups/statistics/locations [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleGroupStatisticsLocations() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleGroupStatisticsLocations() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request) ([]repo.TotalsByOrganizer, error) {
|
ctx := services.NewContext(r.Context())
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Groups.StatsLocationsByPurchasePrice(auth, auth.GID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
stats, err := ctrl.repo.Groups.StatsLocationsByPurchasePrice(ctx, ctx.GID)
|
||||||
|
if err != nil {
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGroupStatisticsLabels godoc
|
// HandleGroupGet godoc
|
||||||
//
|
// @Summary Get the current user's group statistics
|
||||||
// @Summary Get Label Statistics
|
// @Tags Statistics
|
||||||
// @Tags Statistics
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200 {object} []repo.TotalsByOrganizer
|
||||||
// @Success 200 {object} []repo.TotalsByOrganizer
|
// @Router /v1/groups/statistics/labels [GET]
|
||||||
// @Router /v1/groups/statistics/labels [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleGroupStatisticsLabels() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleGroupStatisticsLabels() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request) ([]repo.TotalsByOrganizer, error) {
|
ctx := services.NewContext(r.Context())
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Groups.StatsLabelsByPurchasePrice(auth, auth.GID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
stats, err := ctrl.repo.Groups.StatsLabelsByPurchasePrice(ctx, ctx.GID)
|
||||||
|
if err != nil {
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGroupStatistics godoc
|
// HandleGroupGet godoc
|
||||||
//
|
// @Summary Get the current user's group statistics
|
||||||
// @Summary Get Group Statistics
|
// @Tags Statistics
|
||||||
// @Tags Statistics
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200 {object} repo.GroupStatistics
|
||||||
// @Success 200 {object} repo.GroupStatistics
|
// @Router /v1/groups/statistics [GET]
|
||||||
// @Router /v1/groups/statistics [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleGroupStatistics() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleGroupStatistics() errchain.HandlerFunc {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
fn := func(r *http.Request) (repo.GroupStatistics, error) {
|
ctx := services.NewContext(r.Context())
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Groups.StatsGroup(auth, auth.GID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
stats, err := ctrl.repo.Groups.StatsGroup(ctx, ctx.GID)
|
||||||
|
if err != nil {
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.Respond(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGroupStatisticsPriceOverTime godoc
|
// HandleGroupGet godoc
|
||||||
//
|
// @Summary Queries the changes overtime of the purchase price over time
|
||||||
// @Summary Get Purchase Price Statistics
|
// @Tags Statistics
|
||||||
// @Tags Statistics
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200 {object} repo.ValueOverTime
|
||||||
// @Success 200 {object} repo.ValueOverTime
|
// @Param start query string false "start date"
|
||||||
// @Param start query string false "start date"
|
// @Param end query string false "end date"
|
||||||
// @Param end query string false "end date"
|
// @Router /v1/groups/statistics/purchase-price [GET]
|
||||||
// @Router /v1/groups/statistics/purchase-price [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleGroupStatisticsPriceOverTime() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleGroupStatisticsPriceOverTime() errchain.HandlerFunc {
|
|
||||||
parseDate := func(datestr string, defaultDate time.Time) (time.Time, error) {
|
parseDate := func(datestr string, defaultDate time.Time) (time.Time, error) {
|
||||||
if datestr == "" {
|
if datestr == "" {
|
||||||
return defaultDate, nil
|
return defaultDate, nil
|
||||||
@@ -99,6 +104,6 @@ func (ctrl *V1Controller) HandleGroupStatisticsPriceOverTime() errchain.HandlerF
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusOK, stats)
|
return server.Respond(w, http.StatusOK, stats)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,21 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleUserRegistration godoc
|
// HandleUserSelf godoc
|
||||||
//
|
// @Summary Get the current user
|
||||||
// @Summary Register New User
|
// @Tags User
|
||||||
// @Tags User
|
// @Produce json
|
||||||
// @Produce json
|
// @Param payload body services.UserRegistration true "User Data"
|
||||||
// @Param payload body services.UserRegistration true "User Data"
|
// @Success 204
|
||||||
// @Success 204
|
// @Router /v1/users/register [Post]
|
||||||
// @Router /v1/users/register [Post]
|
func (ctrl *V1Controller) HandleUserRegistration() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleUserRegistration() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
regData := services.UserRegistration{}
|
regData := services.UserRegistration{}
|
||||||
|
|
||||||
@@ -40,19 +38,18 @@ func (ctrl *V1Controller) HandleUserRegistration() errchain.HandlerFunc {
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusNoContent, nil)
|
return server.Respond(w, http.StatusNoContent, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleUserSelf godoc
|
// HandleUserSelf godoc
|
||||||
//
|
// @Summary Get the current user
|
||||||
// @Summary Get User Self
|
// @Tags User
|
||||||
// @Tags User
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 200 {object} server.Result{item=repo.UserOut}
|
||||||
// @Success 200 {object} Wrapped{item=repo.UserOut}
|
// @Router /v1/users/self [GET]
|
||||||
// @Router /v1/users/self [GET]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleUserSelf() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleUserSelf() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
token := services.UseTokenCtx(r.Context())
|
token := services.UseTokenCtx(r.Context())
|
||||||
usr, err := ctrl.svc.User.GetSelf(r.Context(), token)
|
usr, err := ctrl.svc.User.GetSelf(r.Context(), token)
|
||||||
@@ -61,20 +58,19 @@ func (ctrl *V1Controller) HandleUserSelf() errchain.HandlerFunc {
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusOK, Wrap(usr))
|
return server.Respond(w, http.StatusOK, server.Wrap(usr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleUserSelfUpdate godoc
|
// HandleUserSelfUpdate godoc
|
||||||
//
|
// @Summary Update the current user
|
||||||
// @Summary Update Account
|
// @Tags User
|
||||||
// @Tags User
|
// @Produce json
|
||||||
// @Produce json
|
// @Param payload body repo.UserUpdate true "User Data"
|
||||||
// @Param payload body repo.UserUpdate true "User Data"
|
// @Success 200 {object} server.Result{item=repo.UserUpdate}
|
||||||
// @Success 200 {object} Wrapped{item=repo.UserUpdate}
|
// @Router /v1/users/self [PUT]
|
||||||
// @Router /v1/users/self [PUT]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleUserSelfUpdate() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleUserSelfUpdate() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
updateData := repo.UserUpdate{}
|
updateData := repo.UserUpdate{}
|
||||||
if err := server.Decode(r, &updateData); err != nil {
|
if err := server.Decode(r, &updateData); err != nil {
|
||||||
@@ -88,19 +84,18 @@ func (ctrl *V1Controller) HandleUserSelfUpdate() errchain.HandlerFunc {
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusOK, Wrap(newData))
|
return server.Respond(w, http.StatusOK, server.Wrap(newData))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleUserSelfDelete godoc
|
// HandleUserSelfDelete godoc
|
||||||
//
|
// @Summary Deletes the user account
|
||||||
// @Summary Delete Account
|
// @Tags User
|
||||||
// @Tags User
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 204
|
||||||
// @Success 204
|
// @Router /v1/users/self [DELETE]
|
||||||
// @Router /v1/users/self [DELETE]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleUserSelfDelete() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleUserSelfDelete() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
if ctrl.isDemo {
|
if ctrl.isDemo {
|
||||||
return validate.NewRequestError(nil, http.StatusForbidden)
|
return validate.NewRequestError(nil, http.StatusForbidden)
|
||||||
@@ -111,7 +106,7 @@ func (ctrl *V1Controller) HandleUserSelfDelete() errchain.HandlerFunc {
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusNoContent, nil)
|
return server.Respond(w, http.StatusNoContent, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,14 +118,13 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// HandleUserSelfChangePassword godoc
|
// HandleUserSelfChangePassword godoc
|
||||||
//
|
// @Summary Updates the users password
|
||||||
// @Summary Change Password
|
// @Tags User
|
||||||
// @Tags User
|
// @Success 204
|
||||||
// @Success 204
|
// @Param payload body ChangePassword true "Password Payload"
|
||||||
// @Param payload body ChangePassword true "Password Payload"
|
// @Router /v1/users/change-password [PUT]
|
||||||
// @Router /v1/users/change-password [PUT]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
func (ctrl *V1Controller) HandleUserSelfChangePassword() server.HandlerFunc {
|
||||||
func (ctrl *V1Controller) HandleUserSelfChangePassword() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
if ctrl.isDemo {
|
if ctrl.isDemo {
|
||||||
return validate.NewRequestError(nil, http.StatusForbidden)
|
return validate.NewRequestError(nil, http.StatusForbidden)
|
||||||
@@ -149,6 +143,6 @@ func (ctrl *V1Controller) HandleUserSelfChangePassword() errchain.HandlerFunc {
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.JSON(w, http.StatusNoContent, nil)
|
return server.Respond(w, http.StatusNoContent, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/config"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupLogger initializes the zerolog config
|
// setupLogger initializes the zerolog config
|
||||||
@@ -17,8 +18,24 @@ func (a *app) setupLogger() {
|
|||||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}).With().Caller().Logger()
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}).With().Caller().Logger()
|
||||||
}
|
}
|
||||||
|
|
||||||
level, err := zerolog.ParseLevel(a.conf.Log.Level)
|
log.Level(getLevel(a.conf.Log.Level))
|
||||||
if err == nil {
|
}
|
||||||
zerolog.SetGlobalLevel(level)
|
|
||||||
|
func getLevel(l string) zerolog.Level {
|
||||||
|
switch strings.ToLower(l) {
|
||||||
|
case "debug":
|
||||||
|
return zerolog.DebugLevel
|
||||||
|
case "info":
|
||||||
|
return zerolog.InfoLevel
|
||||||
|
case "warn":
|
||||||
|
return zerolog.WarnLevel
|
||||||
|
case "error":
|
||||||
|
return zerolog.ErrorLevel
|
||||||
|
case "fatal":
|
||||||
|
return zerolog.FatalLevel
|
||||||
|
case "panic":
|
||||||
|
return zerolog.PanicLevel
|
||||||
|
default:
|
||||||
|
return zerolog.InfoLevel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,24 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pressly/goose/v3"
|
atlas "ariga.io/atlas/sql/migrate"
|
||||||
|
"entgo.io/ent/dialect/sql/schema"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/hay-kot/homebox/backend/app/api/static/docs"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/data/migrations"
|
||||||
"github.com/hay-kot/httpkit/graceful"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/rs/zerolog"
|
"github.com/hay-kot/homebox/backend/internal/sys/config"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/mid"
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/rs/zerolog/pkgerrors"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/currencies"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"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/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"
|
|
||||||
|
|
||||||
_ "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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -41,50 +27,24 @@ var (
|
|||||||
buildTime = "now"
|
buildTime = "now"
|
||||||
)
|
)
|
||||||
|
|
||||||
func build() string {
|
// @title Go API Templates
|
||||||
short := commit
|
|
||||||
if len(short) > 7 {
|
|
||||||
short = short[:7]
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s, commit %s, built at %s", version, short, buildTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validatePostgresSSLMode(sslMode string) bool {
|
|
||||||
validModes := map[string]bool{
|
|
||||||
"": true,
|
|
||||||
"disable": true,
|
|
||||||
"allow": true,
|
|
||||||
"prefer": true,
|
|
||||||
"require": true,
|
|
||||||
"verify-ca": true,
|
|
||||||
"verify-full": true,
|
|
||||||
}
|
|
||||||
return validModes[strings.ToLower(strings.TrimSpace(sslMode))]
|
|
||||||
}
|
|
||||||
|
|
||||||
// @title Homebox API
|
|
||||||
// @version 1.0
|
// @version 1.0
|
||||||
// @description Track, Manage, and Organize your Things.
|
// @description This is a simple Rest API Server Template that implements some basic User and Authentication patterns to help you get started and bootstrap your next project!.
|
||||||
// @contact.name Homebox Team
|
// @contact.name Don't
|
||||||
// @contact.url https://discord.homebox.software
|
// @license.name MIT
|
||||||
// @host demo.homebox.software
|
|
||||||
// @schemes https http
|
|
||||||
// @BasePath /api
|
// @BasePath /api
|
||||||
// @securityDefinitions.apikey Bearer
|
// @securityDefinitions.apikey Bearer
|
||||||
// @in header
|
// @in header
|
||||||
// @name Authorization
|
// @name Authorization
|
||||||
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
|
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
|
||||||
// @externalDocs.url https://homebox.software/en/api
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
cfg, err := config.New()
|
||||||
|
|
||||||
cfg, err := config.New(build(), "Homebox inventory management system")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
docs.SwaggerInfo.Host = cfg.Swagger.Host
|
||||||
|
|
||||||
if err := run(cfg); err != nil {
|
if err := run(cfg); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -94,10 +54,6 @@ func run(cfg *config.Config) error {
|
|||||||
app := new(cfg)
|
app := new(cfg)
|
||||||
app.setupLogger()
|
app.setupLogger()
|
||||||
|
|
||||||
if cfg.Options.AllowAnalytics {
|
|
||||||
analytics.Send(version, build())
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Initialize Database & Repos
|
// Initialize Database & Repos
|
||||||
|
|
||||||
@@ -106,203 +62,118 @@ func run(cfg *config.Config) error {
|
|||||||
log.Fatal().Err(err).Msg("failed to create data directory")
|
log.Fatal().Err(err).Msg("failed to create data directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.ToLower(cfg.Database.Driver) == "postgres" {
|
c, err := ent.Open("sqlite3", cfg.Storage.SqliteUrl)
|
||||||
if !validatePostgresSSLMode(cfg.Database.SslMode) {
|
|
||||||
log.Fatal().Str("sslmode", cfg.Database.SslMode).Msg("invalid sslmode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
if err != nil {
|
||||||
log.Fatal().
|
log.Fatal().
|
||||||
Err(err).
|
Err(err).
|
||||||
Str("driver", strings.ToLower(cfg.Database.Driver)).
|
Str("driver", "sqlite").
|
||||||
Str("host", cfg.Database.Host).
|
Str("url", cfg.Storage.SqliteUrl).
|
||||||
Str("port", cfg.Database.Port).
|
Msg("failed opening connection to sqlite")
|
||||||
Str("database", cfg.Database.Database).
|
|
||||||
Msg("failed opening connection to {driver} database at {host}:{port}/{database}")
|
|
||||||
}
|
}
|
||||||
|
defer func(c *ent.Client) {
|
||||||
goose.SetBaseFS(migrations.Migrations(strings.ToLower(cfg.Database.Driver)))
|
err := c.Close()
|
||||||
err = goose.SetDialect(strings.ToLower(cfg.Database.Driver))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Str("driver", cfg.Database.Driver).Msg("unsupported database driver")
|
|
||||||
return fmt.Errorf("unsupported database driver: %s", cfg.Database.Driver)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = goose.Up(c.Sql(), strings.ToLower(cfg.Database.Driver))
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to migrate database")
|
|
||||||
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 {
|
if err != nil {
|
||||||
log.Error().
|
log.Fatal().Err(err).Msg("failed to close database connection")
|
||||||
Err(err).
|
|
||||||
Str("path", cfg.Options.CurrencyConfig).
|
|
||||||
Msg("failed to read currency config file")
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
}(c)
|
||||||
|
|
||||||
collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content)))
|
temp := filepath.Join(os.TempDir(), "migrations")
|
||||||
}
|
|
||||||
|
|
||||||
currencies, err := currencies.CollectionCurrencies(collectFuncs...)
|
err = migrations.Write(temp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
|
||||||
Err(err).
|
|
||||||
Msg("failed to collect currencies")
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
app.bus = eventbus.New()
|
dir, err := atlas.NewLocalDir(temp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
options := []schema.MigrateOption{
|
||||||
|
schema.WithDir(dir),
|
||||||
|
schema.WithDropColumn(true),
|
||||||
|
schema.WithDropIndex(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.Schema.Create(context.Background(), options...)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().
|
||||||
|
Err(err).
|
||||||
|
Str("driver", "sqlite").
|
||||||
|
Str("url", cfg.Storage.SqliteUrl).
|
||||||
|
Msg("failed creating schema resources")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.RemoveAll(temp)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("failed to remove temporary directory for database migrations")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
app.db = c
|
app.db = c
|
||||||
app.repos = repo.New(c, app.bus, cfg.Storage.Data)
|
app.repos = repo.New(c, cfg.Storage.Data)
|
||||||
app.services = services.New(
|
app.services = services.New(
|
||||||
app.repos,
|
app.repos,
|
||||||
services.WithAutoIncrementAssetID(cfg.Options.AutoIncrementAssetID),
|
services.WithAutoIncrementAssetID(cfg.Options.AutoIncrementAssetID),
|
||||||
services.WithCurrencies(currencies),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Start Server
|
// Start Server\
|
||||||
|
|
||||||
logger := log.With().Caller().Logger()
|
logger := log.With().Caller().Logger()
|
||||||
|
|
||||||
router := chi.NewMux()
|
mwLogger := mid.Logger(logger)
|
||||||
router.Use(
|
if app.conf.Mode == config.ModeDevelopment {
|
||||||
middleware.RequestID,
|
mwLogger = mid.SugarLogger(logger)
|
||||||
middleware.RealIP,
|
}
|
||||||
mid.Logger(logger),
|
|
||||||
middleware.Recoverer,
|
app.server = server.NewServer(
|
||||||
middleware.StripSlashes,
|
server.WithHost(app.conf.Web.Host),
|
||||||
|
server.WithPort(app.conf.Web.Port),
|
||||||
|
server.WithMiddleware(
|
||||||
|
mwLogger,
|
||||||
|
mid.Errors(logger),
|
||||||
|
mid.Panic(app.conf.Mode == config.ModeDevelopment),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
chain := errchain.New(mid.Errors(logger))
|
app.mountRoutes(app.repos)
|
||||||
|
|
||||||
app.mountRoutes(router, chain, app.repos)
|
log.Info().Msgf("Starting HTTP Server on %s:%s", app.server.Host, app.server.Port)
|
||||||
|
|
||||||
runner := graceful.NewRunner()
|
|
||||||
|
|
||||||
runner.AddFunc("server", func(ctx context.Context) error {
|
|
||||||
httpserver := http.Server{
|
|
||||||
Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Web.Port),
|
|
||||||
Handler: router,
|
|
||||||
ReadTimeout: cfg.Web.ReadTimeout,
|
|
||||||
WriteTimeout: cfg.Web.WriteTimeout,
|
|
||||||
IdleTimeout: cfg.Web.IdleTimeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
_ = httpserver.Shutdown(context.Background())
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Info().Msgf("Server is running on %s:%s", cfg.Web.Host, cfg.Web.Port)
|
|
||||||
return httpserver.ListenAndServe()
|
|
||||||
})
|
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Start Reoccurring Tasks
|
// Start Reoccurring Tasks
|
||||||
|
|
||||||
runner.AddFunc("eventbus", app.bus.Run)
|
go app.startBgTask(time.Duration(24)*time.Hour, func() {
|
||||||
|
_, err := app.repos.AuthTokens.PurgeExpiredTokens(context.Background())
|
||||||
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 {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("failed to purge expired tokens")
|
Msg("failed to purge expired tokens")
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
|
go app.startBgTask(time.Duration(24)*time.Hour, func() {
|
||||||
runner.AddPlugin(NewTask("purge-invitations", time.Duration(24)*time.Hour, func(ctx context.Context) {
|
_, err := app.repos.Groups.InvitationPurge(context.Background())
|
||||||
_, err := app.repos.Groups.InvitationPurge(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("failed to purge expired invitations")
|
Msg("failed to purge expired invitations")
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
|
|
||||||
runner.AddPlugin(NewTask("send-notifications", time.Duration(1)*time.Hour, func(ctx context.Context) {
|
// TODO: Remove through external API that does setup
|
||||||
now := time.Now()
|
if cfg.Demo {
|
||||||
|
log.Info().Msg("Running in demo mode, creating demo data")
|
||||||
if now.Hour() == 8 {
|
app.SetupDemo()
|
||||||
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 {
|
if cfg.Debug.Enabled {
|
||||||
runner.AddFunc("debug", func(ctx context.Context) error {
|
debugrouter := app.debugRouter()
|
||||||
debugserver := http.Server{
|
go func() {
|
||||||
Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port),
|
if err := http.ListenAndServe(":"+cfg.Debug.Port, debugrouter); err != nil {
|
||||||
Handler: app.debugRouter(),
|
log.Fatal().Err(err).Msg("failed to start debug server")
|
||||||
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())
|
return app.server.Start()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
v1 "github.com/sysadminsmedia/homebox/backend/app/api/handlers/v1"
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type tokenHasKey struct {
|
type tokenHasKey struct {
|
||||||
@@ -32,9 +30,9 @@ const (
|
|||||||
// the required roles, a 403 Forbidden will be returned.
|
// the required roles, a 403 Forbidden will be returned.
|
||||||
//
|
//
|
||||||
// WARNING: This middleware _MUST_ be called after mwAuthToken or else it will panic
|
// WARNING: This middleware _MUST_ be called after mwAuthToken or else it will panic
|
||||||
func (a *app) mwRoles(rm RoleMode, required ...string) errchain.Middleware {
|
func (a *app) mwRoles(rm RoleMode, required ...string) server.Middleware {
|
||||||
return func(next errchain.Handler) errchain.Handler {
|
return func(next server.Handler) server.Handler {
|
||||||
return errchain.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
return server.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
maybeToken := ctx.Value(hashedToken)
|
maybeToken := ctx.Value(hashedToken)
|
||||||
@@ -96,6 +94,20 @@ func getQuery(r *http.Request) (string, error) {
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getCookie(r *http.Request) (string, error) {
|
||||||
|
cookie, err := r.Cookie("hb.auth.token")
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("access_token cookie is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := url.QueryUnescape(cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("access_token cookie is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
// mwAuthToken is a middleware that will check the database for a stateful token
|
// mwAuthToken is a middleware that will check the database for a stateful token
|
||||||
// and attach it's user to the request context, or return an appropriate error.
|
// and attach it's user to the request context, or return an appropriate error.
|
||||||
// Authorization support is by token via Headers or Query Parameter
|
// Authorization support is by token via Headers or Query Parameter
|
||||||
@@ -103,35 +115,26 @@ func getQuery(r *http.Request) (string, error) {
|
|||||||
// Example:
|
// Example:
|
||||||
// - header = "Bearer 1234567890"
|
// - header = "Bearer 1234567890"
|
||||||
// - query = "?access_token=1234567890"
|
// - query = "?access_token=1234567890"
|
||||||
func (a *app) mwAuthToken(next errchain.Handler) errchain.Handler {
|
// - cookie = hb.auth.token = 1234567890
|
||||||
return errchain.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
func (a *app) mwAuthToken(next server.Handler) server.Handler {
|
||||||
|
return server.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
keyFuncs := [...]KeyFunc{
|
||||||
|
getBearer,
|
||||||
|
getCookie,
|
||||||
|
getQuery,
|
||||||
|
}
|
||||||
|
|
||||||
var requestToken string
|
var requestToken string
|
||||||
|
for _, keyFunc := range keyFuncs {
|
||||||
// We ignore the error to allow the next strategy to be attempted
|
token, err := keyFunc(r)
|
||||||
{
|
if err == nil {
|
||||||
cookies, _ := v1.GetCookies(r)
|
requestToken = token
|
||||||
if cookies != nil {
|
break
|
||||||
requestToken = cookies.Token
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestToken == "" {
|
if requestToken == "" {
|
||||||
keyFuncs := [...]KeyFunc{
|
return validate.NewRequestError(errors.New("Authorization header or query is required"), http.StatusUnauthorized)
|
||||||
getBearer,
|
|
||||||
getQuery,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, keyFunc := range keyFuncs {
|
|
||||||
token, err := keyFunc(r)
|
|
||||||
if err == nil {
|
|
||||||
requestToken = token
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestToken == "" {
|
|
||||||
return validate.NewRequestError(errors.New("authorization header or query is required"), http.StatusUnauthorized)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
requestToken = strings.TrimPrefix(requestToken, "Bearer ")
|
requestToken = strings.TrimPrefix(requestToken, "Bearer ")
|
||||||
@@ -141,11 +144,7 @@ func (a *app) mwAuthToken(next errchain.Handler) errchain.Handler {
|
|||||||
usr, err := a.services.User.GetSelf(r.Context(), requestToken)
|
usr, err := a.services.User.GetSelf(r.Context(), requestToken)
|
||||||
// Check the database for the token
|
// Check the database for the token
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ent.IsNotFound(err) {
|
return validate.NewRequestError(errors.New("valid authorization header is required"), http.StatusUnauthorized)
|
||||||
return validate.NewRequestError(errors.New("valid authorization token is required"), http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
r = r.WithContext(services.SetUserCtx(r.Context(), &usr, requestToken))
|
r = r.WithContext(services.SetUserCtx(r.Context(), &usr, requestToken))
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
// Package providers provides a authentication abstraction for the backend.
|
|
||||||
package providers
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/hay-kot/httpkit/server"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LoginForm struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
StayLoggedIn bool `json:"stayLoggedIn"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLoginForm(r *http.Request) (LoginForm, error) {
|
|
||||||
loginForm := LoginForm{}
|
|
||||||
|
|
||||||
switch r.Header.Get("Content-Type") {
|
|
||||||
case "application/x-www-form-urlencoded":
|
|
||||||
err := r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
return loginForm, errors.New("failed to parse form")
|
|
||||||
}
|
|
||||||
|
|
||||||
loginForm.Username = r.PostFormValue("username")
|
|
||||||
loginForm.Password = r.PostFormValue("password")
|
|
||||||
loginForm.StayLoggedIn = r.PostFormValue("stayLoggedIn") == "true"
|
|
||||||
case "application/json":
|
|
||||||
err := server.Decode(r, &loginForm)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to decode login form")
|
|
||||||
return loginForm, errors.New("failed to decode login form")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return loginForm, errors.New("invalid content type")
|
|
||||||
}
|
|
||||||
|
|
||||||
if loginForm.Username == "" || loginForm.Password == "" {
|
|
||||||
return loginForm, validate.NewFieldErrors(
|
|
||||||
validate.FieldError{
|
|
||||||
Field: "username",
|
|
||||||
Error: "username or password is empty",
|
|
||||||
},
|
|
||||||
validate.FieldError{
|
|
||||||
Field: "password",
|
|
||||||
Error: "username or password is empty",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return loginForm, nil
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LocalProvider struct {
|
|
||||||
service *services.UserService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLocalProvider(service *services.UserService) *LocalProvider {
|
|
||||||
return &LocalProvider{
|
|
||||||
service: service,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *LocalProvider) Name() string {
|
|
||||||
return "local"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *LocalProvider) Authenticate(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
|
||||||
loginForm, err := getLoginForm(r)
|
|
||||||
if err != nil {
|
|
||||||
return services.UserAuthTokenDetail{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.service.Login(r.Context(), loginForm.Username, loginForm.Password, loginForm.StayLoggedIn)
|
|
||||||
}
|
|
||||||
@@ -10,15 +10,13 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/hay-kot/homebox/backend/app/api/handlers/debughandlers"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
v1 "github.com/hay-kot/homebox/backend/app/api/handlers/v1"
|
||||||
httpSwagger "github.com/swaggo/http-swagger/v2" // http-swagger middleware
|
_ "github.com/hay-kot/homebox/backend/app/api/static/docs"
|
||||||
"github.com/sysadminsmedia/homebox/backend/app/api/handlers/debughandlers"
|
"github.com/hay-kot/homebox/backend/internal/data/ent/authroles"
|
||||||
v1 "github.com/sysadminsmedia/homebox/backend/app/api/handlers/v1"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/sysadminsmedia/homebox/backend/app/api/providers"
|
"github.com/hay-kot/homebox/backend/pkgs/server"
|
||||||
_ "github.com/sysadminsmedia/homebox/backend/app/api/static/docs"
|
httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/authroles"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const prefix = "/api"
|
const prefix = "/api"
|
||||||
@@ -38,142 +36,110 @@ func (a *app) debugRouter() *http.ServeMux {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// registerRoutes registers all the routes for the API
|
// registerRoutes registers all the routes for the API
|
||||||
func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllRepos) {
|
func (a *app) mountRoutes(repos *repo.AllRepos) {
|
||||||
registerMimes()
|
registerMimes()
|
||||||
|
|
||||||
r.Get("/swagger/*", httpSwagger.Handler(
|
a.server.Get("/swagger/*", server.ToHandler(httpSwagger.Handler(
|
||||||
httpSwagger.URL("/swagger/doc.json"),
|
httpSwagger.URL(fmt.Sprintf("%s://%s/swagger/doc.json", a.conf.Swagger.Scheme, a.conf.Swagger.Host)),
|
||||||
))
|
)))
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// API Version 1
|
// API Version 1
|
||||||
|
|
||||||
|
v1Base := v1.BaseUrlFunc(prefix)
|
||||||
|
|
||||||
v1Ctrl := v1.NewControllerV1(
|
v1Ctrl := v1.NewControllerV1(
|
||||||
a.services,
|
a.services,
|
||||||
a.repos,
|
a.repos,
|
||||||
a.bus,
|
|
||||||
a.conf,
|
|
||||||
v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize),
|
v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize),
|
||||||
v1.WithRegistration(a.conf.Options.AllowRegistration),
|
v1.WithRegistration(a.conf.Options.AllowRegistration),
|
||||||
v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode
|
v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode
|
||||||
v1.WithURL(fmt.Sprintf("%s:%s", a.conf.Web.Host, a.conf.Web.Port)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
r.Route(prefix+"/v1", func(r chi.Router) {
|
a.server.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, v1.Build{
|
||||||
r.Get("/status", chain.ToHandlerFunc(v1Ctrl.HandleBase(func() bool { return true }, v1.Build{
|
Version: version,
|
||||||
Version: version,
|
Commit: commit,
|
||||||
Commit: commit,
|
BuildTime: buildTime,
|
||||||
BuildTime: buildTime,
|
}))
|
||||||
})))
|
|
||||||
|
|
||||||
r.Get("/currencies", chain.ToHandlerFunc(v1Ctrl.HandleCurrency()))
|
a.server.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration())
|
||||||
|
a.server.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin())
|
||||||
|
|
||||||
providers := []v1.AuthProvider{
|
userMW := []server.Middleware{
|
||||||
providers.NewLocalProvider(a.services.User),
|
a.mwAuthToken,
|
||||||
}
|
a.mwRoles(RoleModeOr, authroles.RoleUser.String()),
|
||||||
|
}
|
||||||
|
|
||||||
r.Post("/users/register", chain.ToHandlerFunc(v1Ctrl.HandleUserRegistration()))
|
a.server.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf(), userMW...)
|
||||||
r.Post("/users/login", chain.ToHandlerFunc(v1Ctrl.HandleAuthLogin(providers...)))
|
a.server.Put(v1Base("/users/self"), v1Ctrl.HandleUserSelfUpdate(), userMW...)
|
||||||
|
a.server.Delete(v1Base("/users/self"), v1Ctrl.HandleUserSelfDelete(), userMW...)
|
||||||
|
a.server.Post(v1Base("/users/logout"), v1Ctrl.HandleAuthLogout(), userMW...)
|
||||||
|
a.server.Get(v1Base("/users/refresh"), v1Ctrl.HandleAuthRefresh(), userMW...)
|
||||||
|
a.server.Put(v1Base("/users/self/change-password"), v1Ctrl.HandleUserSelfChangePassword(), userMW...)
|
||||||
|
|
||||||
userMW := []errchain.Middleware{
|
a.server.Post(v1Base("/groups/invitations"), v1Ctrl.HandleGroupInvitationsCreate(), userMW...)
|
||||||
a.mwAuthToken,
|
a.server.Get(v1Base("/groups/statistics"), v1Ctrl.HandleGroupStatistics(), userMW...)
|
||||||
a.mwRoles(RoleModeOr, authroles.RoleUser.String()),
|
a.server.Get(v1Base("/groups/statistics/purchase-price"), v1Ctrl.HandleGroupStatisticsPriceOverTime(), userMW...)
|
||||||
}
|
a.server.Get(v1Base("/groups/statistics/locations"), v1Ctrl.HandleGroupStatisticsLocations(), userMW...)
|
||||||
|
a.server.Get(v1Base("/groups/statistics/labels"), v1Ctrl.HandleGroupStatisticsLabels(), userMW...)
|
||||||
|
|
||||||
r.Get("/ws/events", chain.ToHandlerFunc(v1Ctrl.HandleCacheWS(), userMW...))
|
// TODO: I don't like /groups being the URL for users
|
||||||
r.Get("/users/self", chain.ToHandlerFunc(v1Ctrl.HandleUserSelf(), userMW...))
|
a.server.Get(v1Base("/groups"), v1Ctrl.HandleGroupGet(), userMW...)
|
||||||
r.Put("/users/self", chain.ToHandlerFunc(v1Ctrl.HandleUserSelfUpdate(), userMW...))
|
a.server.Put(v1Base("/groups"), v1Ctrl.HandleGroupUpdate(), userMW...)
|
||||||
r.Delete("/users/self", chain.ToHandlerFunc(v1Ctrl.HandleUserSelfDelete(), userMW...))
|
|
||||||
r.Post("/users/logout", chain.ToHandlerFunc(v1Ctrl.HandleAuthLogout(), userMW...))
|
|
||||||
r.Get("/users/refresh", chain.ToHandlerFunc(v1Ctrl.HandleAuthRefresh(), userMW...))
|
|
||||||
r.Put("/users/self/change-password", chain.ToHandlerFunc(v1Ctrl.HandleUserSelfChangePassword(), userMW...))
|
|
||||||
|
|
||||||
r.Post("/groups/invitations", chain.ToHandlerFunc(v1Ctrl.HandleGroupInvitationsCreate(), userMW...))
|
a.server.Post(v1Base("/actions/ensure-asset-ids"), v1Ctrl.HandleEnsureAssetID(), userMW...)
|
||||||
r.Get("/groups/statistics", chain.ToHandlerFunc(v1Ctrl.HandleGroupStatistics(), userMW...))
|
a.server.Post(v1Base("/actions/zero-item-time-fields"), v1Ctrl.HandleItemDateZeroOut(), userMW...)
|
||||||
r.Get("/groups/statistics/purchase-price", chain.ToHandlerFunc(v1Ctrl.HandleGroupStatisticsPriceOverTime(), userMW...))
|
|
||||||
r.Get("/groups/statistics/locations", chain.ToHandlerFunc(v1Ctrl.HandleGroupStatisticsLocations(), userMW...))
|
|
||||||
r.Get("/groups/statistics/labels", chain.ToHandlerFunc(v1Ctrl.HandleGroupStatisticsLabels(), userMW...))
|
|
||||||
|
|
||||||
// TODO: I don't like /groups being the URL for users
|
a.server.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll(), userMW...)
|
||||||
r.Get("/groups", chain.ToHandlerFunc(v1Ctrl.HandleGroupGet(), userMW...))
|
a.server.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate(), userMW...)
|
||||||
r.Put("/groups", chain.ToHandlerFunc(v1Ctrl.HandleGroupUpdate(), userMW...))
|
a.server.Get(v1Base("/locations/tree"), v1Ctrl.HandleLocationTreeQuery(), userMW...)
|
||||||
|
a.server.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet(), userMW...)
|
||||||
|
a.server.Put(v1Base("/locations/{id}"), v1Ctrl.HandleLocationUpdate(), userMW...)
|
||||||
|
a.server.Delete(v1Base("/locations/{id}"), v1Ctrl.HandleLocationDelete(), userMW...)
|
||||||
|
|
||||||
r.Post("/actions/ensure-asset-ids", chain.ToHandlerFunc(v1Ctrl.HandleEnsureAssetID(), userMW...))
|
a.server.Get(v1Base("/labels"), v1Ctrl.HandleLabelsGetAll(), userMW...)
|
||||||
r.Post("/actions/zero-item-time-fields", chain.ToHandlerFunc(v1Ctrl.HandleItemDateZeroOut(), userMW...))
|
a.server.Post(v1Base("/labels"), v1Ctrl.HandleLabelsCreate(), userMW...)
|
||||||
r.Post("/actions/ensure-import-refs", chain.ToHandlerFunc(v1Ctrl.HandleEnsureImportRefs(), userMW...))
|
a.server.Get(v1Base("/labels/{id}"), v1Ctrl.HandleLabelGet(), userMW...)
|
||||||
r.Post("/actions/set-primary-photos", chain.ToHandlerFunc(v1Ctrl.HandleSetPrimaryPhotos(), userMW...))
|
a.server.Put(v1Base("/labels/{id}"), v1Ctrl.HandleLabelUpdate(), userMW...)
|
||||||
|
a.server.Delete(v1Base("/labels/{id}"), v1Ctrl.HandleLabelDelete(), userMW...)
|
||||||
|
|
||||||
r.Get("/locations", chain.ToHandlerFunc(v1Ctrl.HandleLocationGetAll(), userMW...))
|
a.server.Get(v1Base("/items"), v1Ctrl.HandleItemsGetAll(), userMW...)
|
||||||
r.Post("/locations", chain.ToHandlerFunc(v1Ctrl.HandleLocationCreate(), userMW...))
|
a.server.Post(v1Base("/items"), v1Ctrl.HandleItemsCreate(), userMW...)
|
||||||
r.Get("/locations/tree", chain.ToHandlerFunc(v1Ctrl.HandleLocationTreeQuery(), userMW...))
|
a.server.Post(v1Base("/items/import"), v1Ctrl.HandleItemsImport(), userMW...)
|
||||||
r.Get("/locations/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLocationGet(), userMW...))
|
a.server.Get(v1Base("/items/fields"), v1Ctrl.HandleGetAllCustomFieldNames(), userMW...)
|
||||||
r.Put("/locations/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLocationUpdate(), userMW...))
|
a.server.Get(v1Base("/items/fields/values"), v1Ctrl.HandleGetAllCustomFieldValues(), userMW...)
|
||||||
r.Delete("/locations/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLocationDelete(), userMW...))
|
|
||||||
|
|
||||||
r.Get("/labels", chain.ToHandlerFunc(v1Ctrl.HandleLabelsGetAll(), userMW...))
|
a.server.Get(v1Base("/items/{id}"), v1Ctrl.HandleItemGet(), userMW...)
|
||||||
r.Post("/labels", chain.ToHandlerFunc(v1Ctrl.HandleLabelsCreate(), userMW...))
|
a.server.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate(), userMW...)
|
||||||
r.Get("/labels/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLabelGet(), userMW...))
|
a.server.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete(), userMW...)
|
||||||
r.Put("/labels/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLabelUpdate(), userMW...))
|
|
||||||
r.Delete("/labels/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLabelDelete(), userMW...))
|
|
||||||
|
|
||||||
r.Get("/items", chain.ToHandlerFunc(v1Ctrl.HandleItemsGetAll(), userMW...))
|
a.server.Post(v1Base("/items/{id}/attachments"), v1Ctrl.HandleItemAttachmentCreate(), userMW...)
|
||||||
r.Post("/items", chain.ToHandlerFunc(v1Ctrl.HandleItemsCreate(), userMW...))
|
a.server.Put(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentUpdate(), userMW...)
|
||||||
r.Post("/items/import", chain.ToHandlerFunc(v1Ctrl.HandleItemsImport(), userMW...))
|
a.server.Delete(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentDelete(), userMW...)
|
||||||
r.Get("/items/export", chain.ToHandlerFunc(v1Ctrl.HandleItemsExport(), userMW...))
|
|
||||||
r.Get("/items/fields", chain.ToHandlerFunc(v1Ctrl.HandleGetAllCustomFieldNames(), userMW...))
|
|
||||||
r.Get("/items/fields/values", chain.ToHandlerFunc(v1Ctrl.HandleGetAllCustomFieldValues(), userMW...))
|
|
||||||
|
|
||||||
r.Get("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemGet(), userMW...))
|
a.server.Get(v1Base("/items/{id}/maintenance"), v1Ctrl.HandleMaintenanceEntryCreate(), userMW...)
|
||||||
r.Get("/items/{id}/path", chain.ToHandlerFunc(v1Ctrl.HandleItemFullPath(), userMW...))
|
a.server.Post(v1Base("/items/{id}/maintenance"), v1Ctrl.HandleMaintenanceEntryCreate(), userMW...)
|
||||||
r.Put("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemUpdate(), userMW...))
|
a.server.Put(v1Base("/items/{id}/maintenance/{entry_id}"), v1Ctrl.HandleMaintenanceEntryUpdate(), userMW...)
|
||||||
r.Patch("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemPatch(), userMW...))
|
a.server.Delete(v1Base("/items/{id}/maintenance/{entry_id}"), v1Ctrl.HandleMaintenanceEntryDelete(), userMW...)
|
||||||
r.Delete("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemDelete(), userMW...))
|
|
||||||
|
|
||||||
r.Post("/items/{id}/attachments", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentCreate(), userMW...))
|
a.server.Get(v1Base("/asset/{id}"), v1Ctrl.HandleAssetGet(), userMW...)
|
||||||
r.Put("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentUpdate(), userMW...))
|
|
||||||
r.Delete("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentDelete(), userMW...))
|
|
||||||
|
|
||||||
r.Get("/items/{id}/maintenance", chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceLogGet(), userMW...))
|
// Asset-Like endpoints
|
||||||
r.Post("/items/{id}/maintenance", chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceEntryCreate(), userMW...))
|
a.server.Get(
|
||||||
|
v1Base("/qrcode"),
|
||||||
|
v1Ctrl.HandleGenerateQRCode(),
|
||||||
|
a.mwAuthToken, a.mwRoles(RoleModeOr, authroles.RoleUser.String(), authroles.RoleAttachments.String()),
|
||||||
|
)
|
||||||
|
a.server.Get(
|
||||||
|
v1Base("/items/{id}/attachments/{attachment_id}"),
|
||||||
|
v1Ctrl.HandleItemAttachmentGet(),
|
||||||
|
a.mwAuthToken, a.mwRoles(RoleModeOr, authroles.RoleUser.String(), authroles.RoleAttachments.String()),
|
||||||
|
)
|
||||||
|
|
||||||
r.Get("/assets/{id}", chain.ToHandlerFunc(v1Ctrl.HandleAssetGet(), userMW...))
|
// Reporting Services
|
||||||
|
a.server.Get(v1Base("/reporting/bill-of-materials"), v1Ctrl.HandleBillOfMaterialsExport(), userMW...)
|
||||||
|
|
||||||
// Maintenance
|
a.server.NotFound(notFoundHandler())
|
||||||
r.Get("/maintenance", chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceGetAll(), userMW...))
|
|
||||||
r.Put("/maintenance/{id}", chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceEntryUpdate(), userMW...))
|
|
||||||
r.Delete("/maintenance/{id}", chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceEntryDelete(), userMW...))
|
|
||||||
|
|
||||||
// Notifiers
|
|
||||||
r.Get("/notifiers", chain.ToHandlerFunc(v1Ctrl.HandleGetUserNotifiers(), userMW...))
|
|
||||||
r.Post("/notifiers", chain.ToHandlerFunc(v1Ctrl.HandleCreateNotifier(), userMW...))
|
|
||||||
r.Put("/notifiers/{id}", chain.ToHandlerFunc(v1Ctrl.HandleUpdateNotifier(), userMW...))
|
|
||||||
r.Delete("/notifiers/{id}", chain.ToHandlerFunc(v1Ctrl.HandleDeleteNotifier(), userMW...))
|
|
||||||
r.Post("/notifiers/test", chain.ToHandlerFunc(v1Ctrl.HandlerNotifierTest(), userMW...))
|
|
||||||
|
|
||||||
// Asset-Like endpoints
|
|
||||||
assetMW := []errchain.Middleware{
|
|
||||||
a.mwAuthToken,
|
|
||||||
a.mwRoles(RoleModeOr, authroles.RoleUser.String(), authroles.RoleAttachments.String()),
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Get("/qrcode", chain.ToHandlerFunc(v1Ctrl.HandleGenerateQRCode(), assetMW...))
|
|
||||||
r.Get(
|
|
||||||
"/items/{id}/attachments/{attachment_id}",
|
|
||||||
chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentGet(), assetMW...),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Labelmaker
|
|
||||||
r.Get("/labelmaker/location/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGetLocationLabel(), userMW...))
|
|
||||||
r.Get("/labelmaker/item/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGetItemLabel(), userMW...))
|
|
||||||
r.Get("/labelmaker/asset/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGetAssetLabel(), userMW...))
|
|
||||||
|
|
||||||
// Reporting Services
|
|
||||||
r.Get("/reporting/bill-of-materials", chain.ToHandlerFunc(v1Ctrl.HandleBillOfMaterialsExport(), userMW...))
|
|
||||||
|
|
||||||
r.NotFound(http.NotFound)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.NotFound(chain.ToHandlerFunc(notFoundHandler()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerMimes() {
|
func registerMimes() {
|
||||||
@@ -190,13 +156,13 @@ func registerMimes() {
|
|||||||
|
|
||||||
// notFoundHandler perform the main logic around handling the internal SPA embed and ensuring that
|
// notFoundHandler perform the main logic around handling the internal SPA embed and ensuring that
|
||||||
// the client side routing is handled correctly.
|
// the client side routing is handled correctly.
|
||||||
func notFoundHandler() errchain.HandlerFunc {
|
func notFoundHandler() server.HandlerFunc {
|
||||||
tryRead := func(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error {
|
tryRead := func(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error {
|
||||||
f, err := fs.Open(path.Join(prefix, requestedPath))
|
f, err := fs.Open(path.Join(prefix, requestedPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = f.Close() }()
|
defer f.Close()
|
||||||
|
|
||||||
stat, _ := f.Stat()
|
stat, _ := f.Stat()
|
||||||
if stat.IsDir() {
|
if stat.IsDir() {
|
||||||
|
|||||||
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
45
backend/app/tools/migrations/main.go
Normal file
45
backend/app/tools/migrations/main.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/ent/migrate"
|
||||||
|
|
||||||
|
atlas "ariga.io/atlas/sql/migrate"
|
||||||
|
_ "ariga.io/atlas/sql/sqlite"
|
||||||
|
"entgo.io/ent/dialect"
|
||||||
|
"entgo.io/ent/dialect/sql/schema"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
// Create a local migration directory able to understand Atlas migration file format for replay.
|
||||||
|
dir, err := atlas.NewLocalDir("internal/data/migrations/migrations")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed creating atlas migration directory: %v", err)
|
||||||
|
}
|
||||||
|
// Migrate diff options.
|
||||||
|
opts := []schema.MigrateOption{
|
||||||
|
schema.WithDir(dir), // provide migration directory
|
||||||
|
schema.WithMigrationMode(schema.ModeReplay), // provide migration mode
|
||||||
|
schema.WithDialect(dialect.SQLite), // Ent dialect to use
|
||||||
|
schema.WithFormatter(atlas.DefaultFormatter),
|
||||||
|
schema.WithDropIndex(true),
|
||||||
|
schema.WithDropColumn(true),
|
||||||
|
}
|
||||||
|
if len(os.Args) != 2 {
|
||||||
|
log.Fatalln("migration name is required. Use: 'go run -mod=mod ent/migrate/main.go <name>'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate migrations using Atlas support for MySQL (note the Ent dialect option passed above).
|
||||||
|
err = migrate.NamedDiff(ctx, "sqlite://.data/homebox.migration.db?_fk=1", os.Args[1], opts...)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed generating migration file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Migration file generated successfully.")
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY-----
|
|
||||||
eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjo2NTUzNiwiciI6
|
|
||||||
OCwicCI6MX0sInNhbHQiOiJ3bmU3TTd2dndlL2FBS1piUEE2QktsdFNzMkhkSk9v
|
|
||||||
eXlvOTNLMnByRXdJPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94
|
|
||||||
Iiwibm9uY2UiOiJoOWdIMHRsYk9zMnZIbVBTYk5zaGxBQU5TYUlkcVZoQiJ9LCJj
|
|
||||||
aXBoZXJ0ZXh0IjoiTERiQk5ac3ZlVnRMbTlQdkRTa2t6bzRrWGExVGRTTEY5VzVO
|
|
||||||
cGd6M05GNVJLRWlGRmJQRDJDYzhnTWNkRmkrTU8xd2FTUzFGWWdXU3BIdnI3QXZ3
|
|
||||||
K0tUTXVWLzhSZ1pnOE9ieHNJY2xKSlZldHRLTzdzWXY2aWgxM09iZlVBV0lQcGpS
|
|
||||||
ZUQ5UmE3WjJwbWd0SkpBdjl2dlk1RGNNeGRKcFFrOEY1UStLZytSbnhLRUd6Z1ZN
|
|
||||||
MWUxdjF3UGhsOWhVRGRMSFVSTzE5Z0w3aFE9PSJ9
|
|
||||||
-----END ENCRYPTED SIGSTORE PRIVATE KEY-----
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
|
||||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2DXKcerPznDayM+rMJ/25w+ubI8g
|
|
||||||
e3ZTbm07VqLFz6uI2vXqN8X7/72dygtJlUw07FpR0oLXaSia0adaywz1JA==
|
|
||||||
-----END PUBLIC KEY-----
|
|
||||||
115
backend/go.mod
115
backend/go.mod
@@ -1,94 +1,57 @@
|
|||||||
module github.com/sysadminsmedia/homebox/backend
|
module github.com/hay-kot/homebox/backend
|
||||||
|
|
||||||
go 1.23.0
|
go 1.19
|
||||||
|
|
||||||
require (
|
require (
|
||||||
entgo.io/ent v0.14.4
|
ariga.io/atlas v0.9.1-0.20230119145809-92243f7c55cb
|
||||||
github.com/ardanlabs/conf/v3 v3.7.2
|
entgo.io/ent v0.11.8
|
||||||
github.com/containrrr/shoutrrr v0.8.0
|
github.com/ardanlabs/conf/v3 v3.1.4
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.0.8
|
||||||
github.com/go-playground/validator/v10 v10.26.0
|
github.com/go-playground/validator/v10 v10.11.2
|
||||||
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
|
github.com/gocarina/gocsv v0.0.0-20230123225133-763e25b40669
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.3.0
|
||||||
github.com/gorilla/schema v1.4.1
|
github.com/mattn/go-sqlite3 v1.14.16
|
||||||
github.com/hay-kot/httpkit v0.0.11
|
github.com/rs/zerolog v1.29.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/stretchr/testify v1.8.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.28
|
github.com/swaggo/http-swagger v1.3.3
|
||||||
github.com/olahol/melody v1.2.1
|
github.com/swaggo/swag v1.8.10
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/yeqown/go-qrcode/v2 v2.2.1
|
||||||
github.com/pressly/goose/v3 v3.24.2
|
github.com/yeqown/go-qrcode/writer/standard v1.2.1
|
||||||
github.com/rs/zerolog v1.34.0
|
golang.org/x/crypto v0.6.0
|
||||||
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.4
|
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
|
||||||
github.com/yeqown/go-qrcode/writer/standard v1.2.5
|
|
||||||
github.com/zeebo/blake3 v0.2.4
|
|
||||||
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 (
|
require (
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/agext/levenshtein v1.2.3 // indirect
|
github.com/agext/levenshtein v1.2.3 // indirect
|
||||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
|
||||||
github.com/bmatcuk/doublestar v1.3.4 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
|
||||||
github.com/fogleman/gg v1.3.0 // indirect
|
github.com/fogleman/gg v1.3.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/go-openapi/inflect v0.19.0 // indirect
|
||||||
github.com/go-openapi/inflect v0.21.2 // indirect
|
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
github.com/go-openapi/spec v0.20.7 // indirect
|
||||||
github.com/go-openapi/spec v0.21.0 // indirect
|
github.com/go-openapi/swag v0.22.3 // indirect
|
||||||
github.com/go-openapi/swag v0.23.1 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.5.9 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/hashicorp/hcl/v2 v2.15.0 // indirect
|
||||||
github.com/hashicorp/hcl/v2 v2.23.0 // indirect
|
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.2.1 // indirect
|
||||||
github.com/mailru/easyjson v0.9.0 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/swaggo/files v1.0.0 // indirect
|
||||||
github.com/swaggo/files/v2 v2.0.2 // indirect
|
|
||||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||||
github.com/zclconf/go-cty v1.16.2 // indirect
|
github.com/zclconf/go-cty v1.12.1 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 // indirect
|
||||||
golang.org/x/image v0.26.0
|
golang.org/x/mod v0.7.0 // indirect
|
||||||
golang.org/x/mod v0.24.0 // indirect
|
golang.org/x/net v0.6.0 // indirect
|
||||||
golang.org/x/net v0.39.0 // indirect
|
golang.org/x/sys v0.5.0 // indirect
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
golang.org/x/text v0.7.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/tools v0.4.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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.63.0 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.10.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
332
backend/go.sum
332
backend/go.sum
@@ -1,238 +1,174 @@
|
|||||||
ariga.io/atlas v0.32.0 h1:y+77nueMrExLiKlz1CcPKh/nU7VSlWfBbwCShsJyvCw=
|
ariga.io/atlas v0.9.1-0.20230119145809-92243f7c55cb h1:mbsFtavDqGdYwdDpP50LGOOZ2hgyGoJcZeOpbgKMyu4=
|
||||||
ariga.io/atlas v0.32.0/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w=
|
ariga.io/atlas v0.9.1-0.20230119145809-92243f7c55cb/go.mod h1:T230JFcENj4ZZzMkZrXFDSkv+2kXkUgpJ5FQQ5hMcKU=
|
||||||
entgo.io/ent v0.14.4 h1:/DhDraSLXIkBhyiVoJeSshr4ZYi7femzhj6/TckzZuI=
|
entgo.io/ent v0.11.8 h1:M/M0QL1CYCUSdqGRXUrXhFYSDRJPsOOrr+RLEej/gyQ=
|
||||||
entgo.io/ent v0.14.4/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
entgo.io/ent v0.11.8/go.mod h1:ericBi6Q8l3wBH1wEIDfKxw7rcQEuRPyBfbIzjtxJ18=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
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/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
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 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
|
||||||
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
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/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
|
||||||
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
|
||||||
github.com/ardanlabs/conf/v3 v3.7.2 h1:s2VBuDJM6OQfR0erDuopiZ+dHUQVqGxZeLrTsls03dw=
|
github.com/ardanlabs/conf/v3 v3.1.4 h1:c0jJYbqHJcrR/uYImbGC1q7quH3DYxH49zGCT7WLJH4=
|
||||||
github.com/ardanlabs/conf/v3 v3.7.2/go.mod h1:XlL9P0quWP4m1weOVFmlezabinbZLI05niDof/+Ochk=
|
github.com/ardanlabs/conf/v3 v3.1.4/go.mod h1:bIacyuGeZjkTdtszdbvOcuq49VhHpV3+IPZ2ewOAK4I=
|
||||||
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
|
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
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/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/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
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/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
|
||||||
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
|
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||||
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
|
||||||
github.com/go-openapi/inflect v0.21.2 h1:0gClGlGcxifcJR56zwvhaOulnNgnhc4qTAkob5ObnSM=
|
github.com/go-openapi/spec v0.20.7 h1:1Rlu/ZrOCCob0n+JKKJAWhNWMPW8bOZRg8FJaY+0SKI=
|
||||||
github.com/go-openapi/inflect v0.21.2/go.mod h1:INezMuUu7SJQc2AyR3WO0DqqYUJSj8Kb4hBd7WtjlAw=
|
github.com/go-openapi/spec v0.20.7/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
|
||||||
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
|
||||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
|
||||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
|
||||||
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
|
||||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU=
|
||||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s=
|
||||||
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=
|
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
|
||||||
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
github.com/gocarina/gocsv v0.0.0-20230123225133-763e25b40669 h1:MvZzCA/mduVWoBSVKJeMdv+AqXQmZZ8i6p8889ejt/Y=
|
||||||
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ=
|
github.com/gocarina/gocsv v0.0.0-20230123225133-763e25b40669/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
|
||||||
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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
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/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/hashicorp/hcl/v2 v2.15.0 h1:CPDXO6+uORPjKflkWCCwoWc9uRp+zSIPcCQ+BrxV7m8=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/hashicorp/hcl/v2 v2.15.0/go.mod h1:JRmR89jycNkrrqnMmvPDMd56n1rQJ2Q6KocSLCMCXng=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
|
||||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
|
||||||
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/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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
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/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
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.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
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/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
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/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
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 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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
|
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
|
||||||
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
|
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
|
||||||
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/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.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/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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
github.com/swaggo/files v1.0.0 h1:1gGXVIeUFCS/dta17rnP0iOpr6CXFwKD7EO5ID233e4=
|
||||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
github.com/swaggo/files v1.0.0/go.mod h1:N59U6URJLyU1PQgFqPM7wXLMhJx7QAolnvfQkqO13kc=
|
||||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
github.com/swaggo/swag v1.8.10 h1:eExW4bFa52WOjqRzRD58bgWsWfdFJso50lpbeTcmTfo=
|
||||||
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
github.com/swaggo/swag v1.8.10/go.mod h1:ezQVUUhly8dludpVk+/PuwJWvLLanB13ygV5Pr9enSk=
|
||||||
github.com/yeqown/go-qrcode/writer/standard v1.2.5 h1:m+5BUIcbsaG2md76FIqI/oZULrAju8tsk47eOohovQ0=
|
github.com/yeqown/go-qrcode/v2 v2.2.1 h1:Jc1Q916fwC05R8C7mpWDbrT9tyLPaLLKDABoC5XBCe8=
|
||||||
github.com/yeqown/go-qrcode/writer/standard v1.2.5/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
|
github.com/yeqown/go-qrcode/v2 v2.2.1/go.mod h1:2Qsk2APUCPne0TsRo40DIkI5MYnbzYKCnKGEFWrxd24=
|
||||||
|
github.com/yeqown/go-qrcode/writer/standard v1.2.1 h1:FMRZiur5yApUIe4fqtqmcdl/XQTZAZWt2DhkPx4VIW0=
|
||||||
|
github.com/yeqown/go-qrcode/writer/standard v1.2.1/go.mod h1:ZelyDFiVymrauRjUn454iF7bjsabmB1vixkDA5kq2bw=
|
||||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
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/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/zclconf/go-cty v1.12.1 h1:PcupnljUm9EIvbgSHQnHhUr3fO6oFmkOrvs2BAFNXXY=
|
||||||
github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70=
|
github.com/zclconf/go-cty v1.12.1/go.mod h1:s9IfD1LK5ccNMSWCVFCE2rJfHiZgi7JijgeWIMfhLvA=
|
||||||
github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
|
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||||
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
|
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM=
|
||||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
|
||||||
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
|
||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
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.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4=
|
||||||
|
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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 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.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
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/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.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.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=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
// Package currencies provides a shared definition of currencies. This uses a global
|
|
||||||
// variable to hold the currencies.
|
|
||||||
package currencies
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
_ "embed"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed currencies.json
|
|
||||||
var defaults []byte
|
|
||||||
|
|
||||||
type CollectorFunc func() ([]Currency, error)
|
|
||||||
|
|
||||||
func CollectJSON(reader io.Reader) CollectorFunc {
|
|
||||||
return func() ([]Currency, error) {
|
|
||||||
var currencies []Currency
|
|
||||||
err := json.NewDecoder(reader).Decode(¤cies)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return currencies, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func CollectDefaults() CollectorFunc {
|
|
||||||
return CollectJSON(bytes.NewReader(defaults))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CollectionCurrencies(collectors ...CollectorFunc) ([]Currency, error) {
|
|
||||||
out := make([]Currency, 0, len(collectors))
|
|
||||||
for i := range collectors {
|
|
||||||
c, err := collectors[i]()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, c...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Currency struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
Local string `json:"local"`
|
|
||||||
Symbol string `json:"symbol"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CurrencyRegistry struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
registry map[string]Currency
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCurrencyService(currencies []Currency) *CurrencyRegistry {
|
|
||||||
registry := make(map[string]Currency, len(currencies))
|
|
||||||
for i := range currencies {
|
|
||||||
registry[currencies[i].Code] = currencies[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CurrencyRegistry{
|
|
||||||
registry: registry,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cs *CurrencyRegistry) Slice() []Currency {
|
|
||||||
cs.mu.RLock()
|
|
||||||
defer cs.mu.RUnlock()
|
|
||||||
|
|
||||||
out := make([]Currency, 0, len(cs.registry))
|
|
||||||
for key := range cs.registry {
|
|
||||||
out = append(out, cs.registry[key])
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.SortFunc(out, func(a, b Currency) int {
|
|
||||||
if a.Name < b.Name {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.Name > b.Name {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cs *CurrencyRegistry) IsSupported(code string) bool {
|
|
||||||
upper := strings.ToUpper(code)
|
|
||||||
|
|
||||||
cs.mu.RLock()
|
|
||||||
defer cs.mu.RUnlock()
|
|
||||||
_, ok := cs.registry[upper]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
7
backend/internal/core/services/.testdata/import.csv
Normal file
7
backend/internal/core/services/.testdata/import.csv
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Import Ref,Location,Labels,Quantity,Name,Description,Insured,Serial Number,Mode Number,Manufacturer,Notes,Purchase From,Purchased Price,Purchased Time,Lifetime Warranty,Warranty Expires,Warranty Details,Sold To,Sold Price,Sold Time,Sold Notes
|
||||||
|
A,Garage,IOT;Home Assistant; Z-Wave,1,Zooz Universal Relay ZEN17,Description 1,TRUE,,ZEN17,Zooz,,Amazon,39.95,10/13/2021,,10/13/2021,,,,10/13/2021,
|
||||||
|
B,Living Room,IOT;Home Assistant; Z-Wave,1,Zooz Motion Sensor,Description 2,FALSE,,ZSE18,Zooz,,Amazon,29.95,10/15/2021,,10/15/2021,,,,10/15/2021,
|
||||||
|
C,Office,IOT;Home Assistant; Z-Wave,1,Zooz 110v Power Switch,Description 3,TRUE,,ZEN15,Zooz,,Amazon,39.95,10/13/2021,,10/13/2021,,,,10/13/2021,
|
||||||
|
D,Downstairs,IOT;Home Assistant; Z-Wave,1,Ecolink Z-Wave PIR Motion Sensor,Description 4,FALSE,,PIRZWAVE2.5-ECO,Ecolink,,Amazon,35.58,10/21/2020,,10/21/2020,,,,10/21/2020,
|
||||||
|
E,Entry,IOT;Home Assistant; Z-Wave,1,Yale Security Touchscreen Deadbolt,Description 5,TRUE,,YRD226ZW2619,Yale,,Amazon,120.39,10/14/2020,,10/14/2020,,,,10/14/2020,
|
||||||
|
F,Kitchen,IOT;Home Assistant; Z-Wave,1,Smart Rocker Light Dimmer,Description 6,FALSE,,39351,Honeywell,,Amazon,65.98,09/30/2020,,09/30/2020,,,,09/30/2020,
|
||||||
|
7
backend/internal/core/services/.testdata/import.tsv
Normal file
7
backend/internal/core/services/.testdata/import.tsv
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Import Ref Location Labels Quantity Name Description Insured Serial Number Mode Number Manufacturer Notes Purchase From Purchased Price Purchased Time Lifetime Warranty Warranty Expires Warranty Details Sold To Sold Price Sold Time Sold Notes
|
||||||
|
A Garage IOT;Home Assistant; Z-Wave 1 Zooz Universal Relay ZEN17 Description 1 TRUE ZEN17 Zooz Amazon 39.95 10/13/2021 10/13/2021 10/13/2021
|
||||||
|
B Living Room IOT;Home Assistant; Z-Wave 1 Zooz Motion Sensor Description 2 FALSE ZSE18 Zooz Amazon 29.95 10/15/2021 10/15/2021 10/15/2021
|
||||||
|
C Office IOT;Home Assistant; Z-Wave 1 Zooz 110v Power Switch Description 3 TRUE ZEN15 Zooz Amazon 39.95 10/13/2021 10/13/2021 10/13/2021
|
||||||
|
D Downstairs IOT;Home Assistant; Z-Wave 1 Ecolink Z-Wave PIR Motion Sensor Description 4 FALSE PIRZWAVE2.5-ECO Ecolink Amazon 35.58 10/21/2020 10/21/2020 10/21/2020
|
||||||
|
E Entry IOT;Home Assistant; Z-Wave 1 Yale Security Touchscreen Deadbolt Description 5 TRUE YRD226ZW2619 Yale Amazon 120.39 10/14/2020 10/14/2020 10/14/2020
|
||||||
|
F Kitchen IOT;Home Assistant; Z-Wave 1 Smart Rocker Light Dimmer Description 6 FALSE 39351 Honeywell Amazon 65.98 09/30/2020 09/30/2020 09/30/2020
|
||||||
|
@@ -1,24 +1,22 @@
|
|||||||
// Package services provides the core business logic for the application.
|
|
||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/currencies"
|
"github.com/hay-kot/homebox/backend/internal/core/services/reporting"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AllServices struct {
|
type AllServices struct {
|
||||||
User *UserService
|
User *UserService
|
||||||
Group *GroupService
|
Group *GroupService
|
||||||
Items *ItemService
|
Items *ItemService
|
||||||
BackgroundService *BackgroundService
|
Reporting *reporting.ReportingService
|
||||||
Currencies *currencies.CurrencyRegistry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OptionsFunc func(*options)
|
type OptionsFunc func(*options)
|
||||||
|
|
||||||
type options struct {
|
type options struct {
|
||||||
autoIncrementAssetID bool
|
autoIncrementAssetID bool
|
||||||
currencies []currencies.Currency
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithAutoIncrementAssetID(v bool) func(*options) {
|
func WithAutoIncrementAssetID(v bool) func(*options) {
|
||||||
@@ -27,27 +25,13 @@ func WithAutoIncrementAssetID(v bool) func(*options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithCurrencies(v []currencies.Currency) func(*options) {
|
|
||||||
return func(o *options) {
|
|
||||||
o.currencies = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(repos *repo.AllRepos, opts ...OptionsFunc) *AllServices {
|
func New(repos *repo.AllRepos, opts ...OptionsFunc) *AllServices {
|
||||||
if repos == nil {
|
if repos == nil {
|
||||||
panic("repos cannot be nil")
|
panic("repos cannot be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultCurrencies, err := currencies.CollectionCurrencies(
|
|
||||||
currencies.CollectDefaults(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
panic("failed to collect default currencies")
|
|
||||||
}
|
|
||||||
|
|
||||||
options := &options{
|
options := &options{
|
||||||
autoIncrementAssetID: true,
|
autoIncrementAssetID: true,
|
||||||
currencies: defaultCurrencies,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
@@ -61,7 +45,7 @@ func New(repos *repo.AllRepos, opts ...OptionsFunc) *AllServices {
|
|||||||
repo: repos,
|
repo: repos,
|
||||||
autoIncrementAssetID: options.autoIncrementAssetID,
|
autoIncrementAssetID: options.autoIncrementAssetID,
|
||||||
},
|
},
|
||||||
BackgroundService: &BackgroundService{repos, Latest{}},
|
// TODO: don't use global logger
|
||||||
Currencies: currencies.NewCurrencyService(options.currencies),
|
Reporting: reporting.NewReportingService(repos, &log.Logger),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKeys struct {
|
type contextKeys struct {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_SetAuthContext(t *testing.T) {
|
func Test_SetAuthContext(t *testing.T) {
|
||||||
|
|||||||
@@ -3,20 +3,19 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/pkgs/faker"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/currencies"
|
|
||||||
"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/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/pkgs/faker"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
fk = faker.NewFaker()
|
fk = faker.NewFaker()
|
||||||
tbus = eventbus.New()
|
|
||||||
|
|
||||||
tCtx = Context{}
|
tCtx = Context{}
|
||||||
tClient *ent.Client
|
tClient *ent.Client
|
||||||
@@ -49,8 +48,10 @@ func bootstrap() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func MainNoExit(m *testing.M) int {
|
func TestMain(m *testing.M) {
|
||||||
client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1&_time_format=sqlite")
|
rand.Seed(int64(time.Now().Unix()))
|
||||||
|
|
||||||
|
client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed opening connection to sqlite: %v", err)
|
log.Fatalf("failed opening connection to sqlite: %v", err)
|
||||||
}
|
}
|
||||||
@@ -61,14 +62,9 @@ func MainNoExit(m *testing.M) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tClient = client
|
tClient = client
|
||||||
tRepos = repo.New(tClient, tbus, os.TempDir()+"/homebox")
|
tRepos = repo.New(tClient, os.TempDir()+"/homebox")
|
||||||
|
tSvc = New(tRepos)
|
||||||
defaults, _ := currencies.CollectionCurrencies(
|
defer client.Close()
|
||||||
currencies.CollectDefaults(),
|
|
||||||
)
|
|
||||||
|
|
||||||
tSvc = New(tRepos, WithCurrencies(defaults))
|
|
||||||
defer func() { _ = client.Close() }()
|
|
||||||
|
|
||||||
bootstrap()
|
bootstrap()
|
||||||
tCtx = Context{
|
tCtx = Context{
|
||||||
@@ -77,9 +73,5 @@ func MainNoExit(m *testing.M) int {
|
|||||||
UID: tUser.ID,
|
UID: tUser.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.Run()
|
os.Exit(m.Run())
|
||||||
}
|
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
os.Exit(MainNoExit(m))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
HB.location,HB.name,HB.quantity,HB.description,HB.field.Custom Field 1,HB.field.Custom Field 2,HB.field.Custom Field 3
|
|
||||||
loc,Item 1,1,Description 1,Value 1[1],Value 1[2],Value 1[3]
|
|
||||||
loc,Item 2,2,Description 2,Value 2[1],Value 2[2],Value 2[3]
|
|
||||||
loc,Item 3,3,Description 3,Value 3[1],Value 3[2],Value 3[3]
|
|
||||||
|
|
||||||
|
@@ -1,4 +0,0 @@
|
|||||||
HB.location,HB.name,HB.quantity,HB.description
|
|
||||||
loc,Item 1,1,Description 1
|
|
||||||
loc,Item 2,2,Description 2
|
|
||||||
loc,Item 3,3,Description 3
|
|
||||||
|
@@ -1,4 +0,0 @@
|
|||||||
HB.name,HB.asset_id,HB.location,HB.labels
|
|
||||||
Item 1,1,Path / To / Location 1,L1 ; L2 ; L3
|
|
||||||
Item 2,000-002,Path /To/ Location 2,L1;L2;L3
|
|
||||||
Item 3,1000-003,Path / To /Location 3 , L1;L2; L3
|
|
||||||
|
@@ -1,42 +0,0 @@
|
|||||||
package reporting
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gocarina/gocsv"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// =================================================================================================
|
|
||||||
|
|
||||||
type BillOfMaterialsEntry struct {
|
|
||||||
PurchaseDate types.Date `csv:"Purchase Date"`
|
|
||||||
Name string `csv:"Name"`
|
|
||||||
Description string `csv:"Description"`
|
|
||||||
Manufacturer string `csv:"Manufacturer"`
|
|
||||||
SerialNumber string `csv:"Serial Number"`
|
|
||||||
ModelNumber string `csv:"Model Number"`
|
|
||||||
Quantity int `csv:"Quantity"`
|
|
||||||
Price float64 `csv:"Price"`
|
|
||||||
TotalPrice float64 `csv:"Total Price"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BillOfMaterialsCSV returns a byte slice of the Bill of Materials for a given GID in CSV format
|
|
||||||
// See BillOfMaterialsEntry for the format of the output
|
|
||||||
func BillOfMaterialsCSV(entities []repo.ItemOut) ([]byte, error) {
|
|
||||||
bomEntries := make([]BillOfMaterialsEntry, len(entities))
|
|
||||||
for i, entity := range entities {
|
|
||||||
bomEntries[i] = BillOfMaterialsEntry{
|
|
||||||
PurchaseDate: entity.PurchaseTime,
|
|
||||||
Name: entity.Name,
|
|
||||||
Description: entity.Description,
|
|
||||||
Manufacturer: entity.Manufacturer,
|
|
||||||
SerialNumber: entity.SerialNumber,
|
|
||||||
ModelNumber: entity.ModelNumber,
|
|
||||||
Quantity: entity.Quantity,
|
|
||||||
Price: entity.PurchasePrice,
|
|
||||||
TotalPrice: entity.PurchasePrice * float64(entity.Quantity),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return gocsv.MarshalBytes(&bomEntries)
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
// Package eventbus provides an interface for event bus.
|
|
||||||
package eventbus
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Event string
|
|
||||||
|
|
||||||
const (
|
|
||||||
EventLabelMutation Event = "label.mutation"
|
|
||||||
EventLocationMutation Event = "location.mutation"
|
|
||||||
EventItemMutation Event = "item.mutation"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GroupMutationEvent struct {
|
|
||||||
GID uuid.UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
type eventData struct {
|
|
||||||
event Event
|
|
||||||
data any
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventBus struct {
|
|
||||||
started bool
|
|
||||||
ch chan eventData
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
subscribers map[Event][]func(any)
|
|
||||||
}
|
|
||||||
|
|
||||||
func New() *EventBus {
|
|
||||||
return &EventBus{
|
|
||||||
ch: make(chan eventData, 100),
|
|
||||||
subscribers: map[Event][]func(any){
|
|
||||||
EventLabelMutation: {},
|
|
||||||
EventLocationMutation: {},
|
|
||||||
EventItemMutation: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EventBus) Run(ctx context.Context) error {
|
|
||||||
if e.started {
|
|
||||||
panic("event bus already started")
|
|
||||||
}
|
|
||||||
|
|
||||||
e.started = true
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil
|
|
||||||
case event := <-e.ch:
|
|
||||||
e.mu.RLock()
|
|
||||||
arr, ok := e.subscribers[event.event]
|
|
||||||
e.mu.RUnlock()
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fn := range arr {
|
|
||||||
fn(event.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EventBus) Publish(event Event, data any) {
|
|
||||||
e.ch <- eventData{
|
|
||||||
event: event,
|
|
||||||
data: data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EventBus) Subscribe(event Event, fn func(any)) {
|
|
||||||
e.mu.Lock()
|
|
||||||
defer e.mu.Unlock()
|
|
||||||
|
|
||||||
arr, ok := e.subscribers[event]
|
|
||||||
if !ok {
|
|
||||||
panic("event not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
e.subscribers[event] = append(arr, fn)
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// Package reporting provides a way to import CSV files into the database.
|
|
||||||
package reporting
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/csv"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrNoHomeboxHeaders = errors.New("no headers found")
|
|
||||||
ErrMissingRequiredHeaders = errors.New("missing required headers `HB.location` or `HB.name`")
|
|
||||||
)
|
|
||||||
|
|
||||||
// determineSeparator determines the separator used in the CSV file
|
|
||||||
// It returns the separator as a rune and an error if it could not be determined
|
|
||||||
//
|
|
||||||
// It is assumed that the first row is the header row and that the separator is the same
|
|
||||||
// for all rows.
|
|
||||||
//
|
|
||||||
// Supported separators are `,` and `\t`
|
|
||||||
func determineSeparator(data []byte) (rune, error) {
|
|
||||||
// First row
|
|
||||||
firstRow := bytes.Split(data, []byte("\n"))[0]
|
|
||||||
|
|
||||||
// find first comma or /t
|
|
||||||
comma := bytes.IndexByte(firstRow, ',')
|
|
||||||
tab := bytes.IndexByte(firstRow, '\t')
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case comma == -1 && tab == -1:
|
|
||||||
return 0, errors.New("could not determine separator")
|
|
||||||
case tab > comma:
|
|
||||||
return '\t', nil
|
|
||||||
default:
|
|
||||||
return ',', nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readRawCsv reads a CSV file and returns the raw data as a 2D string array
|
|
||||||
// It determines the separator used in the CSV file and returns an error if
|
|
||||||
// it could not be determined
|
|
||||||
func readRawCsv(r io.Reader) ([][]string, error) {
|
|
||||||
data, err := io.ReadAll(r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := csv.NewReader(bytes.NewReader(data))
|
|
||||||
|
|
||||||
// Determine separator
|
|
||||||
sep, err := determineSeparator(data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
reader.Comma = sep
|
|
||||||
|
|
||||||
return reader.ReadAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseHeaders parses the homebox headers from the CSV file and returns a map of the headers
|
|
||||||
// and their column index as well as a list of the field headers (HB.field.*) in the order
|
|
||||||
// they appear in the CSV file
|
|
||||||
//
|
|
||||||
// It returns an error if no homebox headers are found
|
|
||||||
func parseHeaders(headers []string) (hbHeaders map[string]int, fieldHeaders []string, err error) {
|
|
||||||
hbHeaders = map[string]int{} // initialize map
|
|
||||||
|
|
||||||
for col, h := range headers {
|
|
||||||
if strings.HasPrefix(h, "HB.field.") {
|
|
||||||
fieldHeaders = append(fieldHeaders, h)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(h, "HB.") {
|
|
||||||
hbHeaders[h] = col
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
required := []string{"HB.location", "HB.name"}
|
|
||||||
for _, h := range required {
|
|
||||||
if _, ok := hbHeaders[h]; !ok {
|
|
||||||
return nil, nil, ErrMissingRequiredHeaders
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hbHeaders) == 0 {
|
|
||||||
return nil, nil, ErrNoHomeboxHeaders
|
|
||||||
}
|
|
||||||
|
|
||||||
return hbHeaders, fieldHeaders, nil
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package reporting
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ExportItemFields struct {
|
|
||||||
Name string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExportCSVRow struct {
|
|
||||||
ImportRef string `csv:"HB.import_ref"`
|
|
||||||
Location LocationString `csv:"HB.location"`
|
|
||||||
LabelStr LabelString `csv:"HB.labels"`
|
|
||||||
AssetID repo.AssetID `csv:"HB.asset_id"`
|
|
||||||
Archived bool `csv:"HB.archived"`
|
|
||||||
URL string `csv:"HB.url"`
|
|
||||||
|
|
||||||
Name string `csv:"HB.name"`
|
|
||||||
Quantity int `csv:"HB.quantity"`
|
|
||||||
Description string `csv:"HB.description"`
|
|
||||||
Insured bool `csv:"HB.insured"`
|
|
||||||
Notes string `csv:"HB.notes"`
|
|
||||||
|
|
||||||
PurchasePrice float64 `csv:"HB.purchase_price"`
|
|
||||||
PurchaseFrom string `csv:"HB.purchase_from"`
|
|
||||||
PurchaseTime types.Date `csv:"HB.purchase_time"`
|
|
||||||
|
|
||||||
Manufacturer string `csv:"HB.manufacturer"`
|
|
||||||
ModelNumber string `csv:"HB.model_number"`
|
|
||||||
SerialNumber string `csv:"HB.serial_number"`
|
|
||||||
|
|
||||||
LifetimeWarranty bool `csv:"HB.lifetime_warranty"`
|
|
||||||
WarrantyExpires types.Date `csv:"HB.warranty_expires"`
|
|
||||||
WarrantyDetails string `csv:"HB.warranty_details"`
|
|
||||||
|
|
||||||
SoldTo string `csv:"HB.sold_to"`
|
|
||||||
SoldPrice float64 `csv:"HB.sold_price"`
|
|
||||||
SoldTime types.Date `csv:"HB.sold_time"`
|
|
||||||
SoldNotes string `csv:"HB.sold_notes"`
|
|
||||||
|
|
||||||
Fields []ExportItemFields `csv:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// LabelString is a string slice that is used to represent a list of labels.
|
|
||||||
//
|
|
||||||
// For example, a list of labels "Important; Work" would be represented as a
|
|
||||||
// LabelString with the following values:
|
|
||||||
//
|
|
||||||
// LabelString{"Important", "Work"}
|
|
||||||
type LabelString []string
|
|
||||||
|
|
||||||
func parseLabelString(s string) LabelString {
|
|
||||||
v, _ := parseSeparatedString(s, ";")
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ls LabelString) String() string {
|
|
||||||
return strings.Join(ls, "; ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// LocationString is a string slice that is used to represent a location
|
|
||||||
// hierarchy.
|
|
||||||
//
|
|
||||||
// For example, a location hierarchy of "Home / Bedroom / Desk" would be
|
|
||||||
// represented as a LocationString with the following values:
|
|
||||||
//
|
|
||||||
// LocationString{"Home", "Bedroom", "Desk"}
|
|
||||||
type LocationString []string
|
|
||||||
|
|
||||||
func parseLocationString(s string) LocationString {
|
|
||||||
v, _ := parseSeparatedString(s, "/")
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func (csf LocationString) String() string {
|
|
||||||
return strings.Join(csf, " / ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func fromPathSlice(s []repo.ItemPath) LocationString {
|
|
||||||
v := make(LocationString, len(s))
|
|
||||||
|
|
||||||
for i := range s {
|
|
||||||
v[i] = s[i].Name
|
|
||||||
}
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
package reporting
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"reflect"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IOSheet is the representation of a CSV/TSV sheet that is used for importing/exporting
|
|
||||||
// items from homebox. It is used to read/write the data from/to a CSV/TSV file given
|
|
||||||
// the standard format of the file.
|
|
||||||
//
|
|
||||||
// See ExportCSVRow for the format of the data in the sheet.
|
|
||||||
type IOSheet struct {
|
|
||||||
headers []string
|
|
||||||
custom []int
|
|
||||||
index map[string]int
|
|
||||||
Rows []ExportCSVRow
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *IOSheet) indexHeaders() {
|
|
||||||
s.index = make(map[string]int)
|
|
||||||
|
|
||||||
for i, h := range s.headers {
|
|
||||||
if strings.HasPrefix(h, "HB.field") {
|
|
||||||
s.custom = append(s.custom, i)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(h, "HB.") {
|
|
||||||
s.index[h] = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *IOSheet) GetColumn(str string) (col int, ok bool) {
|
|
||||||
if s.index == nil {
|
|
||||||
s.indexHeaders()
|
|
||||||
}
|
|
||||||
|
|
||||||
col, ok = s.index[str]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read reads a CSV/TSV and populates the "Rows" field with the data from the sheet
|
|
||||||
// Custom Fields are supported via the `HB.field.*` headers. The `HB.field.*` the "Name"
|
|
||||||
// of the field is the part after the `HB.field.` prefix. Additionally, Custom Fields with
|
|
||||||
// no value are excluded from the row.Fields slice, this includes empty strings.
|
|
||||||
//
|
|
||||||
// Note That
|
|
||||||
// - the first row is assumed to be the header
|
|
||||||
// - at least 1 row of data is required
|
|
||||||
// - rows and columns must be rectangular (i.e. all rows must have the same number of columns)
|
|
||||||
func (s *IOSheet) Read(data io.Reader) error {
|
|
||||||
sheet, err := readRawCsv(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sheet) < 2 {
|
|
||||||
return fmt.Errorf("sheet must have at least 1 row of data (header + 1)")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.headers = sheet[0]
|
|
||||||
s.Rows = make([]ExportCSVRow, len(sheet)-1)
|
|
||||||
|
|
||||||
for i, row := range sheet[1:] {
|
|
||||||
if len(row) != len(s.headers) {
|
|
||||||
return fmt.Errorf("row has %d columns, expected %d", len(row), len(s.headers))
|
|
||||||
}
|
|
||||||
|
|
||||||
rowData := ExportCSVRow{}
|
|
||||||
|
|
||||||
st := reflect.TypeOf(ExportCSVRow{})
|
|
||||||
|
|
||||||
for i := 0; i < st.NumField(); i++ {
|
|
||||||
field := st.Field(i)
|
|
||||||
tag := field.Tag.Get("csv")
|
|
||||||
if tag == "" || tag == "-" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
col, ok := s.GetColumn(tag)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val := row[col]
|
|
||||||
|
|
||||||
var v interface{}
|
|
||||||
|
|
||||||
switch field.Type {
|
|
||||||
case reflect.TypeOf(""):
|
|
||||||
v = val
|
|
||||||
case reflect.TypeOf(int(0)):
|
|
||||||
v = parseInt(val)
|
|
||||||
case reflect.TypeOf(bool(false)):
|
|
||||||
v = parseBool(val)
|
|
||||||
case reflect.TypeOf(float64(0)):
|
|
||||||
v = parseFloat(val)
|
|
||||||
|
|
||||||
// Custom Types
|
|
||||||
case reflect.TypeOf(types.Date{}):
|
|
||||||
v = types.DateFromString(val)
|
|
||||||
case reflect.TypeOf(repo.AssetID(0)):
|
|
||||||
v, _ = repo.ParseAssetID(val)
|
|
||||||
case reflect.TypeOf(LocationString{}):
|
|
||||||
v = parseLocationString(val)
|
|
||||||
case reflect.TypeOf(LabelString{}):
|
|
||||||
v = parseLabelString(val)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug().
|
|
||||||
Str("tag", tag).
|
|
||||||
Interface("val", v).
|
|
||||||
Str("type", fmt.Sprintf("%T", v)).
|
|
||||||
Msg("parsed value")
|
|
||||||
|
|
||||||
// Nil values are not allowed at the moment. This may change.
|
|
||||||
if v == nil {
|
|
||||||
return fmt.Errorf("could not convert %q to %s", val, field.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
ptrField := reflect.ValueOf(&rowData).Elem().Field(i)
|
|
||||||
ptrField.Set(reflect.ValueOf(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, col := range s.custom {
|
|
||||||
colName := strings.TrimPrefix(s.headers[col], "HB.field.")
|
|
||||||
customVal := row[col]
|
|
||||||
if customVal == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rowData.Fields = append(rowData.Fields, ExportItemFields{
|
|
||||||
Name: colName,
|
|
||||||
Value: customVal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Rows[i] = rowData
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadItems writes the sheet to a writer.
|
|
||||||
func (s *IOSheet) ReadItems(ctx context.Context, items []repo.ItemOut, gid uuid.UUID, repos *repo.AllRepos, hbURL string) error {
|
|
||||||
s.Rows = make([]ExportCSVRow, len(items))
|
|
||||||
|
|
||||||
extraHeaders := map[string]struct{}{}
|
|
||||||
|
|
||||||
for i := range items {
|
|
||||||
item := items[i]
|
|
||||||
|
|
||||||
// TODO: Support fetching nested locations
|
|
||||||
locID := item.Location.ID
|
|
||||||
|
|
||||||
locPaths, err := repos.Locations.PathForLoc(context.Background(), gid, locID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("could not get location path")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
locString := fromPathSlice(locPaths)
|
|
||||||
|
|
||||||
labelString := make([]string, len(item.Labels))
|
|
||||||
|
|
||||||
for i, l := range item.Labels {
|
|
||||||
labelString[i] = l.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
url := generateItemURL(item, hbURL)
|
|
||||||
|
|
||||||
customFields := make([]ExportItemFields, len(item.Fields))
|
|
||||||
|
|
||||||
for i, f := range item.Fields {
|
|
||||||
extraHeaders[f.Name] = struct{}{}
|
|
||||||
|
|
||||||
customFields[i] = ExportItemFields{
|
|
||||||
Name: f.Name,
|
|
||||||
Value: f.TextValue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Rows[i] = ExportCSVRow{
|
|
||||||
// fill struct
|
|
||||||
Location: locString,
|
|
||||||
LabelStr: labelString,
|
|
||||||
|
|
||||||
ImportRef: item.ImportRef,
|
|
||||||
AssetID: item.AssetID,
|
|
||||||
Name: item.Name,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
Description: item.Description,
|
|
||||||
Insured: item.Insured,
|
|
||||||
Archived: item.Archived,
|
|
||||||
URL: url,
|
|
||||||
|
|
||||||
PurchasePrice: item.PurchasePrice,
|
|
||||||
PurchaseFrom: item.PurchaseFrom,
|
|
||||||
PurchaseTime: item.PurchaseTime,
|
|
||||||
|
|
||||||
Manufacturer: item.Manufacturer,
|
|
||||||
ModelNumber: item.ModelNumber,
|
|
||||||
SerialNumber: item.SerialNumber,
|
|
||||||
|
|
||||||
LifetimeWarranty: item.LifetimeWarranty,
|
|
||||||
WarrantyExpires: item.WarrantyExpires,
|
|
||||||
WarrantyDetails: item.WarrantyDetails,
|
|
||||||
|
|
||||||
SoldTo: item.SoldTo,
|
|
||||||
SoldTime: item.SoldTime,
|
|
||||||
SoldPrice: item.SoldPrice,
|
|
||||||
SoldNotes: item.SoldNotes,
|
|
||||||
|
|
||||||
Notes: item.Notes,
|
|
||||||
Fields: customFields,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract and sort additional headers for deterministic output
|
|
||||||
customHeaders := make([]string, 0, len(extraHeaders))
|
|
||||||
|
|
||||||
for k := range extraHeaders {
|
|
||||||
customHeaders = append(customHeaders, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(customHeaders)
|
|
||||||
|
|
||||||
st := reflect.TypeOf(ExportCSVRow{})
|
|
||||||
|
|
||||||
// Write headers
|
|
||||||
for i := 0; i < st.NumField(); i++ {
|
|
||||||
field := st.Field(i)
|
|
||||||
tag := field.Tag.Get("csv")
|
|
||||||
if tag == "" || tag == "-" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
s.headers = append(s.headers, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, h := range customHeaders {
|
|
||||||
s.headers = append(s.headers, "HB.field."+h)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateItemURL(item repo.ItemOut, d string) string {
|
|
||||||
url := ""
|
|
||||||
if item.ID != uuid.Nil {
|
|
||||||
url = fmt.Sprintf("%s/item/%s", d, item.ID.String())
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSV writes the current sheet to a 2d array, for compatibility with TSV/CSV files.
|
|
||||||
func (s *IOSheet) CSV() ([][]string, error) {
|
|
||||||
memcsv := make([][]string, len(s.Rows)+1)
|
|
||||||
|
|
||||||
memcsv[0] = s.headers
|
|
||||||
|
|
||||||
// use struct tags in rows to dertmine column order
|
|
||||||
for i, row := range s.Rows {
|
|
||||||
rowIdx := i + 1
|
|
||||||
|
|
||||||
memcsv[rowIdx] = make([]string, len(s.headers))
|
|
||||||
|
|
||||||
st := reflect.TypeOf(row)
|
|
||||||
|
|
||||||
for i := 0; i < st.NumField(); i++ {
|
|
||||||
field := st.Field(i)
|
|
||||||
tag := field.Tag.Get("csv")
|
|
||||||
if tag == "" || tag == "-" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
col, ok := s.GetColumn(tag)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val := reflect.ValueOf(row).Field(i)
|
|
||||||
|
|
||||||
var v string
|
|
||||||
|
|
||||||
switch field.Type {
|
|
||||||
case reflect.TypeOf(""):
|
|
||||||
v = val.String()
|
|
||||||
case reflect.TypeOf(int(0)):
|
|
||||||
v = strconv.Itoa(int(val.Int()))
|
|
||||||
case reflect.TypeOf(bool(false)):
|
|
||||||
v = strconv.FormatBool(val.Bool())
|
|
||||||
case reflect.TypeOf(float64(0)):
|
|
||||||
v = strconv.FormatFloat(val.Float(), 'f', -1, 64)
|
|
||||||
|
|
||||||
// Custom Types
|
|
||||||
case reflect.TypeOf(types.Date{}):
|
|
||||||
v = val.Interface().(types.Date).String()
|
|
||||||
case reflect.TypeOf(repo.AssetID(0)):
|
|
||||||
v = val.Interface().(repo.AssetID).String()
|
|
||||||
case reflect.TypeOf(LocationString{}):
|
|
||||||
v = val.Interface().(LocationString).String()
|
|
||||||
case reflect.TypeOf(LabelString{}):
|
|
||||||
v = val.Interface().(LabelString).String()
|
|
||||||
default:
|
|
||||||
log.Debug().Str("type", field.Type.String()).Msg("unknown type")
|
|
||||||
}
|
|
||||||
|
|
||||||
memcsv[rowIdx][col] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range row.Fields {
|
|
||||||
col, ok := s.GetColumn("HB.field." + f.Name)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
memcsv[i+1][col] = f.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return memcsv, nil
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
package reporting
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
_ "embed"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
//go:embed .testdata/import/minimal.csv
|
|
||||||
minimalImportCSV []byte
|
|
||||||
|
|
||||||
//go:embed .testdata/import/fields.csv
|
|
||||||
customFieldImportCSV []byte
|
|
||||||
|
|
||||||
//go:embed .testdata/import/types.csv
|
|
||||||
customTypesImportCSV []byte
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSheet_Read(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
data []byte
|
|
||||||
want []ExportCSVRow
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "minimal import",
|
|
||||||
data: minimalImportCSV,
|
|
||||||
want: []ExportCSVRow{
|
|
||||||
{Location: LocationString{"loc"}, Name: "Item 1", Quantity: 1, Description: "Description 1"},
|
|
||||||
{Location: LocationString{"loc"}, Name: "Item 2", Quantity: 2, Description: "Description 2"},
|
|
||||||
{Location: LocationString{"loc"}, Name: "Item 3", Quantity: 3, Description: "Description 3"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "custom field import",
|
|
||||||
data: customFieldImportCSV,
|
|
||||||
want: []ExportCSVRow{
|
|
||||||
{
|
|
||||||
Location: LocationString{"loc"}, Name: "Item 1", Quantity: 1, Description: "Description 1",
|
|
||||||
Fields: []ExportItemFields{
|
|
||||||
{Name: "Custom Field 1", Value: "Value 1[1]"},
|
|
||||||
{Name: "Custom Field 2", Value: "Value 1[2]"},
|
|
||||||
{Name: "Custom Field 3", Value: "Value 1[3]"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Location: LocationString{"loc"}, Name: "Item 2", Quantity: 2, Description: "Description 2",
|
|
||||||
Fields: []ExportItemFields{
|
|
||||||
{Name: "Custom Field 1", Value: "Value 2[1]"},
|
|
||||||
{Name: "Custom Field 2", Value: "Value 2[2]"},
|
|
||||||
{Name: "Custom Field 3", Value: "Value 2[3]"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Location: LocationString{"loc"}, Name: "Item 3", Quantity: 3, Description: "Description 3",
|
|
||||||
Fields: []ExportItemFields{
|
|
||||||
{Name: "Custom Field 1", Value: "Value 3[1]"},
|
|
||||||
{Name: "Custom Field 2", Value: "Value 3[2]"},
|
|
||||||
{Name: "Custom Field 3", Value: "Value 3[3]"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "custom types import",
|
|
||||||
data: customTypesImportCSV,
|
|
||||||
want: []ExportCSVRow{
|
|
||||||
{
|
|
||||||
Name: "Item 1",
|
|
||||||
AssetID: repo.AssetID(1),
|
|
||||||
Location: LocationString{"Path", "To", "Location 1"},
|
|
||||||
LabelStr: LabelString{"L1", "L2", "L3"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Item 2",
|
|
||||||
AssetID: repo.AssetID(2),
|
|
||||||
Location: LocationString{"Path", "To", "Location 2"},
|
|
||||||
LabelStr: LabelString{"L1", "L2", "L3"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "Item 3",
|
|
||||||
AssetID: repo.AssetID(1000003),
|
|
||||||
Location: LocationString{"Path", "To", "Location 3"},
|
|
||||||
LabelStr: LabelString{"L1", "L2", "L3"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
reader := bytes.NewReader(tt.data)
|
|
||||||
|
|
||||||
sheet := &IOSheet{}
|
|
||||||
err := sheet.Read(reader)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case tt.wantErr:
|
|
||||||
require.Error(t, err)
|
|
||||||
default:
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.ElementsMatch(t, tt.want, sheet.Rows)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_parseHeaders(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
rawHeaders []string
|
|
||||||
wantHbHeaders map[string]int
|
|
||||||
wantFieldHeaders []string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no hombox headers",
|
|
||||||
rawHeaders: []string{"Header 1", "Header 2", "Header 3"},
|
|
||||||
wantHbHeaders: nil,
|
|
||||||
wantFieldHeaders: nil,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "field headers only",
|
|
||||||
rawHeaders: []string{"HB.location", "HB.name", "HB.field.1", "HB.field.2", "HB.field.3"},
|
|
||||||
wantHbHeaders: map[string]int{
|
|
||||||
"HB.location": 0,
|
|
||||||
"HB.name": 1,
|
|
||||||
"HB.field.1": 2,
|
|
||||||
"HB.field.2": 3,
|
|
||||||
"HB.field.3": 4,
|
|
||||||
},
|
|
||||||
wantFieldHeaders: []string{"HB.field.1", "HB.field.2", "HB.field.3"},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed headers",
|
|
||||||
rawHeaders: []string{"Header 1", "HB.name", "Header 2", "HB.field.2", "Header 3", "HB.field.3", "HB.location"},
|
|
||||||
wantHbHeaders: map[string]int{
|
|
||||||
"HB.name": 1,
|
|
||||||
"HB.field.2": 3,
|
|
||||||
"HB.field.3": 5,
|
|
||||||
"HB.location": 6,
|
|
||||||
},
|
|
||||||
wantFieldHeaders: []string{"HB.field.2", "HB.field.3"},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
gotHbHeaders, gotFieldHeaders, err := parseHeaders(tt.rawHeaders)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("parseHeaders() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(gotHbHeaders, tt.wantHbHeaders) {
|
|
||||||
t.Errorf("parseHeaders() gotHbHeaders = %v, want %v", gotHbHeaders, tt.wantHbHeaders)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(gotFieldHeaders, tt.wantFieldHeaders) {
|
|
||||||
t.Errorf("parseHeaders() gotFieldHeaders = %v, want %v", gotFieldHeaders, tt.wantFieldHeaders)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_determineSeparator(t *testing.T) {
|
|
||||||
type args struct {
|
|
||||||
data []byte
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
args args
|
|
||||||
want rune
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "comma",
|
|
||||||
args: args{
|
|
||||||
data: []byte("a,b,c"),
|
|
||||||
},
|
|
||||||
want: ',',
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tab",
|
|
||||||
args: args{
|
|
||||||
data: []byte("a\tb\tc"),
|
|
||||||
},
|
|
||||||
want: '\t',
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid",
|
|
||||||
args: args{
|
|
||||||
data: []byte("a;b;c"),
|
|
||||||
},
|
|
||||||
want: 0,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := determineSeparator(tt.args.data)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("determineSeparator() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("determineSeparator() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
85
backend/internal/core/services/reporting/reporting.go
Normal file
85
backend/internal/core/services/reporting/reporting.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package reporting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gocarina/gocsv"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReportingService struct {
|
||||||
|
repos *repo.AllRepos
|
||||||
|
l *zerolog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReportingService(repos *repo.AllRepos, l *zerolog.Logger) *ReportingService {
|
||||||
|
gocsv.SetCSVWriter(func(out io.Writer) *gocsv.SafeCSVWriter {
|
||||||
|
writer := csv.NewWriter(out)
|
||||||
|
writer.Comma = '\t'
|
||||||
|
return gocsv.NewSafeCSVWriter(writer)
|
||||||
|
})
|
||||||
|
|
||||||
|
return &ReportingService{
|
||||||
|
repos: repos,
|
||||||
|
l: l,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================================================
|
||||||
|
|
||||||
|
// NullableTime is a custom type that implements the MarshalCSV interface
|
||||||
|
// to allow for nullable time.Time fields in the CSV output to be empty
|
||||||
|
// and not "0001-01-01". It also overrides the default CSV output format
|
||||||
|
type NullableTime time.Time
|
||||||
|
|
||||||
|
func (t NullableTime) MarshalCSV() (string, error) {
|
||||||
|
if time.Time(t).IsZero() {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
// YYYY-MM-DD
|
||||||
|
return time.Time(t).Format("2006-01-02"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type BillOfMaterialsEntry struct {
|
||||||
|
PurchaseDate NullableTime `csv:"Purchase Date"`
|
||||||
|
Name string `csv:"Name"`
|
||||||
|
Description string `csv:"Description"`
|
||||||
|
Manufacturer string `csv:"Manufacturer"`
|
||||||
|
SerialNumber string `csv:"Serial Number"`
|
||||||
|
ModelNumber string `csv:"Model Number"`
|
||||||
|
Quantity int `csv:"Quantity"`
|
||||||
|
Price float64 `csv:"Price"`
|
||||||
|
TotalPrice float64 `csv:"Total Price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BillOfMaterialsTSV returns a byte slice of the Bill of Materials for a given GID in TSV format
|
||||||
|
// See BillOfMaterialsEntry for the format of the output
|
||||||
|
func (rs *ReportingService) BillOfMaterialsTSV(ctx context.Context, GID uuid.UUID) ([]byte, error) {
|
||||||
|
entities, err := rs.repos.Items.GetAll(ctx, GID)
|
||||||
|
if err != nil {
|
||||||
|
rs.l.Debug().Err(err).Msg("failed to get all items for BOM Csv Reporting")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bomEntries := make([]BillOfMaterialsEntry, len(entities))
|
||||||
|
for i, entity := range entities {
|
||||||
|
bomEntries[i] = BillOfMaterialsEntry{
|
||||||
|
PurchaseDate: NullableTime(entity.PurchaseTime),
|
||||||
|
Name: entity.Name,
|
||||||
|
Description: entity.Description,
|
||||||
|
Manufacturer: entity.Manufacturer,
|
||||||
|
SerialNumber: entity.SerialNumber,
|
||||||
|
ModelNumber: entity.ModelNumber,
|
||||||
|
Quantity: entity.Quantity,
|
||||||
|
Price: entity.PurchasePrice,
|
||||||
|
TotalPrice: entity.PurchasePrice * float64(entity.Quantity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return gocsv.MarshalBytes(&bomEntries)
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package reporting
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func parseSeparatedString(s string, sep string) ([]string, error) {
|
|
||||||
list := strings.Split(s, sep)
|
|
||||||
|
|
||||||
csf := make([]string, 0, len(list))
|
|
||||||
for _, s := range list {
|
|
||||||
trimmed := strings.TrimSpace(s)
|
|
||||||
if trimmed != "" {
|
|
||||||
csf = append(csf, trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return csf, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFloat(s string) float64 {
|
|
||||||
if s == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
f, _ := strconv.ParseFloat(s, 64)
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseBool(s string) bool {
|
|
||||||
b, _ := strconv.ParseBool(s)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseInt(s string) int {
|
|
||||||
i, _ := strconv.Atoi(s)
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package reporting
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Test_parseSeparatedString(t *testing.T) {
|
|
||||||
type args struct {
|
|
||||||
s string
|
|
||||||
sep string
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
args args
|
|
||||||
want []string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "comma",
|
|
||||||
args: args{
|
|
||||||
s: "a,b,c",
|
|
||||||
sep: ",",
|
|
||||||
},
|
|
||||||
want: []string{"a", "b", "c"},
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trimmed comma",
|
|
||||||
args: args{
|
|
||||||
s: "a, b, c",
|
|
||||||
sep: ",",
|
|
||||||
},
|
|
||||||
want: []string{"a", "b", "c"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "excessive whitespace",
|
|
||||||
args: args{
|
|
||||||
s: " a, b, c ",
|
|
||||||
sep: ",",
|
|
||||||
},
|
|
||||||
want: []string{"a", "b", "c"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty",
|
|
||||||
args: args{
|
|
||||||
s: "",
|
|
||||||
sep: ",",
|
|
||||||
},
|
|
||||||
want: []string{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := parseSeparatedString(tt.args.s, tt.args.sep)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("parseSeparatedString() error = %v, wantErr %v", err, tt.wantErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
|
||||||
t.Errorf("parseSeparatedString() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/containrrr/shoutrrr"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Latest struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Date string `json:"date"`
|
|
||||||
}
|
|
||||||
type BackgroundService struct {
|
|
||||||
repos *repo.AllRepos
|
|
||||||
latest Latest
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *BackgroundService) SendNotifiersToday(ctx context.Context) error {
|
|
||||||
// Get All Groups
|
|
||||||
groups, err := svc.repos.Groups.GetAllGroups(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
today := types.DateFromTime(time.Now())
|
|
||||||
|
|
||||||
for i := range groups {
|
|
||||||
group := groups[i]
|
|
||||||
|
|
||||||
entries, err := svc.repos.MaintEntry.GetScheduled(ctx, group.ID, today)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(entries) == 0 {
|
|
||||||
log.Debug().
|
|
||||||
Str("group_name", group.Name).
|
|
||||||
Str("group_id", group.ID.String()).
|
|
||||||
Msg("No scheduled maintenance for today")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
notifiers, err := svc.repos.Notifiers.GetByGroup(ctx, group.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
urls := make([]string, len(notifiers))
|
|
||||||
for i := range notifiers {
|
|
||||||
urls[i] = notifiers[i].URL
|
|
||||||
}
|
|
||||||
|
|
||||||
bldr := strings.Builder{}
|
|
||||||
|
|
||||||
bldr.WriteString("Homebox Maintenance for (")
|
|
||||||
bldr.WriteString(today.String())
|
|
||||||
bldr.WriteString("):\n")
|
|
||||||
|
|
||||||
for i := range entries {
|
|
||||||
entry := entries[i]
|
|
||||||
bldr.WriteString(" - ")
|
|
||||||
bldr.WriteString(entry.Name)
|
|
||||||
bldr.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
var sendErrs []error
|
|
||||||
for i := range urls {
|
|
||||||
err := shoutrrr.Send(urls[i], bldr.String())
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
sendErrs = append(sendErrs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sendErrs) > 0 {
|
|
||||||
return sendErrs[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *BackgroundService) GetLatestGithubRelease(ctx context.Context) error {
|
|
||||||
url := "https://api.github.com/repos/sysadminsmedia/homebox/releases/latest"
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create latest version request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Homebox-Version-Checker")
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to make latest version request: %w", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
err := resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error closing latest version response body: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("latest version unexpected status code: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignoring fields that are not relevant
|
|
||||||
type Release struct {
|
|
||||||
ReleaseVersion string `json:"tag_name"`
|
|
||||||
PublishedAt time.Time `json:"published_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var release Release
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
|
||||||
return fmt.Errorf("failed to decode latest version response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
svc.latest = Latest{
|
|
||||||
Version: release.ReleaseVersion,
|
|
||||||
Date: release.PublishedAt.String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *BackgroundService) GetLatestVersion() Latest {
|
|
||||||
return svc.latest
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/sysadminsmedia/homebox/backend/pkgs/hasher"
|
"github.com/hay-kot/homebox/backend/pkgs/hasher"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GroupService struct {
|
type GroupService struct {
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -32,19 +29,20 @@ func (svc *ItemService) Create(ctx Context, item repo.ItemCreate) (repo.ItemOut,
|
|||||||
return repo.ItemOut{}, err
|
return repo.ItemOut{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
item.AssetID = highest + 1
|
item.AssetID = repo.AssetID(highest + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return svc.repo.Items.Create(ctx, ctx.GID, item)
|
return svc.repo.Items.Create(ctx, ctx.GID, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *ItemService) EnsureAssetID(ctx context.Context, gid uuid.UUID) (int, error) {
|
func (svc *ItemService) EnsureAssetID(ctx context.Context, GID uuid.UUID) (int, error) {
|
||||||
items, err := svc.repo.Items.GetAllZeroAssetID(ctx, gid)
|
items, err := svc.repo.Items.GetAllZeroAssetID(ctx, GID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
highest, err := svc.repo.Items.GetHighestAssetID(ctx, gid)
|
highest, err := svc.repo.Items.GetHighestAssetID(ctx, GID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -53,7 +51,7 @@ func (svc *ItemService) EnsureAssetID(ctx context.Context, gid uuid.UUID) (int,
|
|||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
highest++
|
highest++
|
||||||
|
|
||||||
err = svc.repo.Items.SetAssetID(ctx, gid, item.ID, highest)
|
err = svc.repo.Items.SetAssetID(ctx, GID, item.ID, repo.AssetID(highest))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -63,293 +61,190 @@ func (svc *ItemService) EnsureAssetID(ctx context.Context, gid uuid.UUID) (int,
|
|||||||
|
|
||||||
return finished, nil
|
return finished, nil
|
||||||
}
|
}
|
||||||
|
func (svc *ItemService) CsvImport(ctx context.Context, GID uuid.UUID, data [][]string) (int, error) {
|
||||||
|
loaded := []csvRow{}
|
||||||
|
|
||||||
func (svc *ItemService) EnsureImportRef(ctx context.Context, gid uuid.UUID) (int, error) {
|
// Skip first row
|
||||||
ids, err := svc.repo.Items.GetAllZeroImportRef(ctx, gid)
|
for _, row := range data[1:] {
|
||||||
if err != nil {
|
// Skip empty rows
|
||||||
return 0, err
|
if len(row) == 0 {
|
||||||
}
|
continue
|
||||||
|
|
||||||
finished := 0
|
|
||||||
for _, itemID := range ids {
|
|
||||||
ref := uuid.New().String()[0:8]
|
|
||||||
|
|
||||||
err = svc.repo.Items.Patch(ctx, gid, itemID, repo.ItemPatch{ImportRef: &ref})
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finished++
|
if len(row) != NumOfCols {
|
||||||
}
|
return 0, ErrInvalidCsv
|
||||||
|
|
||||||
return finished, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func serializeLocation[T ~[]string](location T) string {
|
|
||||||
return strings.Join(location, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// CsvImport imports items from a CSV file. using the standard defined format.
|
|
||||||
//
|
|
||||||
// CsvImport applies the following rules/operations
|
|
||||||
//
|
|
||||||
// 1. If the item does not exist, it is created.
|
|
||||||
// 2. If the item has a ImportRef and it exists it is skipped
|
|
||||||
// 3. Locations and Labels are created if they do not exist.
|
|
||||||
func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data io.Reader) (int, error) {
|
|
||||||
sheet := reporting.IOSheet{}
|
|
||||||
|
|
||||||
err := sheet.Read(data)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Labels
|
|
||||||
|
|
||||||
labelMap := make(map[string]uuid.UUID)
|
|
||||||
{
|
|
||||||
labels, err := svc.repo.Labels.GetAll(ctx, gid)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, label := range labels {
|
r := newCsvRow(row)
|
||||||
labelMap[label.Name] = label.ID
|
loaded = append(loaded, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate rows
|
||||||
|
var errMap = map[int][]error{}
|
||||||
|
var hasErr bool
|
||||||
|
for i, r := range loaded {
|
||||||
|
|
||||||
|
errs := r.validate()
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
hasErr = true
|
||||||
|
lineNum := i + 2
|
||||||
|
|
||||||
|
errMap[lineNum] = errs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
if hasErr {
|
||||||
// Locations
|
for lineNum, errs := range errMap {
|
||||||
|
for _, err := range errs {
|
||||||
locationMap := make(map[string]uuid.UUID)
|
log.Error().Err(err).Int("line", lineNum).Msg("csv import error")
|
||||||
{
|
|
||||||
locations, err := svc.repo.Locations.Tree(ctx, gid, repo.TreeQuery{WithItems: false})
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traverse the tree and build a map of location full paths to IDs
|
|
||||||
// where the full path is the location name joined by slashes.
|
|
||||||
var traverse func(location *repo.TreeItem, path []string)
|
|
||||||
traverse = func(location *repo.TreeItem, path []string) {
|
|
||||||
path = append(path, location.Name)
|
|
||||||
|
|
||||||
locationMap[serializeLocation(path)] = location.ID
|
|
||||||
|
|
||||||
for _, child := range location.Children {
|
|
||||||
traverse(child, path)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, location := range locations {
|
// Bootstrap the locations and labels so we can reuse the created IDs for the items
|
||||||
traverse(&location, []string{})
|
locations := map[string]uuid.UUID{}
|
||||||
|
existingLocation, err := svc.repo.Locations.GetAll(ctx, GID, repo.LocationQuery{})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, loc := range existingLocation {
|
||||||
|
locations[loc.Name] = loc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := map[string]uuid.UUID{}
|
||||||
|
existingLabels, err := svc.repo.Labels.GetAll(ctx, GID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, label := range existingLabels {
|
||||||
|
labels[label.Name] = label.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range loaded {
|
||||||
|
|
||||||
|
// Locations
|
||||||
|
if _, exists := locations[row.Location]; !exists {
|
||||||
|
result, err := svc.repo.Locations.Create(ctx, GID, repo.LocationCreate{
|
||||||
|
Name: row.Location,
|
||||||
|
Description: "",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
locations[row.Location] = result.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
|
||||||
|
for _, label := range row.getLabels() {
|
||||||
|
if _, exists := labels[label]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := svc.repo.Labels.Create(ctx, GID, repo.LabelCreate{
|
||||||
|
Name: label,
|
||||||
|
Description: "",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
labels[label] = result.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
highest := repo.AssetID(-1)
|
||||||
// Import items
|
|
||||||
|
|
||||||
// Asset ID Pre-Check
|
|
||||||
highestAID := repo.AssetID(-1)
|
|
||||||
if svc.autoIncrementAssetID {
|
if svc.autoIncrementAssetID {
|
||||||
highestAID, err = svc.repo.Items.GetHighestAssetID(ctx, gid)
|
highest, err = svc.repo.Items.GetHighestAssetID(ctx, GID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finished := 0
|
// Create the items
|
||||||
|
var count int
|
||||||
for i := range sheet.Rows {
|
for _, row := range loaded {
|
||||||
row := sheet.Rows[i]
|
// Check Import Ref
|
||||||
|
if row.Item.ImportRef != "" {
|
||||||
createRequired := true
|
exists, err := svc.repo.Items.CheckRef(ctx, GID, row.Item.ImportRef)
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Preflight check for existing item
|
|
||||||
if row.ImportRef != "" {
|
|
||||||
exists, err := svc.repo.Items.CheckRef(ctx, gid, row.ImportRef)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("error checking for existing item with ref %q: %w", row.ImportRef, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
createRequired = false
|
continue
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Pre-Create Labels as necessary
|
|
||||||
labelIds := make([]uuid.UUID, len(row.LabelStr))
|
|
||||||
|
|
||||||
for j := range row.LabelStr {
|
|
||||||
label := row.LabelStr[j]
|
|
||||||
|
|
||||||
id, ok := labelMap[label]
|
|
||||||
if !ok {
|
|
||||||
newLabel, err := svc.repo.Labels.Create(ctx, gid, repo.LabelCreate{Name: label})
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
id = newLabel.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
labelIds[j] = id
|
|
||||||
labelMap[label] = id
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Pre-Create Locations as necessary
|
|
||||||
path := serializeLocation(row.Location)
|
|
||||||
|
|
||||||
locationID, ok := locationMap[path]
|
|
||||||
if !ok { // Traverse the path of LocationStr and check each path element to see if it exists already, if not create it.
|
|
||||||
paths := []string{}
|
|
||||||
for i, pathElement := range row.Location {
|
|
||||||
paths = append(paths, pathElement)
|
|
||||||
path := serializeLocation(paths)
|
|
||||||
|
|
||||||
locationID, ok = locationMap[path]
|
|
||||||
if !ok {
|
|
||||||
parentID := uuid.Nil
|
|
||||||
|
|
||||||
// Get the parent ID
|
|
||||||
if i > 0 {
|
|
||||||
parentPath := serializeLocation(row.Location[:i])
|
|
||||||
parentID = locationMap[parentPath]
|
|
||||||
}
|
|
||||||
|
|
||||||
newLocation, err := svc.repo.Locations.Create(ctx, gid, repo.LocationCreate{
|
|
||||||
ParentID: parentID,
|
|
||||||
Name: pathElement,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
locationID = newLocation.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
locationMap[path] = locationID
|
|
||||||
}
|
|
||||||
|
|
||||||
locationID, ok = locationMap[path]
|
|
||||||
if !ok {
|
|
||||||
return 0, errors.New("failed to create location")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var effAID repo.AssetID
|
|
||||||
if svc.autoIncrementAssetID && row.AssetID.Nil() {
|
|
||||||
effAID = highestAID + 1
|
|
||||||
highestAID++
|
|
||||||
} else {
|
|
||||||
effAID = row.AssetID
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Create Item
|
|
||||||
var item repo.ItemOut
|
|
||||||
switch {
|
|
||||||
case createRequired:
|
|
||||||
newItem := repo.ItemCreate{
|
|
||||||
ImportRef: row.ImportRef,
|
|
||||||
Name: row.Name,
|
|
||||||
Description: row.Description,
|
|
||||||
AssetID: effAID,
|
|
||||||
LocationID: locationID,
|
|
||||||
LabelIDs: labelIds,
|
|
||||||
}
|
|
||||||
|
|
||||||
item, err = svc.repo.Items.Create(ctx, gid, newItem)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
log.Err(err).Msg("error checking import ref")
|
||||||
}
|
|
||||||
default:
|
|
||||||
item, err = svc.repo.Items.GetByRef(ctx, gid, row.ImportRef)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.ID == uuid.Nil {
|
locationID := locations[row.Location]
|
||||||
panic("item ID is nil on import - this should never happen")
|
labelIDs := []uuid.UUID{}
|
||||||
|
for _, label := range row.getLabels() {
|
||||||
|
labelIDs = append(labelIDs, labels[label])
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := make([]repo.ItemField, len(row.Fields))
|
log.Info().
|
||||||
for i := range row.Fields {
|
Str("name", row.Item.Name).
|
||||||
fields[i] = repo.ItemField{
|
Str("location", row.Location).
|
||||||
Name: row.Fields[i].Name,
|
Msgf("Creating Item: %s", row.Item.Name)
|
||||||
Type: "text",
|
|
||||||
TextValue: row.Fields[i].Value,
|
data := repo.ItemCreate{
|
||||||
}
|
ImportRef: row.Item.ImportRef,
|
||||||
|
Name: row.Item.Name,
|
||||||
|
Description: row.Item.Description,
|
||||||
|
LabelIDs: labelIDs,
|
||||||
|
LocationID: locationID,
|
||||||
}
|
}
|
||||||
|
|
||||||
updateItem := repo.ItemUpdate{
|
if svc.autoIncrementAssetID {
|
||||||
ID: item.ID,
|
highest++
|
||||||
LabelIDs: labelIds,
|
data.AssetID = highest
|
||||||
LocationID: locationID,
|
|
||||||
|
|
||||||
Name: row.Name,
|
|
||||||
Description: row.Description,
|
|
||||||
AssetID: effAID,
|
|
||||||
Insured: row.Insured,
|
|
||||||
Quantity: row.Quantity,
|
|
||||||
Archived: row.Archived,
|
|
||||||
|
|
||||||
PurchasePrice: row.PurchasePrice,
|
|
||||||
PurchaseFrom: row.PurchaseFrom,
|
|
||||||
PurchaseTime: row.PurchaseTime,
|
|
||||||
|
|
||||||
Manufacturer: row.Manufacturer,
|
|
||||||
ModelNumber: row.ModelNumber,
|
|
||||||
SerialNumber: row.SerialNumber,
|
|
||||||
|
|
||||||
LifetimeWarranty: row.LifetimeWarranty,
|
|
||||||
WarrantyExpires: row.WarrantyExpires,
|
|
||||||
WarrantyDetails: row.WarrantyDetails,
|
|
||||||
|
|
||||||
SoldTo: row.SoldTo,
|
|
||||||
SoldTime: row.SoldTime,
|
|
||||||
SoldPrice: row.SoldPrice,
|
|
||||||
SoldNotes: row.SoldNotes,
|
|
||||||
|
|
||||||
Notes: row.Notes,
|
|
||||||
Fields: fields,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err = svc.repo.Items.UpdateByGroup(ctx, gid, updateItem)
|
result, err := svc.repo.Items.Create(ctx, GID, data)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
finished++
|
// Update the item with the rest of the data
|
||||||
}
|
_, err = svc.repo.Items.UpdateByGroup(ctx, GID, repo.ItemUpdate{
|
||||||
|
// Edges
|
||||||
|
LocationID: locationID,
|
||||||
|
LabelIDs: labelIDs,
|
||||||
|
AssetID: data.AssetID,
|
||||||
|
|
||||||
return finished, nil
|
// General Fields
|
||||||
}
|
ID: result.ID,
|
||||||
|
Name: result.Name,
|
||||||
func (svc *ItemService) ExportCSV(ctx context.Context, gid uuid.UUID, hbURL string) ([][]string, error) {
|
Description: result.Description,
|
||||||
items, err := svc.repo.Items.GetAll(ctx, gid)
|
Insured: row.Item.Insured,
|
||||||
if err != nil {
|
Notes: row.Item.Notes,
|
||||||
return nil, err
|
Quantity: row.Item.Quantity,
|
||||||
}
|
|
||||||
|
// Identifies the item as imported
|
||||||
sheet := reporting.IOSheet{}
|
SerialNumber: row.Item.SerialNumber,
|
||||||
|
ModelNumber: row.Item.ModelNumber,
|
||||||
err = sheet.ReadItems(ctx, items, gid, svc.repo, hbURL)
|
Manufacturer: row.Item.Manufacturer,
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
// Purchase
|
||||||
}
|
PurchaseFrom: row.Item.PurchaseFrom,
|
||||||
|
PurchasePrice: row.Item.PurchasePrice,
|
||||||
return sheet.CSV()
|
PurchaseTime: row.Item.PurchaseTime,
|
||||||
}
|
|
||||||
|
// Warranty
|
||||||
func (svc *ItemService) ExportBillOfMaterialsCSV(ctx context.Context, gid uuid.UUID) ([]byte, error) {
|
LifetimeWarranty: row.Item.LifetimeWarranty,
|
||||||
items, err := svc.repo.Items.GetAll(ctx, gid)
|
WarrantyExpires: row.Item.WarrantyExpires,
|
||||||
if err != nil {
|
WarrantyDetails: row.Item.WarrantyDetails,
|
||||||
return nil, err
|
|
||||||
}
|
SoldTo: row.Item.SoldTo,
|
||||||
|
SoldPrice: row.Item.SoldPrice,
|
||||||
return reporting.BillOfMaterialsCSV(items)
|
SoldTime: row.Item.SoldTime,
|
||||||
|
SoldNotes: row.Item.SoldNotes,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,65 +2,89 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/ent/attachment"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (svc *ItemService) AttachmentPath(ctx context.Context, attachmentID uuid.UUID) (*ent.Attachment, error) {
|
func (svc *ItemService) AttachmentPath(ctx context.Context, attachmentId uuid.UUID) (*ent.Document, error) {
|
||||||
attachment, err := svc.repo.Attachments.Get(ctx, attachmentID)
|
attachment, err := svc.repo.Attachments.Get(ctx, attachmentId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return attachment, nil
|
return attachment.Edges.Document, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *ItemService) AttachmentUpdate(ctx Context, 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
|
// Update Attachment
|
||||||
attachment, err := svc.repo.Attachments.Update(ctx, data.ID, data)
|
attachment, err := svc.repo.Attachments.Update(ctx, data.ID, attachment.Type(data.Type))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return repo.ItemOut{}, err
|
return repo.ItemOut{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Document
|
// Update Document
|
||||||
attDoc := attachment
|
attDoc := attachment.Edges.Document
|
||||||
_, err = svc.repo.Attachments.Rename(ctx, attDoc.ID, data.Title)
|
_, err = svc.repo.Docs.Rename(ctx, attDoc.ID, data.Title)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return repo.ItemOut{}, err
|
return repo.ItemOut{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return svc.repo.Items.GetOneByGroup(ctx, ctx.GID, itemID)
|
return svc.repo.Items.GetOneByGroup(ctx, ctx.GID, itemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachmentAdd adds an attachment to an item by creating an entry in the Documents table and linking it to the Attachment
|
// AttachmentAdd adds an attachment to an item by creating an entry in the Documents table and linking it to the Attachment
|
||||||
// Table and Items table. The file provided via the reader is stored on the file system based on the provided
|
// Table and Items table. The file provided via the reader is stored on the file system based on the provided
|
||||||
// relative path during construction of the service.
|
// relative path during construction of the service.
|
||||||
func (svc *ItemService) AttachmentAdd(ctx Context, itemID uuid.UUID, filename string, attachmentType attachment.Type, primary bool, file io.Reader) (repo.ItemOut, error) {
|
func (svc *ItemService) AttachmentAdd(ctx Context, itemId uuid.UUID, filename string, attachmentType attachment.Type, file io.Reader) (repo.ItemOut, error) {
|
||||||
// Get the Item
|
// Get the Item
|
||||||
_, err := svc.repo.Items.GetOneByGroup(ctx, ctx.GID, itemID)
|
_, err := svc.repo.Items.GetOneByGroup(ctx, ctx.GID, itemId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return repo.ItemOut{}, err
|
return repo.ItemOut{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the attachment
|
// Create the document
|
||||||
_, err = svc.repo.Attachments.Create(ctx, itemID, repo.ItemCreateAttachment{Title: filename, Content: file}, attachmentType, primary)
|
doc, err := svc.repo.Docs.Create(ctx, ctx.GID, repo.DocumentCreate{Title: filename, Content: file})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to create attachment")
|
log.Err(err).Msg("failed to create document")
|
||||||
|
return repo.ItemOut{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return svc.repo.Items.GetOneByGroup(ctx, ctx.GID, itemID)
|
// Create the attachment
|
||||||
|
_, err = svc.repo.Attachments.Create(ctx, itemId, doc.ID, attachmentType)
|
||||||
|
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, itemID, attachmentID uuid.UUID) error {
|
func (svc *ItemService) AttachmentDelete(ctx context.Context, gid, itemId, attachmentId uuid.UUID) error {
|
||||||
// Delete the attachment
|
// Get the Item
|
||||||
err := svc.repo.Attachments.Delete(ctx, attachmentID)
|
_, err := svc.repo.Items.GetOneByGroup(ctx, gid, itemId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attachment, err := svc.repo.Attachments.Get(ctx, attachmentId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the attachment
|
||||||
|
err = svc.repo.Attachments.Delete(ctx, attachmentId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove File
|
||||||
|
err = os.Remove(attachment.Edges.Document.Path)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestItemService_AddAttachment(t *testing.T) {
|
func TestItemService_AddAttachment(t *testing.T) {
|
||||||
@@ -24,7 +23,7 @@ func TestItemService_AddAttachment(t *testing.T) {
|
|||||||
Description: "test",
|
Description: "test",
|
||||||
Name: "test",
|
Name: "test",
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, loc)
|
assert.NotNil(t, loc)
|
||||||
|
|
||||||
itmC := repo.ItemCreate{
|
itmC := repo.ItemCreate{
|
||||||
@@ -34,29 +33,29 @@ func TestItemService_AddAttachment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
itm, err := svc.repo.Items.Create(context.Background(), tGroup.ID, itmC)
|
itm, err := svc.repo.Items.Create(context.Background(), tGroup.ID, itmC)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, itm)
|
assert.NotNil(t, itm)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
err := svc.repo.Items.Delete(context.Background(), itm.ID)
|
err := svc.repo.Items.Delete(context.Background(), itm.ID)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
contents := fk.Str(1000)
|
contents := fk.Str(1000)
|
||||||
reader := strings.NewReader(contents)
|
reader := strings.NewReader(contents)
|
||||||
|
|
||||||
// Setup
|
// Setup
|
||||||
afterAttachment, err := svc.AttachmentAdd(tCtx, itm.ID, "testfile.txt", "attachment", false, reader)
|
afterAttachment, err := svc.AttachmentAdd(tCtx, itm.ID, "testfile.txt", "attachment", reader)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, afterAttachment)
|
assert.NotNil(t, afterAttachment)
|
||||||
|
|
||||||
// Check that the file exists
|
// Check that the file exists
|
||||||
storedPath := afterAttachment.Attachments[0].Path
|
storedPath := afterAttachment.Attachments[0].Document.Path
|
||||||
|
|
||||||
// {root}/{group}/{item}/{attachment}
|
// {root}/{group}/{item}/{attachment}
|
||||||
assert.Equal(t, path.Join(temp, "homebox", tGroup.ID.String(), "documents"), path.Dir(storedPath))
|
assert.Equal(t, path.Join(temp, "homebox", tGroup.ID.String(), "documents"), path.Dir(storedPath))
|
||||||
|
|
||||||
// Check that the file contents are correct
|
// Check that the file contents are correct
|
||||||
bts, err := os.ReadFile(storedPath)
|
bts, err := os.ReadFile(storedPath)
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, contents, string(bts))
|
assert.Equal(t, contents, string(bts))
|
||||||
}
|
}
|
||||||
|
|||||||
151
backend/internal/core/services/service_items_csv.go
Normal file
151
backend/internal/core/services/service_items_csv.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func determineSeparator(data []byte) (rune, error) {
|
||||||
|
// First row
|
||||||
|
firstRow := bytes.Split(data, []byte("\n"))[0]
|
||||||
|
|
||||||
|
// find first comma or /t
|
||||||
|
comma := bytes.IndexByte(firstRow, ',')
|
||||||
|
tab := bytes.IndexByte(firstRow, '\t')
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case comma == -1 && tab == -1:
|
||||||
|
return 0, errors.New("could not determine separator")
|
||||||
|
case tab > comma:
|
||||||
|
return '\t', nil
|
||||||
|
default:
|
||||||
|
return ',', nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadCsv(r io.Reader) ([][]string, error) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := csv.NewReader(bytes.NewReader(data))
|
||||||
|
|
||||||
|
// Determine separator
|
||||||
|
sep, err := determineSeparator(data)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.Comma = sep
|
||||||
|
|
||||||
|
return reader.ReadAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrInvalidCsv = errors.New("invalid csv")
|
||||||
|
|
||||||
|
const NumOfCols = 21
|
||||||
|
|
||||||
|
func parseFloat(s string) float64 {
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
f, _ := strconv.ParseFloat(s, 64)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(s string) bool {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "true", "yes", "1":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(s string) int {
|
||||||
|
i, _ := strconv.Atoi(s)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvRow struct {
|
||||||
|
Item repo.ItemOut
|
||||||
|
Location string
|
||||||
|
LabelStr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCsvRow(row []string) csvRow {
|
||||||
|
|
||||||
|
return csvRow{
|
||||||
|
Location: row[1],
|
||||||
|
LabelStr: row[2],
|
||||||
|
Item: repo.ItemOut{
|
||||||
|
ItemSummary: repo.ItemSummary{
|
||||||
|
ImportRef: row[0],
|
||||||
|
Quantity: parseInt(row[3]),
|
||||||
|
Name: row[4],
|
||||||
|
Description: row[5],
|
||||||
|
Insured: parseBool(row[6]),
|
||||||
|
PurchasePrice: parseFloat(row[12]),
|
||||||
|
},
|
||||||
|
SerialNumber: row[7],
|
||||||
|
ModelNumber: row[8],
|
||||||
|
Manufacturer: row[9],
|
||||||
|
Notes: row[10],
|
||||||
|
PurchaseFrom: row[11],
|
||||||
|
PurchaseTime: types.DateFromString(row[13]),
|
||||||
|
LifetimeWarranty: parseBool(row[14]),
|
||||||
|
WarrantyExpires: types.DateFromString(row[15]),
|
||||||
|
WarrantyDetails: row[16],
|
||||||
|
SoldTo: row[17],
|
||||||
|
SoldPrice: parseFloat(row[18]),
|
||||||
|
SoldTime: types.DateFromString(row[19]),
|
||||||
|
SoldNotes: row[20],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c csvRow) getLabels() []string {
|
||||||
|
split := strings.Split(c.LabelStr, ";")
|
||||||
|
|
||||||
|
// Trim each
|
||||||
|
for i, s := range split {
|
||||||
|
split[i] = strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove empty
|
||||||
|
for i, s := range split {
|
||||||
|
if s == "" {
|
||||||
|
split = append(split[:i], split[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return split
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c csvRow) validate() []error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
add := func(err error) {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
required := func(s string, name string) {
|
||||||
|
if s == "" {
|
||||||
|
add(errors.New(name + " is required"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required(c.Location, "Location")
|
||||||
|
required(c.Item.Name, "Name")
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
164
backend/internal/core/services/service_items_csv_test.go
Normal file
164
backend/internal/core/services/service_items_csv_test.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed .testdata/import.csv
|
||||||
|
var CSVData_Comma []byte
|
||||||
|
|
||||||
|
//go:embed .testdata/import.tsv
|
||||||
|
var CSVData_Tab []byte
|
||||||
|
|
||||||
|
func loadcsv() [][]string {
|
||||||
|
reader := csv.NewReader(bytes.NewReader(CSVData_Comma))
|
||||||
|
|
||||||
|
records, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_CorrectDateParsing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expected := []time.Time{
|
||||||
|
time.Date(2021, 10, 13, 0, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2021, 10, 15, 0, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2021, 10, 13, 0, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2020, 10, 21, 0, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2020, 10, 14, 0, 0, 0, 0, time.UTC),
|
||||||
|
time.Date(2020, 9, 30, 0, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
records := loadcsv()
|
||||||
|
|
||||||
|
for i, record := range records {
|
||||||
|
if i == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entity := newCsvRow(record)
|
||||||
|
expected := expected[i-1]
|
||||||
|
|
||||||
|
assert.Equal(t, expected, entity.Item.PurchaseTime.Time(), fmt.Sprintf("Failed on row %d", i))
|
||||||
|
assert.Equal(t, expected, entity.Item.WarrantyExpires.Time(), fmt.Sprintf("Failed on row %d", i))
|
||||||
|
assert.Equal(t, expected, entity.Item.SoldTime.Time(), fmt.Sprintf("Failed on row %d", i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_csvRow_getLabels(t *testing.T) {
|
||||||
|
type fields struct {
|
||||||
|
LabelStr string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields fields
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic test",
|
||||||
|
fields: fields{
|
||||||
|
LabelStr: "IOT;Home Assistant;Z-Wave",
|
||||||
|
},
|
||||||
|
want: []string{"IOT", "Home Assistant", "Z-Wave"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no labels",
|
||||||
|
fields: fields{
|
||||||
|
LabelStr: "",
|
||||||
|
},
|
||||||
|
want: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single label",
|
||||||
|
fields: fields{
|
||||||
|
LabelStr: "IOT",
|
||||||
|
},
|
||||||
|
want: []string{"IOT"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing semicolon",
|
||||||
|
fields: fields{
|
||||||
|
LabelStr: "IOT;",
|
||||||
|
},
|
||||||
|
want: []string{"IOT"},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "whitespace",
|
||||||
|
fields: fields{
|
||||||
|
LabelStr: " IOT; Home Assistant; Z-Wave ",
|
||||||
|
},
|
||||||
|
want: []string{"IOT", "Home Assistant", "Z-Wave"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
c := csvRow{
|
||||||
|
LabelStr: tt.fields.LabelStr,
|
||||||
|
}
|
||||||
|
if got := c.getLabels(); !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("csvRow.getLabels() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_determineSeparator(t *testing.T) {
|
||||||
|
type args struct {
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
want rune
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "comma",
|
||||||
|
args: args{
|
||||||
|
data: CSVData_Comma,
|
||||||
|
},
|
||||||
|
want: ',',
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tab",
|
||||||
|
args: args{
|
||||||
|
data: CSVData_Tab,
|
||||||
|
},
|
||||||
|
want: '\t',
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid",
|
||||||
|
args: args{
|
||||||
|
data: []byte("a;b;c"),
|
||||||
|
},
|
||||||
|
want: 0,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := determineSeparator(tt.args.data)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("determineSeparator() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("determineSeparator() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user