Compare commits
25 Commits
mk/multi-g
...
feat/passw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d2957853 | ||
|
|
2231c54f21 | ||
|
|
5c217c11d2 | ||
|
|
75ad107192 | ||
|
|
4f51e4dc8c | ||
|
|
0e6b893efb | ||
|
|
d022795270 | ||
|
|
d87b68dd5c | ||
|
|
80de7d83f9 | ||
|
|
d322d98013 | ||
|
|
a35c522676 | ||
|
|
a75ab532c2 | ||
|
|
1f155c569f | ||
|
|
d39e7ee97f | ||
|
|
c7ada54e14 | ||
|
|
41881ee8ec | ||
|
|
d7fd68765a | ||
|
|
0c968de1fb | ||
|
|
c83d178182 | ||
|
|
ef06e4fbb2 | ||
|
|
f4c63613d5 | ||
|
|
b96fb4bb9e | ||
|
|
cb949e6c86 | ||
|
|
7ff78530a2 | ||
|
|
29e30bfaba |
@@ -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.24"
|
"golang": "1.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
.gitattributes
vendored
@@ -1,3 +0,0 @@
|
|||||||
backend/internal/data/ent/** linguist-generated=true
|
|
||||||
backend/internal/data/ent/schema/** linguist-generated=false
|
|
||||||
frontend/lib/api/types/** linguist-generated=true
|
|
||||||
40
.github/AGENTS.md
vendored
@@ -1,40 +0,0 @@
|
|||||||
This is a Go based repository with a VueJS client for the frontend built with Vite and Nuxt, with ShadCN.
|
|
||||||
|
|
||||||
To make life easier, the use of a Taskfile is included for the majority of development commands.
|
|
||||||
|
|
||||||
Please follow these guidelines when contributing:
|
|
||||||
|
|
||||||
## Required Before Each Commit
|
|
||||||
- Generate Swagger Files: `task swag --force`
|
|
||||||
- Generate JS API Client: `task typescript-types --force`
|
|
||||||
- Lint Golang: `task go:lint`
|
|
||||||
- Lint frontend: `task ui:fix`
|
|
||||||
|
|
||||||
## Repository Structure
|
|
||||||
### Backend
|
|
||||||
- `backend/`: Contains the backend folders
|
|
||||||
- `backend/app`: Contains main app code including API endpoints
|
|
||||||
- `backend/internal/core`: Contains basic services such as currencies
|
|
||||||
- `backend/data`: Contains all information related to data, including `ent` schemas, repos, migrations, etc.
|
|
||||||
- `backend/data/migrations`: Contains migration data, the `sqlite3` sub-folder contains sqlite migrations, `postgres` sub-folder the postgres migrations, BOTH are REQUIRED.
|
|
||||||
- `backend/data/ent/schema`: Contains the actual `ent` data models.
|
|
||||||
- `backend/data/repo`: Contains the data repositories
|
|
||||||
- `backend/pkgs`: Contains general helper functions and services
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- `frontend/`: Contains initial frontend files
|
|
||||||
- `frontend/components`: Contains the ShadCN components
|
|
||||||
- `frontend/locales`: Contains the i18n JSON for languages
|
|
||||||
- `frontend/pages`: Contains VueJS pages
|
|
||||||
- `frontend/test`: Contains Playwright setup
|
|
||||||
- `frontend/test/e2e`: Contains actual Playwright test files
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
- `docs/`: Contains VitePress based documentation
|
|
||||||
|
|
||||||
## Key Guidelines
|
|
||||||
1. Follow best practices for the various programming languages
|
|
||||||
2. Maintain existing code structure and organization when possible
|
|
||||||
3. Use dependency injection when reasonable
|
|
||||||
4. Write tests for new functionality and after fixing bugs to validate they're fixed
|
|
||||||
5. Document changes to the `docs/` folder when appropriate
|
|
||||||
3
.github/FUNDING.yml
vendored
@@ -1,2 +1 @@
|
|||||||
open_collective: homebox
|
github: [hay-kot]
|
||||||
github: [tankerkiller125,katosdev,tonyaellie]
|
|
||||||
|
|||||||
29
.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,29 +54,6 @@ body:
|
|||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
|
||||||
id: database
|
|
||||||
attributes:
|
|
||||||
label: Database Type
|
|
||||||
description: What database backend are you using?
|
|
||||||
multiple: false
|
|
||||||
options:
|
|
||||||
- SQLite
|
|
||||||
- PostgreSQL
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: dropdown
|
|
||||||
id: arch
|
|
||||||
attributes:
|
|
||||||
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:
|
||||||
|
|||||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,11 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: GitHub Community Support
|
|
||||||
url: https://github.com/sysadminsmedia/homebox/discussions/categories/support
|
|
||||||
about: Get support for issues here
|
|
||||||
- name: Feature Requests
|
|
||||||
url: https://github.com/sysadminsmedia/homebox/discussions/categories/ideas
|
|
||||||
about: Have an idea for Homebox? Share it in our discussions forum. If we decide to take it on we will create an issue for it.
|
|
||||||
- name: Translate
|
|
||||||
url: https://translate.sysadminsmedia.com
|
|
||||||
about: Help us translate Homebox! All contributions and all languages welcome!
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
labels: ["⬆️ enhancement"]
|
---
|
||||||
|
name: "Feature Request"
|
||||||
|
description: "Submit a feature request for the current release"
|
||||||
|
labels: ["feature-request"]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: problem-statement
|
id: problem-statement
|
||||||
@@ -26,5 +29,9 @@ body:
|
|||||||
label: Contributions
|
label: Contributions
|
||||||
description: Please confirm the following
|
description: Please confirm the following
|
||||||
options:
|
options:
|
||||||
- label: I have searched through existing ideas in the discussions to check if this is a duplicate
|
- label: I have searched through existing issues and feature requests to see if my idea has already been proposed.
|
||||||
required: true
|
required: true
|
||||||
|
- label: If this feature is accepted, I would be willing to help implement and maintain this feature.
|
||||||
|
required: false
|
||||||
|
- label: If this feature is accepted, I'm willing to sponsor the development of this feature.
|
||||||
|
required: false
|
||||||
10
.github/ISSUE_TEMPLATE/internal.md
vendored
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
name: "🛠️ Internal / Developer Issue"
|
|
||||||
about: "Unstructured issue for project members only. Outside contributors: please use a standard template."
|
|
||||||
title: "[INT]: "
|
|
||||||
labels: ["internal"]
|
|
||||||
assignees: []
|
|
||||||
---
|
|
||||||
|
|
||||||
**Summary:**
|
|
||||||
[Write here]
|
|
||||||
@@ -1,432 +0,0 @@
|
|||||||
---
|
|
||||||
applyTo: '/backend/app/api/handlers/**/*'
|
|
||||||
---
|
|
||||||
|
|
||||||
# Backend API Handlers Instructions (`/backend/app/api/handlers/v1/`)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
API handlers are the HTTP layer that processes requests, calls services, and returns responses. All handlers use the V1 API pattern with Swagger documentation for auto-generation.
|
|
||||||
|
|
||||||
## Architecture Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP Request → Router → Middleware → Handler → Service → Repository → Database
|
|
||||||
↓
|
|
||||||
HTTP Response
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/app/api/
|
|
||||||
├── routes.go # Route definitions and middleware
|
|
||||||
├── handlers/
|
|
||||||
│ └── v1/
|
|
||||||
│ ├── controller.go # V1Controller struct and dependencies
|
|
||||||
│ ├── v1_ctrl_items.go # Item endpoints
|
|
||||||
│ ├── v1_ctrl_users.go # User endpoints
|
|
||||||
│ ├── v1_ctrl_locations.go # Location endpoints
|
|
||||||
│ ├── v1_ctrl_auth.go # Authentication endpoints
|
|
||||||
│ ├── helpers.go # HTTP helper functions
|
|
||||||
│ ├── query_params.go # Query parameter parsing
|
|
||||||
│ └── assets/ # Asset handling
|
|
||||||
```
|
|
||||||
|
|
||||||
## Handler Structure
|
|
||||||
|
|
||||||
### V1Controller
|
|
||||||
|
|
||||||
All handlers are methods on `V1Controller`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type V1Controller struct {
|
|
||||||
svc *services.AllServices // Service layer
|
|
||||||
repo *repo.AllRepos // Direct repo access (rare)
|
|
||||||
bus *eventbus.EventBus // Event publishing
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ctrl *V1Controller) HandleItemCreate() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
// Handler logic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Swagger Documentation
|
|
||||||
|
|
||||||
**CRITICAL:** Every handler must have Swagger comments for API doc generation:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// HandleItemsGetAll godoc
|
|
||||||
//
|
|
||||||
// @Summary Query All Items
|
|
||||||
// @Tags Items
|
|
||||||
// @Produce json
|
|
||||||
// @Param q query string false "search string"
|
|
||||||
// @Param page query int false "page number"
|
|
||||||
// @Param pageSize query int false "items per page"
|
|
||||||
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
|
|
||||||
// @Router /v1/items [GET]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After modifying Swagger comments, ALWAYS run:**
|
|
||||||
```bash
|
|
||||||
task generate # Regenerates Swagger docs and TypeScript types
|
|
||||||
```
|
|
||||||
|
|
||||||
## Standard Handler Pattern
|
|
||||||
|
|
||||||
### 1. Decode Request
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (ctrl *V1Controller) HandleItemCreate() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
var itemData repo.ItemCreate
|
|
||||||
if err := server.Decode(r, &itemData); err != nil {
|
|
||||||
return validate.NewRequestError(err, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... rest of handler
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Extract Context
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Get current user from request (added by auth middleware)
|
|
||||||
user := ctrl.CurrentUser(r)
|
|
||||||
|
|
||||||
// Create service context with group/user IDs
|
|
||||||
ctx := services.NewContext(r.Context(), user)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Call Service
|
|
||||||
|
|
||||||
```go
|
|
||||||
result, err := ctrl.svc.Items.Create(ctx, itemData)
|
|
||||||
if err != nil {
|
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Return Response
|
|
||||||
|
|
||||||
```go
|
|
||||||
return server.JSON(w, result, http.StatusCreated)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Handler Patterns
|
|
||||||
|
|
||||||
### GET - Single Item
|
|
||||||
|
|
||||||
```go
|
|
||||||
// HandleItemGet godoc
|
|
||||||
//
|
|
||||||
// @Summary Get Item
|
|
||||||
// @Tags Items
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Item ID"
|
|
||||||
// @Success 200 {object} repo.ItemOut
|
|
||||||
// @Router /v1/items/{id} [GET]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemGet() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
id, err := ctrl.RouteUUID(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r))
|
|
||||||
item, err := ctrl.svc.Items.Get(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return validate.NewRequestError(err, http.StatusNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.JSON(w, item, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET - List with Pagination
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
// Parse query parameters
|
|
||||||
query := extractItemQuery(r)
|
|
||||||
|
|
||||||
ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r))
|
|
||||||
items, err := ctrl.svc.Items.GetAll(ctx, query)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.JSON(w, items, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to extract query params
|
|
||||||
func extractItemQuery(r *http.Request) repo.ItemQuery {
|
|
||||||
params := r.URL.Query()
|
|
||||||
return repo.ItemQuery{
|
|
||||||
Page: queryIntOrNegativeOne(params.Get("page")),
|
|
||||||
PageSize: queryIntOrNegativeOne(params.Get("pageSize")),
|
|
||||||
Search: params.Get("q"),
|
|
||||||
LocationIDs: queryUUIDList(params, "locations"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST - Create
|
|
||||||
|
|
||||||
```go
|
|
||||||
// HandleItemCreate godoc
|
|
||||||
//
|
|
||||||
// @Summary Create Item
|
|
||||||
// @Tags Items
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param payload body repo.ItemCreate true "Item Data"
|
|
||||||
// @Success 201 {object} repo.ItemOut
|
|
||||||
// @Router /v1/items [POST]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemCreate() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
var data repo.ItemCreate
|
|
||||||
if err := server.Decode(r, &data); err != nil {
|
|
||||||
return validate.NewRequestError(err, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r))
|
|
||||||
item, err := ctrl.svc.Items.Create(ctx, data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.JSON(w, item, http.StatusCreated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### PUT - Update
|
|
||||||
|
|
||||||
```go
|
|
||||||
// HandleItemUpdate godoc
|
|
||||||
//
|
|
||||||
// @Summary Update Item
|
|
||||||
// @Tags Items
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Item ID"
|
|
||||||
// @Param payload body repo.ItemUpdate true "Item Data"
|
|
||||||
// @Success 200 {object} repo.ItemOut
|
|
||||||
// @Router /v1/items/{id} [PUT]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemUpdate() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
id, err := ctrl.RouteUUID(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var data repo.ItemUpdate
|
|
||||||
if err := server.Decode(r, &data); err != nil {
|
|
||||||
return validate.NewRequestError(err, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r))
|
|
||||||
item, err := ctrl.svc.Items.Update(ctx, id, data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.JSON(w, item, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DELETE
|
|
||||||
|
|
||||||
```go
|
|
||||||
// HandleItemDelete godoc
|
|
||||||
//
|
|
||||||
// @Summary Delete Item
|
|
||||||
// @Tags Items
|
|
||||||
// @Param id path string true "Item ID"
|
|
||||||
// @Success 204
|
|
||||||
// @Router /v1/items/{id} [DELETE]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemDelete() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
id, err := ctrl.RouteUUID(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r))
|
|
||||||
err = ctrl.svc.Items.Delete(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.JSON(w, nil, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### File Upload
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
id, err := ctrl.RouteUUID(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse multipart form
|
|
||||||
err = r.ParseMultipartForm(32 << 20) // 32MB max
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, header, err := r.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r))
|
|
||||||
attachment, err := ctrl.svc.Items.CreateAttachment(ctx, id, file, header.Filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.JSON(w, attachment, http.StatusCreated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Routing
|
|
||||||
|
|
||||||
Routes are defined in `backend/app/api/routes.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (a *app) mountRoutes(repos *repo.AllRepos, svc *services.AllServices) {
|
|
||||||
v1 := v1.NewControllerV1(svc, repos)
|
|
||||||
|
|
||||||
a.server.Get("/api/v1/items", v1.HandleItemsGetAll())
|
|
||||||
a.server.Post("/api/v1/items", v1.HandleItemCreate())
|
|
||||||
a.server.Get("/api/v1/items/{id}", v1.HandleItemGet())
|
|
||||||
a.server.Put("/api/v1/items/{id}", v1.HandleItemUpdate())
|
|
||||||
a.server.Delete("/api/v1/items/{id}", v1.HandleItemDelete())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Helper Functions
|
|
||||||
|
|
||||||
### Query Parameter Parsing
|
|
||||||
|
|
||||||
Located in `query_params.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func queryIntOrNegativeOne(s string) int
|
|
||||||
func queryBool(s string) bool
|
|
||||||
func queryUUIDList(params url.Values, key string) []uuid.UUID
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Helpers
|
|
||||||
|
|
||||||
```go
|
|
||||||
// From httpkit/server
|
|
||||||
server.JSON(w, data, statusCode) // JSON response
|
|
||||||
server.Respond(w, statusCode) // Empty response
|
|
||||||
validate.NewRequestError(err, statusCode) // Error response
|
|
||||||
```
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
```go
|
|
||||||
user := ctrl.CurrentUser(r) // Get authenticated user (from middleware)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a New Endpoint
|
|
||||||
|
|
||||||
### 1. Create Handler
|
|
||||||
|
|
||||||
In `backend/app/api/handlers/v1/v1_ctrl_myentity.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// HandleMyEntityCreate godoc
|
|
||||||
//
|
|
||||||
// @Summary Create MyEntity
|
|
||||||
// @Tags MyEntity
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param payload body repo.MyEntityCreate true "Data"
|
|
||||||
// @Success 201 {object} repo.MyEntityOut
|
|
||||||
// @Router /v1/my-entity [POST]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleMyEntityCreate() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
var data repo.MyEntityCreate
|
|
||||||
if err := server.Decode(r, &data); err != nil {
|
|
||||||
return validate.NewRequestError(err, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r))
|
|
||||||
result, err := ctrl.svc.MyEntity.Create(ctx, data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.JSON(w, result, http.StatusCreated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add Route
|
|
||||||
|
|
||||||
In `backend/app/api/routes.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
a.server.Post("/api/v1/my-entity", v1.HandleMyEntityCreate())
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Generate Docs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
task generate # Generates Swagger docs and TypeScript types
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
task go:build # Verify builds
|
|
||||||
task go:test # Run tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
1. **ALWAYS add Swagger comments** - required for API docs and TypeScript type generation
|
|
||||||
2. **Run `task generate` after handler changes** - updates API documentation
|
|
||||||
3. **Use services, not repos directly** - handlers call services, services call repos
|
|
||||||
4. **Always use `services.Context`** - includes auth and multi-tenancy
|
|
||||||
5. **Handle errors properly** - use `validate.NewRequestError()` with appropriate status codes
|
|
||||||
6. **Validate input** - decode and validate request bodies
|
|
||||||
7. **Return correct status codes** - 200 OK, 201 Created, 204 No Content, 400 Bad Request, 404 Not Found
|
|
||||||
|
|
||||||
## Common Issues
|
|
||||||
|
|
||||||
- **"Missing Swagger docs"** → Add `@Summary`, `@Tags`, `@Router` comments, run `task generate`
|
|
||||||
- **TypeScript types outdated** → Run `task generate` to regenerate
|
|
||||||
- **Auth failures** → Ensure route has auth middleware and `@Security Bearer`
|
|
||||||
- **CORS errors** → Check middleware configuration in `routes.go`
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
---
|
|
||||||
applyTo: '/backend/internal/core/services/**/*'
|
|
||||||
---
|
|
||||||
|
|
||||||
# Backend Services Layer Instructions (`/backend/internal/core/services/`)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The services layer contains business logic that orchestrates between repositories and API handlers. Services handle complex operations, validation, and cross-cutting concerns.
|
|
||||||
|
|
||||||
## Architecture Pattern
|
|
||||||
|
|
||||||
```
|
|
||||||
Handler (API) → Service (Business Logic) → Repository (Data Access) → Database
|
|
||||||
```
|
|
||||||
|
|
||||||
**Separation of concerns:**
|
|
||||||
- **Handlers** (`backend/app/api/handlers/v1/`) - HTTP request/response, routing, auth
|
|
||||||
- **Services** (`backend/internal/core/services/`) - Business logic, orchestration
|
|
||||||
- **Repositories** (`backend/internal/data/repo/`) - Database operations, queries
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/internal/core/services/
|
|
||||||
├── all.go # Service aggregation
|
|
||||||
├── service_items.go # Item business logic
|
|
||||||
├── service_items_attachments.go # Item attachments logic
|
|
||||||
├── service_user.go # User management logic
|
|
||||||
├── service_group.go # Group management logic
|
|
||||||
├── service_background.go # Background tasks
|
|
||||||
├── contexts.go # Service context types
|
|
||||||
├── reporting/ # Reporting subsystem
|
|
||||||
│ ├── eventbus/ # Event bus for notifications
|
|
||||||
│ └── *.go # Report generation logic
|
|
||||||
└── *_test.go # Service tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Service Structure
|
|
||||||
|
|
||||||
### Standard Pattern
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ItemService struct {
|
|
||||||
repo *repo.AllRepos // Access to all repositories
|
|
||||||
filepath string // File storage path
|
|
||||||
autoIncrementAssetID bool // Feature flags
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *ItemService) Create(ctx Context, item repo.ItemCreate) (repo.ItemOut, error) {
|
|
||||||
// 1. Validation
|
|
||||||
if item.Name == "" {
|
|
||||||
return repo.ItemOut{}, errors.New("name required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Business logic
|
|
||||||
if svc.autoIncrementAssetID {
|
|
||||||
highest, err := svc.repo.Items.GetHighestAssetID(ctx, ctx.GID)
|
|
||||||
if err != nil {
|
|
||||||
return repo.ItemOut{}, err
|
|
||||||
}
|
|
||||||
item.AssetID = highest + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Repository call
|
|
||||||
return svc.repo.Items.Create(ctx, ctx.GID, item)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service Context
|
|
||||||
|
|
||||||
Services use a custom `Context` type that extends `context.Context`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Context struct {
|
|
||||||
context.Context
|
|
||||||
GID uuid.UUID // Group ID for multi-tenancy
|
|
||||||
UID uuid.UUID // User ID for audit
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Always use `Context` from services package, not raw `context.Context`.**
|
|
||||||
|
|
||||||
## Common Service Patterns
|
|
||||||
|
|
||||||
### 1. CRUD with Business Logic
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (svc *ItemService) Update(ctx Context, id uuid.UUID, data repo.ItemUpdate) (repo.ItemOut, error) {
|
|
||||||
// Fetch existing
|
|
||||||
existing, err := svc.repo.Items.Get(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return repo.ItemOut{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Business rules
|
|
||||||
if existing.Archived && data.Quantity != nil {
|
|
||||||
return repo.ItemOut{}, errors.New("cannot modify archived items")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update
|
|
||||||
return svc.repo.Items.Update(ctx, id, data)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Orchestrating Multiple Repositories
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (svc *ItemService) CreateWithAttachment(ctx Context, item repo.ItemCreate, file io.Reader) (repo.ItemOut, error) {
|
|
||||||
// Create item
|
|
||||||
created, err := svc.repo.Items.Create(ctx, ctx.GID, item)
|
|
||||||
if err != nil {
|
|
||||||
return repo.ItemOut{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload attachment
|
|
||||||
attachment, err := svc.repo.Attachments.Create(ctx, created.ID, file)
|
|
||||||
if err != nil {
|
|
||||||
// Rollback - delete item
|
|
||||||
_ = svc.repo.Items.Delete(ctx, created.ID)
|
|
||||||
return repo.ItemOut{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
created.Attachments = []repo.AttachmentOut{attachment}
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Background Tasks
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (svc *ItemService) EnsureAssetID(ctx context.Context, gid uuid.UUID) (int, error) {
|
|
||||||
// Get items without asset IDs
|
|
||||||
items, err := svc.repo.Items.GetAllZeroAssetID(ctx, gid)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch assign
|
|
||||||
highest := svc.repo.Items.GetHighestAssetID(ctx, gid)
|
|
||||||
for _, item := range items {
|
|
||||||
highest++
|
|
||||||
_ = svc.repo.Items.Update(ctx, item.ID, repo.ItemUpdate{
|
|
||||||
AssetID: &highest,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(items), nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Event Publishing
|
|
||||||
|
|
||||||
Services can publish events to the event bus:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (svc *ItemService) Delete(ctx Context, id uuid.UUID) error {
|
|
||||||
err := svc.repo.Items.Delete(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish event for notifications
|
|
||||||
svc.repo.Bus.Publish(eventbus.Event{
|
|
||||||
Type: "item.deleted",
|
|
||||||
Data: map[string]interface{}{"id": id},
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Service Aggregation
|
|
||||||
|
|
||||||
All services are bundled in `all.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type AllServices struct {
|
|
||||||
User *UserService
|
|
||||||
Group *GroupService
|
|
||||||
Items *ItemService
|
|
||||||
// ... other services
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(repos *repo.AllRepos, filepath string) *AllServices {
|
|
||||||
return &AllServices{
|
|
||||||
User: &UserService{repo: repos},
|
|
||||||
Items: &ItemService{repo: repos, filepath: filepath},
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Accessed in handlers via:**
|
|
||||||
```go
|
|
||||||
ctrl.svc.Items.Create(ctx, itemData)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Working with Services from Handlers
|
|
||||||
|
|
||||||
Handlers call services, not repositories directly:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// In backend/app/api/handlers/v1/v1_ctrl_items.go
|
|
||||||
func (ctrl *V1Controller) HandleItemCreate() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
var itemData repo.ItemCreate
|
|
||||||
if err := server.Decode(r, &itemData); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get context with group/user IDs
|
|
||||||
ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r))
|
|
||||||
|
|
||||||
// Call service (not repository)
|
|
||||||
item, err := ctrl.svc.Items.Create(ctx, itemData)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.JSON(w, item, http.StatusCreated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Services
|
|
||||||
|
|
||||||
Service tests mock repositories using interfaces:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestItemService_Create(t *testing.T) {
|
|
||||||
mockRepo := &mockItemRepo{
|
|
||||||
CreateFunc: func(ctx context.Context, gid uuid.UUID, data repo.ItemCreate) (repo.ItemOut, error) {
|
|
||||||
return repo.ItemOut{ID: uuid.New(), Name: data.Name}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := &ItemService{repo: &repo.AllRepos{Items: mockRepo}}
|
|
||||||
|
|
||||||
ctx := services.Context{GID: uuid.New(), UID: uuid.New()}
|
|
||||||
result, err := svc.Create(ctx, repo.ItemCreate{Name: "Test"})
|
|
||||||
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "Test", result.Name)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Run service tests:**
|
|
||||||
```bash
|
|
||||||
cd backend && go test ./internal/core/services -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a New Service
|
|
||||||
|
|
||||||
### 1. Create Service File
|
|
||||||
|
|
||||||
Create `backend/internal/core/services/service_myentity.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
package services
|
|
||||||
|
|
||||||
type MyEntityService struct {
|
|
||||||
repo *repo.AllRepos
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *MyEntityService) Create(ctx Context, data repo.MyEntityCreate) (repo.MyEntityOut, error) {
|
|
||||||
// Business logic here
|
|
||||||
return svc.repo.MyEntity.Create(ctx, ctx.GID, data)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add to AllServices
|
|
||||||
|
|
||||||
Edit `backend/internal/core/services/all.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type AllServices struct {
|
|
||||||
// ... existing services
|
|
||||||
MyEntity *MyEntityService
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(repos *repo.AllRepos, filepath string) *AllServices {
|
|
||||||
return &AllServices{
|
|
||||||
// ... existing services
|
|
||||||
MyEntity: &MyEntityService{repo: repos},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Use in Handler
|
|
||||||
|
|
||||||
In `backend/app/api/handlers/v1/`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (ctrl *V1Controller) HandleMyEntityCreate() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r))
|
|
||||||
result, err := ctrl.svc.MyEntity.Create(ctx, data)
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Run Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
task generate # If you modified schemas
|
|
||||||
task go:test # Run all tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Service Responsibilities
|
|
||||||
|
|
||||||
**Services should:**
|
|
||||||
- ✅ Contain business logic and validation
|
|
||||||
- ✅ Orchestrate multiple repository calls
|
|
||||||
- ✅ Handle transactions (when needed)
|
|
||||||
- ✅ Publish events for side effects
|
|
||||||
- ✅ Enforce access control and multi-tenancy
|
|
||||||
- ✅ Transform data between API and repository formats
|
|
||||||
|
|
||||||
**Services should NOT:**
|
|
||||||
- ❌ Handle HTTP requests/responses (that's handlers)
|
|
||||||
- ❌ Construct SQL queries (that's repositories)
|
|
||||||
- ❌ Import handler packages (creates circular deps)
|
|
||||||
- ❌ Directly access database (use repositories)
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
1. **Always use `services.Context`** - includes group/user IDs for multi-tenancy
|
|
||||||
2. **Services call repos, handlers call services** - maintains layer separation
|
|
||||||
3. **No direct database access** - always through repositories
|
|
||||||
4. **Business logic goes here** - not in handlers or repositories
|
|
||||||
5. **Test services independently** - mock repository dependencies
|
|
||||||
|
|
||||||
## Common Patterns to Follow
|
|
||||||
|
|
||||||
- **Validation:** Check business rules before calling repository
|
|
||||||
- **Error wrapping:** Add context to repository errors
|
|
||||||
- **Logging:** Use `log.Ctx(ctx)` for contextual logging
|
|
||||||
- **Transactions:** Use `repo.WithTx()` for multi-step operations
|
|
||||||
- **Events:** Publish to event bus for notifications/side effects
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
---
|
|
||||||
applyTo: 'backend/internal/data/**/*'
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
# Backend Data Layer Instructions (`/backend/internal/data/`)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This directory contains the data access layer using **Ent ORM** (entity framework). It follows a clear separation between schema definitions, generated code, and repository implementations.
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/internal/data/
|
|
||||||
├── ent/ # Ent ORM generated code (DO NOT EDIT)
|
|
||||||
│ ├── schema/ # Schema definitions (EDIT THESE)
|
|
||||||
│ │ ├── item.go # Item entity schema
|
|
||||||
│ │ ├── user.go # User entity schema
|
|
||||||
│ │ ├── location.go # Location entity schema
|
|
||||||
│ │ ├── label.go # Label entity schema
|
|
||||||
│ │ └── mixins/ # Reusable schema mixins
|
|
||||||
│ ├── *.go # Generated entity code
|
|
||||||
│ └── migrate/ # Generated migrations
|
|
||||||
├── repo/ # Repository pattern implementations
|
|
||||||
│ ├── repos_all.go # Aggregates all repositories
|
|
||||||
│ ├── repo_items.go # Item repository
|
|
||||||
│ ├── repo_users.go # User repository
|
|
||||||
│ ├── repo_locations.go # Location repository
|
|
||||||
│ └── *_test.go # Repository tests
|
|
||||||
├── migrations/ # Manual SQL migrations
|
|
||||||
│ ├── sqlite3/ # SQLite-specific migrations
|
|
||||||
│ └── postgres/ # PostgreSQL-specific migrations
|
|
||||||
└── types/ # Custom data types
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ent ORM Workflow
|
|
||||||
|
|
||||||
### 1. Defining Schemas (`ent/schema/`)
|
|
||||||
|
|
||||||
**ALWAYS edit schema files here** - these define your database entities:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Example: backend/internal/data/ent/schema/item.go
|
|
||||||
type Item struct {
|
|
||||||
ent.Schema
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Item) Fields() []ent.Field {
|
|
||||||
return []ent.Field{
|
|
||||||
field.String("name").NotEmpty(),
|
|
||||||
field.Int("quantity").Default(1),
|
|
||||||
field.Bool("archived").Default(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Item) Edges() []ent.Edge {
|
|
||||||
return []ent.Edge{
|
|
||||||
edge.From("location", Location.Type).Ref("items").Unique(),
|
|
||||||
edge.From("labels", Label.Type).Ref("items"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Item) Indexes() []ent.Index {
|
|
||||||
return []ent.Index{
|
|
||||||
index.Fields("name"),
|
|
||||||
index.Fields("archived"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Common schema patterns:**
|
|
||||||
- Use `mixins.BaseMixin{}` for `id`, `created_at`, `updated_at` fields
|
|
||||||
- Use `mixins.DetailsMixin{}` for `name` and `description` fields
|
|
||||||
- Use `GroupMixin{ref: "items"}` to link entities to groups
|
|
||||||
- Add indexes for frequently queried fields
|
|
||||||
|
|
||||||
### 2. Generating Code
|
|
||||||
|
|
||||||
**After modifying any schema file, ALWAYS run:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
task generate
|
|
||||||
```
|
|
||||||
|
|
||||||
This:
|
|
||||||
1. Runs `go generate ./...` in `backend/internal/` (generates Ent code)
|
|
||||||
2. Generates Swagger docs from API handlers
|
|
||||||
3. Generates TypeScript types for frontend
|
|
||||||
|
|
||||||
**Generated files you'll see:**
|
|
||||||
- `ent/*.go` - Entity types, builders, queries
|
|
||||||
- `ent/migrate/migrate.go` - Auto migrations
|
|
||||||
- `ent/predicate/predicate.go` - Query predicates
|
|
||||||
|
|
||||||
**NEVER edit generated files directly** - changes will be overwritten.
|
|
||||||
|
|
||||||
### 3. Using Generated Code in Repositories
|
|
||||||
|
|
||||||
Repositories in `repo/` use the generated Ent client:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Example: backend/internal/data/repo/repo_items.go
|
|
||||||
type ItemsRepository struct {
|
|
||||||
db *ent.Client
|
|
||||||
bus *eventbus.EventBus
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCreate) (ItemOut, error) {
|
|
||||||
entity, err := r.db.Item.Create().
|
|
||||||
SetName(data.Name).
|
|
||||||
SetQuantity(data.Quantity).
|
|
||||||
SetGroupID(gid).
|
|
||||||
Save(ctx)
|
|
||||||
|
|
||||||
return mapToItemOut(entity), err
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Repository Pattern
|
|
||||||
|
|
||||||
### Structure
|
|
||||||
|
|
||||||
Each entity typically has:
|
|
||||||
- **Repository struct** (`ItemsRepository`) - holds DB client and dependencies
|
|
||||||
- **Input types** (`ItemCreate`, `ItemUpdate`) - API input DTOs
|
|
||||||
- **Output types** (`ItemOut`, `ItemSummary`) - API response DTOs
|
|
||||||
- **Query types** (`ItemQuery`) - search/filter parameters
|
|
||||||
- **Mapper functions** (`mapToItemOut`) - converts Ent entities to output DTOs
|
|
||||||
|
|
||||||
### Key Methods
|
|
||||||
|
|
||||||
Repositories typically implement:
|
|
||||||
- `Create(ctx, gid, input)` - Create new entity
|
|
||||||
- `Get(ctx, id)` - Get single entity by ID
|
|
||||||
- `GetAll(ctx, gid, query)` - Query with pagination/filters
|
|
||||||
- `Update(ctx, id, input)` - Update entity
|
|
||||||
- `Delete(ctx, id)` - Delete entity
|
|
||||||
|
|
||||||
### Working with Ent Queries
|
|
||||||
|
|
||||||
**Loading relationships (edges):**
|
|
||||||
```go
|
|
||||||
items, err := r.db.Item.Query().
|
|
||||||
WithLocation(). // Load location edge
|
|
||||||
WithLabels(). // Load labels edge
|
|
||||||
WithChildren(). // Load child items
|
|
||||||
Where(item.GroupIDEQ(gid)).
|
|
||||||
All(ctx)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Filtering:**
|
|
||||||
```go
|
|
||||||
query := r.db.Item.Query().
|
|
||||||
Where(
|
|
||||||
item.GroupIDEQ(gid),
|
|
||||||
item.ArchivedEQ(false),
|
|
||||||
item.NameContainsFold(search),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ordering and pagination:**
|
|
||||||
```go
|
|
||||||
items, err := query.
|
|
||||||
Order(ent.Desc(item.FieldCreatedAt)).
|
|
||||||
Limit(pageSize).
|
|
||||||
Offset((page - 1) * pageSize).
|
|
||||||
All(ctx)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Workflows
|
|
||||||
|
|
||||||
### Adding a New Entity
|
|
||||||
|
|
||||||
1. **Create schema:** `backend/internal/data/ent/schema/myentity.go`
|
|
||||||
2. **Run:** `task generate` (generates Ent code)
|
|
||||||
3. **Create repository:** `backend/internal/data/repo/repo_myentity.go`
|
|
||||||
4. **Add to AllRepos:** Edit `repo/repos_all.go` to include new repo
|
|
||||||
5. **Run tests:** `task go:test`
|
|
||||||
|
|
||||||
### Adding Fields to Existing Entity
|
|
||||||
|
|
||||||
1. **Edit schema:** `backend/internal/data/ent/schema/item.go`
|
|
||||||
```go
|
|
||||||
field.String("new_field").Optional()
|
|
||||||
```
|
|
||||||
2. **Run:** `task generate`
|
|
||||||
3. **Update repository:** Add field to input/output types in `repo/repo_items.go`
|
|
||||||
4. **Update mappers:** Ensure mapper functions handle new field
|
|
||||||
5. **Run tests:** `task go:test`
|
|
||||||
|
|
||||||
### Adding Relationships (Edges)
|
|
||||||
|
|
||||||
1. **Edit both schemas:**
|
|
||||||
```go
|
|
||||||
// In item.go
|
|
||||||
edge.From("location", Location.Type).Ref("items").Unique()
|
|
||||||
|
|
||||||
// In location.go
|
|
||||||
edge.To("items", Item.Type)
|
|
||||||
```
|
|
||||||
2. **Run:** `task generate`
|
|
||||||
3. **Use in queries:** `.WithLocation()` to load the edge
|
|
||||||
4. **Run tests:** `task go:test`
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Repository tests use `enttest` for in-memory SQLite:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestItemRepo(t *testing.T) {
|
|
||||||
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&_fk=1")
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
repo := &ItemsRepository{db: client}
|
|
||||||
// Test methods...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Run repository tests:**
|
|
||||||
```bash
|
|
||||||
cd backend && go test ./internal/data/repo -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
1. **ALWAYS run `task generate` after schema changes** - builds will fail otherwise
|
|
||||||
2. **NEVER edit files in `ent/` except `ent/schema/`** - they're generated
|
|
||||||
3. **Use repositories, not raw Ent queries in services/handlers** - maintains separation
|
|
||||||
4. **Include `group_id` in all queries** - ensures multi-tenancy
|
|
||||||
5. **Use `.WithX()` to load edges** - avoids N+1 queries
|
|
||||||
6. **Test with both SQLite and PostgreSQL** - CI tests both
|
|
||||||
|
|
||||||
## Common Errors
|
|
||||||
|
|
||||||
- **"undefined: ent.ItemX"** → Run `task generate` after schema changes
|
|
||||||
- **Migration conflicts** → Check `migrations/` for manual migration files
|
|
||||||
- **Foreign key violations** → Ensure edges are properly defined in both schemas
|
|
||||||
- **Slow queries** → Add indexes in schema `Indexes()` method
|
|
||||||
157
.github/instructions/code.instructions.md
vendored
@@ -1,157 +0,0 @@
|
|||||||
# Homebox Repository Instructions for Coding Agents
|
|
||||||
|
|
||||||
## Repository Overview
|
|
||||||
|
|
||||||
**Type**: Full-stack home inventory management web app (monorepo)
|
|
||||||
**Size**: ~265 Go files, ~371 TypeScript/Vue files
|
|
||||||
**Build Tool**: Task (Taskfile.yml) - **ALWAYS use `task` commands**
|
|
||||||
**Database**: SQLite (default) or PostgreSQL
|
|
||||||
|
|
||||||
### Stack
|
|
||||||
- **Backend** (`/backend`): Go 1.24+, Chi router, Ent ORM, port 7745
|
|
||||||
- **Frontend** (`/frontend`): Nuxt 4, Vue 3, TypeScript, Tailwind CSS, pnpm 9.1.4+, dev proxies to backend
|
|
||||||
|
|
||||||
## Critical Build & Validation Commands
|
|
||||||
|
|
||||||
### Initial Setup (Run Once)
|
|
||||||
```bash
|
|
||||||
task setup # Installs swag, goose, Go deps, pnpm deps
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Generation (Required Before Backend Work)
|
|
||||||
```bash
|
|
||||||
task generate # Generates Ent ORM, Swagger docs, TypeScript types
|
|
||||||
```
|
|
||||||
**ALWAYS run after**: schema changes, API handler changes, before backend server/tests
|
|
||||||
**Note**: "TypeSpecDef is nil" warnings are normal - ignore them
|
|
||||||
|
|
||||||
### Backend Commands
|
|
||||||
```bash
|
|
||||||
task go:build # Build binary (60-90s)
|
|
||||||
task go:test # Unit tests (5-10s)
|
|
||||||
task go:lint # golangci-lint (6m timeout in CI)
|
|
||||||
task go:all # Tidy + lint + test
|
|
||||||
task go:run # Start server (SQLite)
|
|
||||||
task pr # Full PR validation (3-5 min)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend Commands
|
|
||||||
```bash
|
|
||||||
task ui:dev # Dev server port 3000
|
|
||||||
task ui:check # Type checking
|
|
||||||
task ui:fix # eslint --fix + prettier
|
|
||||||
task ui:watch # Vitest watch mode
|
|
||||||
```
|
|
||||||
**Lint**: Max 1 warning in CI (`pnpm run lint:ci`)
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
```bash
|
|
||||||
task test:ci # Integration tests (15-30s + startup)
|
|
||||||
task test:e2e # Playwright E2E (60s+ per shard, needs playwright install)
|
|
||||||
task pr # Full PR validation: generate + go:all + ui:check + ui:fix + test:ci (3-5 min)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Key Root Files
|
|
||||||
- `Taskfile.yml` - All commands (always use `task`)
|
|
||||||
- `docker-compose.yml`, `Dockerfile*` - Docker configs
|
|
||||||
- `CONTRIBUTING.md` - Contribution guidelines
|
|
||||||
|
|
||||||
### Backend Structure (`/backend`)
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
├── app/
|
|
||||||
│ ├── api/ # Main API application
|
|
||||||
│ │ ├── main.go # Entry point
|
|
||||||
│ │ ├── routes.go # Route definitions
|
|
||||||
│ │ ├── handlers/ # HTTP handlers (v1 API)
|
|
||||||
│ │ ├── static/ # Swagger docs, embedded frontend
|
|
||||||
│ │ └── providers/ # Service providers
|
|
||||||
│ └── tools/
|
|
||||||
│ └── typegen/ # TypeScript type generation tool
|
|
||||||
├── internal/
|
|
||||||
│ ├── core/
|
|
||||||
│ │ └── services/ # Business logic layer
|
|
||||||
│ ├── data/
|
|
||||||
│ │ ├── ent/ # Ent ORM generated code + schemas
|
|
||||||
│ │ │ └── schema/ # Schema definitions (edit these)
|
|
||||||
│ │ └── repo/ # Repository pattern implementations
|
|
||||||
│ ├── sys/ # System utilities (config, validation)
|
|
||||||
│ └── web/ # Web middleware
|
|
||||||
├── pkgs/ # Reusable packages
|
|
||||||
├── go.mod, go.sum # Go dependencies
|
|
||||||
└── .golangci.yml # Linter configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
**Patterns**: Schema/API changes → edit source → `task generate`. Never edit generated code in `ent/`.
|
|
||||||
|
|
||||||
### Frontend Structure (`/frontend`)
|
|
||||||
```
|
|
||||||
frontend/
|
|
||||||
├── app.vue # Root component
|
|
||||||
├── nuxt.config.ts # Nuxt configuration
|
|
||||||
├── package.json # Frontend dependencies
|
|
||||||
├── components/ # Vue components (auto-imported)
|
|
||||||
├── pages/ # File-based routing
|
|
||||||
├── layouts/ # Layout components
|
|
||||||
├── composables/ # Vue composables (auto-imported)
|
|
||||||
├── stores/ # Pinia state stores
|
|
||||||
├── lib/
|
|
||||||
│ └── api/
|
|
||||||
│ └── types/ # Generated TypeScript API types
|
|
||||||
├── locales/ # i18n translations
|
|
||||||
├── test/ # Vitest + Playwright tests
|
|
||||||
├── eslint.config.mjs # ESLint configuration
|
|
||||||
└── tailwind.config.js # Tailwind configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
**Patterns**: Auto-imports for `components/` and `composables/`. API types auto-generated - never edit manually.
|
|
||||||
|
|
||||||
## CI/CD Workflows
|
|
||||||
|
|
||||||
PR checks (`.github/workflows/pull-requests.yaml`) on `main`/`vnext`:
|
|
||||||
1. **Backend**: Go 1.24, golangci-lint, `task go:build`, `task go:coverage`
|
|
||||||
2. **Frontend**: Lint (max 1 warning), typecheck, `task test:ci` (SQLite + PostgreSQL v15-17)
|
|
||||||
3. **E2E**: 4 sharded Playwright runs (60min timeout)
|
|
||||||
|
|
||||||
All must pass before merge.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
1. **Missing tools**: Run `task setup` first (installs swag, goose, deps)
|
|
||||||
2. **Stale generated code**: Always `task generate` after schema/API changes
|
|
||||||
3. **Test failures**: Integration tests may fail first run (race condition) - retry
|
|
||||||
4. **Port in use**: Backend uses 7745 - kill existing process
|
|
||||||
5. **SQLite locked**: Delete `.data/homebox.db-*` files
|
|
||||||
6. **Clean build**: `rm -rf build/ backend/app/api/static/public/ frontend/.nuxt`
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Backend defaults in `Taskfile.yml`:
|
|
||||||
- `HBOX_LOG_LEVEL=debug`
|
|
||||||
- `HBOX_DATABASE_DRIVER=sqlite3` (or `postgres`)
|
|
||||||
- `HBOX_DATABASE_SQLITE_PATH=.data/homebox.db?_pragma=busy_timeout=1000&_pragma=journal_mode=WAL&_fk=1`
|
|
||||||
- PostgreSQL: `HBOX_DATABASE_*` vars for username/password/host/port/database
|
|
||||||
|
|
||||||
## Validation Checklist
|
|
||||||
|
|
||||||
Before PR:
|
|
||||||
- [ ] `task generate` after schema/API changes
|
|
||||||
- [ ] `task pr` passes (includes lint, test, typecheck)
|
|
||||||
- [ ] No build artifacts committed (check `.gitignore`)
|
|
||||||
- [ ] Code matches existing patterns
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
**Dev environment**: `task go:run` (terminal 1) + `task ui:dev` (terminal 2)
|
|
||||||
|
|
||||||
**API changes**: Edit handlers → add Swagger comments → `task generate` → `task go:build` → `task go:test`
|
|
||||||
|
|
||||||
**Schema changes**: Edit `ent/schema/*.go` → `task generate` → update repo methods → `task go:test`
|
|
||||||
|
|
||||||
**Specific tests**: `cd backend && go test ./path -v` or `cd frontend && pnpm run test:watch`
|
|
||||||
|
|
||||||
## Trust These Instructions
|
|
||||||
|
|
||||||
Instructions are validated and current. Only explore further if info is incomplete, incorrect, or you encounter undocumented errors. Use `task --list-all` for all commands.
|
|
||||||
480
.github/instructions/frontend.instructions.md
vendored
@@ -1,480 +0,0 @@
|
|||||||
---
|
|
||||||
applyTo: 'frontend/**/*'
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
# Frontend Components & Pages Instructions (`/frontend/`)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The frontend is a Nuxt 4 application with Vue 3 and TypeScript. It uses auto-imports for components and composables, file-based routing, and generated TypeScript types from the backend API.
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/
|
|
||||||
├── components/ # Vue components (auto-imported)
|
|
||||||
│ ├── Item/ # Item-related components
|
|
||||||
│ ├── Location/ # Location components
|
|
||||||
│ ├── Label/ # Label components
|
|
||||||
│ ├── Form/ # Form components
|
|
||||||
│ └── ui/ # Shadcn-vue UI components
|
|
||||||
├── pages/ # File-based routes (auto-routing)
|
|
||||||
│ ├── index.vue # Home page (/)
|
|
||||||
│ ├── items.vue # Items list (/items)
|
|
||||||
│ ├── item/
|
|
||||||
│ │ └── [id].vue # Item detail (/item/:id)
|
|
||||||
│ ├── locations.vue # Locations list (/locations)
|
|
||||||
│ └── profile.vue # User profile (/profile)
|
|
||||||
├── composables/ # Vue composables (auto-imported)
|
|
||||||
│ ├── use-api.ts # API client wrapper
|
|
||||||
│ ├── use-auth.ts # Authentication
|
|
||||||
│ └── use-user-api.ts # User API helpers
|
|
||||||
├── stores/ # Pinia state management
|
|
||||||
│ ├── auth.ts # Auth state
|
|
||||||
│ └── preferences.ts # User preferences
|
|
||||||
├── lib/
|
|
||||||
│ └── api/
|
|
||||||
│ └── types/ # Generated TypeScript types (DO NOT EDIT)
|
|
||||||
├── layouts/ # Layout components
|
|
||||||
│ └── default.vue # Default layout
|
|
||||||
├── locales/ # i18n translations
|
|
||||||
├── test/ # Tests (Vitest + Playwright)
|
|
||||||
└── nuxt.config.ts # Nuxt configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-Imports
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
Components in `components/` are **automatically imported** - no import statement needed:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- components/Item/Card.vue -->
|
|
||||||
<template>
|
|
||||||
<div class="item-card">{{ item.name }}</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- pages/items.vue - NO import needed -->
|
|
||||||
<template>
|
|
||||||
<ItemCard :item="item" />
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Naming convention:** Nested path becomes component name
|
|
||||||
- `components/Item/Card.vue` → `<ItemCard />`
|
|
||||||
- `components/Form/TextField.vue` → `<FormTextField />`
|
|
||||||
|
|
||||||
### Composables
|
|
||||||
|
|
||||||
Composables in `composables/` are **automatically imported**:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// composables/use-items.ts
|
|
||||||
export function useItems() {
|
|
||||||
const api = useUserApi()
|
|
||||||
|
|
||||||
async function getItems() {
|
|
||||||
const { data } = await api.items.getAll()
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
return { getItems }
|
|
||||||
}
|
|
||||||
|
|
||||||
// pages/items.vue - NO import needed
|
|
||||||
const { getItems } = useItems()
|
|
||||||
const items = await getItems()
|
|
||||||
```
|
|
||||||
|
|
||||||
## File-Based Routing
|
|
||||||
|
|
||||||
Pages in `pages/` automatically become routes:
|
|
||||||
|
|
||||||
```
|
|
||||||
pages/index.vue → /
|
|
||||||
pages/items.vue → /items
|
|
||||||
pages/item/[id].vue → /item/:id
|
|
||||||
pages/locations.vue → /locations
|
|
||||||
pages/location/[id].vue → /location/:id
|
|
||||||
pages/profile.vue → /profile
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dynamic Routes
|
|
||||||
|
|
||||||
Use square brackets for dynamic segments:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- pages/item/[id].vue -->
|
|
||||||
<script setup lang="ts">
|
|
||||||
const route = useRoute()
|
|
||||||
const id = route.params.id
|
|
||||||
|
|
||||||
const { data: item } = await useUserApi().items.getOne(id)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<h1>{{ item.name }}</h1>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Integration
|
|
||||||
|
|
||||||
### Generated Types
|
|
||||||
|
|
||||||
API types are auto-generated from backend Swagger docs:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// lib/api/types/data-contracts.ts (GENERATED - DO NOT EDIT)
|
|
||||||
export interface ItemOut {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
quantity: number
|
|
||||||
createdAt: Date | string
|
|
||||||
updatedAt: Date | string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ItemCreate {
|
|
||||||
name: string
|
|
||||||
quantity?: number
|
|
||||||
locationId?: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Regenerate after backend API changes:**
|
|
||||||
```bash
|
|
||||||
task generate # Runs in backend, updates frontend/lib/api/types/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using the API Client
|
|
||||||
|
|
||||||
The `useUserApi()` composable provides typed API access:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ItemCreate, ItemOut } from '~/lib/api/types/data-contracts'
|
|
||||||
|
|
||||||
const api = useUserApi()
|
|
||||||
|
|
||||||
// GET all items
|
|
||||||
const { data: items } = await api.items.getAll({
|
|
||||||
q: 'search term',
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET single item
|
|
||||||
const { data: item } = await api.items.getOne(itemId)
|
|
||||||
|
|
||||||
// POST create item
|
|
||||||
const newItem: ItemCreate = {
|
|
||||||
name: 'New Item',
|
|
||||||
quantity: 1
|
|
||||||
}
|
|
||||||
const { data: created } = await api.items.create(newItem)
|
|
||||||
|
|
||||||
// PUT update item
|
|
||||||
const { data: updated } = await api.items.update(itemId, {
|
|
||||||
quantity: 5
|
|
||||||
})
|
|
||||||
|
|
||||||
// DELETE item
|
|
||||||
await api.items.delete(itemId)
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Patterns
|
|
||||||
|
|
||||||
### Standard Vue 3 Composition API
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import type { ItemOut } from '~/lib/api/types/data-contracts'
|
|
||||||
|
|
||||||
// Props
|
|
||||||
interface Props {
|
|
||||||
item: ItemOut
|
|
||||||
editable?: boolean
|
|
||||||
}
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
// Emits
|
|
||||||
interface Emits {
|
|
||||||
(e: 'update', item: ItemOut): void
|
|
||||||
(e: 'delete', id: string): void
|
|
||||||
}
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
// State
|
|
||||||
const isEditing = ref(false)
|
|
||||||
const localItem = ref({ ...props.item })
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
const displayName = computed(() => {
|
|
||||||
return props.item.name.toUpperCase()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
function handleSave() {
|
|
||||||
emit('update', localItem.value)
|
|
||||||
isEditing.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="item-card">
|
|
||||||
<h3>{{ displayName }}</h3>
|
|
||||||
<p v-if="!isEditing">Quantity: {{ item.quantity }}</p>
|
|
||||||
|
|
||||||
<input
|
|
||||||
v-if="isEditing"
|
|
||||||
v-model.number="localItem.quantity"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button v-if="editable" @click="isEditing = !isEditing">
|
|
||||||
{{ isEditing ? 'Cancel' : 'Edit' }}
|
|
||||||
</button>
|
|
||||||
<button v-if="isEditing" @click="handleSave">Save</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.item-card {
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Pinia Stores
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useAuthStore } from '~/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
// Access state
|
|
||||||
const user = computed(() => authStore.user)
|
|
||||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
|
||||||
|
|
||||||
// Call actions
|
|
||||||
async function logout() {
|
|
||||||
await authStore.logout()
|
|
||||||
navigateTo('/login')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Form Handling
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useForm } from 'vee-validate'
|
|
||||||
import type { ItemCreate } from '~/lib/api/types/data-contracts'
|
|
||||||
|
|
||||||
const api = useUserApi()
|
|
||||||
|
|
||||||
const { values, errors, handleSubmit } = useForm<ItemCreate>({
|
|
||||||
initialValues: {
|
|
||||||
name: '',
|
|
||||||
quantity: 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (values) => {
|
|
||||||
try {
|
|
||||||
const { data } = await api.items.create(values)
|
|
||||||
navigateTo(`/item/${data.id}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create item:', error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form @submit.prevent="onSubmit">
|
|
||||||
<input v-model="values.name" type="text" placeholder="Item name" />
|
|
||||||
<span v-if="errors.name">{{ errors.name }}</span>
|
|
||||||
|
|
||||||
<input v-model.number="values.quantity" type="number" />
|
|
||||||
<span v-if="errors.quantity">{{ errors.quantity }}</span>
|
|
||||||
|
|
||||||
<button type="submit">Create Item</button>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
### Tailwind CSS
|
|
||||||
|
|
||||||
The project uses Tailwind CSS for styling:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div class="flex items-center justify-between p-4 bg-white rounded-lg shadow-md">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">{{ item.name }}</h3>
|
|
||||||
<span class="text-sm text-gray-500">Qty: {{ item.quantity }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Shadcn-vue Components
|
|
||||||
|
|
||||||
UI components from `components/ui/` (Shadcn-vue):
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<h3>{{ item.name }}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p>{{ item.description }}</p>
|
|
||||||
<Button @click="handleEdit">Edit</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Vitest (Unit/Integration)
|
|
||||||
|
|
||||||
Tests use Vitest with the backend API running:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// test/items.test.ts
|
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { useUserApi } from '~/composables/use-user-api'
|
|
||||||
|
|
||||||
describe('Items API', () => {
|
|
||||||
it('should create and fetch item', async () => {
|
|
||||||
const api = useUserApi()
|
|
||||||
|
|
||||||
// Create item
|
|
||||||
const { data: created } = await api.items.create({
|
|
||||||
name: 'Test Item',
|
|
||||||
quantity: 1
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(created.name).toBe('Test Item')
|
|
||||||
|
|
||||||
// Fetch item
|
|
||||||
const { data: fetched } = await api.items.getOne(created.id)
|
|
||||||
expect(fetched.id).toBe(created.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Run tests:**
|
|
||||||
```bash
|
|
||||||
task ui:watch # Watch mode
|
|
||||||
cd frontend && pnpm run test:ci # CI mode
|
|
||||||
```
|
|
||||||
|
|
||||||
### Playwright (E2E)
|
|
||||||
|
|
||||||
E2E tests in `test/`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// test/e2e/items.spec.ts
|
|
||||||
import { test, expect } from '@playwright/test'
|
|
||||||
|
|
||||||
test('should create new item', async ({ page }) => {
|
|
||||||
await page.goto('/items')
|
|
||||||
|
|
||||||
await page.click('button:has-text("New Item")')
|
|
||||||
await page.fill('input[name="name"]', 'Test Item')
|
|
||||||
await page.fill('input[name="quantity"]', '5')
|
|
||||||
await page.click('button:has-text("Save")')
|
|
||||||
|
|
||||||
await expect(page.locator('text=Test Item')).toBeVisible()
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Run E2E tests:**
|
|
||||||
```bash
|
|
||||||
task test:e2e # Full E2E suite
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding a New Feature
|
|
||||||
|
|
||||||
### 1. Update Backend API
|
|
||||||
|
|
||||||
Make backend changes first (schema, service, handler):
|
|
||||||
```bash
|
|
||||||
# Edit backend files
|
|
||||||
task generate # Regenerates TypeScript types
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create Component
|
|
||||||
|
|
||||||
Create `components/MyFeature/Card.vue`:
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { MyFeatureOut } from '~/lib/api/types/data-contracts'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
feature: MyFeatureOut
|
|
||||||
}
|
|
||||||
defineProps<Props>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>{{ feature.name }}</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Create Page
|
|
||||||
|
|
||||||
Create `pages/my-feature/[id].vue`:
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
const route = useRoute()
|
|
||||||
const api = useUserApi()
|
|
||||||
|
|
||||||
const { data: feature } = await api.myFeature.getOne(route.params.id)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<MyFeatureCard :feature="feature" />
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
task ui:check # Type checking
|
|
||||||
task ui:fix # Linting
|
|
||||||
task ui:watch # Run tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
1. **Never edit generated types** - `lib/api/types/` is auto-generated, run `task generate` after backend changes
|
|
||||||
2. **No manual imports for components/composables** - auto-imported from `components/` and `composables/`
|
|
||||||
3. **Use TypeScript** - all `.vue` files use `<script setup lang="ts">`
|
|
||||||
4. **Follow file-based routing** - pages in `pages/` become routes automatically
|
|
||||||
5. **Use `useUserApi()` for API calls** - provides typed, authenticated API client
|
|
||||||
6. **Max 1 linting warning in CI** - run `task ui:fix` before committing
|
|
||||||
7. **Test with backend running** - integration tests need API server
|
|
||||||
|
|
||||||
## Common Issues
|
|
||||||
|
|
||||||
- **"Type not found"** → Run `task generate` to regenerate types from backend
|
|
||||||
- **Component not found** → Check naming (nested path = component name)
|
|
||||||
- **API call fails** → Ensure backend is running (`task go:run`)
|
|
||||||
- **Lint errors** → Run `task ui:fix` to auto-fix
|
|
||||||
- **Type errors** → Run `task ui:check` for detailed errors
|
|
||||||
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
|
||||||
|
```
|
||||||
BIN
.github/screenshots/1.png
vendored
|
Before Width: | Height: | Size: 53 KiB |
BIN
.github/screenshots/10.png
vendored
|
Before Width: | Height: | Size: 29 KiB |
BIN
.github/screenshots/2.png
vendored
|
Before Width: | Height: | Size: 81 KiB |
BIN
.github/screenshots/3.png
vendored
|
Before Width: | Height: | Size: 87 KiB |
BIN
.github/screenshots/4.png
vendored
|
Before Width: | Height: | Size: 83 KiB |
BIN
.github/screenshots/5.png
vendored
|
Before Width: | Height: | Size: 95 KiB |
BIN
.github/screenshots/6.png
vendored
|
Before Width: | Height: | Size: 61 KiB |
BIN
.github/screenshots/7.png
vendored
|
Before Width: | Height: | Size: 108 KiB |
BIN
.github/screenshots/8.png
vendored
|
Before Width: | Height: | Size: 85 KiB |
BIN
.github/screenshots/9.png
vendored
|
Before Width: | Height: | Size: 86 KiB |
8
.github/screenshots/readme.md
vendored
@@ -1,8 +0,0 @@
|
|||||||
# Screenshots
|
|
||||||
|
|
||||||
These screenshots are taken from our public [Demo](https://demo.homebox.software) instance.
|
|
||||||
Note that whilst we will make every effort to ensure that these are maintained and updated, they may be outdated or missing functionality and we would always advise reviewing our demo instances:
|
|
||||||
|
|
||||||
- [Demo](https://demo.homebox.software)
|
|
||||||
- [Nightly](https://nightly.homebox.software)
|
|
||||||
- [VNext](https://vnext.homebox.software/)
|
|
||||||
213
.github/scripts/update_currencies.py
vendored
@@ -1,213 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
from urllib3.util.retry import Retry
|
|
||||||
|
|
||||||
API_URL = 'https://restcountries.com/v3.1/all?fields=name,common,currencies'
|
|
||||||
# Default to a pinned commit for supply-chain security
|
|
||||||
DEFAULT_ISO_4217_URL = 'https://raw.githubusercontent.com/datasets/currency-codes/052b3088938ba32028a14e75040c286c5e142145/data/codes-all.csv'
|
|
||||||
ISO_4217_URL = os.environ.get('ISO_4217_URL', DEFAULT_ISO_4217_URL)
|
|
||||||
SAVE_PATH = Path('backend/internal/core/currencies/currencies.json')
|
|
||||||
TIMEOUT = 10 # seconds
|
|
||||||
|
|
||||||
# Known currency decimal overrides
|
|
||||||
CURRENCY_DECIMAL_OVERRIDES = {
|
|
||||||
"BTC": 8, # Bitcoin uses 8 decimal places
|
|
||||||
"JPY": 0, # Japanese Yen has no decimal places
|
|
||||||
"BHD": 3, # Bahraini Dinar uses 3 decimal places
|
|
||||||
}
|
|
||||||
DEFAULT_DECIMALS = 2
|
|
||||||
MIN_DECIMALS = 0
|
|
||||||
MAX_DECIMALS = 6
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s %(levelname)s: %(message)s'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_currency_decimals(code, iso_data):
|
|
||||||
"""
|
|
||||||
Get the decimal places for a currency code.
|
|
||||||
Checks overrides first, then ISO data, then uses default.
|
|
||||||
Clamps result to safe range [MIN_DECIMALS, MAX_DECIMALS].
|
|
||||||
"""
|
|
||||||
# Normalize the input code
|
|
||||||
normalized_code = (code or "").strip().upper()
|
|
||||||
|
|
||||||
# First check overrides
|
|
||||||
if normalized_code in CURRENCY_DECIMAL_OVERRIDES:
|
|
||||||
decimals = CURRENCY_DECIMAL_OVERRIDES[normalized_code]
|
|
||||||
# Then check ISO data
|
|
||||||
elif normalized_code in iso_data:
|
|
||||||
decimals = iso_data[normalized_code]
|
|
||||||
# Finally use default
|
|
||||||
else:
|
|
||||||
decimals = DEFAULT_DECIMALS
|
|
||||||
|
|
||||||
# Ensure it's an integer and clamp to safe range
|
|
||||||
try:
|
|
||||||
decimals = int(decimals)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
decimals = DEFAULT_DECIMALS
|
|
||||||
|
|
||||||
return max(MIN_DECIMALS, min(MAX_DECIMALS, decimals))
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_iso_4217_data():
|
|
||||||
"""
|
|
||||||
Fetch ISO 4217 currency data to get minor units (decimal places).
|
|
||||||
Returns a dict mapping currency code to minor units.
|
|
||||||
"""
|
|
||||||
# Log the resolved URL for transparency
|
|
||||||
logging.info("Fetching ISO 4217 data from: %s", ISO_4217_URL)
|
|
||||||
if not ISO_4217_URL.lower().startswith("https://"):
|
|
||||||
logging.error("Refusing non-HTTPS ISO_4217_URL: %s", ISO_4217_URL)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
session = requests.Session()
|
|
||||||
retries = Retry(
|
|
||||||
total=3,
|
|
||||||
backoff_factor=1,
|
|
||||||
status_forcelist=[429, 500, 502, 503, 504],
|
|
||||||
allowed_methods=frozenset(['GET'])
|
|
||||||
)
|
|
||||||
session.mount('https://', HTTPAdapter(max_retries=retries))
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Add Accept header for CSV content
|
|
||||||
headers = {'Accept': 'text/csv'}
|
|
||||||
resp = session.get(ISO_4217_URL, timeout=TIMEOUT, headers=headers)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
logging.error("Failed to fetch ISO 4217 data: %s", e)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# Parse CSV data
|
|
||||||
iso_data = {}
|
|
||||||
try:
|
|
||||||
# Decode with utf-8-sig to strip BOM if present
|
|
||||||
csv_content = resp.content.decode('utf-8-sig')
|
|
||||||
csv_reader = csv.DictReader(io.StringIO(csv_content))
|
|
||||||
|
|
||||||
for row in csv_reader:
|
|
||||||
code = row.get('AlphabeticCode', '').strip()
|
|
||||||
minor_unit = row.get('MinorUnit', '').strip()
|
|
||||||
|
|
||||||
if code and minor_unit != 'N.A.':
|
|
||||||
try:
|
|
||||||
# Convert minor unit to int (decimal places)
|
|
||||||
iso_data[code] = int(minor_unit) if minor_unit.isdigit() else 2
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
iso_data[code] = 2 # Default to 2 if parsing fails
|
|
||||||
|
|
||||||
logging.info("Successfully loaded decimal data for %d currencies from ISO 4217", len(iso_data))
|
|
||||||
return iso_data
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error("Failed to parse ISO 4217 CSV data: %s", e)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_currencies():
|
|
||||||
# First, fetch ISO 4217 data for decimal places
|
|
||||||
iso_data = fetch_iso_4217_data()
|
|
||||||
|
|
||||||
session = requests.Session()
|
|
||||||
retries = Retry(
|
|
||||||
total=3,
|
|
||||||
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():
|
|
||||||
# Get decimal places using the helper function
|
|
||||||
decimals = get_currency_decimals(code, iso_data)
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
'code': code,
|
|
||||||
'local': country_name,
|
|
||||||
'symbol': info.get('symbol', ''),
|
|
||||||
'name': info.get('name', ''),
|
|
||||||
'decimals': decimals
|
|
||||||
})
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
259
.github/scripts/upgrade-test/README.md
vendored
@@ -1,259 +0,0 @@
|
|||||||
# HomeBox Upgrade Testing Workflow
|
|
||||||
|
|
||||||
This document describes the automated upgrade testing workflow for HomeBox.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The upgrade test workflow is designed to ensure data integrity and functionality when upgrading HomeBox from one version to another. It automatically:
|
|
||||||
|
|
||||||
1. Deploys a stable version of HomeBox
|
|
||||||
2. Creates test data (users, items, locations, labels, notifiers, attachments)
|
|
||||||
3. Upgrades to the latest version from the main branch
|
|
||||||
4. Verifies all data and functionality remain intact
|
|
||||||
|
|
||||||
## Workflow File
|
|
||||||
|
|
||||||
**Location**: `.github/workflows/upgrade-test.yaml`
|
|
||||||
|
|
||||||
## Trigger Conditions
|
|
||||||
|
|
||||||
The workflow runs:
|
|
||||||
- **Daily**: Automatically at 2 AM UTC (via cron schedule)
|
|
||||||
- **Manual**: Can be triggered manually via GitHub Actions UI
|
|
||||||
- **On Push**: When changes are made to the workflow files or test scripts
|
|
||||||
|
|
||||||
## Test Scenarios
|
|
||||||
|
|
||||||
### 1. Environment Setup
|
|
||||||
- Pulls the latest stable HomeBox Docker image from GHCR
|
|
||||||
- Starts the application with test configuration
|
|
||||||
- Ensures the service is healthy and ready
|
|
||||||
|
|
||||||
### 2. Data Creation
|
|
||||||
|
|
||||||
The workflow creates comprehensive test data using the `create-test-data.sh` script:
|
|
||||||
|
|
||||||
#### Users and Groups
|
|
||||||
- **Group 1**: 5 users (user1@homebox.test through user5@homebox.test)
|
|
||||||
- **Group 2**: 2 users (user6@homebox.test and user7@homebox.test)
|
|
||||||
- All users have password: `TestPassword123!`
|
|
||||||
|
|
||||||
#### Locations
|
|
||||||
- **Group 1**: Living Room, Garage
|
|
||||||
- **Group 2**: Home Office
|
|
||||||
|
|
||||||
#### Labels
|
|
||||||
- **Group 1**: Electronics, Important
|
|
||||||
- **Group 2**: Work Equipment
|
|
||||||
|
|
||||||
#### Items
|
|
||||||
- **Group 1**: 5 items (Laptop Computer, Power Drill, TV Remote, Tool Box, Coffee Maker)
|
|
||||||
- **Group 2**: 2 items (Monitor, Keyboard)
|
|
||||||
|
|
||||||
#### Attachments
|
|
||||||
- Multiple attachments added to various items (receipts, manuals, warranties)
|
|
||||||
|
|
||||||
#### Notifiers
|
|
||||||
- **Group 1**: Test notifier named "TESTING"
|
|
||||||
|
|
||||||
### 3. Upgrade Process
|
|
||||||
|
|
||||||
1. Stops the stable version container
|
|
||||||
2. Builds a fresh image from the current main branch
|
|
||||||
3. Copies the database to a new location
|
|
||||||
4. Starts the new version with the existing data
|
|
||||||
|
|
||||||
### 4. Verification Tests
|
|
||||||
|
|
||||||
The Playwright test suite (`upgrade-verification.spec.ts`) verifies:
|
|
||||||
|
|
||||||
- ✅ **User Authentication**: All 7 users can log in with their credentials
|
|
||||||
- ✅ **Data Persistence**: All items, locations, and labels are present
|
|
||||||
- ✅ **Attachments**: File attachments are correctly associated with items
|
|
||||||
- ✅ **Notifiers**: The "TESTING" notifier is still configured
|
|
||||||
- ✅ **UI Functionality**: Version display, theme switching work correctly
|
|
||||||
- ✅ **Data Isolation**: Groups can only see their own data
|
|
||||||
|
|
||||||
## Test Data File
|
|
||||||
|
|
||||||
The setup script generates a JSON file at `/tmp/test-users.json` containing:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"email": "user1@homebox.test",
|
|
||||||
"password": "TestPassword123!",
|
|
||||||
"token": "...",
|
|
||||||
"group": "1"
|
|
||||||
},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"locations": {
|
|
||||||
"group1": ["location-id-1", "location-id-2"],
|
|
||||||
"group2": ["location-id-3"]
|
|
||||||
},
|
|
||||||
"labels": {...},
|
|
||||||
"items": {...},
|
|
||||||
"notifiers": {...}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This file is used by the Playwright tests to verify data integrity.
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
### create-test-data.sh
|
|
||||||
|
|
||||||
**Location**: `.github/scripts/upgrade-test/create-test-data.sh`
|
|
||||||
|
|
||||||
**Purpose**: Creates all test data via the HomeBox REST API
|
|
||||||
|
|
||||||
**Environment Variables**:
|
|
||||||
- `HOMEBOX_URL`: Base URL of the HomeBox instance (default: http://localhost:7745)
|
|
||||||
- `TEST_DATA_FILE`: Path to output JSON file (default: /tmp/test-users.json)
|
|
||||||
|
|
||||||
**Requirements**:
|
|
||||||
- `curl`: For API calls
|
|
||||||
- `jq`: For JSON processing
|
|
||||||
|
|
||||||
**Usage**:
|
|
||||||
```bash
|
|
||||||
export HOMEBOX_URL=http://localhost:7745
|
|
||||||
./.github/scripts/upgrade-test/create-test-data.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running Tests Locally
|
|
||||||
|
|
||||||
To run the upgrade tests locally:
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
sudo apt-get install -y jq curl docker.io
|
|
||||||
|
|
||||||
# Install pnpm and Playwright
|
|
||||||
cd frontend
|
|
||||||
pnpm install
|
|
||||||
pnpm exec playwright install --with-deps chromium
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run the test
|
|
||||||
```bash
|
|
||||||
# Start stable version
|
|
||||||
docker run -d \
|
|
||||||
--name homebox-test \
|
|
||||||
-p 7745:7745 \
|
|
||||||
-e HBOX_OPTIONS_ALLOW_REGISTRATION=true \
|
|
||||||
-v /tmp/homebox-data:/data \
|
|
||||||
ghcr.io/sysadminsmedia/homebox:latest
|
|
||||||
|
|
||||||
# Wait for startup
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# Create test data
|
|
||||||
export HOMEBOX_URL=http://localhost:7745
|
|
||||||
./.github/scripts/upgrade-test/create-test-data.sh
|
|
||||||
|
|
||||||
# Stop container
|
|
||||||
docker stop homebox-test
|
|
||||||
docker rm homebox-test
|
|
||||||
|
|
||||||
# Build new version
|
|
||||||
docker build -t homebox:test .
|
|
||||||
|
|
||||||
# Start new version with existing data
|
|
||||||
docker run -d \
|
|
||||||
--name homebox-test \
|
|
||||||
-p 7745:7745 \
|
|
||||||
-e HBOX_OPTIONS_ALLOW_REGISTRATION=true \
|
|
||||||
-v /tmp/homebox-data:/data \
|
|
||||||
homebox:test
|
|
||||||
|
|
||||||
# Wait for startup
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# Run verification tests
|
|
||||||
cd frontend
|
|
||||||
TEST_DATA_FILE=/tmp/test-users.json \
|
|
||||||
E2E_BASE_URL=http://localhost:7745 \
|
|
||||||
pnpm exec playwright test \
|
|
||||||
--project=chromium \
|
|
||||||
test/upgrade/upgrade-verification.spec.ts
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
docker stop homebox-test
|
|
||||||
docker rm homebox-test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Artifacts
|
|
||||||
|
|
||||||
The workflow produces several artifacts:
|
|
||||||
|
|
||||||
1. **playwright-report-upgrade-test**: HTML report of test results
|
|
||||||
2. **playwright-traces**: Detailed traces for debugging failures
|
|
||||||
3. **Docker logs**: Collected on failure for troubleshooting
|
|
||||||
|
|
||||||
## Failure Scenarios
|
|
||||||
|
|
||||||
The workflow will fail if:
|
|
||||||
- The stable version fails to start
|
|
||||||
- Test data creation fails
|
|
||||||
- The new version fails to start with existing data
|
|
||||||
- Any verification test fails
|
|
||||||
- Database migrations fail
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Test Data Creation Fails
|
|
||||||
|
|
||||||
Check the Docker logs:
|
|
||||||
```bash
|
|
||||||
docker logs homebox-old
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify the API is accessible:
|
|
||||||
```bash
|
|
||||||
curl http://localhost:7745/api/v1/status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verification Tests Fail
|
|
||||||
|
|
||||||
1. Download the Playwright report from GitHub Actions artifacts
|
|
||||||
2. Review the HTML report for detailed failure information
|
|
||||||
3. Check traces for visual debugging
|
|
||||||
|
|
||||||
### Database Issues
|
|
||||||
|
|
||||||
If migrations fail:
|
|
||||||
```bash
|
|
||||||
# Check database file
|
|
||||||
ls -lh /tmp/homebox-data-new/homebox.db
|
|
||||||
|
|
||||||
# Check Docker logs for migration errors
|
|
||||||
docker logs homebox-new
|
|
||||||
```
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
Potential improvements:
|
|
||||||
- [ ] Test multiple upgrade paths (e.g., v0.10 → v0.11 → v0.12)
|
|
||||||
- [ ] Test with PostgreSQL backend in addition to SQLite
|
|
||||||
- [ ] Add performance benchmarks
|
|
||||||
- [ ] Test with larger datasets
|
|
||||||
- [ ] Add API-level verification in addition to UI tests
|
|
||||||
- [ ] Test backup and restore functionality
|
|
||||||
|
|
||||||
## Related Files
|
|
||||||
|
|
||||||
- `.github/workflows/upgrade-test.yaml` - Main workflow definition
|
|
||||||
- `.github/scripts/upgrade-test/create-test-data.sh` - Data generation script
|
|
||||||
- `frontend/test/upgrade/upgrade-verification.spec.ts` - Playwright verification tests
|
|
||||||
- `.github/workflows/e2e-partial.yaml` - Standard E2E test workflow (for reference)
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions about this workflow:
|
|
||||||
1. Check the GitHub Actions run logs
|
|
||||||
2. Review this documentation
|
|
||||||
3. Open an issue in the repository
|
|
||||||
413
.github/scripts/upgrade-test/create-test-data.sh
vendored
@@ -1,413 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Script to create test data in HomeBox for upgrade testing
|
|
||||||
# This script creates users, items, attachments, notifiers, locations, and labels
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
HOMEBOX_URL="${HOMEBOX_URL:-http://localhost:7745}"
|
|
||||||
API_URL="${HOMEBOX_URL}/api/v1"
|
|
||||||
TEST_DATA_FILE="${TEST_DATA_FILE:-/tmp/test-users.json}"
|
|
||||||
|
|
||||||
echo "Creating test data in HomeBox at $HOMEBOX_URL"
|
|
||||||
|
|
||||||
# Function to make API calls with error handling
|
|
||||||
api_call() {
|
|
||||||
local method=$1
|
|
||||||
local endpoint=$2
|
|
||||||
local data=$3
|
|
||||||
local token=$4
|
|
||||||
|
|
||||||
if [ -n "$token" ]; then
|
|
||||||
if [ -n "$data" ]; then
|
|
||||||
curl -s -X "$method" \
|
|
||||||
-H "Authorization: Bearer $token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$data" \
|
|
||||||
"$API_URL$endpoint"
|
|
||||||
else
|
|
||||||
curl -s -X "$method" \
|
|
||||||
-H "Authorization: Bearer $token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$API_URL$endpoint"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ -n "$data" ]; then
|
|
||||||
curl -s -X "$method" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$data" \
|
|
||||||
"$API_URL$endpoint"
|
|
||||||
else
|
|
||||||
curl -s -X "$method" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"$API_URL$endpoint"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to register a user and get token
|
|
||||||
register_user() {
|
|
||||||
local email=$1
|
|
||||||
local name=$2
|
|
||||||
local password=$3
|
|
||||||
local group_token=$4
|
|
||||||
|
|
||||||
echo "Registering user: $email"
|
|
||||||
|
|
||||||
local payload="{\"email\":\"$email\",\"name\":\"$name\",\"password\":\"$password\""
|
|
||||||
|
|
||||||
if [ -n "$group_token" ]; then
|
|
||||||
payload="$payload,\"groupToken\":\"$group_token\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
payload="$payload}"
|
|
||||||
|
|
||||||
local response=$(curl -s -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$payload" \
|
|
||||||
"$API_URL/users/register")
|
|
||||||
|
|
||||||
echo "$response"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to login and get token
|
|
||||||
login_user() {
|
|
||||||
local email=$1
|
|
||||||
local password=$2
|
|
||||||
|
|
||||||
echo "Logging in user: $email" >&2
|
|
||||||
|
|
||||||
local response=$(curl -s -X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"username\":\"$email\",\"password\":\"$password\"}" \
|
|
||||||
"$API_URL/users/login")
|
|
||||||
|
|
||||||
echo "$response" | jq -r '.token // empty'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to create an item
|
|
||||||
create_item() {
|
|
||||||
local token=$1
|
|
||||||
local name=$2
|
|
||||||
local description=$3
|
|
||||||
local location_id=$4
|
|
||||||
|
|
||||||
echo "Creating item: $name" >&2
|
|
||||||
|
|
||||||
local payload="{\"name\":\"$name\",\"description\":\"$description\""
|
|
||||||
|
|
||||||
if [ -n "$location_id" ]; then
|
|
||||||
payload="$payload,\"locationId\":\"$location_id\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
payload="$payload}"
|
|
||||||
|
|
||||||
local response=$(curl -s -X POST \
|
|
||||||
-H "Authorization: Bearer $token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$payload" \
|
|
||||||
"$API_URL/items")
|
|
||||||
|
|
||||||
echo "$response"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to create a location
|
|
||||||
create_location() {
|
|
||||||
local token=$1
|
|
||||||
local name=$2
|
|
||||||
local description=$3
|
|
||||||
|
|
||||||
echo "Creating location: $name" >&2
|
|
||||||
|
|
||||||
local response=$(curl -s -X POST \
|
|
||||||
-H "Authorization: Bearer $token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"name\":\"$name\",\"description\":\"$description\"}" \
|
|
||||||
"$API_URL/locations")
|
|
||||||
|
|
||||||
echo "$response"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to create a label
|
|
||||||
create_label() {
|
|
||||||
local token=$1
|
|
||||||
local name=$2
|
|
||||||
local description=$3
|
|
||||||
|
|
||||||
echo "Creating label: $name" >&2
|
|
||||||
|
|
||||||
local response=$(curl -s -X POST \
|
|
||||||
-H "Authorization: Bearer $token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"name\":\"$name\",\"description\":\"$description\"}" \
|
|
||||||
"$API_URL/labels")
|
|
||||||
|
|
||||||
echo "$response"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to create a notifier
|
|
||||||
create_notifier() {
|
|
||||||
local token=$1
|
|
||||||
local name=$2
|
|
||||||
local url=$3
|
|
||||||
|
|
||||||
echo "Creating notifier: $name" >&2
|
|
||||||
|
|
||||||
local response=$(curl -s -X POST \
|
|
||||||
-H "Authorization: Bearer $token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"name\":\"$name\",\"url\":\"$url\",\"isActive\":true}" \
|
|
||||||
"$API_URL/groups/notifiers")
|
|
||||||
|
|
||||||
echo "$response"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to attach a file to an item (creates a dummy attachment)
|
|
||||||
attach_file_to_item() {
|
|
||||||
local token=$1
|
|
||||||
local item_id=$2
|
|
||||||
local filename=$3
|
|
||||||
|
|
||||||
echo "Creating attachment for item: $item_id" >&2
|
|
||||||
|
|
||||||
# Create a temporary file with some content
|
|
||||||
local temp_file=$(mktemp)
|
|
||||||
echo "This is a test attachment for $filename" > "$temp_file"
|
|
||||||
|
|
||||||
local response=$(curl -s -X POST \
|
|
||||||
-H "Authorization: Bearer $token" \
|
|
||||||
-F "file=@$temp_file" \
|
|
||||||
-F "type=attachment" \
|
|
||||||
-F "name=$filename" \
|
|
||||||
"$API_URL/items/$item_id/attachments")
|
|
||||||
|
|
||||||
rm -f "$temp_file"
|
|
||||||
|
|
||||||
echo "$response"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Initialize test data storage
|
|
||||||
echo "{\"users\":[]}" > "$TEST_DATA_FILE"
|
|
||||||
|
|
||||||
echo "=== Step 1: Create first group with 5 users ==="
|
|
||||||
|
|
||||||
# Register first user (creates a new group)
|
|
||||||
user1_response=$(register_user "user1@homebox.test" "User One" "TestPassword123!")
|
|
||||||
user1_token=$(echo "$user1_response" | jq -r '.token // empty')
|
|
||||||
group_token=$(echo "$user1_response" | jq -r '.group.inviteToken // empty')
|
|
||||||
|
|
||||||
if [ -z "$user1_token" ]; then
|
|
||||||
echo "Failed to register first user"
|
|
||||||
echo "Response: $user1_response"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "First user registered with token. Group token: $group_token"
|
|
||||||
|
|
||||||
# Store user1 data
|
|
||||||
jq --arg email "user1@homebox.test" \
|
|
||||||
--arg password "TestPassword123!" \
|
|
||||||
--arg token "$user1_token" \
|
|
||||||
--arg group "1" \
|
|
||||||
'.users += [{"email":$email,"password":$password,"token":$token,"group":$group}]' \
|
|
||||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
|
||||||
|
|
||||||
# Register 4 more users in the same group
|
|
||||||
for i in {2..5}; do
|
|
||||||
echo "Registering user$i in group 1..."
|
|
||||||
user_response=$(register_user "user${i}@homebox.test" "User $i" "TestPassword123!" "$group_token")
|
|
||||||
user_token=$(echo "$user_response" | jq -r '.token // empty')
|
|
||||||
|
|
||||||
if [ -z "$user_token" ]; then
|
|
||||||
echo "Failed to register user$i"
|
|
||||||
echo "Response: $user_response"
|
|
||||||
else
|
|
||||||
echo "user$i registered successfully"
|
|
||||||
# Store user data
|
|
||||||
jq --arg email "user${i}@homebox.test" \
|
|
||||||
--arg password "TestPassword123!" \
|
|
||||||
--arg token "$user_token" \
|
|
||||||
--arg group "1" \
|
|
||||||
'.users += [{"email":$email,"password":$password,"token":$token,"group":$group}]' \
|
|
||||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "=== Step 2: Create second group with 2 users ==="
|
|
||||||
|
|
||||||
# Register first user of second group
|
|
||||||
user6_response=$(register_user "user6@homebox.test" "User Six" "TestPassword123!")
|
|
||||||
user6_token=$(echo "$user6_response" | jq -r '.token // empty')
|
|
||||||
group2_token=$(echo "$user6_response" | jq -r '.group.inviteToken // empty')
|
|
||||||
|
|
||||||
if [ -z "$user6_token" ]; then
|
|
||||||
echo "Failed to register user6"
|
|
||||||
echo "Response: $user6_response"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "user6 registered with token. Group 2 token: $group2_token"
|
|
||||||
|
|
||||||
# Store user6 data
|
|
||||||
jq --arg email "user6@homebox.test" \
|
|
||||||
--arg password "TestPassword123!" \
|
|
||||||
--arg token "$user6_token" \
|
|
||||||
--arg group "2" \
|
|
||||||
'.users += [{"email":$email,"password":$password,"token":$token,"group":$group}]' \
|
|
||||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
|
||||||
|
|
||||||
# Register second user in group 2
|
|
||||||
user7_response=$(register_user "user7@homebox.test" "User Seven" "TestPassword123!" "$group2_token")
|
|
||||||
user7_token=$(echo "$user7_response" | jq -r '.token // empty')
|
|
||||||
|
|
||||||
if [ -z "$user7_token" ]; then
|
|
||||||
echo "Failed to register user7"
|
|
||||||
echo "Response: $user7_response"
|
|
||||||
else
|
|
||||||
echo "user7 registered successfully"
|
|
||||||
# Store user7 data
|
|
||||||
jq --arg email "user7@homebox.test" \
|
|
||||||
--arg password "TestPassword123!" \
|
|
||||||
--arg token "$user7_token" \
|
|
||||||
--arg group "2" \
|
|
||||||
'.users += [{"email":$email,"password":$password,"token":$token,"group":$group}]' \
|
|
||||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Step 3: Create locations for each group ==="
|
|
||||||
|
|
||||||
# Create locations for group 1 (using user1's token)
|
|
||||||
location1=$(create_location "$user1_token" "Living Room" "Main living area")
|
|
||||||
location1_id=$(echo "$location1" | jq -r '.id // empty')
|
|
||||||
echo "Created location: Living Room (ID: $location1_id)"
|
|
||||||
|
|
||||||
location2=$(create_location "$user1_token" "Garage" "Storage and tools")
|
|
||||||
location2_id=$(echo "$location2" | jq -r '.id // empty')
|
|
||||||
echo "Created location: Garage (ID: $location2_id)"
|
|
||||||
|
|
||||||
# Create location for group 2 (using user6's token)
|
|
||||||
location3=$(create_location "$user6_token" "Home Office" "Work from home space")
|
|
||||||
location3_id=$(echo "$location3" | jq -r '.id // empty')
|
|
||||||
echo "Created location: Home Office (ID: $location3_id)"
|
|
||||||
|
|
||||||
# Store locations
|
|
||||||
jq --arg loc1 "$location1_id" \
|
|
||||||
--arg loc2 "$location2_id" \
|
|
||||||
--arg loc3 "$location3_id" \
|
|
||||||
'.locations = {"group1":[$loc1,$loc2],"group2":[$loc3]}' \
|
|
||||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
|
||||||
|
|
||||||
echo "=== Step 4: Create labels for each group ==="
|
|
||||||
|
|
||||||
# Create labels for group 1
|
|
||||||
label1=$(create_label "$user1_token" "Electronics" "Electronic devices")
|
|
||||||
label1_id=$(echo "$label1" | jq -r '.id // empty')
|
|
||||||
echo "Created label: Electronics (ID: $label1_id)"
|
|
||||||
|
|
||||||
label2=$(create_label "$user1_token" "Important" "High priority items")
|
|
||||||
label2_id=$(echo "$label2" | jq -r '.id // empty')
|
|
||||||
echo "Created label: Important (ID: $label2_id)"
|
|
||||||
|
|
||||||
# Create label for group 2
|
|
||||||
label3=$(create_label "$user6_token" "Work Equipment" "Items for work")
|
|
||||||
label3_id=$(echo "$label3" | jq -r '.id // empty')
|
|
||||||
echo "Created label: Work Equipment (ID: $label3_id)"
|
|
||||||
|
|
||||||
# Store labels
|
|
||||||
jq --arg lab1 "$label1_id" \
|
|
||||||
--arg lab2 "$label2_id" \
|
|
||||||
--arg lab3 "$label3_id" \
|
|
||||||
'.labels = {"group1":[$lab1,$lab2],"group2":[$lab3]}' \
|
|
||||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
|
||||||
|
|
||||||
echo "=== Step 5: Create test notifier ==="
|
|
||||||
|
|
||||||
# Create notifier for group 1
|
|
||||||
notifier1=$(create_notifier "$user1_token" "TESTING" "https://example.com/webhook")
|
|
||||||
notifier1_id=$(echo "$notifier1" | jq -r '.id // empty')
|
|
||||||
echo "Created notifier: TESTING (ID: $notifier1_id)"
|
|
||||||
|
|
||||||
# Store notifier
|
|
||||||
jq --arg not1 "$notifier1_id" \
|
|
||||||
'.notifiers = {"group1":[$not1]}' \
|
|
||||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
|
||||||
|
|
||||||
echo "=== Step 6: Create items for all users ==="
|
|
||||||
|
|
||||||
# Create items for users in group 1
|
|
||||||
declare -A user_tokens
|
|
||||||
user_tokens[1]=$user1_token
|
|
||||||
user_tokens[2]=$(echo "$user1_token") # Users in same group share data, but we'll use user1 token
|
|
||||||
user_tokens[3]=$(echo "$user1_token")
|
|
||||||
user_tokens[4]=$(echo "$user1_token")
|
|
||||||
user_tokens[5]=$(echo "$user1_token")
|
|
||||||
|
|
||||||
# Items for group 1 users
|
|
||||||
echo "Creating items for group 1..."
|
|
||||||
item1=$(create_item "$user1_token" "Laptop Computer" "Dell XPS 15 for work" "$location1_id")
|
|
||||||
item1_id=$(echo "$item1" | jq -r '.id // empty')
|
|
||||||
echo "Created item: Laptop Computer (ID: $item1_id)"
|
|
||||||
|
|
||||||
item2=$(create_item "$user1_token" "Power Drill" "DeWalt 20V cordless drill" "$location2_id")
|
|
||||||
item2_id=$(echo "$item2" | jq -r '.id // empty')
|
|
||||||
echo "Created item: Power Drill (ID: $item2_id)"
|
|
||||||
|
|
||||||
item3=$(create_item "$user1_token" "TV Remote" "Samsung TV remote control" "$location1_id")
|
|
||||||
item3_id=$(echo "$item3" | jq -r '.id // empty')
|
|
||||||
echo "Created item: TV Remote (ID: $item3_id)"
|
|
||||||
|
|
||||||
item4=$(create_item "$user1_token" "Tool Box" "Red metal tool box with tools" "$location2_id")
|
|
||||||
item4_id=$(echo "$item4" | jq -r '.id // empty')
|
|
||||||
echo "Created item: Tool Box (ID: $item4_id)"
|
|
||||||
|
|
||||||
item5=$(create_item "$user1_token" "Coffee Maker" "Breville espresso machine" "$location1_id")
|
|
||||||
item5_id=$(echo "$item5" | jq -r '.id // empty')
|
|
||||||
echo "Created item: Coffee Maker (ID: $item5_id)"
|
|
||||||
|
|
||||||
# Items for group 2 users
|
|
||||||
echo "Creating items for group 2..."
|
|
||||||
item6=$(create_item "$user6_token" "Monitor" "27 inch 4K monitor" "$location3_id")
|
|
||||||
item6_id=$(echo "$item6" | jq -r '.id // empty')
|
|
||||||
echo "Created item: Monitor (ID: $item6_id)"
|
|
||||||
|
|
||||||
item7=$(create_item "$user6_token" "Keyboard" "Mechanical keyboard" "$location3_id")
|
|
||||||
item7_id=$(echo "$item7" | jq -r '.id // empty')
|
|
||||||
echo "Created item: Keyboard (ID: $item7_id)"
|
|
||||||
|
|
||||||
# Store items
|
|
||||||
jq --argjson group1_items "[\"$item1_id\",\"$item2_id\",\"$item3_id\",\"$item4_id\",\"$item5_id\"]" \
|
|
||||||
--argjson group2_items "[\"$item6_id\",\"$item7_id\"]" \
|
|
||||||
'.items = {"group1":$group1_items,"group2":$group2_items}' \
|
|
||||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
|
||||||
|
|
||||||
echo "=== Step 7: Add attachments to items ==="
|
|
||||||
|
|
||||||
# Add attachments for group 1 items
|
|
||||||
echo "Adding attachments to group 1 items..."
|
|
||||||
attach_file_to_item "$user1_token" "$item1_id" "laptop-receipt.pdf"
|
|
||||||
attach_file_to_item "$user1_token" "$item1_id" "laptop-warranty.pdf"
|
|
||||||
attach_file_to_item "$user1_token" "$item2_id" "drill-manual.pdf"
|
|
||||||
attach_file_to_item "$user1_token" "$item3_id" "remote-guide.pdf"
|
|
||||||
attach_file_to_item "$user1_token" "$item4_id" "toolbox-inventory.txt"
|
|
||||||
|
|
||||||
# Add attachments for group 2 items
|
|
||||||
echo "Adding attachments to group 2 items..."
|
|
||||||
attach_file_to_item "$user6_token" "$item6_id" "monitor-receipt.pdf"
|
|
||||||
attach_file_to_item "$user6_token" "$item7_id" "keyboard-manual.pdf"
|
|
||||||
|
|
||||||
echo "=== Test Data Creation Complete ==="
|
|
||||||
echo "Test data file saved to: $TEST_DATA_FILE"
|
|
||||||
echo "Summary:"
|
|
||||||
echo " - Users created: 7 (5 in group 1, 2 in group 2)"
|
|
||||||
echo " - Locations created: 3"
|
|
||||||
echo " - Labels created: 3"
|
|
||||||
echo " - Notifiers created: 1"
|
|
||||||
echo " - Items created: 7"
|
|
||||||
echo " - Attachments created: 7"
|
|
||||||
|
|
||||||
# Display the test data file for verification
|
|
||||||
echo ""
|
|
||||||
echo "Test data:"
|
|
||||||
cat "$TEST_DATA_FILE" | jq '.'
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
131
.github/workflows/binaries-publish.yaml
vendored
@@ -1,131 +0,0 @@
|
|||||||
name: Publish Release Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
tags: [ 'v*.*.*' ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
goreleaser:
|
|
||||||
name: goreleaser
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
hashes: ${{ steps.binary.outputs.hashes }}
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
|
||||||
with:
|
|
||||||
go-version: "1.24"
|
|
||||||
cache-dependency-path: backend/go.mod
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
|
||||||
|
|
||||||
- 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: Install Syft
|
|
||||||
working-directory: backend
|
|
||||||
run: |
|
|
||||||
go install github.com/anchore/syft/cmd/syft@latest
|
|
||||||
|
|
||||||
- name: Run GoReleaser
|
|
||||||
id: releaser
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a
|
|
||||||
with:
|
|
||||||
workdir: "backend"
|
|
||||||
distribution: goreleaser
|
|
||||||
version: "~> v2"
|
|
||||||
args: release --clean
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
COSIGN_PWD: ${{ secrets.COSIGN_PWD }}
|
|
||||||
COSIGN_YES: "true"
|
|
||||||
|
|
||||||
- name: Generate binary hashes
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
id: binary
|
|
||||||
env:
|
|
||||||
ARTIFACTS: "${{ steps.releaser.outputs.artifacts }}"
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
checksum_file=$(echo "$ARTIFACTS" | jq -r '.[] | select (.type=="Checksum") | .path')
|
|
||||||
echo "hashes=$(cat $checksum_file | base64 -w0)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Run GoReleaser No Release
|
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a
|
|
||||||
with:
|
|
||||||
workdir: "backend"
|
|
||||||
distribution: goreleaser
|
|
||||||
version: "~> v2"
|
|
||||||
args: release --clean --snapshot --skip=publish
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
COSIGN_PWD: ${{ secrets.COSIGN_PWD }}
|
|
||||||
COSIGN_YES: "true"
|
|
||||||
|
|
||||||
binary-provenance:
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
needs: [ goreleaser ]
|
|
||||||
permissions:
|
|
||||||
actions: read # To read the workflow path.
|
|
||||||
id-token: write # To sign the provenance.
|
|
||||||
contents: write # To add assets to a release.
|
|
||||||
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a
|
|
||||||
with:
|
|
||||||
base64-subjects: "${{ needs.goreleaser.outputs.hashes }}"
|
|
||||||
upload-assets: true # upload to a new release
|
|
||||||
|
|
||||||
verification-with-slsa-verifier:
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
needs: [goreleaser, binary-provenance]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions: read-all
|
|
||||||
steps:
|
|
||||||
- name: Install the verifier
|
|
||||||
uses: slsa-framework/slsa-verifier/actions/installer@ea584f4502babc6f60d9bc799dbbb13c1caa9ee6
|
|
||||||
|
|
||||||
- name: Download assets
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
PROVENANCE: "${{ needs.binary-provenance.outputs.provenance-name }}"
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "*.tar.gz"
|
|
||||||
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "*.zip"
|
|
||||||
gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "$PROVENANCE"
|
|
||||||
- name: Verify assets
|
|
||||||
env:
|
|
||||||
CHECKSUMS: ${{ needs.goreleaser.outputs.hashes }}
|
|
||||||
PROVENANCE: "${{ needs.binary-provenance.outputs.provenance-name }}"
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
checksums=$(echo "$CHECKSUMS" | base64 -d)
|
|
||||||
while read -r line; do
|
|
||||||
fn=$(echo $line | cut -d ' ' -f2)
|
|
||||||
echo "Verifying $fn"
|
|
||||||
slsa-verifier verify-artifact --provenance-path "$PROVENANCE" \
|
|
||||||
--source-uri "github.com/$GITHUB_REPOSITORY" \
|
|
||||||
--source-tag "$GITHUB_REF_NAME" \
|
|
||||||
"$fn"
|
|
||||||
done <<<"$checksums"
|
|
||||||
41
.github/workflows/clear-stale-docker-images.yml
vendored
@@ -1,41 +0,0 @@
|
|||||||
name: Docker Cleanup
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '00 0 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
delete-old-images-main:
|
|
||||||
name: Delete Old Images for Main Repo
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: dataaxiom/ghcr-cleanup-action@cd0cdb900b5dbf3a6f2cc869f0dbb0b8211f50c4
|
|
||||||
with:
|
|
||||||
dry-run: true
|
|
||||||
delete-ghost-images: true
|
|
||||||
delete-partial-images: true
|
|
||||||
delete-orphaned-images: true
|
|
||||||
delete-untagged: true
|
|
||||||
validate: true
|
|
||||||
token: '${{ github.token }}'
|
|
||||||
package: homebox
|
|
||||||
use-regex: true
|
|
||||||
exclude-tags: latest,latest-rootless,main,main-rootless,nightly,nightly-rootless,*.*.*,0.*,0,*.*.*-rootless,0.*-rootless,0-rootless
|
|
||||||
older-than: 180 days
|
|
||||||
|
|
||||||
delete-old-images-devcache:
|
|
||||||
name: Delete Old Devcache Images
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: dataaxiom/ghcr-cleanup-action@cd0cdb900b5dbf3a6f2cc869f0dbb0b8211f50c4
|
|
||||||
with:
|
|
||||||
dry-run: false
|
|
||||||
delete-untagged: true
|
|
||||||
token: '${{ github.token }}'
|
|
||||||
package: devcache
|
|
||||||
older-than: 90 days
|
|
||||||
50
.github/workflows/copilot-setup-steps.yml
vendored
@@ -1,50 +0,0 @@
|
|||||||
name: "Copilot Setup Steps"
|
|
||||||
|
|
||||||
# Automatically run the setup steps when they are changed to allow for easy validation, and
|
|
||||||
# allow manual testing through the repository's "Actions" tab
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- .github/workflows/copilot-setup-steps.yml
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- .github/workflows/copilot-setup-steps.yml
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
|
|
||||||
copilot-setup-steps:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
# Set the permissions to the lowest permissions possible needed for your steps.
|
|
||||||
# Copilot will be given its own token for its operations.
|
|
||||||
permissions:
|
|
||||||
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
# You can define any steps you want, and they will run before the agent starts.
|
|
||||||
# If you do not check out your code, Copilot will do this for you.
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
|
|
||||||
with:
|
|
||||||
node-version: "24"
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
|
||||||
with:
|
|
||||||
go-version: "1.24"
|
|
||||||
cache-dependency-path: backend/go.mod
|
|
||||||
|
|
||||||
- name: Install Task
|
|
||||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Perform setup
|
|
||||||
run: task setup
|
|
||||||
249
.github/workflows/docker-publish-hardened.yaml
vendored
@@ -1,249 +0,0 @@
|
|||||||
name: Docker publish hardened
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '00 0 * * *'
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'Dockerfile.hardened'
|
|
||||||
- '.dockerignore'
|
|
||||||
- '.github/workflows/docker-publish-hardened.yaml'
|
|
||||||
tags: [ 'v*.*.*' ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'Dockerfile.hardened'
|
|
||||||
- '.dockerignore'
|
|
||||||
- '.github/workflows/docker-publish-hardened.yaml'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read # Access to repository contents
|
|
||||||
packages: write # Write access for pushing to GHCR
|
|
||||||
id-token: write # Required for OIDC authentication (if used)
|
|
||||||
attestations: write # Required for signing and attestation (if needed)
|
|
||||||
|
|
||||||
env:
|
|
||||||
DOCKERHUB_REPO: sysadminsmedia/homebox
|
|
||||||
GHCR_REPO: ghcr.io/${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
|
|
||||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
|
||||||
|
|
||||||
- name: Prepare
|
|
||||||
run: |
|
|
||||||
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV
|
|
||||||
platform=${{ matrix.platform }}
|
|
||||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
|
||||||
branch=${{ github.event.pull_request.number || github.ref_name }}
|
|
||||||
echo "BRANCH=${branch//\//-}" >> $GITHUB_ENV
|
|
||||||
echo "DOCKERNAMES=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}" >> $GITHUB_ENV
|
|
||||||
if [[ "${{ github.event_name }}" != "schedule" ]] || [[ "${{ github.ref }}" != refs/tags/* ]]; then
|
|
||||||
echo "DOCKERNAMES=${{ env.GHCR_REPO }}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
name=${{ env.GHCR_REPO }}
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
|
|
||||||
with:
|
|
||||||
image: ghcr.io/sysadminsmedia/binfmt:latest
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=ghcr.io/sysadminsmedia/buildkit:master
|
|
||||||
|
|
||||||
- name: Build and push by digest
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83
|
|
||||||
with:
|
|
||||||
context: . # Explicitly specify the build context
|
|
||||||
file: ./Dockerfile.hardened # Explicitly specify the Dockerfile
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
outputs: type=image,"name=${{ env.DOCKERNAMES }}",push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
|
||||||
cache-from: type=registry,ref=ghcr.io/sysadminsmedia/devcache:${{ env.PLATFORM_PAIR }}-${{ env.BRANCH }}-hardened
|
|
||||||
cache-to: type=registry,ref=ghcr.io/sysadminsmedia/devcache:${{ env.PLATFORM_PAIR }}-${{ env.BRANCH }}-hardened,mode=max,ignore-error=true
|
|
||||||
build-args: |
|
|
||||||
VERSION=${{ github.ref_name }}
|
|
||||||
COMMIT=${{ github.sha }}
|
|
||||||
BUILD_TIME=${{ env.BUILD_TIME }}
|
|
||||||
provenance: mode=max
|
|
||||||
sbom: true
|
|
||||||
annotations: ${{ steps.meta.outputs.annotations }}
|
|
||||||
|
|
||||||
- name: Attest platform-specific images
|
|
||||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.GHCR_REPO }}
|
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
|
|
||||||
- name: Export digest
|
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/digests
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: Upload digest
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
|
||||||
with:
|
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
|
||||||
path: /tmp/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge:
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
needs:
|
|
||||||
- build
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download digests
|
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=ghcr.io/sysadminsmedia/buildkit:master
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
name=${{ env.GHCR_REPO }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=schedule,pattern=nightly
|
|
||||||
flavor: |
|
|
||||||
suffix=-hardened,onlatest=true
|
|
||||||
|
|
||||||
- name: Create manifest list and push GHCR
|
|
||||||
id: push-ghcr
|
|
||||||
working-directory: /tmp/digests
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) 2>&1 | tee /tmp/push-ghcr.out
|
|
||||||
digest=$(grep -oE 'sha256:[a-f0-9]{64}' /tmp/push-ghcr.out | head -n1 || true)
|
|
||||||
if [ -z "$digest" ]; then
|
|
||||||
echo "No digest found in imagetools output:"
|
|
||||||
cat /tmp/push-ghcr.out
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Attest GHCR images
|
|
||||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.GHCR_REPO }}
|
|
||||||
subject-digest: ${{ steps.push-ghcr.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
|
|
||||||
- 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: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *) 2>&1 | tee /tmp/push-dockerhub.out
|
|
||||||
digest=$(grep -oE 'sha256:[a-f0-9]{64}' /tmp/push-dockerhub.out | head -n1 || true)
|
|
||||||
if [ -z "$digest" ]; then
|
|
||||||
echo "No digest found in imagetools output:"
|
|
||||||
cat /tmp/push-dockerhub.out
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Attest Dockerhub images
|
|
||||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
|
||||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
|
||||||
subject-name: docker.io/${{ env.DOCKERHUB_REPO }}
|
|
||||||
subject-digest: ${{ steps.push-dockerhub.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
251
.github/workflows/docker-publish-rootless.yaml
vendored
@@ -1,251 +0,0 @@
|
|||||||
name: Docker publish rootless
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '00 0 * * *'
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'Dockerfile.rootless'
|
|
||||||
- '.dockerignore'
|
|
||||||
- '.github/workflows/docker-publish-rootless.yaml'
|
|
||||||
ignore:
|
|
||||||
- 'docs/**'
|
|
||||||
tags: [ 'v*.*.*' ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'frontend/**'
|
|
||||||
- 'Dockerfile.rootless'
|
|
||||||
- '.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/${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
|
|
||||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
|
||||||
|
|
||||||
- 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@c1e51972afc2121e065aed6d45c65596fe445f3f
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
name=${{ env.GHCR_REPO }}
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
|
|
||||||
with:
|
|
||||||
image: ghcr.io/sysadminsmedia/binfmt:latest
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=ghcr.io/sysadminsmedia/buildkit:master
|
|
||||||
|
|
||||||
- name: Build and push by digest
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83
|
|
||||||
with:
|
|
||||||
context: . # Explicitly specify the build context
|
|
||||||
file: ./Dockerfile.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: mode=max
|
|
||||||
sbom: true
|
|
||||||
annotations: ${{ steps.meta.outputs.annotations }}
|
|
||||||
|
|
||||||
- name: Attest platform-specific images
|
|
||||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.GHCR_REPO }}
|
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
|
|
||||||
- name: Export digest
|
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/digests
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: Upload digest
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
|
||||||
with:
|
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
|
||||||
path: /tmp/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge:
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
needs:
|
|
||||||
- build
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download digests
|
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=ghcr.io/sysadminsmedia/buildkit:master
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
name=${{ env.GHCR_REPO }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=schedule,pattern=nightly
|
|
||||||
flavor: |
|
|
||||||
suffix=-rootless,onlatest=true
|
|
||||||
|
|
||||||
- name: Create manifest list and push GHCR
|
|
||||||
id: push-ghcr
|
|
||||||
working-directory: /tmp/digests
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) 2>&1 | tee /tmp/push-ghcr.out
|
|
||||||
digest=$(grep -oE 'sha256:[a-f0-9]{64}' /tmp/push-ghcr.out | head -n1 || true)
|
|
||||||
if [ -z "$digest" ]; then
|
|
||||||
echo "No digest found in imagetools output:"
|
|
||||||
cat /tmp/push-ghcr.out
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Attest GHCR images
|
|
||||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.GHCR_REPO }}
|
|
||||||
subject-digest: ${{ steps.push-ghcr.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
|
|
||||||
- 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: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *) 2>&1 | tee /tmp/push-dockerhub.out
|
|
||||||
digest=$(grep -oE 'sha256:[a-f0-9]{64}' /tmp/push-dockerhub.out | head -n1 || true)
|
|
||||||
if [ -z "$digest" ]; then
|
|
||||||
echo "No digest found in imagetools output:"
|
|
||||||
cat /tmp/push-dockerhub.out
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Attest Dockerhub images
|
|
||||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
|
||||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
|
||||||
subject-name: docker.io/${{ env.DOCKERHUB_REPO }}
|
|
||||||
subject-digest: ${{ steps.push-dockerhub.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
242
.github/workflows/docker-publish.yaml
vendored
@@ -1,242 +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/${{ github.repository }}
|
|
||||||
|
|
||||||
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: ${{ matrix.runner }}
|
|
||||||
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:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
|
||||||
|
|
||||||
- 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@c1e51972afc2121e065aed6d45c65596fe445f3f
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
name=${{ env.GHCR_REPO }}
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
|
|
||||||
with:
|
|
||||||
image: ghcr.io/sysadminsmedia/binfmt:latest
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=ghcr.io/sysadminsmedia/buildkit:latest
|
|
||||||
|
|
||||||
- name: Build and push by digest
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83
|
|
||||||
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: mode=max
|
|
||||||
sbom: true
|
|
||||||
annotations: ${{ steps.meta.outputs.annotations }}
|
|
||||||
|
|
||||||
- name: Attest platform-specific images
|
|
||||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.GHCR_REPO }}
|
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
|
|
||||||
- name: Export digest
|
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/digests
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: Upload digest
|
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
|
||||||
with:
|
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
|
||||||
path: /tmp/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge:
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read # 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@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Login to GHCR
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
|
||||||
with:
|
|
||||||
driver-opts: |
|
|
||||||
image=ghcr.io/sysadminsmedia/buildkit:master
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
name=${{ env.GHCR_REPO }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=schedule,pattern=nightly
|
|
||||||
|
|
||||||
- name: Create manifest list and push GHCR
|
|
||||||
id: push-ghcr
|
|
||||||
working-directory: /tmp/digests
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) 2>&1 | tee /tmp/push-ghcr.out
|
|
||||||
digest=$(grep -oE 'sha256:[a-f0-9]{64}' /tmp/push-ghcr.out | head -n1 || true)
|
|
||||||
if [ -z "$digest" ]; then
|
|
||||||
echo "No digest found in imagetools output:"
|
|
||||||
cat /tmp/push-ghcr.out
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Attest GHCR images
|
|
||||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.GHCR_REPO }}
|
|
||||||
subject-digest: ${{ steps.push-ghcr.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
|
|
||||||
- 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: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *) 2>&1 | tee /tmp/push-dockerhub.out
|
|
||||||
digest=$(grep -oE 'sha256:[a-f0-9]{64}' /tmp/push-dockerhub.out | head -n1 || true)
|
|
||||||
if [ -z "$digest" ]; then
|
|
||||||
echo "No digest found in imagetools output:"
|
|
||||||
cat /tmp/push-dockerhub.out
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Attest Dockerhub images
|
|
||||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
|
||||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
with:
|
|
||||||
subject-name: docker.io/${{ env.DOCKERHUB_REPO }}
|
|
||||||
subject-digest: ${{ steps.push-dockerhub.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
97
.github/workflows/e2e-partial.yaml
vendored
@@ -1,97 +0,0 @@
|
|||||||
name: E2E (Playwright)
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
checks: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install Task
|
|
||||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
|
||||||
with:
|
|
||||||
go-version: "1.24"
|
|
||||||
cache-dependency-path: backend/go.mod
|
|
||||||
|
|
||||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
|
||||||
|
|
||||||
- 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@ea165f8d65b6e75b540449e92b4886f43607fa02
|
|
||||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
|
||||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
working-directory: frontend
|
|
||||||
|
|
||||||
- name: Download blob reports from GitHub Actions Artifacts
|
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
|
||||||
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@ea165f8d65b6e75b540449e92b4886f43607fa02
|
|
||||||
with:
|
|
||||||
name: html-report--attempt-${{ github.run_attempt }}
|
|
||||||
path: frontend/playwright-report
|
|
||||||
retention-days: 30
|
|
||||||
50
.github/workflows/issue-gatekeeper.yml
vendored
@@ -1,50 +0,0 @@
|
|||||||
name: Issue Gatekeeper
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [ opened ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-permissions:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Verify Internal Template Use
|
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const { owner, repo } = context.repo;
|
|
||||||
const issue_number = context.issue.number;
|
|
||||||
const actor = context.payload.sender.login;
|
|
||||||
|
|
||||||
// 1. Get user permission level
|
|
||||||
const { data: perms } = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
username: actor
|
|
||||||
});
|
|
||||||
|
|
||||||
const isMember = ['admin', 'write'].includes(perms.permission);
|
|
||||||
const body = context.payload.issue.body || "";
|
|
||||||
|
|
||||||
// 2. Check if they used the internal template (or if the issue is blank)
|
|
||||||
// We detect this by checking for our specific template string or the 'internal' label
|
|
||||||
const usedInternal = context.payload.issue.labels.some(l => l.name === 'internal');
|
|
||||||
|
|
||||||
if (usedInternal && !isMember) {
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number,
|
|
||||||
body: `@${actor}, the "Internal" template is restricted to project members. Please use one of the standard bug or feature templates for this repository.`
|
|
||||||
});
|
|
||||||
|
|
||||||
await github.rest.issues.update({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number,
|
|
||||||
state: 'closed'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
17
.github/workflows/partial-backend.yaml
vendored
@@ -1,11 +1,5 @@
|
|||||||
name: Go Build/Test
|
name: Go Build/Test
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
checks: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
@@ -13,21 +7,20 @@ jobs:
|
|||||||
Go:
|
Go:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.24"
|
go-version: "1.21"
|
||||||
cache-dependency-path: backend/go.mod
|
|
||||||
|
|
||||||
- name: Install Task
|
- name: Install Task
|
||||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611
|
uses: arduino/setup-task@v1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20
|
uses: golangci/golangci-lint-action@v4
|
||||||
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
|
||||||
|
|||||||
102
.github/workflows/partial-frontend.yaml
vendored
@@ -1,10 +1,4 @@
|
|||||||
name: Frontend
|
name: Frontend / E2E
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
checks: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
@@ -15,14 +9,20 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v3.0.0
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
- 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
|
||||||
@@ -36,95 +36,33 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Task
|
- name: Install Task
|
||||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611
|
uses: arduino/setup-task@v1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.24"
|
go-version: "1.22"
|
||||||
cache-dependency-path: backend/go.mod
|
|
||||||
|
|
||||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
node-version: 20
|
||||||
|
|
||||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
- uses: pnpm/action-setup@v3.0.0
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install
|
run: pnpm install --shamefully-hoist
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install Task
|
|
||||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611
|
|
||||||
with:
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
|
||||||
with:
|
|
||||||
go-version: "1.24"
|
|
||||||
cache-dependency-path: backend/go.mod
|
|
||||||
|
|
||||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
working-directory: frontend
|
|
||||||
|
|
||||||
- name: Run Integration Tests
|
|
||||||
run: task test:ci:postgresql
|
|
||||||
89
.github/workflows/partial-publish.yaml
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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@v4
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.20"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
id: qemu
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
with:
|
||||||
|
image: tonistiigi/binfmt:latest
|
||||||
|
platforms: all
|
||||||
|
|
||||||
|
- name: install buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
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 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 nightly-rootless image
|
||||||
|
if: ${{ inputs.release == false }}
|
||||||
|
run: |
|
||||||
|
docker build --push --no-cache \
|
||||||
|
--tag=ghcr.io/hay-kot/homebox:${{ inputs.tag }}-rootless \
|
||||||
|
--build-arg=COMMIT=$(git rev-parse HEAD) \
|
||||||
|
--build-arg=BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
|
||||||
|
--file Dockerfile.rootless \
|
||||||
|
--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 .
|
||||||
|
|
||||||
|
- name: build release tagged the rootless image
|
||||||
|
if: ${{ inputs.release == true }}
|
||||||
|
run: |
|
||||||
|
docker build --push --no-cache \
|
||||||
|
--tag ghcr.io/hay-kot/homebox:nightly-rootless \
|
||||||
|
--tag ghcr.io/hay-kot/homebox:latest-rootless \
|
||||||
|
--tag ghcr.io/hay-kot/homebox:${{ inputs.tag }}-rootless \
|
||||||
|
--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 \
|
||||||
|
--file Dockerfile.rootless .
|
||||||
29
.github/workflows/publish.yaml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: Publish Dockers
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: "Deploy Nightly to Fly.io"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||||
|
- run: flyctl deploy --remote-only
|
||||||
|
|
||||||
|
publish-nightly:
|
||||||
|
name: "Publish Nightly"
|
||||||
|
if: github.event_name != 'release'
|
||||||
|
uses: hay-kot/homebox/.github/workflows/partial-publish.yaml@main
|
||||||
|
with:
|
||||||
|
tag: nightly
|
||||||
|
secrets:
|
||||||
|
GH_TOKEN: ${{ secrets.CR_PAT }}
|
||||||
|
|
||||||
|
|
||||||
23
.github/workflows/pull-requests.yaml
vendored
@@ -1,24 +1,9 @@
|
|||||||
name: Pull Request CI
|
name: Pull Request CI
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
checks: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- vnext
|
|
||||||
|
|
||||||
paths:
|
|
||||||
- 'backend/**'
|
|
||||||
- 'frontend/**'
|
|
||||||
- '.github/workflows/partial-backend.yaml'
|
|
||||||
- '.github/workflows/partial-frontend.yaml'
|
|
||||||
- '.github/workflows/e2e-partial.yaml'
|
|
||||||
- '.github/workflows/pull-requests.yaml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-tests:
|
backend-tests:
|
||||||
@@ -26,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
|
|
||||||
77
.github/workflows/tag.yaml
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
name: Publish Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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: 7.30.1
|
||||||
|
|
||||||
|
- name: Build Frontend and Copy to Backend
|
||||||
|
working-directory: frontend
|
||||||
|
run: |
|
||||||
|
pnpm install --shamefully-hoist
|
||||||
|
pnpm run build
|
||||||
|
cp -r ./.output/public ../backend/app/api/static/
|
||||||
|
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v5
|
||||||
|
with:
|
||||||
|
workdir: "backend"
|
||||||
|
distribution: goreleaser
|
||||||
|
version: latest
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
publish-tag:
|
||||||
|
name: "Publish Tag"
|
||||||
|
uses: hay-kot/homebox/.github/workflows/partial-publish.yaml@main
|
||||||
|
with:
|
||||||
|
release: true
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
secrets:
|
||||||
|
GH_TOKEN: ${{ secrets.CR_PAT }}
|
||||||
|
|
||||||
|
deploy-docs:
|
||||||
|
name: Deploy docs
|
||||||
|
needs:
|
||||||
|
- publish-tag
|
||||||
|
- goreleaser
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout main
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- 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
|
||||||
60
.github/workflows/update-currencies.yml
vendored
@@ -1,60 +0,0 @@
|
|||||||
name: Update Currencies
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-currencies:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
|
|
||||||
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 -r .github/workflows/update-currencies/requirements.txt
|
|
||||||
|
|
||||||
- name: Run currency update script
|
|
||||||
run: python .github/scripts/update_currencies.py
|
|
||||||
|
|
||||||
- name: Check for currencies.json changes
|
|
||||||
run: |
|
|
||||||
if git diff --quiet -- backend/internal/core/currencies/currencies.json; 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@98357b18bf14b5342f975ff684046ec3b2a07725
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
branch: update-currencies
|
|
||||||
base: main
|
|
||||||
title: "Update currencies.json"
|
|
||||||
commit-message: "chore: update currencies.json"
|
|
||||||
path: .
|
|
||||||
add-paths: |
|
|
||||||
backend/internal/core/currencies/currencies.json
|
|
||||||
|
|
||||||
- name: No updates needed
|
|
||||||
if: env.changed == 'false'
|
|
||||||
run: echo "✅ currencies.json is already up-to-date"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
requests
|
|
||||||
177
.github/workflows/upgrade-test.yaml
vendored
@@ -1,177 +0,0 @@
|
|||||||
#name: HomeBox Upgrade Test
|
|
||||||
|
|
||||||
# on:
|
|
||||||
# schedule:
|
|
||||||
# Run daily at 2 AM UTC
|
|
||||||
# - cron: '0 2 * * *'
|
|
||||||
# workflow_dispatch: # Allow manual trigger
|
|
||||||
# push:
|
|
||||||
# branches:
|
|
||||||
# - main
|
|
||||||
# paths:
|
|
||||||
# - '.github/workflows/upgrade-test.yaml'
|
|
||||||
# - '.github/scripts/upgrade-test/**'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
upgrade-test:
|
|
||||||
name: Test Upgrade Path
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 60
|
|
||||||
permissions:
|
|
||||||
contents: read # Read repository contents
|
|
||||||
packages: read # Pull Docker images from GHCR
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v3.0.0
|
|
||||||
with:
|
|
||||||
version: 9.12.2
|
|
||||||
|
|
||||||
- name: Install Playwright
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
pnpm install
|
|
||||||
pnpm exec playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Create test data directory
|
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/homebox-data-old
|
|
||||||
mkdir -p /tmp/homebox-data-new
|
|
||||||
chmod -R 777 /tmp/homebox-data-old
|
|
||||||
chmod -R 777 /tmp/homebox-data-new
|
|
||||||
|
|
||||||
# Step 1: Pull and deploy latest stable version
|
|
||||||
- name: Pull latest stable HomeBox image
|
|
||||||
run: |
|
|
||||||
docker pull ghcr.io/sysadminsmedia/homebox:latest
|
|
||||||
|
|
||||||
- name: Start HomeBox (stable version)
|
|
||||||
run: |
|
|
||||||
docker run -d \
|
|
||||||
--name homebox-old \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-p 7745:7745 \
|
|
||||||
-e HBOX_LOG_LEVEL=debug \
|
|
||||||
-e HBOX_OPTIONS_ALLOW_REGISTRATION=true \
|
|
||||||
-e TZ=UTC \
|
|
||||||
-v /tmp/homebox-data-old:/data \
|
|
||||||
ghcr.io/sysadminsmedia/homebox:latest
|
|
||||||
|
|
||||||
# Wait for the service to be ready
|
|
||||||
timeout 60 bash -c 'until curl -f http://localhost:7745/api/v1/status; do sleep 2; done'
|
|
||||||
echo "HomeBox stable version is ready"
|
|
||||||
|
|
||||||
# Step 2: Create test data
|
|
||||||
- name: Create test data
|
|
||||||
run: |
|
|
||||||
chmod +x .github/scripts/upgrade-test/create-test-data.sh
|
|
||||||
.github/scripts/upgrade-test/create-test-data.sh
|
|
||||||
env:
|
|
||||||
HOMEBOX_URL: http://localhost:7745
|
|
||||||
|
|
||||||
- name: Verify initial data creation
|
|
||||||
run: |
|
|
||||||
echo "Verifying test data was created..."
|
|
||||||
# Check if database file exists and has content
|
|
||||||
if [ -f /tmp/homebox-data-old/homebox.db ]; then
|
|
||||||
ls -lh /tmp/homebox-data-old/homebox.db
|
|
||||||
echo "Database file exists"
|
|
||||||
else
|
|
||||||
echo "Database file not found!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Stop old HomeBox instance
|
|
||||||
run: |
|
|
||||||
docker stop homebox-old
|
|
||||||
docker rm homebox-old
|
|
||||||
|
|
||||||
# Step 3: Build latest version from main branch
|
|
||||||
- name: Build HomeBox from main branch
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
--build-arg VERSION=main \
|
|
||||||
--build-arg COMMIT=${{ github.sha }} \
|
|
||||||
--build-arg BUILD_TIME="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
|
||||||
-t homebox:test \
|
|
||||||
-f Dockerfile \
|
|
||||||
.
|
|
||||||
|
|
||||||
# Step 4: Copy data and start new version
|
|
||||||
- name: Copy data to new location
|
|
||||||
run: |
|
|
||||||
cp -r /tmp/homebox-data-old/* /tmp/homebox-data-new/
|
|
||||||
chmod -R 777 /tmp/homebox-data-new
|
|
||||||
|
|
||||||
- name: Start HomeBox (new version)
|
|
||||||
run: |
|
|
||||||
docker run -d \
|
|
||||||
--name homebox-new \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-p 7745:7745 \
|
|
||||||
-e HBOX_LOG_LEVEL=debug \
|
|
||||||
-e HBOX_OPTIONS_ALLOW_REGISTRATION=true \
|
|
||||||
-e TZ=UTC \
|
|
||||||
-v /tmp/homebox-data-new:/data \
|
|
||||||
homebox:test
|
|
||||||
|
|
||||||
# Wait for the service to be ready
|
|
||||||
timeout 60 bash -c 'until curl -f http://localhost:7745/api/v1/status; do sleep 2; done'
|
|
||||||
echo "HomeBox new version is ready"
|
|
||||||
|
|
||||||
# Step 5: Run verification tests with Playwright
|
|
||||||
- name: Run verification tests
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
TEST_DATA_FILE=/tmp/test-users.json \
|
|
||||||
E2E_BASE_URL=http://localhost:7745 \
|
|
||||||
pnpm exec playwright test \
|
|
||||||
-c ./test/playwright.config.ts \
|
|
||||||
--project=chromium \
|
|
||||||
test/upgrade/upgrade-verification.spec.ts
|
|
||||||
env:
|
|
||||||
HOMEBOX_URL: http://localhost:7745
|
|
||||||
|
|
||||||
- name: Upload Playwright report
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: playwright-report-upgrade-test
|
|
||||||
path: frontend/playwright-report/
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload test traces
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: playwright-traces
|
|
||||||
path: frontend/test-results/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Collect logs on failure
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
echo "=== Docker logs for new version ==="
|
|
||||||
docker logs homebox-new || true
|
|
||||||
echo "=== Database content ==="
|
|
||||||
ls -la /tmp/homebox-data-new/ || true
|
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
docker stop homebox-new || true
|
|
||||||
docker rm homebox-new || true
|
|
||||||
docker rmi homebox:test || true
|
|
||||||
18
.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,10 @@ dist
|
|||||||
|
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
backend/app/api/app
|
backend/app/api/app
|
||||||
backend/app/api/__debug_bin*
|
backend/app/api/__debug_bin
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# Nuxt Publish Dir
|
# Nuxt Publish Dir
|
||||||
backend/app/api/static/public/*
|
backend/app/api/static/public/*
|
||||||
!backend/app/api/static/public/.gitkeep
|
!backend/app/api/static/public/.gitkeep
|
||||||
backend/api
|
backend/api
|
||||||
|
|
||||||
docs/.vitepress/cache/
|
|
||||||
.data/
|
|
||||||
|
|
||||||
# Playwright
|
|
||||||
frontend/test-results/
|
|
||||||
frontend/playwright-report/
|
|
||||||
frontend/blob-report/
|
|
||||||
frontend/playwright/.cache/
|
|
||||||
603
.gitlab-ci.yml
@@ -1,603 +0,0 @@
|
|||||||
include:
|
|
||||||
- template: Jobs/SAST.gitlab-ci.yml
|
|
||||||
- component: $CI_SERVER_FQDN/components/code-quality-oss/codequality-os-scanners-integration/codequality-oss@1.1.4
|
|
||||||
- component: $CI_SERVER_FQDN/components/code-intelligence/golang-code-intel@v0.3.1
|
|
||||||
- component: $CI_SERVER_FQDN/components/code-intelligence/typescript-code-intel@v0.3.1
|
|
||||||
inputs:
|
|
||||||
node_version: 24
|
|
||||||
- component: $CI_SERVER_FQDN/components/secret-detection/secret-detection@2.1.0
|
|
||||||
|
|
||||||
variables:
|
|
||||||
GITLAB_ADVANCED_SAST_ENABLED: 'true'
|
|
||||||
ADVANCED_SAST_PARTIAL_SCAN: 'differential'
|
|
||||||
DOCKER_DRIVER: overlay2
|
|
||||||
DOCKER_TLS_CERTDIR: "/certs"
|
|
||||||
# Registry configuration - adjust as needed
|
|
||||||
CI_REGISTRY_IMAGE: $CI_REGISTRY/$CI_PROJECT_PATH
|
|
||||||
|
|
||||||
stages:
|
|
||||||
- test
|
|
||||||
- build-binaries
|
|
||||||
- build-docker
|
|
||||||
- release
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Test Jobs
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
# Backend Tests (Go)
|
|
||||||
test:backend:
|
|
||||||
stage: test
|
|
||||||
image: golang:1.24
|
|
||||||
cache:
|
|
||||||
key:
|
|
||||||
files:
|
|
||||||
- backend/go.sum
|
|
||||||
paths:
|
|
||||||
- backend/.go-pkg-cache/
|
|
||||||
policy: pull-push
|
|
||||||
before_script:
|
|
||||||
- export GOMODCACHE=$(pwd)/backend/.go-pkg-cache
|
|
||||||
# Install Task
|
|
||||||
- sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin
|
|
||||||
script:
|
|
||||||
- cd backend
|
|
||||||
- task go:lint
|
|
||||||
- task go:build
|
|
||||||
- task go:coverage
|
|
||||||
coverage: '/coverage: \d+.\d+% of statements/'
|
|
||||||
artifacts:
|
|
||||||
reports:
|
|
||||||
coverage_report:
|
|
||||||
coverage_format: cobertura
|
|
||||||
path: backend/coverage.out
|
|
||||||
paths:
|
|
||||||
- backend/coverage.out
|
|
||||||
expire_in: 7 days
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
# Frontend Lint and Typecheck
|
|
||||||
test:frontend:lint:
|
|
||||||
stage: test
|
|
||||||
image: node:22-alpine
|
|
||||||
cache:
|
|
||||||
key:
|
|
||||||
files:
|
|
||||||
- frontend/pnpm-lock.yaml
|
|
||||||
paths:
|
|
||||||
- frontend/node_modules/
|
|
||||||
- .pnpm-store/
|
|
||||||
policy: pull-push
|
|
||||||
before_script:
|
|
||||||
- npm install -g pnpm@9.15.3
|
|
||||||
- pnpm config set store-dir $(pwd)/.pnpm-store
|
|
||||||
script:
|
|
||||||
- cd frontend
|
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
- pnpm run lint:ci
|
|
||||||
- pnpm run typecheck
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
# Frontend Integration Tests (SQLite)
|
|
||||||
test:frontend:integration:
|
|
||||||
stage: test
|
|
||||||
image: node:22
|
|
||||||
cache:
|
|
||||||
- key:
|
|
||||||
files:
|
|
||||||
- frontend/pnpm-lock.yaml
|
|
||||||
paths:
|
|
||||||
- frontend/node_modules/
|
|
||||||
- .pnpm-store/
|
|
||||||
policy: pull-push
|
|
||||||
- key:
|
|
||||||
files:
|
|
||||||
- backend/go.sum
|
|
||||||
paths:
|
|
||||||
- backend/.go-pkg-cache/
|
|
||||||
policy: pull-push
|
|
||||||
before_script:
|
|
||||||
- npm install -g pnpm@9.15.3
|
|
||||||
- pnpm config set store-dir $(pwd)/.pnpm-store
|
|
||||||
# Install Task
|
|
||||||
- sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin
|
|
||||||
# Install Go
|
|
||||||
- wget -q https://go.dev/dl/go1.24.0.linux-amd64.tar.gz
|
|
||||||
- tar -C /usr/local -xzf go1.24.0.linux-amd64.tar.gz
|
|
||||||
- export PATH=$PATH:/usr/local/go/bin
|
|
||||||
- export GOMODCACHE=$(pwd)/backend/.go-pkg-cache
|
|
||||||
script:
|
|
||||||
- cd frontend
|
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
- cd ..
|
|
||||||
- task test:ci
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
# Frontend Integration Tests (PostgreSQL Matrix)
|
|
||||||
test:frontend:integration:postgresql:
|
|
||||||
stage: test
|
|
||||||
image: node:22
|
|
||||||
services:
|
|
||||||
- name: postgres:${POSTGRES_VERSION}
|
|
||||||
alias: postgres
|
|
||||||
variables:
|
|
||||||
POSTGRES_USER: homebox
|
|
||||||
POSTGRES_PASSWORD: homebox
|
|
||||||
POSTGRES_DB: homebox
|
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
|
||||||
parallel:
|
|
||||||
matrix:
|
|
||||||
- POSTGRES_VERSION: ["17", "16", "15"]
|
|
||||||
cache:
|
|
||||||
- key:
|
|
||||||
files:
|
|
||||||
- frontend/pnpm-lock.yaml
|
|
||||||
paths:
|
|
||||||
- frontend/node_modules/
|
|
||||||
- .pnpm-store/
|
|
||||||
policy: pull-push
|
|
||||||
- key:
|
|
||||||
files:
|
|
||||||
- backend/go.sum
|
|
||||||
paths:
|
|
||||||
- backend/.go-pkg-cache/
|
|
||||||
policy: pull-push
|
|
||||||
before_script:
|
|
||||||
- npm install -g pnpm@9.15.3
|
|
||||||
- pnpm config set store-dir $(pwd)/.pnpm-store
|
|
||||||
# Install Task
|
|
||||||
- sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin
|
|
||||||
# Install Go
|
|
||||||
- wget -q https://go.dev/dl/go1.24.0.linux-amd64.tar.gz
|
|
||||||
- tar -C /usr/local -xzf go1.24.0.linux-amd64.tar.gz
|
|
||||||
- export PATH=$PATH:/usr/local/go/bin
|
|
||||||
- export GOMODCACHE=$(pwd)/backend/.go-pkg-cache
|
|
||||||
script:
|
|
||||||
- cd frontend
|
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
- cd ..
|
|
||||||
- task test:ci:postgresql
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
# E2E Tests (Playwright) - Sharded
|
|
||||||
test:e2e:playwright:
|
|
||||||
stage: test
|
|
||||||
image: mcr.microsoft.com/playwright:v1.48.2-jammy
|
|
||||||
timeout: 1h
|
|
||||||
parallel:
|
|
||||||
matrix:
|
|
||||||
- SHARD_INDEX: ["1", "2", "3", "4"]
|
|
||||||
SHARD_TOTAL: "4"
|
|
||||||
cache:
|
|
||||||
- key:
|
|
||||||
files:
|
|
||||||
- frontend/pnpm-lock.yaml
|
|
||||||
paths:
|
|
||||||
- frontend/node_modules/
|
|
||||||
- .pnpm-store/
|
|
||||||
policy: pull-push
|
|
||||||
- key:
|
|
||||||
files:
|
|
||||||
- backend/go.sum
|
|
||||||
paths:
|
|
||||||
- backend/.go-pkg-cache/
|
|
||||||
policy: pull-push
|
|
||||||
before_script:
|
|
||||||
- npm install -g pnpm@9.15.3
|
|
||||||
- pnpm config set store-dir $(pwd)/.pnpm-store
|
|
||||||
# Install Task
|
|
||||||
- sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin
|
|
||||||
# Install Go
|
|
||||||
- wget -q https://go.dev/dl/go1.24.0.linux-amd64.tar.gz
|
|
||||||
- tar -C /usr/local -xzf go1.24.0.linux-amd64.tar.gz
|
|
||||||
- export PATH=$PATH:/usr/local/go/bin
|
|
||||||
- export GOMODCACHE=$(pwd)/backend/.go-pkg-cache
|
|
||||||
script:
|
|
||||||
- cd frontend
|
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
- cd ..
|
|
||||||
- cd backend && go mod download
|
|
||||||
- task test:e2e -- --shard=$SHARD_INDEX/$SHARD_TOTAL
|
|
||||||
artifacts:
|
|
||||||
when: always
|
|
||||||
paths:
|
|
||||||
- frontend/blob-report/
|
|
||||||
expire_in: 2 days
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
# E2E Reports Merge
|
|
||||||
test:e2e:merge-reports:
|
|
||||||
stage: test
|
|
||||||
image: mcr.microsoft.com/playwright:v1.48.2-jammy
|
|
||||||
needs:
|
|
||||||
- test:e2e:playwright
|
|
||||||
cache:
|
|
||||||
key:
|
|
||||||
files:
|
|
||||||
- frontend/pnpm-lock.yaml
|
|
||||||
paths:
|
|
||||||
- frontend/node_modules/
|
|
||||||
- .pnpm-store/
|
|
||||||
policy: pull
|
|
||||||
before_script:
|
|
||||||
- npm install -g pnpm@9.15.3
|
|
||||||
- pnpm config set store-dir $(pwd)/.pnpm-store
|
|
||||||
script:
|
|
||||||
- cd frontend
|
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
# Download all blob reports
|
|
||||||
- mkdir -p all-blob-reports
|
|
||||||
# GitLab automatically downloads artifacts from dependencies
|
|
||||||
- cp -r ../frontend/blob-report/* all-blob-reports/ || true
|
|
||||||
- pnpm exec playwright merge-reports --reporter html,github ./all-blob-reports || true
|
|
||||||
artifacts:
|
|
||||||
when: always
|
|
||||||
paths:
|
|
||||||
- frontend/playwright-report/
|
|
||||||
expire_in: 30 days
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
when: always
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
when: always
|
|
||||||
|
|
||||||
# Update Currencies (Scheduled Job)
|
|
||||||
update:currencies:
|
|
||||||
stage: test
|
|
||||||
image: python:3.11
|
|
||||||
cache:
|
|
||||||
key: python-currencies
|
|
||||||
paths:
|
|
||||||
- .pip-cache/
|
|
||||||
before_script:
|
|
||||||
- pip install --cache-dir .pip-cache -r .github/workflows/update-currencies/requirements.txt
|
|
||||||
script:
|
|
||||||
- python .github/scripts/update_currencies.py
|
|
||||||
- |
|
|
||||||
if git diff --quiet -- backend/internal/core/currencies/currencies.json; then
|
|
||||||
echo "✅ currencies.json is already up-to-date"
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "Changes detected in currencies.json"
|
|
||||||
git config user.name "GitLab CI"
|
|
||||||
git config user.email "ci@gitlab.com"
|
|
||||||
git checkout -b update-currencies-$CI_COMMIT_SHORT_SHA
|
|
||||||
git add backend/internal/core/currencies/currencies.json
|
|
||||||
git commit -m "chore: update currencies.json"
|
|
||||||
git push -o merge_request.create -o merge_request.target=$CI_DEFAULT_BRANCH -o merge_request.title="Update currencies.json" origin update-currencies-$CI_COMMIT_SHORT_SHA
|
|
||||||
fi
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "schedule" && $UPDATE_CURRENCIES == "true"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $UPDATE_CURRENCIES == "true"
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Binary Build with GoReleaser
|
|
||||||
# ==========================================
|
|
||||||
build:binaries:
|
|
||||||
stage: build-binaries
|
|
||||||
image: golang:1.24
|
|
||||||
cache:
|
|
||||||
- key:
|
|
||||||
files:
|
|
||||||
- frontend/pnpm-lock.yaml
|
|
||||||
paths:
|
|
||||||
- frontend/node_modules/
|
|
||||||
- .pnpm-store/
|
|
||||||
policy: pull-push
|
|
||||||
- key:
|
|
||||||
files:
|
|
||||||
- backend/go.sum
|
|
||||||
paths:
|
|
||||||
- backend/.go-pkg-cache/
|
|
||||||
policy: pull-push
|
|
||||||
before_script:
|
|
||||||
# Install Node.js and pnpm for frontend build
|
|
||||||
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
|
||||||
- apt-get install -y nodejs
|
|
||||||
- npm install -g pnpm@9.15.3
|
|
||||||
# Configure pnpm store
|
|
||||||
- pnpm config set store-dir $(pwd)/.pnpm-store
|
|
||||||
# Install GoReleaser
|
|
||||||
- curl -sfL https://goreleaser.com/static/run | bash -s -- check
|
|
||||||
- curl -sfL https://goreleaser.com/static/run | bash -s -- --version
|
|
||||||
# Configure Go cache
|
|
||||||
- export GOMODCACHE=$(pwd)/backend/.go-pkg-cache
|
|
||||||
script:
|
|
||||||
# Build frontend
|
|
||||||
- cd frontend
|
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
- pnpm run build
|
|
||||||
- cp -r ./.output/public ../backend/app/api/static/
|
|
||||||
- cd ..
|
|
||||||
# Run GoReleaser
|
|
||||||
- cd backend
|
|
||||||
- |
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "Building release for tag: $CI_COMMIT_TAG"
|
|
||||||
curl -sfL https://goreleaser.com/static/run | bash -s -- release --clean --skip=publish
|
|
||||||
else
|
|
||||||
echo "Building snapshot"
|
|
||||||
curl -sfL https://goreleaser.com/static/run | bash -s -- release --clean --snapshot --skip=publish
|
|
||||||
fi
|
|
||||||
artifacts:
|
|
||||||
name: "homebox-binaries-$CI_COMMIT_REF_SLUG"
|
|
||||||
paths:
|
|
||||||
- backend/dist/
|
|
||||||
expire_in: 30 days
|
|
||||||
rules:
|
|
||||||
- if: $CI_COMMIT_TAG
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Docker Build Jobs - Regular
|
|
||||||
# ==========================================
|
|
||||||
.docker_build_template:
|
|
||||||
stage: build-docker
|
|
||||||
image: docker:latest
|
|
||||||
services:
|
|
||||||
- docker:dind
|
|
||||||
before_script:
|
|
||||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
|
||||||
variables:
|
|
||||||
DOCKER_BUILDKIT: 1
|
|
||||||
DOCKERFILE: Dockerfile
|
|
||||||
IMAGE_SUFFIX: ""
|
|
||||||
script:
|
|
||||||
- export VERSION=${CI_COMMIT_TAG:-$CI_COMMIT_REF_NAME}
|
|
||||||
- export COMMIT=$CI_COMMIT_SHA
|
|
||||||
- export BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
- export CACHE_TAG=${IMAGE_SUFFIX:-regular}
|
|
||||||
# Build and push for the specific platform with layer caching
|
|
||||||
- |
|
|
||||||
docker buildx create --use --name builder-${CI_JOB_ID} || true
|
|
||||||
docker buildx build \
|
|
||||||
--platform $PLATFORM \
|
|
||||||
--build-arg VERSION=$VERSION \
|
|
||||||
--build-arg COMMIT=$COMMIT \
|
|
||||||
--build-arg BUILD_TIME=$BUILD_TIME \
|
|
||||||
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE/cache:${PLATFORM_PAIR}-${CACHE_TAG}-$CI_COMMIT_REF_SLUG \
|
|
||||||
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE/cache:${PLATFORM_PAIR}-${CACHE_TAG}-$CI_DEFAULT_BRANCH \
|
|
||||||
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE/cache:${PLATFORM_PAIR}-${CACHE_TAG}-$CI_COMMIT_REF_SLUG,mode=max \
|
|
||||||
--file ./$DOCKERFILE \
|
|
||||||
--tag $CI_REGISTRY_IMAGE${IMAGE_SUFFIX}:${CI_COMMIT_REF_SLUG}-${PLATFORM_PAIR} \
|
|
||||||
--push \
|
|
||||||
.
|
|
||||||
docker buildx rm builder-${CI_JOB_ID} || true
|
|
||||||
rules:
|
|
||||||
- if: $CI_COMMIT_TAG
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
|
|
||||||
docker:build:amd64:
|
|
||||||
extends: .docker_build_template
|
|
||||||
variables:
|
|
||||||
PLATFORM: linux/amd64
|
|
||||||
PLATFORM_PAIR: linux-amd64
|
|
||||||
|
|
||||||
docker:build:arm64:
|
|
||||||
extends: .docker_build_template
|
|
||||||
variables:
|
|
||||||
PLATFORM: linux/arm64
|
|
||||||
PLATFORM_PAIR: linux-arm64
|
|
||||||
|
|
||||||
docker:build:armv7:
|
|
||||||
extends: .docker_build_template
|
|
||||||
variables:
|
|
||||||
PLATFORM: linux/arm/v7
|
|
||||||
PLATFORM_PAIR: linux-arm-v7
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Docker Build Jobs - Rootless
|
|
||||||
# ==========================================
|
|
||||||
docker:build:rootless:amd64:
|
|
||||||
extends: .docker_build_template
|
|
||||||
variables:
|
|
||||||
PLATFORM: linux/amd64
|
|
||||||
PLATFORM_PAIR: linux-amd64
|
|
||||||
DOCKERFILE: Dockerfile.rootless
|
|
||||||
IMAGE_SUFFIX: -rootless
|
|
||||||
|
|
||||||
docker:build:rootless:arm64:
|
|
||||||
extends: .docker_build_template
|
|
||||||
variables:
|
|
||||||
PLATFORM: linux/arm64
|
|
||||||
PLATFORM_PAIR: linux-arm64
|
|
||||||
DOCKERFILE: Dockerfile.rootless
|
|
||||||
IMAGE_SUFFIX: -rootless
|
|
||||||
|
|
||||||
docker:build:rootless:armv7:
|
|
||||||
extends: .docker_build_template
|
|
||||||
variables:
|
|
||||||
PLATFORM: linux/arm/v7
|
|
||||||
PLATFORM_PAIR: linux-arm-v7
|
|
||||||
DOCKERFILE: Dockerfile.rootless
|
|
||||||
IMAGE_SUFFIX: -rootless
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Docker Build Jobs - Hardened
|
|
||||||
# ==========================================
|
|
||||||
docker:build:hardened:amd64:
|
|
||||||
extends: .docker_build_template
|
|
||||||
variables:
|
|
||||||
PLATFORM: linux/amd64
|
|
||||||
PLATFORM_PAIR: linux-amd64
|
|
||||||
DOCKERFILE: Dockerfile.hardened
|
|
||||||
IMAGE_SUFFIX: -hardened
|
|
||||||
|
|
||||||
docker:build:hardened:arm64:
|
|
||||||
extends: .docker_build_template
|
|
||||||
variables:
|
|
||||||
PLATFORM: linux/arm64
|
|
||||||
PLATFORM_PAIR: linux-arm64
|
|
||||||
DOCKERFILE: Dockerfile.hardened
|
|
||||||
IMAGE_SUFFIX: -hardened
|
|
||||||
|
|
||||||
docker:build:hardened:armv7:
|
|
||||||
extends: .docker_build_template
|
|
||||||
variables:
|
|
||||||
PLATFORM: linux/arm/v7
|
|
||||||
PLATFORM_PAIR: linux-arm-v7
|
|
||||||
DOCKERFILE: Dockerfile.hardened
|
|
||||||
IMAGE_SUFFIX: -hardened
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Docker Manifest Creation - Regular
|
|
||||||
# ==========================================
|
|
||||||
docker:manifest:
|
|
||||||
stage: release
|
|
||||||
image: docker:latest
|
|
||||||
services:
|
|
||||||
- docker:dind
|
|
||||||
before_script:
|
|
||||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
|
||||||
script:
|
|
||||||
- export VERSION=${CI_COMMIT_TAG:-$CI_COMMIT_REF_NAME}
|
|
||||||
# Create manifest for regular image
|
|
||||||
- |
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-arm64 \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-arm-v7
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
|
|
||||||
# Tag as latest on main branch or create version tags
|
|
||||||
- |
|
|
||||||
if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE:latest \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-arm64 \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-arm-v7
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE:latest
|
|
||||||
fi
|
|
||||||
- |
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
# Create version tag
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-arm64 \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-arm-v7
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
|
|
||||||
|
|
||||||
# Create major.minor tag if semantic version
|
|
||||||
MAJOR_MINOR=$(echo $CI_COMMIT_TAG | sed -E 's/^v?([0-9]+\.[0-9]+)\..*/\1/')
|
|
||||||
if [ -n "$MAJOR_MINOR" ]; then
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE:$MAJOR_MINOR \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-arm64 \
|
|
||||||
$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_SLUG}-linux-arm-v7
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE:$MAJOR_MINOR
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
needs:
|
|
||||||
- docker:build:amd64
|
|
||||||
- docker:build:arm64
|
|
||||||
- docker:build:armv7
|
|
||||||
rules:
|
|
||||||
- if: $CI_COMMIT_TAG
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Docker Manifest Creation - Rootless
|
|
||||||
# ==========================================
|
|
||||||
docker:manifest:rootless:
|
|
||||||
stage: release
|
|
||||||
image: docker:latest
|
|
||||||
services:
|
|
||||||
- docker:dind
|
|
||||||
before_script:
|
|
||||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
|
||||||
script:
|
|
||||||
- export VERSION=${CI_COMMIT_TAG:-$CI_COMMIT_REF_NAME}
|
|
||||||
# Create manifest for rootless image
|
|
||||||
- |
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE-rootless:$CI_COMMIT_REF_SLUG \
|
|
||||||
$CI_REGISTRY_IMAGE-rootless:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE-rootless:${CI_COMMIT_REF_SLUG}-linux-arm64
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE-rootless:$CI_COMMIT_REF_SLUG
|
|
||||||
# Tag as latest on main branch or create version tags
|
|
||||||
- |
|
|
||||||
if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE-rootless:latest \
|
|
||||||
$CI_REGISTRY_IMAGE-rootless:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE-rootless:${CI_COMMIT_REF_SLUG}-linux-arm64
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE-rootless:latest
|
|
||||||
fi
|
|
||||||
- |
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE-rootless:$CI_COMMIT_TAG \
|
|
||||||
$CI_REGISTRY_IMAGE-rootless:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE-rootless:${CI_COMMIT_REF_SLUG}-linux-arm64
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE-rootless:$CI_COMMIT_TAG
|
|
||||||
|
|
||||||
MAJOR_MINOR=$(echo $CI_COMMIT_TAG | sed -E 's/^v?([0-9]+\.[0-9]+)\..*/\1/')
|
|
||||||
if [ -n "$MAJOR_MINOR" ]; then
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE-rootless:$MAJOR_MINOR \
|
|
||||||
$CI_REGISTRY_IMAGE-rootless:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE-rootless:${CI_COMMIT_REF_SLUG}-linux-arm64
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE-rootless:$MAJOR_MINOR
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
needs:
|
|
||||||
- docker:build:rootless:amd64
|
|
||||||
- docker:build:rootless:arm64
|
|
||||||
rules:
|
|
||||||
- if: $CI_COMMIT_TAG
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Docker Manifest Creation - Hardened
|
|
||||||
# ==========================================
|
|
||||||
docker:manifest:hardened:
|
|
||||||
stage: release
|
|
||||||
image: docker:latest
|
|
||||||
services:
|
|
||||||
- docker:dind
|
|
||||||
before_script:
|
|
||||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
|
||||||
script:
|
|
||||||
- export VERSION=${CI_COMMIT_TAG:-$CI_COMMIT_REF_NAME}
|
|
||||||
# Create manifest for hardened image
|
|
||||||
- |
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE-hardened:$CI_COMMIT_REF_SLUG \
|
|
||||||
$CI_REGISTRY_IMAGE-hardened:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE-hardened:${CI_COMMIT_REF_SLUG}-linux-arm64
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE-hardened:$CI_COMMIT_REF_SLUG
|
|
||||||
# Tag as latest on main branch or create version tags
|
|
||||||
- |
|
|
||||||
if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE-hardened:latest \
|
|
||||||
$CI_REGISTRY_IMAGE-hardened:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE-hardened:${CI_COMMIT_REF_SLUG}-linux-arm64
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE-hardened:latest
|
|
||||||
fi
|
|
||||||
- |
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE-hardened:$CI_COMMIT_TAG \
|
|
||||||
$CI_REGISTRY_IMAGE-hardened:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE-hardened:${CI_COMMIT_REF_SLUG}-linux-arm64
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE-hardened:$CI_COMMIT_TAG
|
|
||||||
|
|
||||||
MAJOR_MINOR=$(echo $CI_COMMIT_TAG | sed -E 's/^v?([0-9]+\.[0-9]+)\..*/\1/')
|
|
||||||
if [ -n "$MAJOR_MINOR" ]; then
|
|
||||||
docker manifest create $CI_REGISTRY_IMAGE-hardened:$MAJOR_MINOR \
|
|
||||||
$CI_REGISTRY_IMAGE-hardened:${CI_COMMIT_REF_SLUG}-linux-amd64 \
|
|
||||||
$CI_REGISTRY_IMAGE-hardened:${CI_COMMIT_REF_SLUG}-linux-arm64
|
|
||||||
docker manifest push $CI_REGISTRY_IMAGE-hardened:$MAJOR_MINOR
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
needs:
|
|
||||||
- docker:build:hardened:amd64
|
|
||||||
- docker:build:hardened:arm64
|
|
||||||
rules:
|
|
||||||
- if: $CI_COMMIT_TAG
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=
|
|
||||||
entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/sysadminsmedia/homebox/backend v0.0.0-20251228172914-2a6773d1d610 h1:kNLtnxaPaOryBUZ7RgUHPQVWxIExXYR/q9pYCbum5Vk=
|
|
||||||
github.com/sysadminsmedia/homebox/backend v0.0.0-20251228172914-2a6773d1d610/go.mod h1:9zHHw5TNttw5Kn4Wks+SxwXmJPz6PgGNbnB4BtF1Z4c=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@@ -3,7 +3,7 @@ package schema
|
|||||||
import (
|
import (
|
||||||
"entgo.io/ent"
|
"entgo.io/ent"
|
||||||
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/schema/mixins"
|
"github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
|
||||||
)
|
)
|
||||||
|
|
||||||
type {{ .Scaffold.model }} struct {
|
type {{ .Scaffold.model }} struct {
|
||||||
|
|||||||
14
.vscode/launch.json
vendored
@@ -16,14 +16,15 @@
|
|||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "debug",
|
"mode": "debug",
|
||||||
"program": "${workspaceFolder}/backend/app/api/",
|
"program": "${workspaceRoot}/backend/app/api/",
|
||||||
"args": [],
|
"args": [],
|
||||||
"env": {
|
"env": {
|
||||||
"HBOX_DEMO": "true",
|
"HBOX_DEMO": "true",
|
||||||
"HBOX_LOG_LEVEL": "debug",
|
"HBOX_LOG_LEVEL": "debug",
|
||||||
"HBOX_DEBUG_ENABLED": "true"
|
"HBOX_DEBUG_ENABLED": "true",
|
||||||
|
"HBOX_STORAGE_DATA": "${workspaceRoot}/backend/.data",
|
||||||
|
"HBOX_STORAGE_SQLITE_URL": "${workspaceRoot}/backend/.data/homebox.db?_fk=1"
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Launch Frontend",
|
"name": "Launch Frontend",
|
||||||
@@ -37,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",
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
9
.vscode/settings.json
vendored
@@ -4,7 +4,7 @@
|
|||||||
},
|
},
|
||||||
"explorer.fileNesting.enabled": true,
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"package.json": "package-lock.json, yarn.lock, eslint.config.mjs, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js",
|
"package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js",
|
||||||
"docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml",
|
"docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml",
|
||||||
"README.md": "LICENSE, SECURITY.md"
|
"README.md": "LICENSE, SECURITY.md"
|
||||||
},
|
},
|
||||||
@@ -22,8 +22,6 @@
|
|||||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
},
|
},
|
||||||
"eslint.format.enable": true,
|
"eslint.format.enable": true,
|
||||||
"eslint.validate": ["javascript", "typescript", "vue"],
|
|
||||||
"eslint.useFlatConfig": true,
|
|
||||||
"css.validate": false,
|
"css.validate": false,
|
||||||
"tailwindCSS.includeLanguages": {
|
"tailwindCSS.includeLanguages": {
|
||||||
"vue": "html",
|
"vue": "html",
|
||||||
@@ -32,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).
|
|
||||||
@@ -33,6 +33,8 @@ If you're using `taskfile` you can run `task --list-all` for a list of all comma
|
|||||||
|
|
||||||
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 frontend server.
|
||||||
|
|
||||||
### API Development Notes
|
### API Development Notes
|
||||||
|
|
||||||
start command `task go:run`
|
start command `task go:run`
|
||||||
@@ -42,9 +44,9 @@ start command `task go:run`
|
|||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
@@ -52,4 +54,4 @@ start command `task ui:dev`
|
|||||||
|
|
||||||
Create a new tag in GitHub with the version number vX.X.X. This will trigger a new release to be created.
|
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
|
Test -> Goreleaser -> Publish Release -> Trigger Docker Builds -> Deploy Docs + Fly.io Demo
|
||||||
91
Dockerfile
@@ -1,101 +1,50 @@
|
|||||||
# Node dependencies stage
|
|
||||||
FROM public.ecr.aws/docker/library/node:22-alpine AS frontend-dependencies
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install pnpm globally (caching layer)
|
# Build Nuxt
|
||||||
|
FROM node:18-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:22-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 TARGETOS
|
|
||||||
ARG TARGETARCH
|
|
||||||
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++
|
||||||
if [ "$TARGETARCH" != "arm" ] || [ "$TARGETARCH" != "riscv64" ]; then apk --no-cache add libwebp libavif libheif libjxl; fi
|
|
||||||
|
|
||||||
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=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
|
||||||
|
|
||||||
# Use cache for Go build artifacts
|
# Production Stage
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
FROM alpine:latest
|
||||||
if [ "$TARGETARCH" = "arm" ] || [ "$TARGETARCH" = "riscv64" ]; \
|
|
||||||
then echo "nodynamic" $TARGETOS $TARGETARCH; CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
|
|
||||||
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
|
|
||||||
-tags nodynamic -o /go/bin/api -v ./app/api/*.go; \
|
|
||||||
else \
|
|
||||||
echo $TARGETOS $TARGETARCH; CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
|
|
||||||
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
|
|
||||||
-o /go/bin/api -v ./app/api/*.go; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM public.ecr.aws/docker/library/alpine:latest
|
|
||||||
ENV HBOX_MODE=production
|
ENV HBOX_MODE=production
|
||||||
ENV HBOX_STORAGE_CONN_STRING=file:///?no_tmp_dir=true
|
ENV HBOX_STORAGE_DATA=/data/
|
||||||
ENV HBOX_STORAGE_PREFIX_PATH=data
|
ENV HBOX_STORAGE_SQLITE_URL=/data/homebox.db?_pragma=busy_timeout=2000&_pragma=journal_mode=WAL&_fk=1
|
||||||
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
|
||||||
RUN apk --no-cache add ca-certificates wget && \
|
|
||||||
if [ "$TARGETARCH" != "arm" ] || [ "$TARGETARCH" != "riscv64" ]; then apk --no-cache add libwebp libavif libheif libjxl; fi
|
|
||||||
|
|
||||||
# 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,136 +0,0 @@
|
|||||||
# ---------------------------------------
|
|
||||||
# Node dependencies stage
|
|
||||||
# ---------------------------------------
|
|
||||||
FROM public.ecr.aws/docker/library/node:22-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:22-alpine AS frontend-builder
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install pnpm globally again (it can reuse the cache if not changed)
|
|
||||||
RUN npm install -g pnpm
|
|
||||||
|
|
||||||
# Copy over source files and node_modules from dependencies stage
|
|
||||||
COPY frontend .
|
|
||||||
COPY --from=frontend-dependencies /app/node_modules ./node_modules
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
# ---------------------------------------
|
|
||||||
# Go dependencies stage
|
|
||||||
# ---------------------------------------
|
|
||||||
FROM public.ecr.aws/docker/library/golang:alpine AS builder-dependencies
|
|
||||||
WORKDIR /go/src/app
|
|
||||||
|
|
||||||
# Copy go.mod and go.sum for better caching
|
|
||||||
COPY ./backend/go.mod ./backend/go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# ---------------------------------------
|
|
||||||
# Build API + healthcheck stage
|
|
||||||
# ---------------------------------------
|
|
||||||
FROM public.ecr.aws/docker/library/golang:alpine AS builder
|
|
||||||
ARG TARGETOS
|
|
||||||
ARG TARGETARCH
|
|
||||||
ARG BUILD_TIME
|
|
||||||
ARG COMMIT
|
|
||||||
ARG VERSION
|
|
||||||
|
|
||||||
# Install necessary build tools
|
|
||||||
RUN apk update && \
|
|
||||||
apk upgrade && \
|
|
||||||
apk add --no-cache git build-base gcc g++
|
|
||||||
|
|
||||||
WORKDIR /go/src/app
|
|
||||||
|
|
||||||
# Copy Go modules (from dependencies stage) and source code
|
|
||||||
COPY --from=builder-dependencies /go/pkg/mod /go/pkg/mod
|
|
||||||
COPY ./backend .
|
|
||||||
|
|
||||||
# Clear old public files and copy new ones from frontend build
|
|
||||||
RUN rm -rf ./app/api/public
|
|
||||||
COPY --from=frontend-builder /app/.output/public ./app/api/static/public
|
|
||||||
|
|
||||||
# Use cache for Go build artifacts to build Homebox API
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
|
||||||
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
|
|
||||||
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
|
|
||||||
-tags nodynamic -o /go/bin/api -v ./app/api/*.go
|
|
||||||
|
|
||||||
RUN chmod +x /go/bin/api
|
|
||||||
RUN mkdir /app
|
|
||||||
RUN mkdir /data
|
|
||||||
|
|
||||||
# ---------- Build static healthcheck helper ----------
|
|
||||||
# A small Go program that GETs the status URL and exits 0 on 2xx.
|
|
||||||
RUN cat > /tmp/healthcheck.go <<'EOF'
|
|
||||||
package main
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
func main() {
|
|
||||||
url := "http://127.0.0.1:7745/api/v1/status"
|
|
||||||
if len(os.Args) > 1 { url = os.Args[1] }
|
|
||||||
c := &http.Client{ Timeout: 3 * time.Second }
|
|
||||||
resp, err := c.Get(url)
|
|
||||||
if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode/100 != 2 {
|
|
||||||
fmt.Fprintln(os.Stderr, "unexpected status:", resp.StatusCode)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
|
||||||
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
|
||||||
go build -ldflags "-s -w" -o /go/bin/hc /tmp/healthcheck.go
|
|
||||||
|
|
||||||
# ---------------------------------------
|
|
||||||
# Production stage
|
|
||||||
# ---------------------------------------
|
|
||||||
FROM gcr.io/distroless/static:nonroot
|
|
||||||
ENV HBOX_MODE=production
|
|
||||||
ENV HBOX_STORAGE_CONN_STRING=file:///?no_tmp_dir=true
|
|
||||||
ENV HBOX_STORAGE_PREFIX_PATH=data
|
|
||||||
ENV HBOX_DATABASE_SQLITE_PATH=/data/homebox.db?_pragma=busy_timeout=2000&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite
|
|
||||||
|
|
||||||
# Create application directory and copy over built Go binary and assets
|
|
||||||
COPY --from=builder --chown=65532:65532 /app /app
|
|
||||||
COPY --from=builder --chown=65532:65532 --chmod=755 /go/bin/api /app
|
|
||||||
COPY --from=builder --chown=65532:65532 /data /data
|
|
||||||
|
|
||||||
# Copy the healthcheck helper
|
|
||||||
COPY --from=builder --chown=65532:65532 --chmod=755 /go/bin/hc /app/healthcheck
|
|
||||||
|
|
||||||
# Labels and configuration for the final image
|
|
||||||
LABEL Name=homebox Version=0.0.1
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/sysadminsmedia/homebox"
|
|
||||||
|
|
||||||
# Expose necessary ports for Homebox
|
|
||||||
EXPOSE 7745
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Persist volume for data
|
|
||||||
VOLUME [ "/data" ]
|
|
||||||
|
|
||||||
# Entrypoint and CMD
|
|
||||||
USER 65532
|
|
||||||
ENTRYPOINT [ "/app/api" ]
|
|
||||||
CMD [ "/data/config.yml" ]
|
|
||||||
|
|
||||||
# JSON exec-form healthcheck (no shell, no wget)
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
||||||
CMD ["/app/healthcheck", "http://127.0.0.1:7745/api/v1/status"]
|
|
||||||
@@ -1,108 +1,53 @@
|
|||||||
# Node dependencies stage
|
|
||||||
FROM public.ecr.aws/docker/library/node:22-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
|
||||||
|
|
||||||
# Build Nuxt (frontend) stage
|
|
||||||
FROM public.ecr.aws/docker/library/node:22-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 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 TARGETOS
|
|
||||||
ARG TARGETARCH
|
|
||||||
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++
|
||||||
if [ "$TARGETARCH" != "arm" ] || [ "$TARGETARCH" != "riscv64" ]; then apk --no-cache add libwebp libavif libheif libjxl; fi
|
|
||||||
|
|
||||||
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=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 && \
|
||||||
|
chmod +x /go/bin/api && \
|
||||||
|
# create a directory so that we can copy it in the next stage
|
||||||
|
mkdir /data
|
||||||
|
|
||||||
# Use cache for Go build artifacts
|
# Production Stage
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
FROM gcr.io/distroless/static
|
||||||
if [ "$TARGETARCH" = "arm" ] || [ "$TARGETARCH" = "riscv64" ]; \
|
|
||||||
then echo "nodynamic" $TARGETOS $TARGETARCH; CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
|
|
||||||
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
|
|
||||||
-tags nodynamic -o /go/bin/api -v ./app/api/*.go; \
|
|
||||||
else \
|
|
||||||
echo $TARGETOS $TARGETARCH; CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
|
|
||||||
-ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
|
|
||||||
-o /go/bin/api -v ./app/api/*.go; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
RUN mkdir /data
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM public.ecr.aws/docker/library/alpine:latest
|
|
||||||
ENV HBOX_MODE=production
|
ENV HBOX_MODE=production
|
||||||
ENV HBOX_STORAGE_CONN_STRING=file:///?no_tmp_dir=true
|
ENV HBOX_STORAGE_DATA=/data/
|
||||||
ENV HBOX_STORAGE_PREFIX_PATH=data
|
ENV HBOX_STORAGE_SQLITE_URL=/data/homebox.db?_fk=1
|
||||||
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
|
# Copy the binary and the (empty) /data dir and
|
||||||
RUN apk --no-cache add ca-certificates wget && \
|
# change the ownership to the low-privileged user
|
||||||
if [ "$TARGETARCH" != "arm" ] || [ "$TARGETARCH" != "riscv64" ]; then apk --no-cache add libwebp libavif libheif libjxl; fi
|
|
||||||
|
|
||||||
# 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 /go/bin/api /app
|
||||||
COPY --from=builder --chown=nonroot /data /data
|
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 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
|
|
||||||
|
|
||||||
# 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" ]
|
VOLUME [ "/data" ]
|
||||||
|
|
||||||
# Entrypoint and CMD
|
# Drop root and run as low-privileged user
|
||||||
USER nonroot
|
USER nonroot
|
||||||
ENTRYPOINT [ "/app/api" ]
|
ENTRYPOINT [ "/app" ]
|
||||||
CMD [ "/data/config.yml" ]
|
CMD [ "/data/config.yml" ]
|
||||||
|
|||||||
77
README.md
@@ -1,60 +1,22 @@
|
|||||||
<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>
|
||||||
<p align="center" style="width: 100%;">
|
|
||||||
<img src="https://img.shields.io/github/check-runs/sysadminsmedia/homebox/main" alt="Github Checks"/>
|
|
||||||
<img src="https://img.shields.io/github/license/sysadminsmedia/homebox"/>
|
|
||||||
<img src="https://img.shields.io/github/v/release/sysadminsmedia/homebox?sort=semver&display_name=release"/>
|
|
||||||
<img src="https://img.shields.io/weblate/progress/homebox?server=https%3A%2F%2Ftranslate.sysadminsmedia.com"/>
|
|
||||||
</p>
|
|
||||||
<p align="center" style="width: 100%;">
|
|
||||||
<img src="https://img.shields.io/reddit/subreddit-subscribers/homebox"/>
|
|
||||||
<img src="https://img.shields.io/mastodon/follow/110749314839831923?domain=infosec.exchange"/>
|
|
||||||
<img src="https://img.shields.io/lemmy/homebox%40lemmy.world?label=lemmy"/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## What is HomeBox
|
|
||||||
|
|
||||||
HomeBox is the inventory and organization system built for the Home User! With a focus on simplicity and ease of use, Homebox is the perfect solution for your home inventory, organization, and management needs. While developing this project, We've tried to keep the following principles in mind:
|
|
||||||
|
|
||||||
- 🧘 _Simple but Expandable_ - Homebox is designed to be simple and easy to use. No complicated setup or configuration required. But expandable to whatever level of infrastructure you want to put into it.
|
|
||||||
- 🚀 _Blazingly Fast_ - Homebox is written in Go, which makes it extremely fast and requires minimal resources to deploy. In general, idle memory usage is less than 50MB for the whole container.
|
|
||||||
- 📦 _Portable_ - Homebox is designed to be portable and run on anywhere. We use SQLite and an embedded Web UI to make it easy to deploy, use, and backup.
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
- 📇 Rich Organization - Organize your items into categories, locations, and tags. You can also create custom fields to store additional information about your items.
|
|
||||||
- 🔍 Powerful Search - Quickly find items in your inventory using the powerful search feature.
|
|
||||||
- 📸 Image Upload - Upload images of your items to make it easy to identify them.
|
|
||||||
- 📄 Document and Warranty Tracking - Keep track of important documents and warranties for your items.
|
|
||||||
- 💰 Purchase & Maintenance Tracking - Track purchase dates, prices, and maintenance schedules for your items.
|
|
||||||
- 📱 Responsive Design - Homebox is designed to work on any device, including desktops, tablets, and smartphones.
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
You can also try the demo instances of Homebox:
|
|
||||||
- [Demo](https://demo.homebox.software)
|
|
||||||
- [Nightly](https://nightly.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 or hardened image, ensure data
|
# If using the rootless image, ensure data
|
||||||
# folder has correct permissions
|
# folder has correct permissions
|
||||||
mkdir -p /path/to/data/folder
|
mkdir -p /path/to/data/folder
|
||||||
chown 65532:65532 -R /path/to/data/folder
|
chown 65532:65532 -R /path/to/data/folder
|
||||||
@@ -64,31 +26,18 @@ docker run -d \
|
|||||||
--publish 3100:7745 \
|
--publish 3100:7745 \
|
||||||
--env TZ=Europe/Bucharest \
|
--env TZ=Europe/Bucharest \
|
||||||
--volume /path/to/data/folder/:/data \
|
--volume /path/to/data/folder/:/data \
|
||||||
ghcr.io/sysadminsmedia/homebox:latest
|
ghcr.io/hay-kot/homebox:latest
|
||||||
# ghcr.io/sysadminsmedia/homebox:latest-rootless
|
# ghcr.io/hay-kot/homebox:latest-rootless
|
||||||
# ghcr.io/sysadminsmedia/homebox:latest-hardened
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- CONTRIBUTING -->
|
<!-- CONTRIBUTING -->
|
||||||
|
|
||||||
## 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**.
|
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
|
||||||
|
|
||||||
To get started with code based contributions, please see our [contributing guide](https://homebox.software/en/contribute/get-started.html).
|
If you are not a coder, 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.
|
||||||
|
|
||||||
If you are not a coder and can't help translate, you can still contribute financially. Financial contributions help us maintain the project and keep demos running.
|
|
||||||
|
|
||||||
## 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/).
|
|
||||||
|
|
||||||
[](https://translate.sysadminsmedia.com/engage/homebox/)
|
|
||||||
|
|
||||||
|
<a href="https://www.buymeacoffee.com/haykot" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-green.png" alt="Buy Me A Coffee" style="height: 30px !important;width: 107px !important;" ></a>
|
||||||
## Credits
|
## Credits
|
||||||
- Original project by [@hay-kot](https://github.com/hay-kot)
|
|
||||||
- Logo by [@lakotelman](https://github.com/lakotelman)
|
|
||||||
|
|
||||||
### Contributors
|
- Logo by [@lakotelman](https://github.com/lakotelman)
|
||||||
<a href="https://github.com/sysadminsmedia/homebox/graphs/contributors">
|
|
||||||
<img src="https://contrib.rocks/image?repo=sysadminsmedia/homebox" />
|
|
||||||
</a>
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
116
Taskfile.yml
@@ -2,18 +2,21 @@ version: "3"
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
HBOX_LOG_LEVEL: debug
|
HBOX_LOG_LEVEL: debug
|
||||||
HBOX_DATABASE_DRIVER: sqlite3
|
HBOX_STORAGE_SQLITE_URL: .data/homebox.db?_pragma=busy_timeout=1000&_pragma=journal_mode=WAL&_fk=1
|
||||||
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"
|
||||||
|
HBOX_MAILER_HOST: 127.0.0.1
|
||||||
|
HBOX_MAILER_PORT: 1025
|
||||||
|
HBOX_MAILER_USERNAME: c836555d57d205
|
||||||
|
HBOX_MAILER_PASSWORD: 3ff2f9986f3cff
|
||||||
|
HBOX_MAILER_FROM: info@example.com
|
||||||
tasks:
|
tasks:
|
||||||
setup:
|
setup:
|
||||||
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:
|
swag:
|
||||||
desc: Generate swagger docs
|
desc: Generate swagger docs
|
||||||
@@ -23,23 +26,19 @@ tasks:
|
|||||||
INTERNAL: "../../../internal"
|
INTERNAL: "../../../internal"
|
||||||
PKGS: "../../../pkgs"
|
PKGS: "../../../pkgs"
|
||||||
cmds:
|
cmds:
|
||||||
|
- swag fmt --dir={{ .API }}
|
||||||
- swag init --dir={{ .API }},{{ .INTERNAL }}/core/services,{{ .INTERNAL }}/data/repo --parseDependency
|
- swag init --dir={{ .API }},{{ .INTERNAL }}/core/services,{{ .INTERNAL }}/data/repo --parseDependency
|
||||||
- npx -y -p swagger2openapi swagger2openapi --outfile ./docs/openapi-3.json ./docs/swagger.json
|
|
||||||
- npx -y -p swagger2openapi swagger2openapi --yaml --outfile ./docs/openapi-3.yaml ./docs/swagger.json
|
|
||||||
- cp -r ./docs/swagger.json ../../../../docs/en/api/swagger-2.0.json
|
|
||||||
- cp -r ./docs/swagger.yaml ../../../../docs/en/api/swagger-2.0.yaml
|
|
||||||
- cp -r ./docs/openapi-3.json ../../../../docs/en/api/openapi-3.0.json
|
|
||||||
- cp -r ./docs/openapi-3.yaml ../../../../docs/en/api/openapi-3.0.yaml
|
|
||||||
sources:
|
sources:
|
||||||
- "./backend/app/api/**/*"
|
- "./backend/app/api/**/*"
|
||||||
- "./backend/internal/data/**"
|
- "./backend/internal/data/**"
|
||||||
- "./backend/internal/core/services/**/*"
|
- "./backend/internal/core/services/**/*"
|
||||||
- "./backend/app/tools/typegen/main.go"
|
- "./backend/app/tools/typegen/main.go"
|
||||||
|
|
||||||
typescript-types:
|
typescript-types:
|
||||||
desc: Generates typescript types from swagger definition
|
desc: Generates typescript types from swagger definition
|
||||||
cmds:
|
cmds:
|
||||||
- |
|
- |
|
||||||
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 \
|
||||||
@@ -55,12 +54,9 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- task: swag
|
- task: swag
|
||||||
- task: typescript-types
|
- task: typescript-types
|
||||||
- cp ./backend/app/api/static/docs/swagger.json docs/en/api/openapi-2.0.json
|
- cp ./backend/app/api/static/docs/swagger.json docs/docs/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
|
dir: backend
|
||||||
deps:
|
deps:
|
||||||
@@ -69,48 +65,11 @@ tasks:
|
|||||||
- go run ./app/api/ {{ .CLI_ARGS }}
|
- 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:ci:with-frontend:
|
|
||||||
desc: Run backend with frontend in CI mode
|
|
||||||
dir: frontend
|
|
||||||
cmds:
|
|
||||||
- pnpm install
|
|
||||||
- pnpm run build
|
|
||||||
- cp -r ./.output/public ../backend/app/api/static/
|
|
||||||
- task: go:ci
|
|
||||||
silent: true
|
|
||||||
|
|
||||||
go:test:
|
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
|
dir: backend
|
||||||
cmds:
|
cmds:
|
||||||
- go test {{ .CLI_ARGS }} ./...
|
- 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
|
||||||
@@ -125,6 +84,12 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
|
|
||||||
|
go:fmt:
|
||||||
|
desc: Runs go fmt on the backend
|
||||||
|
dir: backend
|
||||||
|
cmds:
|
||||||
|
- gofumpt -w .
|
||||||
|
|
||||||
go:lint:
|
go:lint:
|
||||||
desc: Runs golangci-lint
|
desc: Runs golangci-lint
|
||||||
dir: backend
|
dir: backend
|
||||||
@@ -153,6 +118,13 @@ tasks:
|
|||||||
sources:
|
sources:
|
||||||
- "./backend/internal/data/ent/schema/**/*"
|
- "./backend/internal/data/ent/schema/**/*"
|
||||||
|
|
||||||
|
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
|
dir: frontend
|
||||||
@@ -163,14 +135,7 @@ tasks:
|
|||||||
desc: Run frontend development server
|
desc: Run frontend development server
|
||||||
dir: frontend
|
dir: frontend
|
||||||
cmds:
|
cmds:
|
||||||
- pnpm dev --no-fork
|
- 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
|
||||||
@@ -189,37 +154,10 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- cd backend && go build ./app/api
|
- cd backend && go build ./app/api
|
||||||
- backend/api &
|
- backend/api &
|
||||||
- sleep 15
|
- 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 15
|
|
||||||
- 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:with-frontend
|
|
||||||
- pnpm exec playwright install-deps
|
|
||||||
- pnpm exec playwright install
|
|
||||||
- sleep 30
|
|
||||||
- TEST_SHUTDOWN_API_SERVER=true E2E_BASE_URL=http://localhost:7745 pnpm exec playwright test -c ./test/playwright.config.ts {{ .CLI_ARGS }}
|
|
||||||
|
|
||||||
pr:
|
pr:
|
||||||
desc: Runs all tasks required for a PR
|
desc: Runs all tasks required for a PR
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
@@ -1,81 +1,77 @@
|
|||||||
version: "2"
|
run:
|
||||||
|
timeout: 10m
|
||||||
|
linters-settings:
|
||||||
|
errcheck:
|
||||||
|
exclude-functions:
|
||||||
|
- (net/http.ResponseWriter).Write
|
||||||
|
goconst:
|
||||||
|
min-len: 5
|
||||||
|
min-occurrences: 5
|
||||||
|
exhaustive:
|
||||||
|
default-signifies-exhaustive: true
|
||||||
|
revive:
|
||||||
|
ignore-generated-header: false
|
||||||
|
severity: warning
|
||||||
|
confidence: 3
|
||||||
|
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.
|
||||||
|
gocritic:
|
||||||
|
enabled-checks:
|
||||||
|
- ruleguard
|
||||||
|
testifylint:
|
||||||
|
enable-all: true
|
||||||
|
tagalign:
|
||||||
|
order:
|
||||||
|
- json
|
||||||
|
- schema
|
||||||
|
- yaml
|
||||||
|
- yml
|
||||||
|
- toml
|
||||||
|
- validate
|
||||||
linters:
|
linters:
|
||||||
default: none
|
disable-all: true
|
||||||
enable:
|
enable:
|
||||||
- asciicheck
|
- asciicheck
|
||||||
- bodyclose
|
- bodyclose
|
||||||
- copyloopvar
|
|
||||||
- depguard
|
- depguard
|
||||||
- dogsled
|
- dogsled
|
||||||
- errcheck
|
- errcheck
|
||||||
- errorlint
|
- errorlint
|
||||||
- exhaustive
|
- exhaustive
|
||||||
|
- exportloopref
|
||||||
- gochecknoinits
|
- gochecknoinits
|
||||||
- goconst
|
- goconst
|
||||||
- gocritic
|
- gocritic
|
||||||
- gocyclo
|
- gocyclo
|
||||||
|
- gofmt
|
||||||
- goprintffuncname
|
- goprintffuncname
|
||||||
|
- gosimple
|
||||||
- govet
|
- govet
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- misspell
|
- misspell
|
||||||
- nakedret
|
- nakedret
|
||||||
- revive
|
- revive
|
||||||
- sqlclosecheck
|
|
||||||
- staticcheck
|
- staticcheck
|
||||||
|
- stylecheck
|
||||||
- tagalign
|
- tagalign
|
||||||
- testifylint
|
- testifylint
|
||||||
|
- typecheck
|
||||||
|
- typecheck
|
||||||
- unconvert
|
- unconvert
|
||||||
- unused
|
- unused
|
||||||
- whitespace
|
- whitespace
|
||||||
- zerologlint
|
- zerologlint
|
||||||
settings:
|
- sqlclosecheck
|
||||||
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:
|
issues:
|
||||||
fix: true
|
exclude-use-default: false
|
||||||
formatters:
|
fix: false
|
||||||
enable:
|
exclude-dirs:
|
||||||
- gofmt
|
- internal/data/ent.*
|
||||||
exclusions:
|
|
||||||
generated: lax
|
|
||||||
paths:
|
|
||||||
- internal/data/ent.*
|
|
||||||
- third_party$
|
|
||||||
- builtin$
|
|
||||||
- examples$
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: 2
|
|
||||||
|
|
||||||
# This is an example .goreleaser.yml file with some sensible defaults.
|
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||||
# Make sure to check the documentation at https://goreleaser.com
|
# Make sure to check the documentation at https://goreleaser.com
|
||||||
before:
|
before:
|
||||||
@@ -14,56 +12,35 @@ builds:
|
|||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
|
- "386"
|
||||||
|
- arm
|
||||||
- arm64
|
- arm64
|
||||||
- riscv64
|
ignore:
|
||||||
flags:
|
- goos: windows
|
||||||
- -trimpath
|
goarch: arm
|
||||||
ldflags:
|
- goos: windows
|
||||||
- -s -w
|
goarch: "386"
|
||||||
- -X main.version={{.Version}}
|
|
||||||
- -X main.commit={{.Commit}}
|
|
||||||
- -X main.date={{.Date}}
|
|
||||||
tags:
|
|
||||||
- >-
|
|
||||||
{{- if eq .Arch "riscv64" }}nodynamic
|
|
||||||
{{- else if eq .Os "freebsd" }}nodynamic
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
signs:
|
|
||||||
- cmd: cosign
|
|
||||||
signature: "${artifact}.sigstore.json"
|
|
||||||
args:
|
|
||||||
- sign-blob
|
|
||||||
- "--bundle=${signature}"
|
|
||||||
- "${artifact}"
|
|
||||||
- "--yes"
|
|
||||||
artifacts: checksum
|
|
||||||
output: true
|
|
||||||
archives:
|
archives:
|
||||||
- formats: [ 'tar.gz' ]
|
- format: tar.gz
|
||||||
# this name template makes the OS and Arch compatible with the results of uname.
|
# this name template makes the OS and Arch compatible with the results of uname.
|
||||||
name_template: >-
|
name_template: >-
|
||||||
{{ .ProjectName }}_
|
{{ .ProjectName }}_
|
||||||
{{- title .Os }}_
|
{{- title .Os }}_
|
||||||
{{- if eq .Arch "amd64" }}x86_64
|
{{- if eq .Arch "amd64" }}x86_64
|
||||||
|
{{- else if eq .Arch "386" }}i386
|
||||||
{{- else }}{{ .Arch }}{{ end }}
|
{{- else }}{{ .Arch }}{{ end }}
|
||||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||||
# use zip for windows archives
|
# use zip for windows archives
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
formats: [ 'zip' ]
|
format: zip
|
||||||
sboms:
|
|
||||||
- artifacts: archive
|
|
||||||
release:
|
|
||||||
extra_files:
|
|
||||||
- glob: dist/*.sig
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: 'checksums.txt'
|
name_template: 'checksums.txt'
|
||||||
snapshot:
|
snapshot:
|
||||||
version_template: "{{ incpatch .Version }}-next"
|
name_template: "{{ incpatch .Version }}-next"
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
|
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
"github.com/hay-kot/homebox/backend/internal/sys/config"
|
||||||
"github.com/sysadminsmedia/homebox/backend/pkgs/mailer"
|
"github.com/hay-kot/homebox/backend/pkgs/mailer"
|
||||||
)
|
)
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
conf *config.Config
|
conf *config.Config
|
||||||
mailer mailer.Mailer
|
mailer *mailer.Mailer
|
||||||
db *ent.Client
|
db *ent.Client
|
||||||
repos *repo.AllRepos
|
repos *repo.AllRepos
|
||||||
services *services.AllServices
|
services *services.AllServices
|
||||||
@@ -23,7 +23,7 @@ func new(conf *config.Config) *app {
|
|||||||
conf: conf,
|
conf: conf,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mailer = mailer.Mailer{
|
s.mailer = &mailer.Mailer{
|
||||||
Host: s.conf.Mailer.Host,
|
Host: s.conf.Mailer.Host,
|
||||||
Port: s.conf.Mailer.Port,
|
Port: s.conf.Mailer.Port,
|
||||||
Username: s.conf.Mailer.Username,
|
Username: s.conf.Mailer.Username,
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ func NewTask(name string, interval time.Duration, fn func(context.Context)) *Bac
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (tsk *BackgroundTask) Start(ctx context.Context) error {
|
func (tsk *BackgroundTask) Start(ctx context.Context) error {
|
||||||
tsk.Fn(ctx)
|
|
||||||
|
|
||||||
timer := time.NewTimer(tsk.Interval)
|
timer := time.NewTimer(tsk.Interval)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
|||||||
@@ -2,20 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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 := `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
|
||||||
,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,,,,,,,
|
||||||
@@ -30,44 +28,37 @@ func (a *app) SetupDemo() error {
|
|||||||
Password: "demo",
|
Password: "demo",
|
||||||
}
|
}
|
||||||
|
|
||||||
// If demo user already exists, skip all demo seeding tasks
|
// First check if we've already setup a demo user and skip if so
|
||||||
if a.services.User.ExistsByEmail(ctx, registration.Email) {
|
log.Debug().Msg("Checking if demo user already exists")
|
||||||
log.Info().Msg("Demo user already exists; skipping demo seeding")
|
_, err := a.services.User.Login(ctx, registration.Email, registration.Password, false)
|
||||||
return nil
|
if err == nil {
|
||||||
|
log.Info().Msg("Demo user already exists, skipping setup")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, register the demo user
|
log.Debug().Msg("Demo user does not exist, setting up demo")
|
||||||
log.Debug().Msg("Registering demo user")
|
_, err = a.services.User.RegisterUser(ctx, registration)
|
||||||
_, err := a.services.User.RegisterUser(ctx, registration)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ent.IsConstraintError(err) {
|
|
||||||
// Concurrent creation race: treat as exists and skip
|
|
||||||
log.Info().Msg("Demo user concurrently created; skipping seeding")
|
|
||||||
return 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") // nolint
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login the demo user to get a token
|
|
||||||
token, err := a.services.User.Login(ctx, registration.Email, registration.Password, false)
|
token, err := a.services.User.Login(ctx, registration.Email, registration.Password, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to login demo user")
|
log.Err(err).Msg("Failed to login demo user")
|
||||||
return errors.New("failed to setup demo")
|
log.Fatal().Msg("Failed to setup demo")
|
||||||
}
|
}
|
||||||
self, err := a.services.User.GetSelf(ctx, token.Raw)
|
self, err := a.services.User.GetSelf(ctx, token.Raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("Failed to get self")
|
log.Err(err).Msg("Failed to get self")
|
||||||
return errors.New("failed to setup demo")
|
log.Fatal().Msg("Failed to setup demo")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = a.services.Items.CsvImport(ctx, self.DefaultGroupID, strings.NewReader(csvText))
|
_, err = a.services.Items.CsvImport(ctx, self.GroupID, strings.NewReader(csvText))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/httpkit/server"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sysadminsmedia/homebox/backend/app/api/providers"
|
|
||||||
"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"
|
"github.com/olahol/melody"
|
||||||
)
|
)
|
||||||
@@ -59,12 +57,6 @@ func WithSecureCookies(secure bool) func(*V1Controller) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithURL(url string) func(*V1Controller) {
|
|
||||||
return func(ctrl *V1Controller) {
|
|
||||||
ctrl.url = url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type V1Controller struct {
|
type V1Controller struct {
|
||||||
cookieSecure bool
|
cookieSecure bool
|
||||||
repo *repo.AllRepos
|
repo *repo.AllRepos
|
||||||
@@ -73,9 +65,6 @@ type V1Controller struct {
|
|||||||
isDemo bool
|
isDemo bool
|
||||||
allowRegistration bool
|
allowRegistration bool
|
||||||
bus *eventbus.EventBus
|
bus *eventbus.EventBus
|
||||||
url string
|
|
||||||
config *config.Config
|
|
||||||
oidcProvider *providers.OIDCProvider
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -88,63 +77,44 @@ type (
|
|||||||
}
|
}
|
||||||
|
|
||||||
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"`
|
|
||||||
OIDC OIDCStatus `json:"oidc"`
|
|
||||||
}
|
|
||||||
|
|
||||||
OIDCStatus struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
ButtonText string `json:"buttonText,omitempty"`
|
|
||||||
AutoRedirect bool `json:"autoRedirect,omitempty"`
|
|
||||||
AllowLocal bool `json:"allowLocal"`
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
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, bus *eventbus.EventBus, options ...func(*V1Controller)) *V1Controller {
|
||||||
ctrl := &V1Controller{
|
ctrl := &V1Controller{
|
||||||
repo: repos,
|
repo: repos,
|
||||||
svc: svc,
|
svc: svc,
|
||||||
allowRegistration: true,
|
allowRegistration: true,
|
||||||
bus: bus,
|
bus: bus,
|
||||||
config: config,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range options {
|
for _, opt := range options {
|
||||||
opt(ctrl)
|
opt(ctrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrl.initOIDCProvider()
|
|
||||||
|
|
||||||
return ctrl
|
return ctrl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctrl *V1Controller) initOIDCProvider() {
|
|
||||||
if ctrl.config.OIDC.Enabled {
|
|
||||||
oidcProvider, err := providers.NewOIDCProvider(ctrl.svc.User, &ctrl.config.OIDC, &ctrl.config.Options, ctrl.cookieSecure)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to initialize OIDC provider at startup")
|
|
||||||
} else {
|
|
||||||
ctrl.oidcProvider = oidcProvider
|
|
||||||
log.Info().Msg("OIDC provider initialized successfully at startup")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleBase godoc
|
// HandleBase godoc
|
||||||
//
|
//
|
||||||
// @Summary Application Info
|
// @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) errchain.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.JSON(w, http.StatusOK, APISummary{
|
||||||
@@ -152,27 +122,19 @@ func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) errchain.Hand
|
|||||||
Title: "Homebox",
|
Title: "Homebox",
|
||||||
Message: "Track, Manage, and Organize your Things",
|
Message: "Track, Manage, and Organize your Things",
|
||||||
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,
|
|
||||||
OIDC: OIDCStatus{
|
|
||||||
Enabled: ctrl.config.OIDC.Enabled,
|
|
||||||
ButtonText: ctrl.config.OIDC.ButtonText,
|
|
||||||
AutoRedirect: ctrl.config.OIDC.AutoRedirect,
|
|
||||||
AllowLocal: ctrl.config.Options.AllowLocalLogin,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleCurrency godoc
|
// HandleCurrency godoc
|
||||||
//
|
//
|
||||||
// @Summary Currency
|
// @Summary Currency
|
||||||
// @Tags Base
|
// @Tags Base
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} currencies.Currency
|
// @Success 200 {object} currencies.Currency
|
||||||
// @Router /v1/currency [GET]
|
// @Router /v1/currency [GET]
|
||||||
func (ctrl *V1Controller) HandleCurrency() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleCurrency() errchain.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
// Set Cache for 10 Minutes
|
// Set Cache for 10 Minutes
|
||||||
@@ -191,7 +153,7 @@ func (ctrl *V1Controller) HandleCacheWS() errchain.HandlerFunc {
|
|||||||
|
|
||||||
m.HandleConnect(func(s *melody.Session) {
|
m.HandleConnect(func(s *melody.Session) {
|
||||||
auth := services.NewContext(s.Request.Context())
|
auth := services.NewContext(s.Request.Context())
|
||||||
s.Set("gid", auth.GID)
|
s.Set("gid", auth.GroupID)
|
||||||
})
|
})
|
||||||
|
|
||||||
factory := func(e string) func(data any) {
|
factory := func(e string) func(data any) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/httpkit/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/core/services/reporting/eventbus"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ActionAmountResult struct {
|
type ActionAmountResult struct {
|
||||||
@@ -22,7 +20,7 @@ func actionHandlerFactory(ref string, fn func(context.Context, uuid.UUID) (int,
|
|||||||
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 := fn(ctx, ctx.GroupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Str("action_ref", ref).Msg("failed to run action")
|
log.Err(err).Str("action_ref", ref).Msg("failed to run action")
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
@@ -34,126 +32,52 @@ func actionHandlerFactory(ref string, fn func(context.Context, uuid.UUID) (int,
|
|||||||
|
|
||||||
// HandleEnsureAssetID godoc
|
// HandleEnsureAssetID godoc
|
||||||
//
|
//
|
||||||
// @Summary Ensure Asset IDs
|
// @Summary Ensure Asset IDs
|
||||||
// @Description Ensures all items in the database have an asset ID
|
// @Description Ensures all items in the database have an asset ID
|
||||||
// @Tags Actions
|
// @Tags Actions
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} ActionAmountResult
|
// @Success 200 {object} ActionAmountResult
|
||||||
// @Router /v1/actions/ensure-asset-ids [Post]
|
// @Router /v1/actions/ensure-asset-ids [Post]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleEnsureAssetID() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleEnsureAssetID() errchain.HandlerFunc {
|
||||||
return actionHandlerFactory("ensure asset IDs", ctrl.svc.Items.EnsureAssetID)
|
return actionHandlerFactory("ensure asset IDs", ctrl.svc.Items.EnsureAssetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleEnsureImportRefs godoc
|
// HandleEnsureImportRefs godoc
|
||||||
//
|
//
|
||||||
// @Summary Ensures Import Refs
|
// @Summary Ensures Import Refs
|
||||||
// @Description Ensures all items in the database have an import ref
|
// @Description Ensures all items in the database have an import ref
|
||||||
// @Tags Actions
|
// @Tags Actions
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} ActionAmountResult
|
// @Success 200 {object} ActionAmountResult
|
||||||
// @Router /v1/actions/ensure-import-refs [Post]
|
// @Router /v1/actions/ensure-import-refs [Post]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleEnsureImportRefs() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleEnsureImportRefs() errchain.HandlerFunc {
|
||||||
return actionHandlerFactory("ensure import refs", ctrl.svc.Items.EnsureImportRef)
|
return actionHandlerFactory("ensure import refs", ctrl.svc.Items.EnsureImportRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemDateZeroOut godoc
|
// HandleItemDateZeroOut godoc
|
||||||
//
|
//
|
||||||
// @Summary Zero Out Time Fields
|
// @Summary Zero Out Time Fields
|
||||||
// @Description Resets all item date fields to the beginning of the day
|
// @Description Resets all item date fields to the beginning of the day
|
||||||
// @Tags Actions
|
// @Tags Actions
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} ActionAmountResult
|
// @Success 200 {object} ActionAmountResult
|
||||||
// @Router /v1/actions/zero-item-time-fields [Post]
|
// @Router /v1/actions/zero-item-time-fields [Post]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleItemDateZeroOut() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemDateZeroOut() errchain.HandlerFunc {
|
||||||
return actionHandlerFactory("zero out date time", ctrl.repo.Items.ZeroOutTimeFields)
|
return actionHandlerFactory("zero out date time", ctrl.repo.Items.ZeroOutTimeFields)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSetPrimaryPhotos godoc
|
// HandleSetPrimaryPhotos godoc
|
||||||
//
|
//
|
||||||
// @Summary Set Primary Photos
|
// @Summary Set Primary Photos
|
||||||
// @Description Sets the first photo of each item as the primary photo
|
// @Description Sets the first photo of each item as the primary photo
|
||||||
// @Tags Actions
|
// @Tags Actions
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} ActionAmountResult
|
// @Success 200 {object} ActionAmountResult
|
||||||
// @Router /v1/actions/set-primary-photos [Post]
|
// @Router /v1/actions/set-primary-photos [Post]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleSetPrimaryPhotos() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleSetPrimaryPhotos() errchain.HandlerFunc {
|
||||||
return actionHandlerFactory("ensure asset IDs", ctrl.repo.Items.SetPrimaryPhotos)
|
return actionHandlerFactory("ensure asset IDs", ctrl.repo.Items.SetPrimaryPhotos)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleCreateMissingThumbnails godoc
|
|
||||||
//
|
|
||||||
// @Summary Create Missing Thumbnails
|
|
||||||
// @Description Creates thumbnails for items that are missing them
|
|
||||||
// @Tags Actions
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} ActionAmountResult
|
|
||||||
// @Router /v1/actions/create-missing-thumbnails [Post]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleCreateMissingThumbnails() errchain.HandlerFunc {
|
|
||||||
return actionHandlerFactory("create missing thumbnails", ctrl.repo.Attachments.CreateMissingThumbnails)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WipeInventoryOptions represents the options for wiping inventory
|
|
||||||
type WipeInventoryOptions struct {
|
|
||||||
WipeLabels bool `json:"wipeLabels"`
|
|
||||||
WipeLocations bool `json:"wipeLocations"`
|
|
||||||
WipeMaintenance bool `json:"wipeMaintenance"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleWipeInventory godoc
|
|
||||||
//
|
|
||||||
// @Summary Wipe Inventory
|
|
||||||
// @Description Deletes all items in the inventory
|
|
||||||
// @Tags Actions
|
|
||||||
// @Produce json
|
|
||||||
// @Param options body WipeInventoryOptions false "Wipe options"
|
|
||||||
// @Success 200 {object} ActionAmountResult
|
|
||||||
// @Router /v1/actions/wipe-inventory [Post]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleWipeInventory() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
if ctrl.isDemo {
|
|
||||||
return validate.NewRequestError(errors.New("wipe inventory is not allowed in demo mode"), http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := services.NewContext(r.Context())
|
|
||||||
|
|
||||||
// Check if user is owner
|
|
||||||
if !ctx.User.IsOwner {
|
|
||||||
return validate.NewRequestError(errors.New("only group owners can wipe inventory"), http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse options from request body
|
|
||||||
var options WipeInventoryOptions
|
|
||||||
if err := server.Decode(r, &options); err != nil {
|
|
||||||
// If no body provided, use default (false for all)
|
|
||||||
options = WipeInventoryOptions{
|
|
||||||
WipeLabels: false,
|
|
||||||
WipeLocations: false,
|
|
||||||
WipeMaintenance: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalCompleted, err := ctrl.repo.Items.WipeInventory(ctx, ctx.GID, options.WipeLabels, options.WipeLocations, options.WipeMaintenance)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Str("action_ref", "wipe inventory").Msg("failed to run action")
|
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish mutation events for wiped resources
|
|
||||||
if ctrl.bus != nil {
|
|
||||||
if options.WipeLabels {
|
|
||||||
ctrl.bus.Publish(eventbus.EventLabelMutation, eventbus.GroupMutationEvent{GID: ctx.GID})
|
|
||||||
}
|
|
||||||
if options.WipeLocations {
|
|
||||||
ctrl.bus.Publish(eventbus.EventLocationMutation, eventbus.GroupMutationEvent{GID: ctx.GID})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.JSON(w, http.StatusOK, ActionAmountResult{Completed: totalCompleted})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,24 +6,24 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/httpkit/server"
|
||||||
"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/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleAssetGet godocs
|
// HandleAssetGet godocs
|
||||||
//
|
//
|
||||||
// @Summary Get Item by Asset ID
|
// @Summary Get Item by Asset ID
|
||||||
// @Tags Items
|
// @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() errchain.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())
|
||||||
@@ -37,7 +37,7 @@ func (ctrl *V1Controller) HandleAssetGet() errchain.HandlerFunc {
|
|||||||
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.JSON(w, http.StatusBadRequest, "Invalid page number")
|
||||||
}
|
}
|
||||||
@@ -46,13 +46,13 @@ func (ctrl *V1Controller) HandleAssetGet() errchain.HandlerFunc {
|
|||||||
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.JSON(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.GroupID, 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)
|
||||||
|
|||||||
@@ -2,17 +2,16 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/httpkit/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 (
|
const (
|
||||||
@@ -29,8 +28,8 @@ 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"`
|
StayLoggedIn bool `json:"stayLoggedIn"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -80,15 +79,17 @@ type AuthProvider interface {
|
|||||||
|
|
||||||
// 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 payload body LoginForm true "Login Data"
|
// @Param username formData string false "string" example(admin@admin.com)
|
||||||
// @Param provider query string false "auth provider"
|
// @Param password formData string false "string" example(admin)
|
||||||
// @Produce json
|
// @Param payload body LoginForm true "Login Data"
|
||||||
// @Success 200 {object} TokenResponse
|
// @Param provider query string false "auth provider"
|
||||||
// @Router /v1/users/login [POST]
|
// @Produce json
|
||||||
|
// @Success 200 {object} TokenResponse
|
||||||
|
// @Router /v1/users/login [POST]
|
||||||
func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFunc {
|
||||||
if len(ps) == 0 {
|
if len(ps) == 0 {
|
||||||
panic("no auth providers provided")
|
panic("no auth providers provided")
|
||||||
@@ -107,11 +108,6 @@ func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFu
|
|||||||
provider = "local"
|
provider = "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block local only when disabled
|
|
||||||
if provider == "local" && !ctrl.config.Options.AllowLocalLogin {
|
|
||||||
return validate.NewRequestError(fmt.Errorf("local login is not enabled"), http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the provider
|
// Get the provider
|
||||||
p, ok := providers[provider]
|
p, ok := providers[provider]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -120,11 +116,11 @@ func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFu
|
|||||||
|
|
||||||
newToken, err := p.Authenticate(w, r)
|
newToken, err := p.Authenticate(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("authentication failed")
|
log.Err(err).Msg("failed to authenticate")
|
||||||
return validate.NewUnauthorizedError()
|
return server.JSON(w, http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken)
|
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true)
|
||||||
return server.JSON(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,
|
||||||
@@ -135,11 +131,11 @@ 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() errchain.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())
|
||||||
@@ -159,13 +155,13 @@ func (ctrl *V1Controller) HandleAuthLogout() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleAuthRefresh godoc
|
// HandleAuthRefresh 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() errchain.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())
|
||||||
@@ -178,7 +174,7 @@ func (ctrl *V1Controller) HandleAuthRefresh() errchain.HandlerFunc {
|
|||||||
return validate.NewUnauthorizedError()
|
return validate.NewUnauthorizedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false, newToken.AttachmentToken)
|
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, false)
|
||||||
return server.JSON(w, http.StatusOK, newToken)
|
return server.JSON(w, http.StatusOK, newToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +183,7 @@ func noPort(host string) string {
|
|||||||
return strings.Split(host, ":")[0]
|
return strings.Split(host, ":")[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool, attachmentToken string) {
|
func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string, expires time.Time, remember bool) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieNameRemember,
|
Name: cookieNameRemember,
|
||||||
Value: strconv.FormatBool(remember),
|
Value: strconv.FormatBool(remember),
|
||||||
@@ -219,19 +215,6 @@ func (ctrl *V1Controller) setCookies(w http.ResponseWriter, domain, token string
|
|||||||
HttpOnly: false,
|
HttpOnly: false,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set attachment token cookie (accessible to frontend, not HttpOnly)
|
|
||||||
if attachmentToken != "" {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "hb.auth.attachment_token",
|
|
||||||
Value: attachmentToken,
|
|
||||||
Expires: expires,
|
|
||||||
Domain: domain,
|
|
||||||
Secure: ctrl.cookieSecure,
|
|
||||||
HttpOnly: false,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
|
func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
|
||||||
@@ -265,77 +248,4 @@ func (ctrl *V1Controller) unsetCookies(w http.ResponseWriter, domain string) {
|
|||||||
HttpOnly: false,
|
HttpOnly: false,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Unset attachment token cookie
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "hb.auth.attachment_token",
|
|
||||||
Value: "",
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
Domain: domain,
|
|
||||||
Secure: ctrl.cookieSecure,
|
|
||||||
HttpOnly: false,
|
|
||||||
Path: "/",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleOIDCLogin godoc
|
|
||||||
//
|
|
||||||
// @Summary OIDC Login Initiation
|
|
||||||
// @Tags Authentication
|
|
||||||
// @Produce json
|
|
||||||
// @Success 302
|
|
||||||
// @Router /v1/users/login/oidc [GET]
|
|
||||||
func (ctrl *V1Controller) HandleOIDCLogin() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
// Forbidden if OIDC is not enabled
|
|
||||||
if !ctrl.config.OIDC.Enabled {
|
|
||||||
return validate.NewRequestError(fmt.Errorf("OIDC is not enabled"), http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if OIDC provider is available
|
|
||||||
if ctrl.oidcProvider == nil {
|
|
||||||
log.Error().Msg("OIDC provider not initialized")
|
|
||||||
return validate.NewRequestError(errors.New("OIDC provider not available"), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initiate OIDC flow
|
|
||||||
_, err := ctrl.oidcProvider.InitiateOIDCFlow(w, r)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleOIDCCallback godoc
|
|
||||||
//
|
|
||||||
// @Summary OIDC Callback Handler
|
|
||||||
// @Tags Authentication
|
|
||||||
// @Param code query string true "Authorization code"
|
|
||||||
// @Param state query string true "State parameter"
|
|
||||||
// @Success 302
|
|
||||||
// @Router /v1/users/login/oidc/callback [GET]
|
|
||||||
func (ctrl *V1Controller) HandleOIDCCallback() errchain.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
// Forbidden if OIDC is not enabled
|
|
||||||
if !ctrl.config.OIDC.Enabled {
|
|
||||||
return validate.NewRequestError(fmt.Errorf("OIDC is not enabled"), http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if OIDC provider is available
|
|
||||||
if ctrl.oidcProvider == nil {
|
|
||||||
log.Error().Msg("OIDC provider not initialized")
|
|
||||||
return validate.NewRequestError(errors.New("OIDC provider not available"), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle callback
|
|
||||||
newToken, err := ctrl.oidcProvider.HandleCallback(w, r)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("OIDC callback failed")
|
|
||||||
http.Redirect(w, r, "/?oidc_error=oidc_auth_failed", http.StatusFound)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set cookies and redirect to home
|
|
||||||
ctrl.setCookies(w, noPort(r.Host), newToken.Raw, newToken.ExpiresAt, true, newToken.AttachmentToken)
|
|
||||||
http.Redirect(w, r, "/home", http.StatusFound)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/google/uuid"
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/adapters"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -25,29 +22,20 @@ type (
|
|||||||
ExpiresAt time.Time `json:"expiresAt"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
Uses int `json:"uses"`
|
Uses int `json:"uses"`
|
||||||
}
|
}
|
||||||
|
|
||||||
GroupMemberAdd struct {
|
|
||||||
UserID uuid.UUID `json:"userId" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
GroupAcceptInvitationResponse struct {
|
|
||||||
ID uuid.UUID `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleGroupGet godoc
|
// HandleGroupGet godoc
|
||||||
//
|
//
|
||||||
// @Summary Get 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleGroupGet() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request) (repo.Group, error) {
|
fn := func(r *http.Request) (repo.Group, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Groups.GroupByID(auth, auth.GID)
|
return ctrl.repo.Groups.GroupByID(auth, auth.GroupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
return adapters.Command(fn, http.StatusOK)
|
||||||
@@ -55,13 +43,13 @@ func (ctrl *V1Controller) HandleGroupGet() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleGroupUpdate godoc
|
// HandleGroupUpdate godoc
|
||||||
//
|
//
|
||||||
// @Summary Update 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleGroupUpdate() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, body repo.GroupUpdate) (repo.Group, error) {
|
fn := func(r *http.Request, body repo.GroupUpdate) (repo.Group, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
@@ -81,13 +69,13 @@ func (ctrl *V1Controller) HandleGroupUpdate() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleGroupInvitationsCreate godoc
|
// HandleGroupInvitationsCreate godoc
|
||||||
//
|
//
|
||||||
// @Summary Create Group Invitation
|
// @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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleGroupInvitationsCreate() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, body GroupInvitationCreate) (GroupInvitation, error) {
|
fn := func(r *http.Request, body GroupInvitationCreate) (GroupInvitation, error) {
|
||||||
if body.ExpiresAt.IsZero() {
|
if body.ExpiresAt.IsZero() {
|
||||||
@@ -107,182 +95,3 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() errchain.HandlerFunc {
|
|||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
return adapters.Action(fn, http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGroupsGetAll godoc
|
|
||||||
//
|
|
||||||
// @Summary Get All Groups
|
|
||||||
// @Tags Group
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} []repo.Group
|
|
||||||
// @Router /v1/groups [Get]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleGroupsGetAll() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request) ([]repo.Group, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Groups.GetAllGroups(auth, auth.UID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGroupCreate godoc
|
|
||||||
//
|
|
||||||
// @Summary Create Group
|
|
||||||
// @Tags Group
|
|
||||||
// @Produce json
|
|
||||||
// @Param name body string true "Group Name"
|
|
||||||
// @Success 201 {object} repo.Group
|
|
||||||
// @Router /v1/groups [Post]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleGroupCreate() errchain.HandlerFunc {
|
|
||||||
type CreateRequest struct {
|
|
||||||
Name string `json:"name" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
fn := func(r *http.Request, body CreateRequest) (repo.Group, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.svc.Group.CreateGroup(auth, body.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGroupDelete godoc
|
|
||||||
//
|
|
||||||
// @Summary Delete Group
|
|
||||||
// @Tags Group
|
|
||||||
// @Produce json
|
|
||||||
// @Success 204
|
|
||||||
// @Router /v1/groups/{id} [Delete]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleGroupDelete() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request) (any, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
err := ctrl.svc.Group.DeleteGroup(auth)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGroupInvitationsGetAll godoc
|
|
||||||
//
|
|
||||||
// @Summary Get All Group Invitations
|
|
||||||
// @Tags Group
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} []repo.GroupInvitation
|
|
||||||
// @Router /v1/groups/invitations [Get]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleGroupInvitationsGetAll() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request) ([]repo.GroupInvitation, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Groups.InvitationGetAll(auth, auth.GID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGroupMembersGetAll godoc
|
|
||||||
//
|
|
||||||
// @Summary Get All Group Members
|
|
||||||
// @Tags Group
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} []repo.UserOut
|
|
||||||
// @Router /v1/groups/{id}/members [Get]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleGroupMembersGetAll() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request) ([]repo.UserOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.Users.GetUsersByGroupID(auth, auth.GID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGroupMemberAdd godoc
|
|
||||||
//
|
|
||||||
// @Summary Add User to Group
|
|
||||||
// @Tags Group
|
|
||||||
// @Produce json
|
|
||||||
// @Param payload body GroupMemberAdd true "User ID"
|
|
||||||
// @Success 204
|
|
||||||
// @Router /v1/groups/{id}/members [Post]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleGroupMemberAdd() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, body GroupMemberAdd) (any, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
err := ctrl.svc.Group.AddMember(auth, body.UserID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGroupMemberRemove godoc
|
|
||||||
//
|
|
||||||
// @Summary Remove User from Group
|
|
||||||
// @Tags Group
|
|
||||||
// @Produce json
|
|
||||||
// @Param user_id path string true "User ID"
|
|
||||||
// @Success 204
|
|
||||||
// @Router /v1/groups/{id}/members/{user_id} [Delete]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleGroupMemberRemove() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, userID uuid.UUID) (any, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
err := ctrl.svc.Group.RemoveMember(auth, userID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("user_id", fn, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGroupInvitationsDelete godoc
|
|
||||||
//
|
|
||||||
// @Summary Delete Group Invitation
|
|
||||||
// @Tags Group
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Invitation ID"
|
|
||||||
// @Success 204
|
|
||||||
// @Router /v1/groups/invitations/{id} [Delete]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleGroupInvitationsDelete() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, id uuid.UUID) (any, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
err := ctrl.svc.Group.DeleteInvitation(auth, id)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGroupInvitationsAccept godoc
|
|
||||||
//
|
|
||||||
// @Summary Accept Group Invitation
|
|
||||||
// @Tags Group
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Invitation Token"
|
|
||||||
// @Success 200 {object} GroupAcceptInvitationResponse
|
|
||||||
// @Router /v1/groups/invitations/{id} [Post]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleGroupInvitationsAccept() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request) (GroupAcceptInvitationResponse, error) {
|
|
||||||
token := chi.URLParam(r, "id")
|
|
||||||
if token == "" {
|
|
||||||
return GroupAcceptInvitationResponse{}, validate.NewRequestError(errors.New("token is required"), http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
group, err := ctrl.svc.Group.AcceptInvitation(auth, token)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, errors.New("user already a member of this group")) {
|
|
||||||
return GroupAcceptInvitationResponse{}, validate.NewRequestError(err, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
return GroupAcceptInvitationResponse{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return GroupAcceptInvitationResponse{ID: group.ID, Name: group.Name}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,164 +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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleItemTemplatesGetAll godoc
|
|
||||||
//
|
|
||||||
// @Summary Get All Item Templates
|
|
||||||
// @Tags Item Templates
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} []repo.ItemTemplateSummary
|
|
||||||
// @Router /v1/templates [GET]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemTemplatesGetAll() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request) ([]repo.ItemTemplateSummary, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.ItemTemplates.GetAll(r.Context(), auth.GID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleItemTemplatesGet godoc
|
|
||||||
//
|
|
||||||
// @Summary Get Item Template
|
|
||||||
// @Tags Item Templates
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Template ID"
|
|
||||||
// @Success 200 {object} repo.ItemTemplateOut
|
|
||||||
// @Router /v1/templates/{id} [GET]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemTemplatesGet() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (repo.ItemTemplateOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.ItemTemplates.GetOne(r.Context(), auth.GID, ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleItemTemplatesCreate godoc
|
|
||||||
//
|
|
||||||
// @Summary Create Item Template
|
|
||||||
// @Tags Item Templates
|
|
||||||
// @Produce json
|
|
||||||
// @Param payload body repo.ItemTemplateCreate true "Template Data"
|
|
||||||
// @Success 201 {object} repo.ItemTemplateOut
|
|
||||||
// @Router /v1/templates [POST]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemTemplatesCreate() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, body repo.ItemTemplateCreate) (repo.ItemTemplateOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
return ctrl.repo.ItemTemplates.Create(r.Context(), auth.GID, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleItemTemplatesUpdate godoc
|
|
||||||
//
|
|
||||||
// @Summary Update Item Template
|
|
||||||
// @Tags Item Templates
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Template ID"
|
|
||||||
// @Param payload body repo.ItemTemplateUpdate true "Template Data"
|
|
||||||
// @Success 200 {object} repo.ItemTemplateOut
|
|
||||||
// @Router /v1/templates/{id} [PUT]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemTemplatesUpdate() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, ID uuid.UUID, body repo.ItemTemplateUpdate) (repo.ItemTemplateOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
body.ID = ID
|
|
||||||
return ctrl.repo.ItemTemplates.Update(r.Context(), auth.GID, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleItemTemplatesDelete godoc
|
|
||||||
//
|
|
||||||
// @Summary Delete Item Template
|
|
||||||
// @Tags Item Templates
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Template ID"
|
|
||||||
// @Success 204
|
|
||||||
// @Router /v1/templates/{id} [DELETE]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemTemplatesDelete() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
err := ctrl.repo.ItemTemplates.Delete(r.Context(), auth.GID, ID)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ItemTemplateCreateItemRequest struct {
|
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
|
||||||
Description string `json:"description" validate:"max=1000"`
|
|
||||||
LocationID uuid.UUID `json:"locationId" validate:"required"`
|
|
||||||
LabelIDs []uuid.UUID `json:"labelIds"`
|
|
||||||
Quantity *int `json:"quantity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleItemTemplatesCreateItem godoc
|
|
||||||
//
|
|
||||||
// @Summary Create Item from Template
|
|
||||||
// @Tags Item Templates
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Template ID"
|
|
||||||
// @Param payload body ItemTemplateCreateItemRequest true "Item Data"
|
|
||||||
// @Success 201 {object} repo.ItemOut
|
|
||||||
// @Router /v1/templates/{id}/create-item [POST]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemTemplatesCreateItem() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, templateID uuid.UUID, body ItemTemplateCreateItemRequest) (repo.ItemOut, error) {
|
|
||||||
auth := services.NewContext(r.Context())
|
|
||||||
|
|
||||||
template, err := ctrl.repo.ItemTemplates.GetOne(r.Context(), auth.GID, templateID)
|
|
||||||
if err != nil {
|
|
||||||
return repo.ItemOut{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
quantity := template.DefaultQuantity
|
|
||||||
if body.Quantity != nil {
|
|
||||||
quantity = *body.Quantity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build custom fields from template
|
|
||||||
fields := make([]repo.ItemField, len(template.Fields))
|
|
||||||
for i, f := range template.Fields {
|
|
||||||
fields[i] = repo.ItemField{
|
|
||||||
Type: f.Type,
|
|
||||||
Name: f.Name,
|
|
||||||
TextValue: f.TextValue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create item with all template data in a single transaction
|
|
||||||
return ctrl.repo.Items.CreateFromTemplate(r.Context(), auth.GID, repo.ItemCreateFromTemplate{
|
|
||||||
Name: body.Name,
|
|
||||||
Description: body.Description,
|
|
||||||
Quantity: quantity,
|
|
||||||
LocationID: body.LocationID,
|
|
||||||
LabelIDs: body.LabelIDs,
|
|
||||||
Insured: template.DefaultInsured,
|
|
||||||
Manufacturer: template.DefaultManufacturer,
|
|
||||||
ModelNumber: template.DefaultModelNumber,
|
|
||||||
LifetimeWarranty: template.DefaultLifetimeWarranty,
|
|
||||||
WarrantyDetails: template.DefaultWarrantyDetails,
|
|
||||||
Fields: fields,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusCreated)
|
|
||||||
}
|
|
||||||
@@ -4,36 +4,33 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/adapters"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/httpkit/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 Query 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)
|
||||||
// @Param parentIds query []string false "parent Ids" collectionFormat(multi)
|
// @Param parentIds query []string false "parent Ids" collectionFormat(multi)
|
||||||
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
|
// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{}
|
||||||
// @Router /v1/items [GET]
|
// @Router /v1/items [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc {
|
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 +52,15 @@ 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")),
|
ParentItemIDs: queryUUIDList(params, "parentIds"),
|
||||||
OnlyWithoutPhoto: queryBool(params.Get("onlyWithoutPhoto")),
|
IncludeArchived: queryBool(params.Get("includeArchived")),
|
||||||
OnlyWithPhoto: queryBool(params.Get("onlyWithPhoto")),
|
Fields: filterFieldItems(params["fields"]),
|
||||||
ParentItemIDs: queryUUIDList(params, "parentIds"),
|
OrderBy: params.Get("orderBy"),
|
||||||
IncludeArchived: queryBool(params.Get("includeArchived")),
|
|
||||||
Fields: filterFieldItems(params["fields"]),
|
|
||||||
OrderBy: params.Get("orderBy"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(v.Search, "#") {
|
if strings.HasPrefix(v.Search, "#") {
|
||||||
@@ -85,18 +79,7 @@ func (ctrl *V1Controller) HandleItemsGetAll() 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())
|
||||||
|
|
||||||
items, err := ctrl.repo.Items.QueryByGroup(ctx, ctx.GID, extractQuery(r))
|
items, err := ctrl.repo.Items.QueryByGroup(ctx, ctx.GroupID, 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.JSON(w, http.StatusOK, repo.PaginationResult[repo.ItemSummary]{
|
||||||
@@ -112,22 +95,22 @@ func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleItemFullPath godoc
|
// HandleItemFullPath godoc
|
||||||
//
|
//
|
||||||
// @Summary Get the full path of an item
|
// @Summary Get the full path of an 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.ItemPath
|
// @Success 200 {object} []repo.ItemPath
|
||||||
// @Router /v1/items/{id}/path [GET]
|
// @Router /v1/items/{id}/path [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleItemFullPath() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemFullPath() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID) ([]repo.ItemPath, error) {
|
fn := func(r *http.Request, ID uuid.UUID) ([]repo.ItemPath, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
item, err := ctrl.repo.Items.GetOneByGroup(auth, auth.GID, ID)
|
item, err := ctrl.repo.Items.GetOneByGroup(auth, auth.GroupID, ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
paths, err := ctrl.repo.Locations.PathForLoc(auth, auth.GID, item.Location.ID)
|
paths, err := ctrl.repo.Locations.PathForLoc(auth, auth.GroupID, item.Location.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -154,13 +137,13 @@ func (ctrl *V1Controller) HandleItemFullPath() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleItemsCreate godoc
|
// HandleItemsCreate godoc
|
||||||
//
|
//
|
||||||
// @Summary Create 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 201 {object} repo.ItemSummary
|
// @Success 201 {object} repo.ItemSummary
|
||||||
// @Router /v1/items [POST]
|
// @Router /v1/items [POST]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleItemsCreate() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemsCreate() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, body repo.ItemCreate) (repo.ItemOut, error) {
|
fn := func(r *http.Request, body repo.ItemCreate) (repo.ItemOut, error) {
|
||||||
return ctrl.svc.Items.Create(services.NewContext(r.Context()), body)
|
return ctrl.svc.Items.Create(services.NewContext(r.Context()), body)
|
||||||
@@ -171,18 +154,18 @@ func (ctrl *V1Controller) HandleItemsCreate() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleItemGet godocs
|
// HandleItemGet godocs
|
||||||
//
|
//
|
||||||
// @Summary Get Item
|
// @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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemGet() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (repo.ItemOut, error) {
|
fn := func(r *http.Request, ID uuid.UUID) (repo.ItemOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
|
|
||||||
return ctrl.repo.Items.GetOneByGroup(auth, auth.GID, ID)
|
return ctrl.repo.Items.GetOneByGroup(auth, auth.GroupID, ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusOK)
|
return adapters.CommandID("id", fn, http.StatusOK)
|
||||||
@@ -190,17 +173,17 @@ func (ctrl *V1Controller) HandleItemGet() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleItemDelete godocs
|
// HandleItemDelete godocs
|
||||||
//
|
//
|
||||||
// @Summary Delete 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemDelete() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
err := ctrl.repo.Items.DeleteByGroup(auth, auth.GID, ID)
|
err := ctrl.repo.Items.DeleteByGroup(auth, auth.GroupID, ID)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,20 +192,20 @@ func (ctrl *V1Controller) HandleItemDelete() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleItemUpdate godocs
|
// HandleItemUpdate godocs
|
||||||
//
|
//
|
||||||
// @Summary Update 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemUpdate() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID, body repo.ItemUpdate) (repo.ItemOut, error) {
|
fn := func(r *http.Request, ID uuid.UUID, body repo.ItemUpdate) (repo.ItemOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
|
|
||||||
body.ID = ID
|
body.ID = ID
|
||||||
return ctrl.repo.Items.UpdateByGroup(auth, auth.GID, body)
|
return ctrl.repo.Items.UpdateByGroup(auth, auth.GroupID, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
return adapters.ActionID("id", fn, http.StatusOK)
|
||||||
@@ -230,62 +213,43 @@ func (ctrl *V1Controller) HandleItemUpdate() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleItemPatch godocs
|
// HandleItemPatch godocs
|
||||||
//
|
//
|
||||||
// @Summary Update 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.ItemPatch true "Item Data"
|
// @Param payload body repo.ItemPatch true "Item Data"
|
||||||
// @Success 200 {object} repo.ItemOut
|
// @Success 200 {object} repo.ItemOut
|
||||||
// @Router /v1/items/{id} [Patch]
|
// @Router /v1/items/{id} [Patch]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleItemPatch() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemPatch() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID, body repo.ItemPatch) (repo.ItemOut, error) {
|
fn := func(r *http.Request, ID uuid.UUID, body repo.ItemPatch) (repo.ItemOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
|
|
||||||
body.ID = ID
|
body.ID = ID
|
||||||
err := ctrl.repo.Items.Patch(auth, auth.GID, ID, body)
|
err := ctrl.repo.Items.Patch(auth, auth.GroupID, ID, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return repo.ItemOut{}, err
|
return repo.ItemOut{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctrl.repo.Items.GetOneByGroup(auth, auth.GID, ID)
|
return ctrl.repo.Items.GetOneByGroup(auth, auth.GroupID, ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
return adapters.ActionID("id", fn, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemDuplicate godocs
|
|
||||||
//
|
|
||||||
// @Summary Duplicate Item
|
|
||||||
// @Tags Items
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Item ID"
|
|
||||||
// @Param payload body repo.DuplicateOptions true "Duplicate Options"
|
|
||||||
// @Success 201 {object} repo.ItemOut
|
|
||||||
// @Router /v1/items/{id}/duplicate [POST]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemDuplicate() errchain.HandlerFunc {
|
|
||||||
fn := func(r *http.Request, ID uuid.UUID, options repo.DuplicateOptions) (repo.ItemOut, error) {
|
|
||||||
ctx := services.NewContext(r.Context())
|
|
||||||
return ctrl.svc.Items.Duplicate(ctx, ctx.GID, ID, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGetAllCustomFieldNames godocs
|
// HandleGetAllCustomFieldNames godocs
|
||||||
//
|
//
|
||||||
// @Summary Get All Custom Field Names
|
// @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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleGetAllCustomFieldNames() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request) ([]string, error) {
|
fn := func(r *http.Request) ([]string, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Items.GetAllCustomFieldNames(auth, auth.GID)
|
return ctrl.repo.Items.GetAllCustomFieldNames(auth, auth.GroupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
return adapters.Command(fn, http.StatusOK)
|
||||||
@@ -293,13 +257,13 @@ func (ctrl *V1Controller) HandleGetAllCustomFieldNames() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleGetAllCustomFieldValues godocs
|
// HandleGetAllCustomFieldValues godocs
|
||||||
//
|
//
|
||||||
// @Summary Get All Custom Field Values
|
// @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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleGetAllCustomFieldValues() errchain.HandlerFunc {
|
||||||
type query struct {
|
type query struct {
|
||||||
Field string `schema:"field" validate:"required"`
|
Field string `schema:"field" validate:"required"`
|
||||||
@@ -307,7 +271,7 @@ func (ctrl *V1Controller) HandleGetAllCustomFieldValues() errchain.HandlerFunc {
|
|||||||
|
|
||||||
fn := func(r *http.Request, q query) ([]string, error) {
|
fn := func(r *http.Request, q query) ([]string, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Items.GetAllCustomFieldValues(auth, auth.GID, q.Field)
|
return ctrl.repo.Items.GetAllCustomFieldValues(auth, auth.GroupID, q.Field)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Query(fn, http.StatusOK)
|
return adapters.Query(fn, http.StatusOK)
|
||||||
@@ -315,14 +279,13 @@ func (ctrl *V1Controller) HandleGetAllCustomFieldValues() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleItemsImport godocs
|
// HandleItemsImport godocs
|
||||||
//
|
//
|
||||||
// @Summary Import Items
|
// @Summary Import Items
|
||||||
// @Tags Items
|
// @Tags Items
|
||||||
// @Accept multipart/form-data
|
// @Produce json
|
||||||
// @Produce json
|
// @Success 204
|
||||||
// @Success 204
|
// @Param csv formData file true "Image to upload"
|
||||||
// @Param csv formData file true "Image to upload"
|
// @Router /v1/items/import [Post]
|
||||||
// @Router /v1/items/import [Post]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemsImport() errchain.HandlerFunc {
|
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)
|
||||||
@@ -337,9 +300,9 @@ func (ctrl *V1Controller) HandleItemsImport() errchain.HandlerFunc {
|
|||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
tenant := services.UseTenantCtx(r.Context())
|
user := services.UseUserCtx(r.Context())
|
||||||
|
|
||||||
_, err = ctrl.svc.Items.CsvImport(r.Context(), tenant, file)
|
_, err = ctrl.svc.Items.CsvImport(r.Context(), user.GroupID, file)
|
||||||
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)
|
||||||
@@ -351,29 +314,26 @@ func (ctrl *V1Controller) HandleItemsImport() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleItemsExport godocs
|
// HandleItemsExport godocs
|
||||||
//
|
//
|
||||||
// @Summary Export Items
|
// @Summary Export Items
|
||||||
// @Tags Items
|
// @Tags Items
|
||||||
// @Success 200 {string} string "text/csv"
|
// @Success 200 {string} string "text/csv"
|
||||||
// @Router /v1/items/export [GET]
|
// @Router /v1/items/export [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleItemsExport() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemsExport() 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())
|
||||||
|
|
||||||
csvData, err := ctrl.svc.Items.ExportCSV(r.Context(), ctx.GID, GetHBURL(r.Header.Get("Referer"), ctrl.url))
|
csvData, err := ctrl.svc.Items.ExportTSV(r.Context(), ctx.GroupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to export items")
|
log.Err(err).Msg("failed to export items")
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
timestamp := time.Now().Format("2006-01-02_15-04-05") // YYYY-MM-DD_HH-MM-SS format
|
w.Header().Set("Content-Type", "text/tsv")
|
||||||
filename := fmt.Sprintf("homebox-items_%s.csv", timestamp) // add timestamp to filename
|
w.Header().Set("Content-Disposition", "attachment;filename=homebox-items.tsv")
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/csv")
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment;filename=%s", filename))
|
|
||||||
|
|
||||||
writer := csv.NewWriter(w)
|
writer := csv.NewWriter(w)
|
||||||
writer.Comma = ','
|
writer.Comma = '\t'
|
||||||
return writer.WriteAll(csvData)
|
return writer.WriteAll(csvData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,16 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"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/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/httpkit/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"
|
|
||||||
|
|
||||||
"gocloud.dev/blob"
|
|
||||||
_ "gocloud.dev/blob/azureblob"
|
|
||||||
_ "gocloud.dev/blob/fileblob"
|
|
||||||
_ "gocloud.dev/blob/gcsblob"
|
|
||||||
_ "gocloud.dev/blob/memblob"
|
|
||||||
_ "gocloud.dev/blob/s3blob"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -32,19 +23,17 @@ type (
|
|||||||
|
|
||||||
// HandleItemAttachmentCreate godocs
|
// HandleItemAttachmentCreate godocs
|
||||||
//
|
//
|
||||||
// @Summary Create Item Attachment
|
// @Summary Create Item Attachment
|
||||||
// @Tags Items Attachments
|
// @Tags Items Attachments
|
||||||
// @Accept multipart/form-data
|
// @Produce json
|
||||||
// @Produce json
|
// @Param id path string true "Item ID"
|
||||||
// @Param id path string true "Item ID"
|
// @Param file formData file true "File attachment"
|
||||||
// @Param file formData file true "File attachment"
|
// @Param type formData string true "Type of file"
|
||||||
// @Param type formData string false "Type of file"
|
// @Param name formData string true "name of the file including extension"
|
||||||
// @Param primary formData bool false "Is this the primary attachment"
|
// @Success 200 {object} repo.ItemOut
|
||||||
// @Param name formData string true "name of the file including extension"
|
// @Failure 422 {object} validate.ErrorResponse
|
||||||
// @Success 200 {object} repo.ItemOut
|
// @Router /v1/items/{id}/attachments [POST]
|
||||||
// @Failure 422 {object} validate.ErrorResponse
|
// @Security Bearer
|
||||||
// @Router /v1/items/{id}/attachments [POST]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc {
|
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)
|
||||||
@@ -83,19 +72,13 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc {
|
|||||||
ext := filepath.Ext(attachmentName)
|
ext := filepath.Ext(attachmentName)
|
||||||
|
|
||||||
switch strings.ToLower(ext) {
|
switch strings.ToLower(ext) {
|
||||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".avif", ".ico", ".heic", ".jxl":
|
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff":
|
||||||
attachmentType = attachment.TypePhoto.String()
|
attachmentType = attachment.TypePhoto.String()
|
||||||
default:
|
default:
|
||||||
attachmentType = attachment.TypeAttachment.String()
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -108,7 +91,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 {
|
||||||
@@ -122,41 +104,41 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleItemAttachmentGet godocs
|
// HandleItemAttachmentGet godocs
|
||||||
//
|
//
|
||||||
// @Summary Get Item Attachment
|
// @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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemAttachmentGet() errchain.HandlerFunc {
|
||||||
return ctrl.handleItemAttachmentsHandler
|
return ctrl.handleItemAttachmentsHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemAttachmentDelete godocs
|
// HandleItemAttachmentDelete godocs
|
||||||
//
|
//
|
||||||
// @Summary Delete Item Attachment
|
// @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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemAttachmentDelete() errchain.HandlerFunc {
|
||||||
return ctrl.handleItemAttachmentsHandler
|
return ctrl.handleItemAttachmentsHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleItemAttachmentUpdate godocs
|
// HandleItemAttachmentUpdate godocs
|
||||||
//
|
//
|
||||||
// @Summary Update Item Attachment
|
// @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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleItemAttachmentUpdate() errchain.HandlerFunc {
|
||||||
return ctrl.handleItemAttachmentsHandler
|
return ctrl.handleItemAttachmentsHandler
|
||||||
}
|
}
|
||||||
@@ -175,44 +157,18 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
|||||||
ctx := services.NewContext(r.Context())
|
ctx := services.NewContext(r.Context())
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
doc, err := ctrl.svc.Items.AttachmentPath(r.Context(), ctx.GID, attachmentID)
|
doc, err := ctrl.svc.Items.AttachmentPath(r.Context(), attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
bucket, err := blob.OpenBucket(ctx, ctrl.repo.Attachments.GetConnString())
|
http.ServeFile(w, r, doc.Path)
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to open bucket")
|
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
file, err := bucket.NewReader(ctx, ctrl.repo.Attachments.GetFullPath(doc.Path), nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to open file")
|
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
defer func(file *blob.Reader) {
|
|
||||||
err := file.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to close file")
|
|
||||||
}
|
|
||||||
}(file)
|
|
||||||
defer func(bucket *blob.Bucket) {
|
|
||||||
err := bucket.Close()
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to close bucket")
|
|
||||||
}
|
|
||||||
}(bucket)
|
|
||||||
|
|
||||||
// Set the Content-Disposition header for RFC6266 compliance
|
|
||||||
disposition := "inline; filename*=UTF-8''" + url.QueryEscape(doc.Title)
|
|
||||||
w.Header().Set("Content-Disposition", disposition)
|
|
||||||
http.ServeContent(w, r, doc.Title, doc.CreatedAt, file)
|
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
// Delete Attachment Handler
|
// Delete Attachment Handler
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
err = ctrl.svc.Items.AttachmentDelete(r.Context(), ctx.GID, ID, attachmentID)
|
err = ctrl.svc.Items.AttachmentDelete(r.Context(), ctx.GroupID, ID, attachmentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to delete attachment")
|
log.Err(err).Msg("failed to delete attachment")
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
@@ -230,9 +186,9 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
attachment.ID = attachmentID
|
attachment.ID = attachmentID
|
||||||
val, err := ctrl.svc.Items.AttachmentUpdate(ctx, ctx.GID, ID, &attachment)
|
val, err := ctrl.svc.Items.AttachmentUpdate(ctx, ID, &attachment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to update attachment")
|
log.Err(err).Msg("failed to delete attachment")
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, ctrl.config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,24 +4,24 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/adapters"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleLabelsGetAll godoc
|
// HandleLabelsGetAll godoc
|
||||||
//
|
//
|
||||||
// @Summary Get All Labels
|
// @Summary Get All Labels
|
||||||
// @Tags Labels
|
// @Tags Labels
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} []repo.LabelOut
|
// @Success 200 {object} []repo.LabelOut
|
||||||
// @Router /v1/labels [GET]
|
// @Router /v1/labels [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleLabelsGetAll() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLabelsGetAll() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request) ([]repo.LabelSummary, error) {
|
fn := func(r *http.Request) ([]repo.LabelSummary, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Labels.GetAll(auth, auth.GID)
|
return ctrl.repo.Labels.GetAll(auth, auth.GroupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
return adapters.Command(fn, http.StatusOK)
|
||||||
@@ -29,17 +29,17 @@ func (ctrl *V1Controller) HandleLabelsGetAll() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleLabelsCreate godoc
|
// HandleLabelsCreate godoc
|
||||||
//
|
//
|
||||||
// @Summary Create 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLabelsCreate() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, data repo.LabelCreate) (repo.LabelOut, error) {
|
fn := func(r *http.Request, data repo.LabelCreate) (repo.LabelOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Labels.Create(auth, auth.GID, data)
|
return ctrl.repo.Labels.Create(auth, auth.GroupID, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
return adapters.Action(fn, http.StatusCreated)
|
||||||
@@ -47,17 +47,17 @@ func (ctrl *V1Controller) HandleLabelsCreate() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleLabelDelete godocs
|
// HandleLabelDelete godocs
|
||||||
//
|
//
|
||||||
// @Summary Delete 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLabelDelete() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
err := ctrl.repo.Labels.DeleteByGroup(auth, auth.GID, ID)
|
err := ctrl.repo.Labels.DeleteByGroup(auth, auth.GroupID, ID)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,17 +66,17 @@ func (ctrl *V1Controller) HandleLabelDelete() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleLabelGet godocs
|
// HandleLabelGet godocs
|
||||||
//
|
//
|
||||||
// @Summary Get Label
|
// @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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLabelGet() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (repo.LabelOut, error) {
|
fn := func(r *http.Request, ID uuid.UUID) (repo.LabelOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Labels.GetOneByGroup(auth, auth.GID, ID)
|
return ctrl.repo.Labels.GetOneByGroup(auth, auth.GroupID, ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusOK)
|
return adapters.CommandID("id", fn, http.StatusOK)
|
||||||
@@ -84,18 +84,18 @@ func (ctrl *V1Controller) HandleLabelGet() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleLabelUpdate godocs
|
// HandleLabelUpdate godocs
|
||||||
//
|
//
|
||||||
// @Summary Update 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLabelUpdate() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID, data repo.LabelUpdate) (repo.LabelOut, error) {
|
fn := func(r *http.Request, ID uuid.UUID, data repo.LabelUpdate) (repo.LabelOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
data.ID = ID
|
data.ID = ID
|
||||||
return ctrl.repo.Labels.UpdateByGroup(auth, auth.GID, data)
|
return ctrl.repo.Labels.UpdateByGroup(auth, auth.GroupID, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
return adapters.ActionID("id", fn, http.StatusOK)
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/adapters"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleLocationTreeQuery godoc
|
// HandleLocationTreeQuery godoc
|
||||||
//
|
//
|
||||||
// @Summary Get Locations Tree
|
// @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} []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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLocationTreeQuery() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, query repo.TreeQuery) ([]repo.TreeItem, error) {
|
fn := func(r *http.Request, query repo.TreeQuery) ([]repo.TreeItem, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Locations.Tree(auth, auth.GID, query)
|
return ctrl.repo.Locations.Tree(auth, auth.GroupID, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Query(fn, http.StatusOK)
|
return adapters.Query(fn, http.StatusOK)
|
||||||
@@ -32,17 +30,17 @@ func (ctrl *V1Controller) HandleLocationTreeQuery() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// 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} []repo.LocationOutCount
|
// @Success 200 {object} []repo.LocationOutCount
|
||||||
// @Router /v1/locations [GET]
|
// @Router /v1/locations [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleLocationGetAll() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLocationGetAll() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, q repo.LocationQuery) ([]repo.LocationOutCount, error) {
|
fn := func(r *http.Request, q repo.LocationQuery) ([]repo.LocationOutCount, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Locations.GetAll(auth, auth.GID, q)
|
return ctrl.repo.Locations.GetAll(auth, auth.GroupID, q)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Query(fn, http.StatusOK)
|
return adapters.Query(fn, http.StatusOK)
|
||||||
@@ -50,17 +48,17 @@ func (ctrl *V1Controller) HandleLocationGetAll() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleLocationCreate godoc
|
// HandleLocationCreate godoc
|
||||||
//
|
//
|
||||||
// @Summary Create 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLocationCreate() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, createData repo.LocationCreate) (repo.LocationOut, error) {
|
fn := func(r *http.Request, createData repo.LocationCreate) (repo.LocationOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Locations.Create(auth, auth.GID, createData)
|
return ctrl.repo.Locations.Create(auth, auth.GroupID, createData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
return adapters.Action(fn, http.StatusCreated)
|
||||||
@@ -68,80 +66,36 @@ func (ctrl *V1Controller) HandleLocationCreate() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleLocationDelete godoc
|
// HandleLocationDelete godoc
|
||||||
//
|
//
|
||||||
// @Summary Delete Location
|
// @Summary Delete 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 204
|
||||||
// @Router /v1/locations/{id} [DELETE]
|
// @Router /v1/locations/{id} [DELETE]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleLocationDelete() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLocationDelete() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
err := ctrl.repo.Locations.DeleteByGroup(auth, auth.GID, ID)
|
err := ctrl.repo.Locations.DeleteByGroup(auth, auth.GroupID, ID)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusNoContent)
|
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
|
|
||||||
quantity := float64(item.Quantity)
|
|
||||||
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 {
|
|
||||||
return repo.LocationOut{}, err
|
|
||||||
}
|
|
||||||
location.TotalPrice += childLocationWithPrice.TotalPrice
|
|
||||||
}
|
|
||||||
|
|
||||||
return location, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleLocationGet godoc
|
// HandleLocationGet godoc
|
||||||
//
|
//
|
||||||
// @Summary Get 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 200 {object} repo.LocationOut
|
// @Success 200 {object} repo.LocationOut
|
||||||
// @Router /v1/locations/{id} [GET]
|
// @Router /v1/locations/{id} [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleLocationGet() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLocationGet() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (repo.LocationOut, error) {
|
fn := func(r *http.Request, ID uuid.UUID) (repo.LocationOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
var location, err = ctrl.GetLocationWithPrice(auth, auth.GID, ID)
|
return ctrl.repo.Locations.GetOneByGroup(auth, auth.GroupID, ID)
|
||||||
|
|
||||||
return location, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusOK)
|
return adapters.CommandID("id", fn, http.StatusOK)
|
||||||
@@ -149,19 +103,19 @@ func (ctrl *V1Controller) HandleLocationGet() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleLocationUpdate godoc
|
// HandleLocationUpdate godoc
|
||||||
//
|
//
|
||||||
// @Summary Update Location
|
// @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"
|
||||||
// @Param payload body repo.LocationUpdate true "Location Data"
|
// @Param payload body repo.LocationUpdate true "Location Data"
|
||||||
// @Success 200 {object} repo.LocationOut
|
// @Success 200 {object} repo.LocationOut
|
||||||
// @Router /v1/locations/{id} [PUT]
|
// @Router /v1/locations/{id} [PUT]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleLocationUpdate() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleLocationUpdate() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID, body repo.LocationUpdate) (repo.LocationOut, error) {
|
fn := func(r *http.Request, ID uuid.UUID, body repo.LocationUpdate) (repo.LocationOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
body.ID = ID
|
body.ID = ID
|
||||||
return ctrl.repo.Locations.UpdateByGroup(auth, auth.GID, ID, body)
|
return ctrl.repo.Locations.UpdateByGroup(auth, auth.GroupID, ID, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
return adapters.ActionID("id", fn, http.StatusOK)
|
||||||
|
|||||||
@@ -4,26 +4,24 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/adapters"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleMaintenanceLogGet godoc
|
// HandleMaintenanceLogGet godoc
|
||||||
//
|
//
|
||||||
// @Summary Get Maintenance Log
|
// @Summary Get Maintenance Log
|
||||||
// @Tags Item Maintenance
|
// @Tags Maintenance
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Item ID"
|
// @Success 200 {object} repo.MaintenanceLog
|
||||||
// @Param filters query repo.MaintenanceFilters false "which maintenance to retrieve"
|
// @Router /v1/items/{id}/maintenance [GET]
|
||||||
// @Success 200 {array} repo.MaintenanceEntryWithDetails[]
|
// @Security Bearer
|
||||||
// @Router /v1/items/{id}/maintenance [GET]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleMaintenanceLogGet() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleMaintenanceLogGet() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID, filters repo.MaintenanceFilters) ([]repo.MaintenanceEntryWithDetails, error) {
|
fn := func(r *http.Request, ID uuid.UUID, q repo.MaintenanceLogQuery) (repo.MaintenanceLog, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.MaintEntry.GetMaintenanceByItemID(auth, auth.GID, ID, filters)
|
return ctrl.repo.MaintEntry.GetLog(auth, auth.GroupID, ID, q)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.QueryID("id", fn, http.StatusOK)
|
return adapters.QueryID("id", fn, http.StatusOK)
|
||||||
@@ -31,14 +29,13 @@ func (ctrl *V1Controller) HandleMaintenanceLogGet() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleMaintenanceEntryCreate godoc
|
// HandleMaintenanceEntryCreate godoc
|
||||||
//
|
//
|
||||||
// @Summary Create Maintenance Entry
|
// @Summary Create Maintenance Entry
|
||||||
// @Tags Item Maintenance
|
// @Tags Maintenance
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Item ID"
|
// @Param payload body repo.MaintenanceEntryCreate true "Entry Data"
|
||||||
// @Param payload body repo.MaintenanceEntryCreate true "Entry Data"
|
// @Success 201 {object} repo.MaintenanceEntry
|
||||||
// @Success 201 {object} repo.MaintenanceEntry
|
// @Router /v1/items/{id}/maintenance [POST]
|
||||||
// @Router /v1/items/{id}/maintenance [POST]
|
// @Security Bearer
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleMaintenanceEntryCreate() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleMaintenanceEntryCreate() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, itemID uuid.UUID, body repo.MaintenanceEntryCreate) (repo.MaintenanceEntry, error) {
|
fn := func(r *http.Request, itemID uuid.UUID, body repo.MaintenanceEntryCreate) (repo.MaintenanceEntry, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
@@ -47,3 +44,39 @@ func (ctrl *V1Controller) HandleMaintenanceEntryCreate() errchain.HandlerFunc {
|
|||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusCreated)
|
return adapters.ActionID("id", fn, http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleMaintenanceEntryDelete godoc
|
||||||
|
//
|
||||||
|
// @Summary Delete Maintenance Entry
|
||||||
|
// @Tags Maintenance
|
||||||
|
// @Produce json
|
||||||
|
// @Success 204
|
||||||
|
// @Router /v1/items/{id}/maintenance/{entry_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("entry_id", fn, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() 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("entry_id", fn, http.StatusOK)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -5,20 +5,20 @@ import (
|
|||||||
|
|
||||||
"github.com/containrrr/shoutrrr"
|
"github.com/containrrr/shoutrrr"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/adapters"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"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
|
// HandleGetUserNotifiers godoc
|
||||||
//
|
//
|
||||||
// @Summary Get Notifiers
|
// @Summary Get Notifiers
|
||||||
// @Tags Notifiers
|
// @Tags Notifiers
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} []repo.NotifierOut
|
// @Success 200 {object} []repo.NotifierOut
|
||||||
// @Router /v1/notifiers [GET]
|
// @Router /v1/notifiers [GET]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleGetUserNotifiers() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleGetUserNotifiers() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, _ struct{}) ([]repo.NotifierOut, error) {
|
fn := func(r *http.Request, _ struct{}) ([]repo.NotifierOut, error) {
|
||||||
user := services.UseUserCtx(r.Context())
|
user := services.UseUserCtx(r.Context())
|
||||||
@@ -30,17 +30,17 @@ func (ctrl *V1Controller) HandleGetUserNotifiers() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleCreateNotifier godoc
|
// HandleCreateNotifier godoc
|
||||||
//
|
//
|
||||||
// @Summary Create Notifier
|
// @Summary Create Notifier
|
||||||
// @Tags Notifiers
|
// @Tags Notifiers
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param payload body repo.NotifierCreate true "Notifier Data"
|
// @Param payload body repo.NotifierCreate true "Notifier Data"
|
||||||
// @Success 200 {object} repo.NotifierOut
|
// @Success 200 {object} repo.NotifierOut
|
||||||
// @Router /v1/notifiers [POST]
|
// @Router /v1/notifiers [POST]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleCreateNotifier() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleCreateNotifier() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, in repo.NotifierCreate) (repo.NotifierOut, error) {
|
fn := func(r *http.Request, in repo.NotifierCreate) (repo.NotifierOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Notifiers.Create(auth, auth.GID, auth.UID, in)
|
return ctrl.repo.Notifiers.Create(auth, auth.GroupID, auth.UserID, in)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Action(fn, http.StatusCreated)
|
return adapters.Action(fn, http.StatusCreated)
|
||||||
@@ -48,16 +48,16 @@ func (ctrl *V1Controller) HandleCreateNotifier() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleDeleteNotifier godocs
|
// HandleDeleteNotifier godocs
|
||||||
//
|
//
|
||||||
// @Summary Delete a Notifier
|
// @Summary Delete a Notifier
|
||||||
// @Tags Notifiers
|
// @Tags Notifiers
|
||||||
// @Param id path string true "Notifier ID"
|
// @Param id path string true "Notifier ID"
|
||||||
// @Success 204
|
// @Success 204
|
||||||
// @Router /v1/notifiers/{id} [DELETE]
|
// @Router /v1/notifiers/{id} [DELETE]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleDeleteNotifier() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleDeleteNotifier() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
fn := func(r *http.Request, ID uuid.UUID) (any, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return nil, ctrl.repo.Notifiers.Delete(auth, auth.UID, ID)
|
return nil, ctrl.repo.Notifiers.Delete(auth, auth.UserID, ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.CommandID("id", fn, http.StatusNoContent)
|
return adapters.CommandID("id", fn, http.StatusNoContent)
|
||||||
@@ -65,17 +65,17 @@ func (ctrl *V1Controller) HandleDeleteNotifier() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleUpdateNotifier godocs
|
// HandleUpdateNotifier godocs
|
||||||
//
|
//
|
||||||
// @Summary Update Notifier
|
// @Summary Update Notifier
|
||||||
// @Tags Notifiers
|
// @Tags Notifiers
|
||||||
// @Param id path string true "Notifier ID"
|
// @Param id path string true "Notifier ID"
|
||||||
// @Param payload body repo.NotifierUpdate true "Notifier Data"
|
// @Param payload body repo.NotifierUpdate true "Notifier Data"
|
||||||
// @Success 200 {object} repo.NotifierOut
|
// @Success 200 {object} repo.NotifierOut
|
||||||
// @Router /v1/notifiers/{id} [PUT]
|
// @Router /v1/notifiers/{id} [PUT]
|
||||||
// @Security Bearer
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandleUpdateNotifier() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleUpdateNotifier() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request, ID uuid.UUID, in repo.NotifierUpdate) (repo.NotifierOut, error) {
|
fn := func(r *http.Request, ID uuid.UUID, in repo.NotifierUpdate) (repo.NotifierOut, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Notifiers.Update(auth, auth.UID, ID, in)
|
return ctrl.repo.Notifiers.Update(auth, auth.UserID, ID, in)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.ActionID("id", fn, http.StatusOK)
|
return adapters.ActionID("id", fn, http.StatusOK)
|
||||||
@@ -83,13 +83,14 @@ func (ctrl *V1Controller) HandleUpdateNotifier() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandlerNotifierTest godoc
|
// HandlerNotifierTest godoc
|
||||||
//
|
//
|
||||||
// @Summary Test Notifier
|
// @Summary Test Notifier
|
||||||
// @Tags Notifiers
|
// @Tags Notifiers
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param url query string true "URL"
|
// @Param id path string true "Notifier ID"
|
||||||
// @Success 204
|
// @Param url query string true "URL"
|
||||||
// @Router /v1/notifiers/test [POST]
|
// @Success 204
|
||||||
// @Security Bearer
|
// @Router /v1/notifiers/test [POST]
|
||||||
|
// @Security Bearer
|
||||||
func (ctrl *V1Controller) HandlerNotifierTest() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandlerNotifierTest() errchain.HandlerFunc {
|
||||||
type body struct {
|
type body struct {
|
||||||
URL string `json:"url" validate:"required"`
|
URL string `json:"url" validate:"required"`
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
package v1
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
|
||||||
"github.com/hay-kot/httpkit/server"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UPCITEMDBResponse struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
Items []struct {
|
|
||||||
Ean string `json:"ean"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Upc string `json:"upc"`
|
|
||||||
Brand string `json:"brand"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
Color string `json:"color"`
|
|
||||||
Size string `json:"size"`
|
|
||||||
Dimension string `json:"dimension"`
|
|
||||||
Weight string `json:"weight"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
LowestRecordedPrice float64 `json:"lowest_recorded_price"`
|
|
||||||
HighestRecordedPrice float64 `json:"highest_recorded_price"`
|
|
||||||
Images []string `json:"images"`
|
|
||||||
Offers []struct {
|
|
||||||
Merchant string `json:"merchant"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
ListPrice string `json:"list_price"`
|
|
||||||
Price float64 `json:"price"`
|
|
||||||
Shipping string `json:"shipping"`
|
|
||||||
Condition string `json:"condition"`
|
|
||||||
Availability string `json:"availability"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
UpdatedT int `json:"updated_t"`
|
|
||||||
} `json:"offers"`
|
|
||||||
Asin string `json:"asin"`
|
|
||||||
Elid string `json:"elid"`
|
|
||||||
} `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BARCODESPIDER_COMResponse struct {
|
|
||||||
ItemResponse struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"item_response"`
|
|
||||||
ItemAttributes struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Upc string `json:"upc"`
|
|
||||||
Ean string `json:"ean"`
|
|
||||||
ParentCategory string `json:"parent_category"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
Brand string `json:"brand"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
Mpn string `json:"mpn"`
|
|
||||||
Manufacturer string `json:"manufacturer"`
|
|
||||||
Publisher string `json:"publisher"`
|
|
||||||
Asin string `json:"asin"`
|
|
||||||
Color string `json:"color"`
|
|
||||||
Size string `json:"size"`
|
|
||||||
Weight string `json:"weight"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
IsAdult string `json:"is_adult"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
} `json:"item_attributes"`
|
|
||||||
Stores []struct {
|
|
||||||
StoreName string `json:"store_name"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
Price string `json:"price"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
Updated string `json:"updated"`
|
|
||||||
} `json:"Stores"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleGenerateQRCode godoc
|
|
||||||
//
|
|
||||||
// @Summary Search EAN from Barcode
|
|
||||||
// @Tags Items
|
|
||||||
// @Produce json
|
|
||||||
// @Param data query string false "barcode to be searched"
|
|
||||||
// @Success 200 {object} []repo.BarcodeProduct
|
|
||||||
// @Router /v1/products/search-from-barcode [GET]
|
|
||||||
// @Security Bearer
|
|
||||||
func (ctrl *V1Controller) HandleProductSearchFromBarcode(conf config.BarcodeAPIConf) errchain.HandlerFunc {
|
|
||||||
type query struct {
|
|
||||||
// 80 characters is the longest non-2D barcode length (GS1-128)
|
|
||||||
EAN string `schema:"productEAN" validate:"required,max=80"`
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
q, err := adapters.DecodeQuery[query](r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const TIMEOUT_SEC = 10
|
|
||||||
|
|
||||||
log.Info().Msg("Processing barcode lookup request on: " + q.EAN)
|
|
||||||
|
|
||||||
// Search on UPCITEMDB
|
|
||||||
var products []repo.BarcodeProduct
|
|
||||||
|
|
||||||
// www.ean-search.org/: not free
|
|
||||||
|
|
||||||
// Example code: dewalt 5035048748428
|
|
||||||
|
|
||||||
upcitemdb := func(iEan string) ([]repo.BarcodeProduct, error) {
|
|
||||||
client := &http.Client{Timeout: TIMEOUT_SEC * time.Second}
|
|
||||||
resp, err := client.Get("https://api.upcitemdb.com/prod/trial/lookup?upc=" + iEan)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
err = errors.Join(err, resp.Body.Close())
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We Read the response body on the line below.
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uncomment the following string for debug
|
|
||||||
// sb := string(body)
|
|
||||||
// log.Debug().Msg("Response: " + sb)
|
|
||||||
|
|
||||||
var result UPCITEMDBResponse
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil { // Parse []byte to go struct pointer
|
|
||||||
log.Error().Msg("Can not unmarshal JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
var res []repo.BarcodeProduct
|
|
||||||
|
|
||||||
for _, it := range result.Items {
|
|
||||||
var p repo.BarcodeProduct
|
|
||||||
p.SearchEngineName = "upcitemdb.com"
|
|
||||||
p.Barcode = iEan
|
|
||||||
|
|
||||||
p.Item.Description = it.Description
|
|
||||||
p.Item.Name = it.Title
|
|
||||||
p.Manufacturer = it.Brand
|
|
||||||
p.ModelNumber = it.Model
|
|
||||||
if len(it.Images) != 0 {
|
|
||||||
p.ImageURL = it.Images[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
res = append(res, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ps, err := upcitemdb(q.EAN)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Msg("Can not retrieve product from upcitemdb.com" + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Barcode spider implementation
|
|
||||||
barcodespider := func(tokenAPI string, iEan string) ([]repo.BarcodeProduct, error) {
|
|
||||||
if len(tokenAPI) == 0 {
|
|
||||||
return nil, errors.New("no api token configured for barcodespider. " +
|
|
||||||
"Please define the api token in environment variable HBOX_BARCODE_TOKEN_BARCODESPIDER")
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(
|
|
||||||
"GET", "https://api.barcodespider.com/v1/lookup?upc="+iEan, nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Add("token", tokenAPI)
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: TIMEOUT_SEC * time.Second}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// defer the call to Body.Close(). We also check the error code, and merge
|
|
||||||
// it with the other error in this code to avoid error overiding.
|
|
||||||
defer func() {
|
|
||||||
err = errors.Join(err, resp.Body.Close())
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("barcodespider API returned status code: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We Read the response body on the line below.
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uncomment the following string for debug
|
|
||||||
// sb := string(body)
|
|
||||||
// log.Debug().Msg("Response: " + sb)
|
|
||||||
|
|
||||||
var result BARCODESPIDER_COMResponse
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil { // Parse []byte to go struct pointer
|
|
||||||
log.Error().Msg("Can not unmarshal JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: check 200 code on HTTP response.
|
|
||||||
var p repo.BarcodeProduct
|
|
||||||
p.Barcode = iEan
|
|
||||||
p.SearchEngineName = "barcodespider.com"
|
|
||||||
p.Item.Name = result.ItemAttributes.Title
|
|
||||||
p.Item.Description = result.ItemAttributes.Description
|
|
||||||
p.Manufacturer = result.ItemAttributes.Brand
|
|
||||||
p.ModelNumber = result.ItemAttributes.Model
|
|
||||||
p.ImageURL = result.ItemAttributes.Image
|
|
||||||
|
|
||||||
var res []repo.BarcodeProduct
|
|
||||||
res = append(res, p)
|
|
||||||
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ps2, err := barcodespider(conf.TokenBarcodespider, q.EAN)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Msg("Can not retrieve product from barcodespider.com: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge everything.
|
|
||||||
products = append(products, ps...)
|
|
||||||
|
|
||||||
products = append(products, ps2...)
|
|
||||||
|
|
||||||
// Retrieve images if possible
|
|
||||||
for i := range products {
|
|
||||||
p := &products[i]
|
|
||||||
|
|
||||||
if len(p.ImageURL) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate URL is HTTPS
|
|
||||||
u, err := url.Parse(p.ImageURL)
|
|
||||||
if err != nil || u.Scheme != "https" {
|
|
||||||
log.Warn().Msg("Skipping non-HTTPS image URL: " + p.ImageURL)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: TIMEOUT_SEC * time.Second}
|
|
||||||
res, err := client.Get(p.ImageURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn().Msg("Cannot fetch image for URL: " + p.ImageURL + ": " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
err = errors.Join(err, res.Body.Close())
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Validate response
|
|
||||||
if res.StatusCode != http.StatusOK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check content type
|
|
||||||
contentType := res.Header.Get("Content-Type")
|
|
||||||
if !strings.HasPrefix(contentType, "image/") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit image size to 8MB
|
|
||||||
limitedReader := io.LimitReader(res.Body, 8*1024*1024)
|
|
||||||
|
|
||||||
// Read data of image
|
|
||||||
bytes, err := io.ReadAll(limitedReader)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn().Msg(err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to Base64
|
|
||||||
var base64Encoding string
|
|
||||||
|
|
||||||
// Determine the content type of the image file
|
|
||||||
mimeType := http.DetectContentType(bytes)
|
|
||||||
|
|
||||||
// Prepend the appropriate URI scheme header depending
|
|
||||||
// on the MIME type
|
|
||||||
switch mimeType {
|
|
||||||
case "image/jpeg":
|
|
||||||
base64Encoding += "data:image/jpeg;base64,"
|
|
||||||
case "image/png":
|
|
||||||
base64Encoding += "data:image/png;base64,"
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append the base64 encoded output
|
|
||||||
base64Encoding += base64.StdEncoding.EncodeToString(bytes)
|
|
||||||
|
|
||||||
p.ImageBase64 = base64Encoding
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(products) != 0 {
|
|
||||||
return server.JSON(w, http.StatusOK, products)
|
|
||||||
}
|
|
||||||
|
|
||||||
return server.JSON(w, http.StatusNoContent, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/adapters"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/adapters"
|
|
||||||
"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,13 +20,13 @@ var qrcodeLogo []byte
|
|||||||
|
|
||||||
// HandleGenerateQRCode godoc
|
// HandleGenerateQRCode godoc
|
||||||
//
|
//
|
||||||
// @Summary Create QR Code
|
// @Summary Create QR Code
|
||||||
// @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() errchain.HandlerFunc {
|
||||||
type query struct {
|
type query struct {
|
||||||
// 4,296 characters is the maximum length of a QR code
|
// 4,296 characters is the maximum length of a QR code
|
||||||
|
|||||||
@@ -3,29 +3,29 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleBillOfMaterialsExport godoc
|
// HandleBillOfMaterialsExport godoc
|
||||||
//
|
//
|
||||||
// @Summary Export Bill of Materials
|
// @Summary Export Bill of Materials
|
||||||
// @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() errchain.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
tenant := services.UseTenantCtx(r.Context())
|
actor := services.UseUserCtx(r.Context())
|
||||||
|
|
||||||
csv, err := ctrl.svc.Items.ExportBillOfMaterialsCSV(r.Context(), tenant)
|
csv, err := ctrl.svc.Items.ExportBillOfMaterialsTSV(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/tsv")
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename=bill-of-materials.csv")
|
w.Header().Set("Content-Disposition", "attachment; filename=bill-of-materials.tsv")
|
||||||
_, err = w.Write(csv)
|
_, err = w.Write(csv)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,26 +4,26 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/adapters"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/httpkit/server"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleGroupStatisticsLocations godoc
|
// HandleGroupStatisticsLocations godoc
|
||||||
//
|
//
|
||||||
// @Summary Get Location 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleGroupStatisticsLocations() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request) ([]repo.TotalsByOrganizer, error) {
|
fn := func(r *http.Request) ([]repo.TotalsByOrganizer, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Groups.StatsLocationsByPurchasePrice(auth, auth.GID)
|
return ctrl.repo.Groups.StatsLocationsByPurchasePrice(auth, auth.GroupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
return adapters.Command(fn, http.StatusOK)
|
||||||
@@ -31,16 +31,16 @@ func (ctrl *V1Controller) HandleGroupStatisticsLocations() errchain.HandlerFunc
|
|||||||
|
|
||||||
// HandleGroupStatisticsLabels godoc
|
// HandleGroupStatisticsLabels godoc
|
||||||
//
|
//
|
||||||
// @Summary Get Label 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleGroupStatisticsLabels() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request) ([]repo.TotalsByOrganizer, error) {
|
fn := func(r *http.Request) ([]repo.TotalsByOrganizer, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Groups.StatsLabelsByPurchasePrice(auth, auth.GID)
|
return ctrl.repo.Groups.StatsLabelsByPurchasePrice(auth, auth.GroupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
return adapters.Command(fn, http.StatusOK)
|
||||||
@@ -48,16 +48,16 @@ func (ctrl *V1Controller) HandleGroupStatisticsLabels() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleGroupStatistics godoc
|
// HandleGroupStatistics godoc
|
||||||
//
|
//
|
||||||
// @Summary Get 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() errchain.HandlerFunc {
|
func (ctrl *V1Controller) HandleGroupStatistics() errchain.HandlerFunc {
|
||||||
fn := func(r *http.Request) (repo.GroupStatistics, error) {
|
fn := func(r *http.Request) (repo.GroupStatistics, error) {
|
||||||
auth := services.NewContext(r.Context())
|
auth := services.NewContext(r.Context())
|
||||||
return ctrl.repo.Groups.StatsGroup(auth, auth.GID)
|
return ctrl.repo.Groups.StatsGroup(auth, auth.GroupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return adapters.Command(fn, http.StatusOK)
|
return adapters.Command(fn, http.StatusOK)
|
||||||
@@ -65,14 +65,14 @@ func (ctrl *V1Controller) HandleGroupStatistics() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleGroupStatisticsPriceOverTime godoc
|
// HandleGroupStatisticsPriceOverTime godoc
|
||||||
//
|
//
|
||||||
// @Summary Get Purchase Price Statistics
|
// @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() errchain.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 == "" {
|
||||||
@@ -94,7 +94,7 @@ func (ctrl *V1Controller) HandleGroupStatisticsPriceOverTime() errchain.HandlerF
|
|||||||
return validate.NewRequestError(err, http.StatusBadRequest)
|
return validate.NewRequestError(err, http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := ctrl.repo.Groups.StatsPurchasePrice(ctx, ctx.GID, startDate, endDate)
|
stats, err := ctrl.repo.Groups.StatsPurchasePrice(ctx, ctx.GroupID, startDate, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,30 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/adapters"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/httpkit/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
|
// HandleUserRegistration godoc
|
||||||
//
|
//
|
||||||
// @Summary Register New 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
|
||||||
// @Failure 403 {string} string "Local login is not enabled"
|
// @Router /v1/users/register [Post]
|
||||||
// @Router /v1/users/register [Post]
|
|
||||||
func (ctrl *V1Controller) HandleUserRegistration() errchain.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 {
|
||||||
// Forbidden if local login is not enabled
|
|
||||||
if !ctrl.config.Options.AllowLocalLogin {
|
|
||||||
return validate.NewRequestError(fmt.Errorf("local login is not enabled"), http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
regData := services.UserRegistration{}
|
regData := services.UserRegistration{}
|
||||||
|
|
||||||
if err := server.Decode(r, ®Data); err != nil {
|
if err := server.Decode(r, ®Data); err != nil {
|
||||||
@@ -52,12 +48,12 @@ func (ctrl *V1Controller) HandleUserRegistration() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleUserSelf godoc
|
// HandleUserSelf godoc
|
||||||
//
|
//
|
||||||
// @Summary Get User Self
|
// @Summary Get User Self
|
||||||
// @Tags User
|
// @Tags User
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} Wrapped{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() errchain.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())
|
||||||
@@ -73,13 +69,13 @@ func (ctrl *V1Controller) HandleUserSelf() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleUserSelfUpdate godoc
|
// HandleUserSelfUpdate godoc
|
||||||
//
|
//
|
||||||
// @Summary Update Account
|
// @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} Wrapped{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() errchain.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{}
|
||||||
@@ -100,12 +96,12 @@ func (ctrl *V1Controller) HandleUserSelfUpdate() errchain.HandlerFunc {
|
|||||||
|
|
||||||
// HandleUserSelfDelete godoc
|
// HandleUserSelfDelete godoc
|
||||||
//
|
//
|
||||||
// @Summary Delete 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() errchain.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 {
|
||||||
@@ -121,21 +117,19 @@ func (ctrl *V1Controller) HandleUserSelfDelete() errchain.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type (
|
type ChangePassword struct {
|
||||||
ChangePassword struct {
|
Current string `json:"current,omitempty"`
|
||||||
Current string `json:"current,omitempty"`
|
New string `json:"new,omitempty"`
|
||||||
New string `json:"new,omitempty"`
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleUserSelfChangePassword godoc
|
// HandleUserSelfChangePassword godoc
|
||||||
//
|
//
|
||||||
// @Summary Change 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() errchain.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 {
|
||||||
@@ -150,7 +144,7 @@ func (ctrl *V1Controller) HandleUserSelfChangePassword() errchain.HandlerFunc {
|
|||||||
|
|
||||||
ctx := services.NewContext(r.Context())
|
ctx := services.NewContext(r.Context())
|
||||||
|
|
||||||
ok := ctrl.svc.User.ChangePassword(ctx, cp.Current, cp.New)
|
ok := ctrl.svc.User.PasswordChange(ctx, cp.Current, cp.New)
|
||||||
if !ok {
|
if !ok {
|
||||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -158,3 +152,73 @@ func (ctrl *V1Controller) HandleUserSelfChangePassword() errchain.HandlerFunc {
|
|||||||
return server.JSON(w, http.StatusNoContent, nil)
|
return server.JSON(w, http.StatusNoContent, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleUserSelfChangePasswordWithToken godoc
|
||||||
|
//
|
||||||
|
// @Summary Change Password
|
||||||
|
// @Tags User
|
||||||
|
// @Success 204
|
||||||
|
// @Param payload body ChangePassword true "Password Payload"
|
||||||
|
// @Router /v1/users/change-password-token [PUT]
|
||||||
|
func (ctrl *V1Controller) HandleUserSelfChangePasswordWithToken() errchain.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
tokenQueryParam := r.URL.Query().Get("token")
|
||||||
|
if tokenQueryParam == "" {
|
||||||
|
return validate.NewRequestError(fmt.Errorf("missing token query param"), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctrl.isDemo {
|
||||||
|
return validate.NewRequestError(nil, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cp ChangePassword
|
||||||
|
err := server.Decode(r, &cp)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("user failed to change password")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := services.NewContext(r.Context())
|
||||||
|
|
||||||
|
ok := ctrl.svc.User.PasswordChange(ctx, cp.Current, cp.New)
|
||||||
|
if !ok {
|
||||||
|
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.JSON(w, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUserRequestPasswordReset godoc
|
||||||
|
//
|
||||||
|
// @Summary Request Password Reset
|
||||||
|
// @Tags User
|
||||||
|
// @Produce json
|
||||||
|
// @Param payload body services.PasswordResetRequest true "User Data"
|
||||||
|
// @Success 204
|
||||||
|
// @Router /v1/users/request-password-reset [Post]
|
||||||
|
func (ctrl *V1Controller) HandleUserRequestPasswordReset() errchain.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if ctrl.isDemo {
|
||||||
|
return validate.NewRequestError(nil, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := adapters.DecodeBody[services.PasswordResetRequest](r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err = ctrl.svc.User.PasswordResetRequest(ctx, v)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().
|
||||||
|
Err(err).
|
||||||
|
Str("email", v.Email).
|
||||||
|
Msg("failed to request password reset")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return server.JSON(w, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"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
|
||||||
@@ -18,10 +18,7 @@ func (a *app) setupLogger() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
level, err := zerolog.ParseLevel(a.conf.Log.Level)
|
level, err := zerolog.ParseLevel(a.conf.Log.Level)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
log.Error().Err(err).Str("level", a.conf.Log.Level).Msg("invalid log level, falling back to info")
|
|
||||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
|
||||||
} else {
|
|
||||||
zerolog.SetGlobalLevel(level)
|
zerolog.SetGlobalLevel(level)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,34 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
atlas "ariga.io/atlas/sql/migrate"
|
||||||
|
"entgo.io/ent/dialect/sql/schema"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/pressly/goose/v3"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/analytics"
|
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/currencies"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/migrations"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/repo"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/config"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/web/mid"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
"github.com/hay-kot/httpkit/graceful"
|
"github.com/hay-kot/httpkit/graceful"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/rs/zerolog/pkgerrors"
|
"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/config"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/web/mid"
|
|
||||||
"go.balki.me/anyhttp"
|
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/hay-kot/homebox/backend/pkgs/cgofreesqlite"
|
||||||
_ "github.com/sysadminsmedia/homebox/backend/internal/data/migrations/postgres"
|
|
||||||
_ "github.com/sysadminsmedia/homebox/backend/internal/data/migrations/sqlite3"
|
|
||||||
_ "github.com/sysadminsmedia/homebox/backend/pkgs/cgofreesqlite"
|
|
||||||
|
|
||||||
_ "gocloud.dev/pubsub/awssnssqs"
|
|
||||||
_ "gocloud.dev/pubsub/azuresb"
|
|
||||||
_ "gocloud.dev/pubsub/gcppubsub"
|
|
||||||
_ "gocloud.dev/pubsub/kafkapubsub"
|
|
||||||
_ "gocloud.dev/pubsub/mempubsub"
|
|
||||||
_ "gocloud.dev/pubsub/natspubsub"
|
|
||||||
_ "gocloud.dev/pubsub/rabbitpubsub"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -57,33 +46,15 @@ func build() string {
|
|||||||
return fmt.Sprintf("%s, commit %s, built at %s", version, short, buildTime)
|
return fmt.Sprintf("%s, commit %s, built at %s", version, short, buildTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validatePostgresSSLMode(sslMode string) bool {
|
// @title Homebox API
|
||||||
validModes := map[string]bool{
|
// @version 1.0
|
||||||
"": true,
|
// @description Track, Manage, and Organize your Things.
|
||||||
"disable": true,
|
// @contact.name Don't
|
||||||
"allow": true,
|
// @BasePath /api
|
||||||
"prefer": true,
|
// @securityDefinitions.apikey Bearer
|
||||||
"require": true,
|
// @in header
|
||||||
"verify-ca": true,
|
// @name Authorization
|
||||||
"verify-full": true,
|
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
|
||||||
}
|
|
||||||
return validModes[strings.ToLower(strings.TrimSpace(sslMode))]
|
|
||||||
}
|
|
||||||
|
|
||||||
// @title Homebox API
|
|
||||||
// @version 1.0
|
|
||||||
// @description Track, Manage, and Organize your Things.
|
|
||||||
// @contact.name Homebox Team
|
|
||||||
// @contact.url https://discord.homebox.software
|
|
||||||
// @host demo.homebox.software
|
|
||||||
// @schemes https http
|
|
||||||
// @BasePath /api
|
|
||||||
// @securityDefinitions.apikey Bearer
|
|
||||||
// @in header
|
|
||||||
// @name Authorization
|
|
||||||
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
|
|
||||||
// @externalDocs.url https://homebox.software/en/api
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||||
|
|
||||||
@@ -103,77 +74,94 @@ func run(cfg *config.Config) error {
|
|||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Initialize Database & Repos
|
// Initialize Database & Repos
|
||||||
err := setupStorageDir(cfg)
|
|
||||||
|
err := os.MkdirAll(cfg.Storage.Data, 0o755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Fatal().Err(err).Msg("failed to create data directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.ToLower(cfg.Database.Driver) == config.DriverPostgres {
|
c, err := ent.Open("sqlite3", cfg.Storage.SqliteURL)
|
||||||
if !validatePostgresSSLMode(cfg.Database.SslMode) {
|
|
||||||
log.Error().Str("sslmode", cfg.Database.SslMode).Msg("invalid sslmode")
|
|
||||||
return fmt.Errorf("invalid sslmode: %s", cfg.Database.SslMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
databaseURL, err := setupDatabaseURL(cfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Fatal().
|
||||||
}
|
|
||||||
|
|
||||||
c, err := ent.Open(strings.ToLower(cfg.Database.Driver), databaseURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().
|
|
||||||
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) {
|
||||||
return fmt.Errorf("failed opening connection to %s database at %s:%s/%s: %w",
|
err := c.Close()
|
||||||
strings.ToLower(cfg.Database.Driver),
|
if err != nil {
|
||||||
cfg.Database.Host,
|
log.Fatal().Err(err).Msg("failed to close database connection")
|
||||||
cfg.Database.Port,
|
}
|
||||||
cfg.Database.Database,
|
}(c)
|
||||||
err,
|
|
||||||
)
|
temp := filepath.Join(os.TempDir(), "migrations")
|
||||||
|
|
||||||
|
err = migrations.Write(temp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
migrationsFs, err := migrations.Migrations(strings.ToLower(cfg.Database.Driver))
|
dir, err := atlas.NewLocalDir(temp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get migrations for %s: %w", strings.ToLower(cfg.Database.Driver), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
goose.SetBaseFS(migrationsFs)
|
|
||||||
err = goose.SetDialect(strings.ToLower(cfg.Database.Driver))
|
|
||||||
if err != nil {
|
|
||||||
log.Error().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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
collectFuncs, err := loadCurrencies(cfg)
|
options := []schema.MigrateOption{
|
||||||
|
schema.WithDir(dir),
|
||||||
|
schema.WithDropColumn(true),
|
||||||
|
schema.WithDropIndex(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.Schema.Create(context.Background(), options...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Fatal(). // nolint
|
||||||
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collectFuncs := []currencies.CollectorFunc{
|
||||||
|
currencies.CollectDefaults(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Options.CurrencyConfig != "" {
|
||||||
|
log.Info().
|
||||||
|
Str("path", cfg.Options.CurrencyConfig).
|
||||||
|
Msg("loading currency config file")
|
||||||
|
|
||||||
|
content, err := os.ReadFile(cfg.Options.CurrencyConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().
|
||||||
|
Err(err).
|
||||||
|
Str("path", cfg.Options.CurrencyConfig).
|
||||||
|
Msg("failed to read currency config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content)))
|
||||||
|
}
|
||||||
|
|
||||||
currencies, err := currencies.CollectionCurrencies(collectFuncs...)
|
currencies, err := currencies.CollectionCurrencies(collectFuncs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Fatal().
|
||||||
Err(err).
|
Err(err).
|
||||||
Msg("failed to collect currencies")
|
Msg("failed to collect currencies")
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.bus = eventbus.New()
|
app.bus = eventbus.New()
|
||||||
app.db = c
|
app.db = c
|
||||||
app.repos = repo.New(c, app.bus, cfg.Storage, cfg.Database.PubSubConnString, cfg.Thumbnail)
|
app.repos = repo.New(c, app.bus, cfg.Storage.Data)
|
||||||
app.services = services.New(
|
app.services = services.New(
|
||||||
app.repos,
|
app.repos,
|
||||||
|
app.conf.BaseURL,
|
||||||
|
app.mailer,
|
||||||
services.WithAutoIncrementAssetID(cfg.Options.AutoIncrementAssetID),
|
services.WithAutoIncrementAssetID(cfg.Options.AutoIncrementAssetID),
|
||||||
services.WithCurrencies(currencies),
|
services.WithCurrencies(currencies),
|
||||||
)
|
)
|
||||||
@@ -194,7 +182,7 @@ func run(cfg *config.Config) error {
|
|||||||
|
|
||||||
chain := errchain.New(mid.Errors(logger))
|
chain := errchain.New(mid.Errors(logger))
|
||||||
|
|
||||||
app.mountRoutes(router, chain, app.repos)
|
app.mountRoutes(router, chain)
|
||||||
|
|
||||||
runner := graceful.NewRunner()
|
runner := graceful.NewRunner()
|
||||||
|
|
||||||
@@ -212,51 +200,74 @@ func run(cfg *config.Config) error {
|
|||||||
_ = httpserver.Shutdown(context.Background())
|
_ = httpserver.Shutdown(context.Background())
|
||||||
}()
|
}()
|
||||||
|
|
||||||
listener, addrType, addrCfg, err := anyhttp.GetListener(cfg.Web.Host)
|
|
||||||
if err == nil {
|
|
||||||
switch addrType {
|
|
||||||
case anyhttp.SystemdFD:
|
|
||||||
sysdCfg := addrCfg.(*anyhttp.SysdConfig)
|
|
||||||
if sysdCfg.IdleTimeout != nil {
|
|
||||||
log.Error().Msg("idle timeout not yet supported. Please remove and try again")
|
|
||||||
return errors.New("idle timeout not yet supported. Please remove and try again")
|
|
||||||
}
|
|
||||||
fallthrough
|
|
||||||
case anyhttp.UnixSocket:
|
|
||||||
log.Info().Msgf("Server is running on %s", cfg.Web.Host)
|
|
||||||
return httpserver.Serve(listener)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Debug().Msgf("anyhttp error: %v", err)
|
|
||||||
}
|
|
||||||
log.Info().Msgf("Server is running on %s:%s", cfg.Web.Host, cfg.Web.Port)
|
log.Info().Msgf("Server is running on %s:%s", cfg.Web.Host, cfg.Web.Port)
|
||||||
return httpserver.ListenAndServe()
|
return httpserver.ListenAndServe()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
// Start Reoccurring Tasks
|
// Start Reoccurring Tasks
|
||||||
registerRecurringTasks(app, cfg, runner)
|
|
||||||
|
|
||||||
// Send analytics if enabled at around midnight UTC
|
runner.AddFunc("eventbus", app.bus.Run)
|
||||||
if cfg.Options.AllowAnalytics {
|
|
||||||
analyticsTime := time.Second
|
runner.AddFunc("seed_database", func(ctx context.Context) error {
|
||||||
runner.AddPlugin(NewTask("send-analytics", analyticsTime, func(ctx context.Context) {
|
// TODO: Remove through external API that does setup
|
||||||
for {
|
if cfg.Demo {
|
||||||
now := time.Now().UTC()
|
log.Info().Msg("Running in demo mode, creating demo data")
|
||||||
nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
app.SetupDemo()
|
||||||
dur := time.Until(nextMidnight)
|
}
|
||||||
analyticsTime = dur
|
return nil
|
||||||
select {
|
})
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
runner.AddPlugin(NewTask("purge-tokens", time.Duration(24)*time.Hour, func(ctx context.Context) {
|
||||||
case <-time.After(dur):
|
_, err := app.repos.AuthTokens.PurgeExpiredTokens(ctx)
|
||||||
log.Debug().Msg("running send analytics")
|
if err != nil {
|
||||||
err := analytics.Send(version, build())
|
log.Error().
|
||||||
if err != nil {
|
Err(err).
|
||||||
log.Error().Err(err).Msg("failed to send analytics")
|
Msg("failed to purge expired tokens")
|
||||||
}
|
}
|
||||||
}
|
}))
|
||||||
|
|
||||||
|
runner.AddPlugin(NewTask("purge-invitations", time.Duration(24)*time.Hour, func(ctx context.Context) {
|
||||||
|
_, err := app.repos.Groups.InvitationPurge(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Msg("failed to purge expired invitations")
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
runner.AddPlugin(NewTask("send-notifications", time.Duration(1)*time.Hour, func(ctx context.Context) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if now.Hour() == 8 {
|
||||||
|
fmt.Println("run notifiers")
|
||||||
|
err := app.services.BackgroundService.SendNotifiersToday(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Msg("failed to send notifiers")
|
||||||
}
|
}
|
||||||
}))
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
if cfg.Debug.Enabled {
|
||||||
|
runner.AddFunc("debug", func(ctx context.Context) error {
|
||||||
|
debugserver := http.Server{
|
||||||
|
Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port),
|
||||||
|
Handler: app.debugRouter(),
|
||||||
|
ReadTimeout: cfg.Web.ReadTimeout,
|
||||||
|
WriteTimeout: cfg.Web.WriteTimeout,
|
||||||
|
IdleTimeout: cfg.Web.IdleTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
_ = debugserver.Shutdown(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Info().Msgf("Debug server is running on %s:%s", cfg.Web.Host, cfg.Debug.Port)
|
||||||
|
return debugserver.ListenAndServe()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return runner.Start(context.Background())
|
return runner.Start(context.Background())
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
v1 "github.com/hay-kot/homebox/backend/app/api/handlers/v1"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/ent"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
v1 "github.com/sysadminsmedia/homebox/backend/app/api/handlers/v1"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type tokenHasKey struct {
|
type tokenHasKey struct {
|
||||||
@@ -153,53 +152,3 @@ func (a *app) mwAuthToken(next errchain.Handler) errchain.Handler {
|
|||||||
return next.ServeHTTP(w, r)
|
return next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// mwTenant is a middleware that will parse the X-Tenant header and validate the user has access
|
|
||||||
// to the requested tenant. If no header is provided, the user's default group is used.
|
|
||||||
//
|
|
||||||
// WARNING: This middleware _MUST_ be called after mwAuthToken
|
|
||||||
func (a *app) mwTenant(next errchain.Handler) errchain.Handler {
|
|
||||||
return errchain.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Get the user from context (set by mwAuthToken)
|
|
||||||
user := services.UseUserCtx(ctx)
|
|
||||||
if user == nil {
|
|
||||||
return validate.NewRequestError(errors.New("user context not found"), http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
tenantID := user.DefaultGroupID
|
|
||||||
|
|
||||||
// Check for X-Tenant header or tenant query parameter
|
|
||||||
tenantHeader := r.Header.Get("X-Tenant")
|
|
||||||
if tenantHeader == "" {
|
|
||||||
tenantHeader = r.URL.Query().Get("tenant")
|
|
||||||
}
|
|
||||||
|
|
||||||
if tenantHeader != "" {
|
|
||||||
parsedTenantID, err := uuid.Parse(tenantHeader)
|
|
||||||
if err != nil {
|
|
||||||
return validate.NewRequestError(errors.New("invalid X-Tenant header format"), http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate user has access to the requested tenant
|
|
||||||
hasAccess := false
|
|
||||||
for _, gid := range user.GroupIDs {
|
|
||||||
if gid == parsedTenantID {
|
|
||||||
hasAccess = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasAccess {
|
|
||||||
return validate.NewRequestError(errors.New("user does not have access to the requested tenant"), http.StatusForbidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
tenantID = parsedTenantID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the tenant in context
|
|
||||||
r = r.WithContext(services.SetTenantCtx(ctx, tenantID))
|
|
||||||
return next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/sys/validate"
|
||||||
"github.com/hay-kot/httpkit/server"
|
"github.com/hay-kot/httpkit/server"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type LoginForm struct {
|
type LoginForm struct {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package providers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
"github.com/hay-kot/homebox/backend/internal/core/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LocalProvider struct {
|
type LocalProvider struct {
|
||||||
|
|||||||
@@ -1,626 +0,0 @@
|
|||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type OIDCProvider struct {
|
|
||||||
service *services.UserService
|
|
||||||
config *config.OIDCConf
|
|
||||||
options *config.Options
|
|
||||||
cookieSecure bool
|
|
||||||
provider *oidc.Provider
|
|
||||||
verifier *oidc.IDTokenVerifier
|
|
||||||
endpoint oauth2.Endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
type OIDCClaims struct {
|
|
||||||
Email string
|
|
||||||
Groups []string
|
|
||||||
Name string
|
|
||||||
Subject string
|
|
||||||
Issuer string
|
|
||||||
EmailVerified *bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOIDCProvider(service *services.UserService, config *config.OIDCConf, options *config.Options, cookieSecure bool) (*OIDCProvider, error) {
|
|
||||||
if !config.Enabled {
|
|
||||||
return nil, fmt.Errorf("OIDC is not enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate required configuration
|
|
||||||
if config.ClientID == "" {
|
|
||||||
return nil, fmt.Errorf("OIDC client ID is required when OIDC is enabled (set HBOX_OIDC_CLIENT_ID)")
|
|
||||||
}
|
|
||||||
if config.ClientSecret == "" {
|
|
||||||
return nil, fmt.Errorf("OIDC client secret is required when OIDC is enabled (set HBOX_OIDC_CLIENT_SECRET)")
|
|
||||||
}
|
|
||||||
if config.IssuerURL == "" {
|
|
||||||
return nil, fmt.Errorf("OIDC issuer URL is required when OIDC is enabled (set HBOX_OIDC_ISSUER_URL)")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), config.RequestTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
provider, err := oidc.NewProvider(ctx, config.IssuerURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create OIDC provider from issuer URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create ID token verifier
|
|
||||||
verifier := provider.Verifier(&oidc.Config{
|
|
||||||
ClientID: config.ClientID,
|
|
||||||
})
|
|
||||||
|
|
||||||
log.Info().
|
|
||||||
Str("issuer", config.IssuerURL).
|
|
||||||
Str("client_id", config.ClientID).
|
|
||||||
Str("scope", config.Scope).
|
|
||||||
Msg("OIDC provider initialized successfully with discovery")
|
|
||||||
|
|
||||||
return &OIDCProvider{
|
|
||||||
service: service,
|
|
||||||
config: config,
|
|
||||||
options: options,
|
|
||||||
cookieSecure: cookieSecure,
|
|
||||||
provider: provider,
|
|
||||||
verifier: verifier,
|
|
||||||
endpoint: provider.Endpoint(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *OIDCProvider) Name() string {
|
|
||||||
return "oidc"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticate implements the AuthProvider interface but is not used for OIDC
|
|
||||||
// OIDC uses dedicated endpoints: GET /api/v1/users/login/oidc and GET /api/v1/users/login/oidc/callback
|
|
||||||
func (p *OIDCProvider) Authenticate(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
|
||||||
_ = w
|
|
||||||
_ = r
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("OIDC authentication uses dedicated endpoints: /api/v1/users/login/oidc")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthenticateWithBaseURL is the main authentication method that requires baseURL
|
|
||||||
// Called from handleCallback after state, nonce, and PKCE verification
|
|
||||||
func (p *OIDCProvider) AuthenticateWithBaseURL(baseURL, expectedNonce, pkceVerifier string, _ http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
if code == "" {
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("missing authorization code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get OAuth2 config for this request
|
|
||||||
oauth2Config := p.getOAuth2Config(baseURL)
|
|
||||||
|
|
||||||
// Exchange code for token with timeout and PKCE verifier
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), p.config.RequestTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
token, err := oauth2Config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", pkceVerifier))
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to exchange OIDC code for token")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("failed to exchange code for token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract ID token
|
|
||||||
idToken, ok := token.Extra("id_token").(string)
|
|
||||||
if !ok {
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("no id_token in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and validate the ID token using the library's verifier with timeout
|
|
||||||
verifyCtx, verifyCancel := context.WithTimeout(r.Context(), p.config.RequestTimeout)
|
|
||||||
defer verifyCancel()
|
|
||||||
|
|
||||||
idTokenStruct, err := p.verifier.Verify(verifyCtx, idToken)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to verify ID token")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("failed to verify ID token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract claims from the verified token using dynamic parsing
|
|
||||||
var rawClaims map[string]interface{}
|
|
||||||
if err := idTokenStruct.Claims(&rawClaims); err != nil {
|
|
||||||
log.Err(err).Msg("failed to extract claims from ID token")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("failed to extract claims from ID token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to retrieve UserInfo claims; use them as primary, fallback to ID token claims.
|
|
||||||
finalClaims := rawClaims
|
|
||||||
userInfoCtx, uiCancel := context.WithTimeout(r.Context(), p.config.RequestTimeout)
|
|
||||||
defer uiCancel()
|
|
||||||
|
|
||||||
userInfo, uiErr := p.provider.UserInfo(userInfoCtx, oauth2.StaticTokenSource(token))
|
|
||||||
if uiErr != nil {
|
|
||||||
log.Debug().Err(uiErr).Msg("OIDC UserInfo fetch failed; falling back to ID token claims")
|
|
||||||
} else {
|
|
||||||
var uiClaims map[string]interface{}
|
|
||||||
if err := userInfo.Claims(&uiClaims); err != nil {
|
|
||||||
log.Debug().Err(err).Msg("failed to decode UserInfo claims; falling back to ID token claims")
|
|
||||||
} else {
|
|
||||||
finalClaims = mergeOIDCClaims(uiClaims, rawClaims) // UserInfo first, then fill gaps from ID token
|
|
||||||
log.Debug().Int("userinfo_claims", len(uiClaims)).Int("id_token_claims", len(rawClaims)).Int("merged_claims", len(finalClaims)).Msg("merged UserInfo and ID token claims")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse claims using configurable claim names (after merge)
|
|
||||||
claims, err := p.parseOIDCClaims(finalClaims)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to parse OIDC claims")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("failed to parse OIDC claims: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify nonce claim matches expected value (nonce only from ID token; ensure preserved in merged map)
|
|
||||||
tokenNonce, exists := finalClaims["nonce"]
|
|
||||||
if !exists {
|
|
||||||
log.Warn().Msg("nonce claim missing from ID token - possible replay attack")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("nonce claim missing from token")
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenNonceStr, ok := tokenNonce.(string)
|
|
||||||
if !ok {
|
|
||||||
log.Warn().Msg("nonce claim is not a string in ID token")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("invalid nonce claim format")
|
|
||||||
}
|
|
||||||
|
|
||||||
if tokenNonceStr != expectedNonce {
|
|
||||||
log.Warn().Str("received", tokenNonceStr).Str("expected", expectedNonce).Msg("OIDC nonce mismatch - possible replay attack")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("nonce parameter mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email is verified
|
|
||||||
if p.config.VerifyEmail {
|
|
||||||
if claims.EmailVerified == nil {
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("email verification status not found in token claims")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !*claims.EmailVerified {
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("email not verified")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check group authorization if configured
|
|
||||||
if p.config.AllowedGroups != "" {
|
|
||||||
allowedGroups := strings.Split(p.config.AllowedGroups, ",")
|
|
||||||
if !p.hasAllowedGroup(claims.Groups, allowedGroups) {
|
|
||||||
log.Warn().
|
|
||||||
Strs("user_groups", claims.Groups).
|
|
||||||
Strs("allowed_groups", allowedGroups).
|
|
||||||
Str("user", claims.Email).
|
|
||||||
Msg("user not in allowed groups")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("user not in allowed groups")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine username from claims
|
|
||||||
email := claims.Email
|
|
||||||
if email == "" {
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("no email found in token claims")
|
|
||||||
}
|
|
||||||
if claims.Subject == "" {
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("no subject (sub) claim present")
|
|
||||||
}
|
|
||||||
if claims.Issuer == "" {
|
|
||||||
claims.Issuer = p.config.IssuerURL // fallback to configured issuer, though spec requires 'iss'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the dedicated OIDC login method (issuer + subject identity)
|
|
||||||
sessionToken, err := p.service.LoginOIDC(r.Context(), claims.Issuer, claims.Subject, email, claims.Name)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Str("email", email).Str("issuer", claims.Issuer).Str("subject", claims.Subject).Msg("OIDC login failed")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("OIDC login failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *OIDCProvider) parseOIDCClaims(rawClaims map[string]interface{}) (OIDCClaims, error) {
|
|
||||||
var claims OIDCClaims
|
|
||||||
|
|
||||||
// Parse email claim
|
|
||||||
key := p.config.EmailClaim
|
|
||||||
if key == "" {
|
|
||||||
key = "email"
|
|
||||||
}
|
|
||||||
if emailValue, exists := rawClaims[key]; exists {
|
|
||||||
if email, ok := emailValue.(string); ok {
|
|
||||||
claims.Email = email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse email_verified claim
|
|
||||||
if p.config.VerifyEmail {
|
|
||||||
key = p.config.EmailVerifiedClaim
|
|
||||||
if key == "" {
|
|
||||||
key = "email_verified"
|
|
||||||
}
|
|
||||||
if emailVerifiedValue, exists := rawClaims[key]; exists {
|
|
||||||
switch v := emailVerifiedValue.(type) {
|
|
||||||
case bool:
|
|
||||||
claims.EmailVerified = &v
|
|
||||||
case string:
|
|
||||||
if b, err := strconv.ParseBool(v); err == nil {
|
|
||||||
claims.EmailVerified = &b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse name claim
|
|
||||||
key = p.config.NameClaim
|
|
||||||
if key == "" {
|
|
||||||
key = "name"
|
|
||||||
}
|
|
||||||
if nameValue, exists := rawClaims[key]; exists {
|
|
||||||
if name, ok := nameValue.(string); ok {
|
|
||||||
claims.Name = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse groups claim
|
|
||||||
key = p.config.GroupClaim
|
|
||||||
if key == "" {
|
|
||||||
key = "groups"
|
|
||||||
}
|
|
||||||
if groupsValue, exists := rawClaims[key]; exists {
|
|
||||||
switch groups := groupsValue.(type) {
|
|
||||||
case []interface{}:
|
|
||||||
for _, group := range groups {
|
|
||||||
if groupStr, ok := group.(string); ok {
|
|
||||||
claims.Groups = append(claims.Groups, groupStr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case []string:
|
|
||||||
claims.Groups = groups
|
|
||||||
case string:
|
|
||||||
// Single group as string
|
|
||||||
claims.Groups = []string{groups}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse subject claim (always "sub")
|
|
||||||
if subValue, exists := rawClaims["sub"]; exists {
|
|
||||||
if subject, ok := subValue.(string); ok {
|
|
||||||
claims.Subject = subject
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Parse issuer claim ("iss")
|
|
||||||
if issValue, exists := rawClaims["iss"]; exists {
|
|
||||||
if iss, ok := issValue.(string); ok {
|
|
||||||
claims.Issuer = iss
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *OIDCProvider) hasAllowedGroup(userGroups, allowedGroups []string) bool {
|
|
||||||
if len(allowedGroups) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
allowedGroupsMap := make(map[string]bool)
|
|
||||||
for _, group := range allowedGroups {
|
|
||||||
allowedGroupsMap[strings.TrimSpace(group)] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, userGroup := range userGroups {
|
|
||||||
if allowedGroupsMap[userGroup] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *OIDCProvider) GetAuthURL(baseURL, state, nonce, pkceVerifier string) string {
|
|
||||||
oauth2Config := p.getOAuth2Config(baseURL)
|
|
||||||
pkceChallenge := generatePKCEChallenge(pkceVerifier)
|
|
||||||
return oauth2Config.AuthCodeURL(state,
|
|
||||||
oidc.Nonce(nonce),
|
|
||||||
oauth2.SetAuthURLParam("code_challenge", pkceChallenge),
|
|
||||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *OIDCProvider) getOAuth2Config(baseURL string) oauth2.Config {
|
|
||||||
// Construct full redirect URL with dedicated callback endpoint
|
|
||||||
redirectURL, err := url.JoinPath(baseURL, "/api/v1/users/login/oidc/callback")
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to construct redirect URL")
|
|
||||||
return oauth2.Config{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return oauth2.Config{
|
|
||||||
ClientID: p.config.ClientID,
|
|
||||||
ClientSecret: p.config.ClientSecret,
|
|
||||||
RedirectURL: redirectURL,
|
|
||||||
Endpoint: p.endpoint,
|
|
||||||
Scopes: strings.Fields(p.config.Scope),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initiateOIDCFlow handles the initial OIDC authentication request by redirecting to the provider
|
|
||||||
func (p *OIDCProvider) initiateOIDCFlow(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
|
||||||
// Generate state parameter for CSRF protection
|
|
||||||
state, err := generateSecureToken()
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to generate OIDC state parameter")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("internal server error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate nonce parameter for replay attack protection
|
|
||||||
nonce, err := generateSecureToken()
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to generate OIDC nonce parameter")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("internal server error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate PKCE verifier for code interception protection
|
|
||||||
pkceVerifier, err := generatePKCEVerifier()
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to generate OIDC PKCE verifier")
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("internal server error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get base URL from request
|
|
||||||
baseURL := p.getBaseURL(r)
|
|
||||||
u, _ := url.Parse(baseURL)
|
|
||||||
domain := u.Hostname()
|
|
||||||
if domain == "" {
|
|
||||||
domain = noPort(r.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store state in session cookie for validation
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "oidc_state",
|
|
||||||
Value: state,
|
|
||||||
Expires: time.Now().Add(p.config.StateExpiry),
|
|
||||||
Domain: domain,
|
|
||||||
Secure: p.isSecure(r),
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Store nonce in session cookie for validation
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "oidc_nonce",
|
|
||||||
Value: nonce,
|
|
||||||
Expires: time.Now().Add(p.config.StateExpiry),
|
|
||||||
Domain: domain,
|
|
||||||
Secure: p.isSecure(r),
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Store PKCE verifier in session cookie for token exchange
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "oidc_pkce_verifier",
|
|
||||||
Value: pkceVerifier,
|
|
||||||
Expires: time.Now().Add(p.config.StateExpiry),
|
|
||||||
Domain: domain,
|
|
||||||
Secure: p.isSecure(r),
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Generate auth URL and redirect
|
|
||||||
authURL := p.GetAuthURL(baseURL, state, nonce, pkceVerifier)
|
|
||||||
http.Redirect(w, r, authURL, http.StatusFound)
|
|
||||||
|
|
||||||
// Return empty token since this is a redirect response
|
|
||||||
return services.UserAuthTokenDetail{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCallback processes the OAuth2 callback from the OIDC provider
|
|
||||||
func (p *OIDCProvider) handleCallback(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
|
||||||
// Helper to clear state cookie using computed domain
|
|
||||||
baseURL := p.getBaseURL(r)
|
|
||||||
u, _ := url.Parse(baseURL)
|
|
||||||
domain := u.Hostname()
|
|
||||||
if domain == "" {
|
|
||||||
domain = noPort(r.Host)
|
|
||||||
}
|
|
||||||
clearCookies := func() {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "oidc_state",
|
|
||||||
Value: "",
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
Domain: domain,
|
|
||||||
MaxAge: -1,
|
|
||||||
Secure: p.isSecure(r),
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "oidc_nonce",
|
|
||||||
Value: "",
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
Domain: domain,
|
|
||||||
MaxAge: -1,
|
|
||||||
Secure: p.isSecure(r),
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "oidc_pkce_verifier",
|
|
||||||
Value: "",
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
Domain: domain,
|
|
||||||
MaxAge: -1,
|
|
||||||
Secure: p.isSecure(r),
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for OAuth error responses first
|
|
||||||
if errCode := r.URL.Query().Get("error"); errCode != "" {
|
|
||||||
errDesc := r.URL.Query().Get("error_description")
|
|
||||||
log.Warn().Str("error", errCode).Str("description", errDesc).Msg("OIDC provider returned error")
|
|
||||||
clearCookies()
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("OIDC provider error: %s - %s", errCode, errDesc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify state parameter
|
|
||||||
stateCookie, err := r.Cookie("oidc_state")
|
|
||||||
if err != nil {
|
|
||||||
log.Warn().Err(err).Msg("OIDC state cookie not found - possible CSRF attack or expired session")
|
|
||||||
clearCookies()
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("state cookie not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
stateParam := r.URL.Query().Get("state")
|
|
||||||
if stateParam == "" {
|
|
||||||
log.Warn().Msg("OIDC state parameter missing from callback")
|
|
||||||
clearCookies()
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("state parameter missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
if stateParam != stateCookie.Value {
|
|
||||||
log.Warn().Str("received", stateParam).Str("expected", stateCookie.Value).Msg("OIDC state mismatch - possible CSRF attack")
|
|
||||||
clearCookies()
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("state parameter mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify nonce parameter
|
|
||||||
nonceCookie, err := r.Cookie("oidc_nonce")
|
|
||||||
if err != nil {
|
|
||||||
log.Warn().Err(err).Msg("OIDC nonce cookie not found - possible replay attack or expired session")
|
|
||||||
clearCookies()
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("nonce cookie not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify PKCE verifier parameter
|
|
||||||
pkceCookie, err := r.Cookie("oidc_pkce_verifier")
|
|
||||||
if err != nil {
|
|
||||||
log.Warn().Err(err).Msg("OIDC PKCE verifier cookie not found - possible code interception attack or expired session")
|
|
||||||
clearCookies()
|
|
||||||
return services.UserAuthTokenDetail{}, fmt.Errorf("PKCE verifier cookie not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear cookies before proceeding to token verification
|
|
||||||
clearCookies()
|
|
||||||
|
|
||||||
// Use the existing callback logic but return the token instead of redirecting
|
|
||||||
return p.AuthenticateWithBaseURL(baseURL, nonceCookie.Value, pkceCookie.Value, w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
func generateSecureToken() (string, error) {
|
|
||||||
// Generate 32 bytes of cryptographically secure random data
|
|
||||||
bytes := make([]byte, 32)
|
|
||||||
_, err := rand.Read(bytes)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to generate secure random token: %w", err)
|
|
||||||
}
|
|
||||||
// Use URL-safe base64 encoding without padding for clean URLs
|
|
||||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generatePKCEVerifier generates a cryptographically secure code verifier for PKCE
|
|
||||||
func generatePKCEVerifier() (string, error) {
|
|
||||||
// PKCE verifier must be 43-128 characters, we'll use 43 for efficiency
|
|
||||||
// 32 bytes = 43 characters when base64url encoded without padding
|
|
||||||
bytes := make([]byte, 32)
|
|
||||||
_, err := rand.Read(bytes)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to generate PKCE verifier: %w", err)
|
|
||||||
}
|
|
||||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generatePKCEChallenge generates a code challenge from a verifier using S256 method
|
|
||||||
func generatePKCEChallenge(verifier string) string {
|
|
||||||
hash := sha256.Sum256([]byte(verifier))
|
|
||||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func noPort(host string) string {
|
|
||||||
return strings.Split(host, ":")[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *OIDCProvider) getBaseURL(r *http.Request) string {
|
|
||||||
scheme := "http"
|
|
||||||
if r.TLS != nil {
|
|
||||||
scheme = "https"
|
|
||||||
} else if p.options.TrustProxy && r.Header.Get("X-Forwarded-Proto") == "https" {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
|
|
||||||
host := r.Host
|
|
||||||
if p.options.Hostname != "" {
|
|
||||||
host = p.options.Hostname
|
|
||||||
} else if p.options.TrustProxy {
|
|
||||||
if xfHost := r.Header.Get("X-Forwarded-Host"); xfHost != "" {
|
|
||||||
host = xfHost
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return scheme + "://" + host
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *OIDCProvider) isSecure(r *http.Request) bool {
|
|
||||||
_ = r
|
|
||||||
return p.cookieSecure
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitiateOIDCFlow starts the OIDC authentication flow by redirecting to the provider
|
|
||||||
func (p *OIDCProvider) InitiateOIDCFlow(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
|
||||||
return p.initiateOIDCFlow(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleCallback processes the OIDC callback and returns the authenticated user token
|
|
||||||
func (p *OIDCProvider) HandleCallback(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
|
|
||||||
return p.handleCallback(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeOIDCClaims(primary, secondary map[string]interface{}) map[string]interface{} {
|
|
||||||
// primary has precedence; fill missing/empty values from secondary.
|
|
||||||
merged := make(map[string]interface{}, len(primary)+len(secondary))
|
|
||||||
for k, v := range primary {
|
|
||||||
merged[k] = v
|
|
||||||
}
|
|
||||||
for k, v := range secondary {
|
|
||||||
if existing, ok := merged[k]; !ok || isEmptyClaim(existing) {
|
|
||||||
merged[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
func isEmptyClaim(v interface{}) bool {
|
|
||||||
if v == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
switch val := v.(type) {
|
|
||||||
case string:
|
|
||||||
return val == ""
|
|
||||||
case []interface{}:
|
|
||||||
return len(val) == 0
|
|
||||||
case []string:
|
|
||||||
return len(val) == 0
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/hay-kot/httpkit/graceful"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/pkgs/utils"
|
|
||||||
"gocloud.dev/pubsub"
|
|
||||||
)
|
|
||||||
|
|
||||||
func registerRecurringTasks(app *app, cfg *config.Config, runner *graceful.Runner) {
|
|
||||||
runner.AddFunc("eventbus", app.bus.Run)
|
|
||||||
|
|
||||||
runner.AddFunc("seed_database", func(ctx context.Context) error {
|
|
||||||
if cfg.Demo {
|
|
||||||
log.Info().Msg("Running in demo mode, creating demo data")
|
|
||||||
err := app.SetupDemo()
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to setup demo data")
|
|
||||||
return fmt.Errorf("failed to setup demo data: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
runner.AddPlugin(NewTask("purge-tokens", 24*time.Hour, func(ctx context.Context) {
|
|
||||||
_, err := app.repos.AuthTokens.PurgeExpiredTokens(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to purge expired tokens")
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
runner.AddPlugin(NewTask("purge-invitations", 24*time.Hour, func(ctx context.Context) {
|
|
||||||
_, err := app.repos.Groups.InvitationPurge(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to purge expired invitations")
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
runner.AddPlugin(NewTask("send-notifications", time.Hour, func(ctx context.Context) {
|
|
||||||
now := time.Now()
|
|
||||||
if now.Hour() == 8 {
|
|
||||||
fmt.Println("run notifiers")
|
|
||||||
err := app.services.BackgroundService.SendNotifiersToday(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to send notifiers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
if cfg.Thumbnail.Enabled {
|
|
||||||
runner.AddFunc("create-thumbnails-subscription", func(ctx context.Context) error {
|
|
||||||
pubsubString, err := utils.GenerateSubPubConn(cfg.Database.PubSubConnString, "thumbnails")
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to generate pubsub connection string")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
topic, err := pubsub.OpenTopic(ctx, pubsubString)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func(topic *pubsub.Topic, ctx context.Context) {
|
|
||||||
err := topic.Shutdown(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("fail to shutdown pubsub topic")
|
|
||||||
}
|
|
||||||
}(topic, ctx)
|
|
||||||
|
|
||||||
subscription, err := pubsub.OpenSubscription(ctx, pubsubString)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to open pubsub topic")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer func(topic *pubsub.Subscription, ctx context.Context) {
|
|
||||||
err := topic.Shutdown(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("fail to shutdown pubsub topic")
|
|
||||||
}
|
|
||||||
}(subscription, ctx)
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
msg, err := subscription.Receive(ctx)
|
|
||||||
log.Debug().Msg("received thumbnail generation request from pubsub topic")
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to receive message from pubsub topic")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if msg == nil {
|
|
||||||
log.Warn().Msg("received nil message from pubsub topic")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
groupId, err := uuid.Parse(msg.Metadata["group_id"])
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("group_id", msg.Metadata["group_id"]).Msg("failed to parse group ID from message metadata")
|
|
||||||
}
|
|
||||||
attachmentId, err := uuid.Parse(msg.Metadata["attachment_id"])
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("attachment_id", msg.Metadata["attachment_id"]).Msg("failed to parse attachment ID from message metadata")
|
|
||||||
}
|
|
||||||
err = app.repos.Attachments.CreateThumbnail(ctx, groupId, attachmentId, msg.Metadata["title"], msg.Metadata["path"])
|
|
||||||
if err != nil {
|
|
||||||
log.Err(err).Msg("failed to create thumbnail")
|
|
||||||
}
|
|
||||||
msg.Ack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Options.GithubReleaseCheck {
|
|
||||||
runner.AddPlugin(NewTask("get-latest-github-release", time.Hour, func(ctx context.Context) {
|
|
||||||
log.Debug().Msg("running get latest github release")
|
|
||||||
err := app.services.BackgroundService.GetLatestGithubRelease(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to get latest github release")
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.Debug.Enabled {
|
|
||||||
runner.AddFunc("debug", func(ctx context.Context) error {
|
|
||||||
debugserver := http.Server{
|
|
||||||
Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port),
|
|
||||||
Handler: app.debugRouter(),
|
|
||||||
ReadTimeout: cfg.Web.ReadTimeout,
|
|
||||||
WriteTimeout: cfg.Web.WriteTimeout,
|
|
||||||
IdleTimeout: cfg.Web.IdleTimeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
_ = debugserver.Shutdown(context.Background())
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Info().Msgf("Debug server is running on %s:%s", cfg.Web.Host, cfg.Debug.Port)
|
|
||||||
return debugserver.ListenAndServe()
|
|
||||||
})
|
|
||||||
// Print the configuration to the console
|
|
||||||
cfg.Print()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,14 +10,13 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/hay-kot/homebox/backend/app/api/handlers/debughandlers"
|
||||||
|
v1 "github.com/hay-kot/homebox/backend/app/api/handlers/v1"
|
||||||
|
"github.com/hay-kot/homebox/backend/app/api/providers"
|
||||||
|
_ "github.com/hay-kot/homebox/backend/app/api/static/docs"
|
||||||
|
"github.com/hay-kot/homebox/backend/internal/data/ent/authroles"
|
||||||
"github.com/hay-kot/httpkit/errchain"
|
"github.com/hay-kot/httpkit/errchain"
|
||||||
httpSwagger "github.com/swaggo/http-swagger/v2" // http-swagger middleware
|
httpSwagger "github.com/swaggo/http-swagger/v2" // http-swagger middleware
|
||||||
"github.com/sysadminsmedia/homebox/backend/app/api/handlers/debughandlers"
|
|
||||||
v1 "github.com/sysadminsmedia/homebox/backend/app/api/handlers/v1"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/app/api/providers"
|
|
||||||
_ "github.com/sysadminsmedia/homebox/backend/app/api/static/docs"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/authroles"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const prefix = "/api"
|
const prefix = "/api"
|
||||||
@@ -38,7 +36,7 @@ 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(r *chi.Mux, chain *errchain.ErrChain) {
|
||||||
registerMimes()
|
registerMimes()
|
||||||
|
|
||||||
r.Get("/swagger/*", httpSwagger.Handler(
|
r.Get("/swagger/*", httpSwagger.Handler(
|
||||||
@@ -48,167 +46,123 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 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.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) {
|
r.Get(v1Base("/status"), chain.ToHandlerFunc(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()))
|
r.Get(v1Base("/currencies"), chain.ToHandlerFunc(v1Ctrl.HandleCurrency()))
|
||||||
|
|
||||||
providers := []v1.AuthProvider{
|
providers := []v1.AuthProvider{
|
||||||
providers.NewLocalProvider(a.services.User),
|
providers.NewLocalProvider(a.services.User),
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Post("/users/register", chain.ToHandlerFunc(v1Ctrl.HandleUserRegistration()))
|
r.Post(v1Base("/users/register"), chain.ToHandlerFunc(v1Ctrl.HandleUserRegistration()))
|
||||||
r.Post("/users/login", chain.ToHandlerFunc(v1Ctrl.HandleAuthLogin(providers...)))
|
r.Post(v1Base("/users/login"), chain.ToHandlerFunc(v1Ctrl.HandleAuthLogin(providers...)))
|
||||||
|
r.Post(v1Base("/users/request-password-reset"), chain.ToHandlerFunc(v1Ctrl.HandleUserRequestPasswordReset()))
|
||||||
|
|
||||||
if a.conf.OIDC.Enabled {
|
userMW := []errchain.Middleware{
|
||||||
r.Get("/users/login/oidc", chain.ToHandlerFunc(v1Ctrl.HandleOIDCLogin()))
|
a.mwAuthToken,
|
||||||
r.Get("/users/login/oidc/callback", chain.ToHandlerFunc(v1Ctrl.HandleOIDCCallback()))
|
a.mwRoles(RoleModeOr, authroles.RoleUser.String()),
|
||||||
}
|
}
|
||||||
|
|
||||||
userMW := []errchain.Middleware{
|
r.Get(v1Base("/ws/events"), chain.ToHandlerFunc(v1Ctrl.HandleCacheWS(), userMW...))
|
||||||
a.mwAuthToken,
|
r.Get(v1Base("/users/self"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelf(), userMW...))
|
||||||
a.mwTenant,
|
r.Put(v1Base("/users/self"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelfUpdate(), userMW...))
|
||||||
a.mwRoles(RoleModeOr, authroles.RoleUser.String()),
|
r.Delete(v1Base("/users/self"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelfDelete(), userMW...))
|
||||||
}
|
r.Post(v1Base("/users/logout"), chain.ToHandlerFunc(v1Ctrl.HandleAuthLogout(), userMW...))
|
||||||
|
r.Get(v1Base("/users/refresh"), chain.ToHandlerFunc(v1Ctrl.HandleAuthRefresh(), userMW...))
|
||||||
|
r.Put(v1Base("/users/self/change-password"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelfChangePassword(), userMW...))
|
||||||
|
r.Put(v1Base("/users/self/change-password-token"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelfChangePasswordWithToken()))
|
||||||
|
|
||||||
r.Get("/ws/events", chain.ToHandlerFunc(v1Ctrl.HandleCacheWS(), userMW...))
|
r.Post(v1Base("/groups/invitations"), chain.ToHandlerFunc(v1Ctrl.HandleGroupInvitationsCreate(), userMW...))
|
||||||
|
r.Get(v1Base("/groups/statistics"), chain.ToHandlerFunc(v1Ctrl.HandleGroupStatistics(), userMW...))
|
||||||
|
r.Get(v1Base("/groups/statistics/purchase-price"), chain.ToHandlerFunc(v1Ctrl.HandleGroupStatisticsPriceOverTime(), userMW...))
|
||||||
|
r.Get(v1Base("/groups/statistics/locations"), chain.ToHandlerFunc(v1Ctrl.HandleGroupStatisticsLocations(), userMW...))
|
||||||
|
r.Get(v1Base("/groups/statistics/labels"), chain.ToHandlerFunc(v1Ctrl.HandleGroupStatisticsLabels(), userMW...))
|
||||||
|
|
||||||
// User management endpoints
|
// TODO: I don't like /groups being the URL for users
|
||||||
r.Get("/users/self", chain.ToHandlerFunc(v1Ctrl.HandleUserSelf(), userMW...))
|
r.Get(v1Base("/groups"), chain.ToHandlerFunc(v1Ctrl.HandleGroupGet(), userMW...))
|
||||||
r.Put("/users/self", chain.ToHandlerFunc(v1Ctrl.HandleUserSelfUpdate(), userMW...))
|
r.Put(v1Base("/groups"), chain.ToHandlerFunc(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...))
|
|
||||||
|
|
||||||
// Group management endpoints
|
r.Post(v1Base("/actions/ensure-asset-ids"), chain.ToHandlerFunc(v1Ctrl.HandleEnsureAssetID(), userMW...))
|
||||||
r.Get("/groups", chain.ToHandlerFunc(v1Ctrl.HandleGroupsGetAll(), userMW...))
|
r.Post(v1Base("/actions/zero-item-time-fields"), chain.ToHandlerFunc(v1Ctrl.HandleItemDateZeroOut(), userMW...))
|
||||||
r.Post("/groups", chain.ToHandlerFunc(v1Ctrl.HandleGroupCreate(), userMW...))
|
r.Post(v1Base("/actions/ensure-import-refs"), chain.ToHandlerFunc(v1Ctrl.HandleEnsureImportRefs(), userMW...))
|
||||||
r.Get("/groups/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupGet(), userMW...))
|
r.Post(v1Base("/actions/set-primary-photos"), chain.ToHandlerFunc(v1Ctrl.HandleSetPrimaryPhotos(), userMW...))
|
||||||
r.Put("/groups/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupUpdate(), userMW...))
|
|
||||||
r.Delete("/groups/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupDelete(), userMW...))
|
|
||||||
|
|
||||||
r.Get("/groups/{id}/members", chain.ToHandlerFunc(v1Ctrl.HandleGroupMembersGetAll(), userMW...))
|
r.Get(v1Base("/locations"), chain.ToHandlerFunc(v1Ctrl.HandleLocationGetAll(), userMW...))
|
||||||
r.Post("/groups/{id}/members", chain.ToHandlerFunc(v1Ctrl.HandleGroupMemberAdd(), userMW...))
|
r.Post(v1Base("/locations"), chain.ToHandlerFunc(v1Ctrl.HandleLocationCreate(), userMW...))
|
||||||
r.Delete("/groups/{id}/members/{user_id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupMemberRemove(), userMW...))
|
r.Get(v1Base("/locations/tree"), chain.ToHandlerFunc(v1Ctrl.HandleLocationTreeQuery(), userMW...))
|
||||||
|
r.Get(v1Base("/locations/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleLocationGet(), userMW...))
|
||||||
|
r.Put(v1Base("/locations/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleLocationUpdate(), userMW...))
|
||||||
|
r.Delete(v1Base("/locations/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleLocationDelete(), userMW...))
|
||||||
|
|
||||||
r.Get("/groups/invitations", chain.ToHandlerFunc(v1Ctrl.HandleGroupInvitationsGetAll(), userMW...))
|
r.Get(v1Base("/labels"), chain.ToHandlerFunc(v1Ctrl.HandleLabelsGetAll(), userMW...))
|
||||||
r.Post("/groups/invitations", chain.ToHandlerFunc(v1Ctrl.HandleGroupInvitationsCreate(), userMW...))
|
r.Post(v1Base("/labels"), chain.ToHandlerFunc(v1Ctrl.HandleLabelsCreate(), userMW...))
|
||||||
r.Post("/groups/invitations/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupInvitationsAccept(), userMW...))
|
r.Get(v1Base("/labels/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleLabelGet(), userMW...))
|
||||||
r.Delete("/groups/invitations/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupInvitationsDelete(), userMW...))
|
r.Put(v1Base("/labels/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleLabelUpdate(), userMW...))
|
||||||
r.Get("/groups/statistics", chain.ToHandlerFunc(v1Ctrl.HandleGroupStatistics(), userMW...))
|
r.Delete(v1Base("/labels/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleLabelDelete(), 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...))
|
|
||||||
|
|
||||||
// Action endpoints
|
r.Get(v1Base("/items"), chain.ToHandlerFunc(v1Ctrl.HandleItemsGetAll(), userMW...))
|
||||||
r.Post("/actions/ensure-asset-ids", chain.ToHandlerFunc(v1Ctrl.HandleEnsureAssetID(), userMW...))
|
r.Post(v1Base("/items"), chain.ToHandlerFunc(v1Ctrl.HandleItemsCreate(), userMW...))
|
||||||
r.Post("/actions/zero-item-time-fields", chain.ToHandlerFunc(v1Ctrl.HandleItemDateZeroOut(), userMW...))
|
r.Post(v1Base("/items/import"), chain.ToHandlerFunc(v1Ctrl.HandleItemsImport(), userMW...))
|
||||||
r.Post("/actions/ensure-import-refs", chain.ToHandlerFunc(v1Ctrl.HandleEnsureImportRefs(), userMW...))
|
r.Get(v1Base("/items/export"), chain.ToHandlerFunc(v1Ctrl.HandleItemsExport(), userMW...))
|
||||||
r.Post("/actions/set-primary-photos", chain.ToHandlerFunc(v1Ctrl.HandleSetPrimaryPhotos(), userMW...))
|
r.Get(v1Base("/items/fields"), chain.ToHandlerFunc(v1Ctrl.HandleGetAllCustomFieldNames(), userMW...))
|
||||||
r.Post("/actions/create-missing-thumbnails", chain.ToHandlerFunc(v1Ctrl.HandleCreateMissingThumbnails(), userMW...))
|
r.Get(v1Base("/items/fields/values"), chain.ToHandlerFunc(v1Ctrl.HandleGetAllCustomFieldValues(), userMW...))
|
||||||
r.Post("/actions/wipe-inventory", chain.ToHandlerFunc(v1Ctrl.HandleWipeInventory(), userMW...))
|
|
||||||
|
|
||||||
// Location endpoints
|
r.Get(v1Base("/items/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleItemGet(), userMW...))
|
||||||
r.Get("/locations", chain.ToHandlerFunc(v1Ctrl.HandleLocationGetAll(), userMW...))
|
r.Get(v1Base("/items/{id}/path"), chain.ToHandlerFunc(v1Ctrl.HandleItemFullPath(), userMW...))
|
||||||
r.Post("/locations", chain.ToHandlerFunc(v1Ctrl.HandleLocationCreate(), userMW...))
|
r.Put(v1Base("/items/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleItemUpdate(), userMW...))
|
||||||
r.Get("/locations/tree", chain.ToHandlerFunc(v1Ctrl.HandleLocationTreeQuery(), userMW...))
|
r.Patch(v1Base("/items/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleItemPatch(), userMW...))
|
||||||
r.Get("/locations/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLocationGet(), userMW...))
|
r.Delete(v1Base("/items/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleItemDelete(), userMW...))
|
||||||
r.Put("/locations/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLocationUpdate(), userMW...))
|
|
||||||
r.Delete("/locations/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLocationDelete(), userMW...))
|
|
||||||
|
|
||||||
// Labels (tags) endpoints
|
r.Post(v1Base("/items/{id}/attachments"), chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentCreate(), userMW...))
|
||||||
r.Get("/labels", chain.ToHandlerFunc(v1Ctrl.HandleLabelsGetAll(), userMW...))
|
r.Put(v1Base("/items/{id}/attachments/{attachment_id}"), chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentUpdate(), userMW...))
|
||||||
r.Post("/labels", chain.ToHandlerFunc(v1Ctrl.HandleLabelsCreate(), userMW...))
|
r.Delete(v1Base("/items/{id}/attachments/{attachment_id}"), chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentDelete(), userMW...))
|
||||||
r.Get("/labels/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLabelGet(), userMW...))
|
|
||||||
r.Put("/labels/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLabelUpdate(), userMW...))
|
|
||||||
r.Delete("/labels/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLabelDelete(), userMW...))
|
|
||||||
|
|
||||||
// Item endpoints
|
r.Get(v1Base("/items/{id}/maintenance"), chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceLogGet(), userMW...))
|
||||||
r.Get("/items", chain.ToHandlerFunc(v1Ctrl.HandleItemsGetAll(), userMW...))
|
r.Post(v1Base("/items/{id}/maintenance"), chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceEntryCreate(), userMW...))
|
||||||
r.Post("/items", chain.ToHandlerFunc(v1Ctrl.HandleItemsCreate(), userMW...))
|
r.Put(v1Base("/items/{id}/maintenance/{entry_id}"), chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceEntryUpdate(), userMW...))
|
||||||
r.Post("/items/import", chain.ToHandlerFunc(v1Ctrl.HandleItemsImport(), userMW...))
|
r.Delete(v1Base("/items/{id}/maintenance/{entry_id}"), chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceEntryDelete(), 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...))
|
r.Get(v1Base("/assets/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleAssetGet(), userMW...))
|
||||||
r.Get("/items/{id}/path", chain.ToHandlerFunc(v1Ctrl.HandleItemFullPath(), userMW...))
|
|
||||||
r.Put("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemUpdate(), userMW...))
|
|
||||||
r.Patch("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemPatch(), userMW...))
|
|
||||||
r.Delete("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemDelete(), userMW...))
|
|
||||||
r.Post("/items/{id}/duplicate", chain.ToHandlerFunc(v1Ctrl.HandleItemDuplicate(), userMW...))
|
|
||||||
|
|
||||||
// Item attachment endpoints
|
// Notifiers
|
||||||
r.Post("/items/{id}/attachments", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentCreate(), userMW...))
|
r.Get(v1Base("/notifiers"), chain.ToHandlerFunc(v1Ctrl.HandleGetUserNotifiers(), userMW...))
|
||||||
r.Put("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentUpdate(), userMW...))
|
r.Post(v1Base("/notifiers"), chain.ToHandlerFunc(v1Ctrl.HandleCreateNotifier(), userMW...))
|
||||||
r.Delete("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentDelete(), userMW...))
|
r.Put(v1Base("/notifiers/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleUpdateNotifier(), userMW...))
|
||||||
|
r.Delete(v1Base("/notifiers/{id}"), chain.ToHandlerFunc(v1Ctrl.HandleDeleteNotifier(), userMW...))
|
||||||
|
r.Post(v1Base("/notifiers/test"), chain.ToHandlerFunc(v1Ctrl.HandlerNotifierTest(), userMW...))
|
||||||
|
|
||||||
// Item maintenance endpoints
|
// Asset-Like endpoints
|
||||||
r.Get("/items/{id}/maintenance", chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceLogGet(), userMW...))
|
assetMW := []errchain.Middleware{
|
||||||
r.Post("/items/{id}/maintenance", chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceEntryCreate(), userMW...))
|
a.mwAuthToken,
|
||||||
|
a.mwRoles(RoleModeOr, authroles.RoleUser.String(), authroles.RoleAttachments.String()),
|
||||||
|
}
|
||||||
|
|
||||||
r.Get("/assets/{id}", chain.ToHandlerFunc(v1Ctrl.HandleAssetGet(), userMW...))
|
r.Get(
|
||||||
|
v1Base("/qrcode"),
|
||||||
|
chain.ToHandlerFunc(v1Ctrl.HandleGenerateQRCode(), assetMW...),
|
||||||
|
)
|
||||||
|
r.Get(
|
||||||
|
v1Base("/items/{id}/attachments/{attachment_id}"),
|
||||||
|
chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentGet(), assetMW...),
|
||||||
|
)
|
||||||
|
|
||||||
// Item Templates
|
// Reporting Services
|
||||||
r.Get("/templates", chain.ToHandlerFunc(v1Ctrl.HandleItemTemplatesGetAll(), userMW...))
|
r.Get(v1Base("/reporting/bill-of-materials"), chain.ToHandlerFunc(v1Ctrl.HandleBillOfMaterialsExport(), userMW...))
|
||||||
r.Post("/templates", chain.ToHandlerFunc(v1Ctrl.HandleItemTemplatesCreate(), userMW...))
|
|
||||||
r.Get("/templates/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemTemplatesGet(), userMW...))
|
|
||||||
r.Put("/templates/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemTemplatesUpdate(), userMW...))
|
|
||||||
r.Delete("/templates/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemTemplatesDelete(), userMW...))
|
|
||||||
r.Post("/templates/{id}/create-item", chain.ToHandlerFunc(v1Ctrl.HandleItemTemplatesCreateItem(), userMW...))
|
|
||||||
|
|
||||||
// Maintenance
|
|
||||||
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("/products/search-from-barcode", chain.ToHandlerFunc(v1Ctrl.HandleProductSearchFromBarcode(a.conf.Barcode), userMW...))
|
|
||||||
|
|
||||||
r.Get("/qrcode", chain.ToHandlerFunc(v1Ctrl.HandleGenerateQRCode(), assetMW...))
|
|
||||||
r.Get(
|
|
||||||
"/items/{id}/attachments/{attachment_id}",
|
|
||||||
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()))
|
r.NotFound(chain.ToHandlerFunc(notFoundHandler()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/core/currencies"
|
|
||||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupStorageDir handles the creation and validation of the storage directory.
|
|
||||||
func setupStorageDir(cfg *config.Config) error {
|
|
||||||
if strings.HasPrefix(cfg.Storage.ConnString, "file:///./") {
|
|
||||||
raw := strings.TrimPrefix(cfg.Storage.ConnString, "file:///./")
|
|
||||||
clean := filepath.Clean(raw)
|
|
||||||
absBase, err := filepath.Abs(clean)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to get absolute path for storage connection string")
|
|
||||||
return fmt.Errorf("failed to get absolute path for storage connection string: %w", err)
|
|
||||||
}
|
|
||||||
absBase = strings.ReplaceAll(absBase, "\\", "/")
|
|
||||||
storageDir := filepath.Join(absBase, cfg.Storage.PrefixPath)
|
|
||||||
storageDir = strings.ReplaceAll(storageDir, "\\", "/")
|
|
||||||
if !strings.HasPrefix(storageDir, absBase+"/") && storageDir != absBase {
|
|
||||||
log.Error().Str("path", storageDir).Msg("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path")
|
|
||||||
return fmt.Errorf("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path")
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(storageDir, 0o750); err != nil {
|
|
||||||
log.Error().Err(err).Msg("failed to create data directory")
|
|
||||||
return fmt.Errorf("failed to create data directory: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupDatabaseURL returns the database URL and ensures any required directories exist.
|
|
||||||
func setupDatabaseURL(cfg *config.Config) (string, error) {
|
|
||||||
databaseURL := ""
|
|
||||||
switch strings.ToLower(cfg.Database.Driver) {
|
|
||||||
case config.DriverSqlite3:
|
|
||||||
databaseURL = cfg.Database.SqlitePath
|
|
||||||
dbFilePath := strings.Split(cfg.Database.SqlitePath, "?")[0]
|
|
||||||
dbDir := filepath.Dir(dbFilePath)
|
|
||||||
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
|
||||||
log.Error().Err(err).Str("path", dbDir).Msg("failed to create SQLite database directory")
|
|
||||||
return "", fmt.Errorf("failed to create SQLite database directory: %w", err)
|
|
||||||
}
|
|
||||||
case config.DriverPostgres:
|
|
||||||
databaseURL = fmt.Sprintf("host=%s port=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Database, cfg.Database.SslMode)
|
|
||||||
if cfg.Database.Username != "" {
|
|
||||||
databaseURL += fmt.Sprintf(" user=%s", cfg.Database.Username)
|
|
||||||
}
|
|
||||||
if cfg.Database.Password != "" {
|
|
||||||
databaseURL += fmt.Sprintf(" password=%s", cfg.Database.Password)
|
|
||||||
}
|
|
||||||
if cfg.Database.SslRootCert != "" {
|
|
||||||
if _, err := os.Stat(cfg.Database.SslRootCert); err != nil {
|
|
||||||
log.Error().Err(err).Str("path", cfg.Database.SslRootCert).Msg("SSL root certificate file is not accessible")
|
|
||||||
return "", fmt.Errorf("SSL root certificate file is not accessible: %w", err)
|
|
||||||
}
|
|
||||||
databaseURL += fmt.Sprintf(" sslrootcert=%s", cfg.Database.SslRootCert)
|
|
||||||
}
|
|
||||||
if cfg.Database.SslCert != "" {
|
|
||||||
if _, err := os.Stat(cfg.Database.SslCert); err != nil {
|
|
||||||
log.Error().Err(err).Str("path", cfg.Database.SslCert).Msg("SSL certificate file is not accessible")
|
|
||||||
return "", fmt.Errorf("SSL certificate file is not accessible: %w", err)
|
|
||||||
}
|
|
||||||
databaseURL += fmt.Sprintf(" sslcert=%s", cfg.Database.SslCert)
|
|
||||||
}
|
|
||||||
if cfg.Database.SslKey != "" {
|
|
||||||
if _, err := os.Stat(cfg.Database.SslKey); err != nil {
|
|
||||||
log.Error().Err(err).Str("path", cfg.Database.SslKey).Msg("SSL key file is not accessible")
|
|
||||||
return "", fmt.Errorf("SSL key file is not accessible: %w", err)
|
|
||||||
}
|
|
||||||
databaseURL += fmt.Sprintf(" sslkey=%s", cfg.Database.SslKey)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
log.Error().Str("driver", cfg.Database.Driver).Msg("unsupported database driver")
|
|
||||||
return "", fmt.Errorf("unsupported database driver: %s", cfg.Database.Driver)
|
|
||||||
}
|
|
||||||
return databaseURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadCurrencies loads currency data from config if provided.
|
|
||||||
func loadCurrencies(cfg *config.Config) ([]currencies.CollectorFunc, error) {
|
|
||||||
collectFuncs := []currencies.CollectorFunc{
|
|
||||||
currencies.CollectDefaults(),
|
|
||||||
}
|
|
||||||
if cfg.Options.CurrencyConfig != "" {
|
|
||||||
log.Info().Str("path", cfg.Options.CurrencyConfig).Msg("loading currency config file")
|
|
||||||
content, err := os.ReadFile(cfg.Options.CurrencyConfig)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("path", cfg.Options.CurrencyConfig).Msg("failed to read currency config file")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content)))
|
|
||||||
}
|
|
||||||
return collectFuncs, nil
|
|
||||||
}
|
|
||||||