├── .devcontainer
├── Dockerfile
└── devcontainer.json
├── .dockerignore
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── feature_request.yml
├── pull_request_template.md
└── workflows
│ ├── partial-backend.yaml
│ ├── partial-frontend.yaml
│ ├── partial-publish.yaml
│ ├── publish.yaml
│ ├── pull-requests.yaml
│ └── tag.yaml
├── .gitignore
├── .scaffold
└── model
│ ├── scaffold.yaml
│ └── templates
│ └── model.go
├── .vscode
├── launch.json
└── settings.json
├── CONTRIBUTING.md
├── Dockerfile
├── Dockerfile.rootless
├── LICENSE
├── README.md
├── SECURITY.md
├── Taskfile.yml
├── backend
├── .gitignore
├── .golangci.yml
├── .goreleaser.yaml
├── app
│ ├── api
│ │ ├── app.go
│ │ ├── bgrunner.go
│ │ ├── demo.go
│ │ ├── handlers
│ │ │ ├── debughandlers
│ │ │ │ └── debug.go
│ │ │ └── v1
│ │ │ │ ├── assets
│ │ │ │ └── QRIcon.png
│ │ │ │ ├── controller.go
│ │ │ │ ├── partials.go
│ │ │ │ ├── query_params.go
│ │ │ │ ├── v1_ctrl_actions.go
│ │ │ │ ├── v1_ctrl_assets.go
│ │ │ │ ├── v1_ctrl_auth.go
│ │ │ │ ├── v1_ctrl_group.go
│ │ │ │ ├── v1_ctrl_items.go
│ │ │ │ ├── v1_ctrl_items_attachments.go
│ │ │ │ ├── v1_ctrl_labels.go
│ │ │ │ ├── v1_ctrl_locations.go
│ │ │ │ ├── v1_ctrl_maint_entry.go
│ │ │ │ ├── v1_ctrl_notifiers.go
│ │ │ │ ├── v1_ctrl_qrcode.go
│ │ │ │ ├── v1_ctrl_reporting.go
│ │ │ │ ├── v1_ctrl_statistics.go
│ │ │ │ └── v1_ctrl_user.go
│ │ ├── logger.go
│ │ ├── main.go
│ │ ├── middleware.go
│ │ ├── providers
│ │ │ ├── doc.go
│ │ │ ├── extractors.go
│ │ │ └── local.go
│ │ ├── routes.go
│ │ └── static
│ │ │ ├── docs
│ │ │ ├── docs.go
│ │ │ ├── swagger.json
│ │ │ └── swagger.yaml
│ │ │ └── public
│ │ │ └── .gitkeep
│ └── tools
│ │ ├── migrations
│ │ └── main.go
│ │ └── typegen
│ │ └── main.go
├── go.mod
├── go.sum
├── internal
│ ├── core
│ │ ├── currencies
│ │ │ ├── currencies.go
│ │ │ └── currencies.json
│ │ └── services
│ │ │ ├── all.go
│ │ │ ├── contexts.go
│ │ │ ├── contexts_test.go
│ │ │ ├── main_test.go
│ │ │ ├── reporting
│ │ │ ├── .testdata
│ │ │ │ └── import
│ │ │ │ │ ├── fields.csv
│ │ │ │ │ ├── minimal.csv
│ │ │ │ │ └── types.csv
│ │ │ ├── bill_of_materials.go
│ │ │ ├── eventbus
│ │ │ │ └── eventbus.go
│ │ │ ├── import.go
│ │ │ ├── io_row.go
│ │ │ ├── io_sheet.go
│ │ │ ├── io_sheet_test.go
│ │ │ ├── value_parsers.go
│ │ │ └── value_parsers_test.go
│ │ │ ├── service_background.go
│ │ │ ├── service_group.go
│ │ │ ├── service_items.go
│ │ │ ├── service_items_attachments.go
│ │ │ ├── service_items_attachments_test.go
│ │ │ ├── service_user.go
│ │ │ └── service_user_defaults.go
│ ├── data
│ │ ├── ent
│ │ │ ├── attachment.go
│ │ │ ├── attachment
│ │ │ │ ├── attachment.go
│ │ │ │ └── where.go
│ │ │ ├── attachment_create.go
│ │ │ ├── attachment_delete.go
│ │ │ ├── attachment_query.go
│ │ │ ├── attachment_update.go
│ │ │ ├── authroles.go
│ │ │ ├── authroles
│ │ │ │ ├── authroles.go
│ │ │ │ └── where.go
│ │ │ ├── authroles_create.go
│ │ │ ├── authroles_delete.go
│ │ │ ├── authroles_query.go
│ │ │ ├── authroles_update.go
│ │ │ ├── authtokens.go
│ │ │ ├── authtokens
│ │ │ │ ├── authtokens.go
│ │ │ │ └── where.go
│ │ │ ├── authtokens_create.go
│ │ │ ├── authtokens_delete.go
│ │ │ ├── authtokens_query.go
│ │ │ ├── authtokens_update.go
│ │ │ ├── client.go
│ │ │ ├── document.go
│ │ │ ├── document
│ │ │ │ ├── document.go
│ │ │ │ └── where.go
│ │ │ ├── document_create.go
│ │ │ ├── document_delete.go
│ │ │ ├── document_query.go
│ │ │ ├── document_update.go
│ │ │ ├── ent.go
│ │ │ ├── enttest
│ │ │ │ └── enttest.go
│ │ │ ├── external.go
│ │ │ ├── generate.go
│ │ │ ├── group.go
│ │ │ ├── group
│ │ │ │ ├── group.go
│ │ │ │ └── where.go
│ │ │ ├── group_create.go
│ │ │ ├── group_delete.go
│ │ │ ├── group_query.go
│ │ │ ├── group_update.go
│ │ │ ├── groupinvitationtoken.go
│ │ │ ├── groupinvitationtoken
│ │ │ │ ├── groupinvitationtoken.go
│ │ │ │ └── where.go
│ │ │ ├── groupinvitationtoken_create.go
│ │ │ ├── groupinvitationtoken_delete.go
│ │ │ ├── groupinvitationtoken_query.go
│ │ │ ├── groupinvitationtoken_update.go
│ │ │ ├── has_id.go
│ │ │ ├── hook
│ │ │ │ └── hook.go
│ │ │ ├── item.go
│ │ │ ├── item
│ │ │ │ ├── item.go
│ │ │ │ └── where.go
│ │ │ ├── item_create.go
│ │ │ ├── item_delete.go
│ │ │ ├── item_query.go
│ │ │ ├── item_update.go
│ │ │ ├── itemfield.go
│ │ │ ├── itemfield
│ │ │ │ ├── itemfield.go
│ │ │ │ └── where.go
│ │ │ ├── itemfield_create.go
│ │ │ ├── itemfield_delete.go
│ │ │ ├── itemfield_query.go
│ │ │ ├── itemfield_update.go
│ │ │ ├── label.go
│ │ │ ├── label
│ │ │ │ ├── label.go
│ │ │ │ └── where.go
│ │ │ ├── label_create.go
│ │ │ ├── label_delete.go
│ │ │ ├── label_query.go
│ │ │ ├── label_update.go
│ │ │ ├── location.go
│ │ │ ├── location
│ │ │ │ ├── location.go
│ │ │ │ └── where.go
│ │ │ ├── location_create.go
│ │ │ ├── location_delete.go
│ │ │ ├── location_query.go
│ │ │ ├── location_update.go
│ │ │ ├── maintenanceentry.go
│ │ │ ├── maintenanceentry
│ │ │ │ ├── maintenanceentry.go
│ │ │ │ └── where.go
│ │ │ ├── maintenanceentry_create.go
│ │ │ ├── maintenanceentry_delete.go
│ │ │ ├── maintenanceentry_query.go
│ │ │ ├── maintenanceentry_update.go
│ │ │ ├── migrate
│ │ │ │ ├── migrate.go
│ │ │ │ └── schema.go
│ │ │ ├── mutation.go
│ │ │ ├── notifier.go
│ │ │ ├── notifier
│ │ │ │ ├── notifier.go
│ │ │ │ └── where.go
│ │ │ ├── notifier_create.go
│ │ │ ├── notifier_delete.go
│ │ │ ├── notifier_query.go
│ │ │ ├── notifier_update.go
│ │ │ ├── predicate
│ │ │ │ └── predicate.go
│ │ │ ├── runtime.go
│ │ │ ├── runtime
│ │ │ │ └── runtime.go
│ │ │ ├── schema
│ │ │ │ ├── attachment.go
│ │ │ │ ├── auth_roles.go
│ │ │ │ ├── auth_tokens.go
│ │ │ │ ├── document.go
│ │ │ │ ├── group.go
│ │ │ │ ├── group_invitation_token.go
│ │ │ │ ├── item.go
│ │ │ │ ├── item_field.go
│ │ │ │ ├── label.go
│ │ │ │ ├── location.go
│ │ │ │ ├── maintenance_entry.go
│ │ │ │ ├── mixins
│ │ │ │ │ └── base.go
│ │ │ │ ├── notifier.go
│ │ │ │ ├── templates
│ │ │ │ │ └── has_id.tmpl
│ │ │ │ └── user.go
│ │ │ ├── tx.go
│ │ │ ├── user.go
│ │ │ ├── user
│ │ │ │ ├── user.go
│ │ │ │ └── where.go
│ │ │ ├── user_create.go
│ │ │ ├── user_delete.go
│ │ │ ├── user_query.go
│ │ │ └── user_update.go
│ │ ├── migrations
│ │ │ ├── migrations.go
│ │ │ └── migrations
│ │ │ │ ├── 20220929052825_init.sql
│ │ │ │ ├── 20221001210956_group_invitations.sql
│ │ │ │ ├── 20221009173029_add_user_roles.sql
│ │ │ │ ├── 20221020043305_allow_nesting_types.sql
│ │ │ │ ├── 20221101041931_add_archived_field.sql
│ │ │ │ ├── 20221113012312_add_asset_id_field.sql
│ │ │ │ ├── 20221203053132_add_token_roles.sql
│ │ │ │ ├── 20221205230404_drop_document_tokens.sql
│ │ │ │ ├── 20221205234214_add_maintenance_entries.sql
│ │ │ │ ├── 20221205234812_cascade_delete_roles.sql
│ │ │ │ ├── 20230227024134_add_scheduled_date.sql
│ │ │ │ ├── 20230305065819_add_notifier_types.sql
│ │ │ │ ├── 20230305071524_add_group_id_to_notifiers.sql
│ │ │ │ ├── 20231006213457_add_primary_attachment_flag.sql
│ │ │ │ └── atlas.sum
│ │ ├── repo
│ │ │ ├── asset_id_type.go
│ │ │ ├── asset_id_type_test.go
│ │ │ ├── automappers.go
│ │ │ ├── id_set.go
│ │ │ ├── main_test.go
│ │ │ ├── map_helpers.go
│ │ │ ├── pagination.go
│ │ │ ├── query_helpers.go
│ │ │ ├── repo_documents.go
│ │ │ ├── repo_documents_test.go
│ │ │ ├── repo_group.go
│ │ │ ├── repo_group_test.go
│ │ │ ├── repo_item_attachments.go
│ │ │ ├── repo_item_attachments_test.go
│ │ │ ├── repo_items.go
│ │ │ ├── repo_items_test.go
│ │ │ ├── repo_labels.go
│ │ │ ├── repo_labels_test.go
│ │ │ ├── repo_locations.go
│ │ │ ├── repo_locations_test.go
│ │ │ ├── repo_maintenance_entry.go
│ │ │ ├── repo_maintenance_entry_test.go
│ │ │ ├── repo_notifier.go
│ │ │ ├── repo_tokens.go
│ │ │ ├── repo_tokens_test.go
│ │ │ ├── repo_users.go
│ │ │ ├── repo_users_test.go
│ │ │ └── repos_all.go
│ │ └── types
│ │ │ └── date.go
│ ├── sys
│ │ ├── config
│ │ │ ├── conf.go
│ │ │ ├── conf_database.go
│ │ │ ├── conf_logger.go
│ │ │ ├── conf_mailer.go
│ │ │ └── conf_mailer_test.go
│ │ └── validate
│ │ │ ├── errors.go
│ │ │ └── validate.go
│ └── web
│ │ ├── adapters
│ │ ├── actions.go
│ │ ├── adapters.go
│ │ ├── command.go
│ │ ├── decoders.go
│ │ ├── doc.go
│ │ └── query.go
│ │ └── mid
│ │ ├── doc.go
│ │ ├── errors.go
│ │ └── logger.go
└── pkgs
│ ├── cgofreesqlite
│ └── sqlite.go
│ ├── faker
│ ├── random.go
│ └── randoms_test.go
│ ├── hasher
│ ├── doc.go
│ ├── password.go
│ ├── password_test.go
│ ├── token.go
│ └── token_test.go
│ ├── mailer
│ ├── mailer.go
│ ├── mailer_test.go
│ ├── message.go
│ ├── message_test.go
│ ├── templates.go
│ ├── templates
│ │ └── welcome.html
│ └── test-mailer-template.json
│ ├── pathlib
│ ├── pathlib.go
│ └── pathlib_test.go
│ └── set
│ ├── funcs.go
│ ├── funcs_test.go
│ ├── set.go
│ └── set_test.go
├── docker-compose.yml
├── docs
├── docs
│ ├── api
│ │ └── openapi-2.0.json
│ ├── assets
│ │ ├── img
│ │ │ ├── favicon.svg
│ │ │ ├── homebox-email-banner.jpg
│ │ │ └── lilbox.svg
│ │ └── stylesheets
│ │ │ └── extras.css
│ ├── build.md
│ ├── import-csv.md
│ ├── index.md
│ ├── quick-start.md
│ └── tips-tricks.md
├── mkdocs.yml
└── requirements.txt
├── fly.toml
├── frontend
├── .eslintrc.js
├── .nuxtignore
├── app.vue
├── assets
│ └── css
│ │ └── main.css
├── components
│ ├── App
│ │ ├── Header.vue
│ │ ├── HeaderDecor.vue
│ │ ├── ImportDialog.vue
│ │ ├── Logo.vue
│ │ └── Toast.vue
│ ├── Base
│ │ ├── Button.vue
│ │ ├── Card.vue
│ │ ├── Container.vue
│ │ ├── Modal.vue
│ │ └── SectionHeader.vue
│ ├── DetailAction.vue
│ ├── Form
│ │ ├── Autocomplete.vue
│ │ ├── Autocomplete2.vue
│ │ ├── Checkbox.vue
│ │ ├── DatePicker.vue
│ │ ├── Multiselect.vue
│ │ ├── Password.vue
│ │ ├── Select.vue
│ │ ├── TextArea.vue
│ │ └── TextField.vue
│ ├── Item
│ │ ├── AttachmentsList.vue
│ │ ├── Card.vue
│ │ ├── CreateModal.vue
│ │ └── View
│ │ │ ├── Selectable.vue
│ │ │ ├── Table.types.ts
│ │ │ └── Table.vue
│ ├── Label
│ │ ├── Chip.vue
│ │ └── CreateModal.vue
│ ├── Location
│ │ ├── Card.vue
│ │ ├── CreateModal.vue
│ │ ├── Selector.vue
│ │ └── Tree
│ │ │ ├── Node.vue
│ │ │ ├── Root.vue
│ │ │ └── tree-state.ts
│ ├── ModalConfirm.vue
│ ├── Search
│ │ └── Filter.vue
│ └── global
│ │ ├── CopyText.vue
│ │ ├── Currency.vue
│ │ ├── DateTime.vue
│ │ ├── DetailsSection
│ │ ├── DetailsSection.vue
│ │ └── types.ts
│ │ ├── DropZone.vue
│ │ ├── Markdown.vue
│ │ ├── PageQRCode.vue
│ │ ├── PasswordScore.vue
│ │ ├── Spacer.vue
│ │ ├── StatCard
│ │ ├── StatCard.vue
│ │ └── types.ts
│ │ ├── Subtitle.vue
│ │ ├── Table.types.ts
│ │ └── Table.vue
├── composables
│ ├── use-api.ts
│ ├── use-auth-context.ts
│ ├── use-confirm.ts
│ ├── use-css-var.ts
│ ├── use-defer.ts
│ ├── use-formatters.ts
│ ├── use-ids.ts
│ ├── use-item-search.ts
│ ├── use-location-helpers.ts
│ ├── use-min-loader.ts
│ ├── use-notifier.ts
│ ├── use-password-score.ts
│ ├── use-preferences.ts
│ ├── use-route-params.ts
│ ├── use-server-events.ts
│ ├── use-theme.ts
│ ├── utils.test.ts
│ └── utils.ts
├── global.d.ts
├── layouts
│ ├── 404.vue
│ ├── default.vue
│ └── empty.vue
├── lib
│ ├── api
│ │ ├── __test__
│ │ │ ├── factories
│ │ │ │ └── index.ts
│ │ │ ├── public.test.ts
│ │ │ ├── test-utils.ts
│ │ │ └── user
│ │ │ │ ├── group.test.ts
│ │ │ │ ├── items.test.ts
│ │ │ │ ├── labels.test.ts
│ │ │ │ ├── locations.test.ts
│ │ │ │ ├── notifier.test.ts
│ │ │ │ ├── stats.test.ts
│ │ │ │ └── user.test.ts
│ │ ├── base
│ │ │ ├── base-api.test.ts
│ │ │ ├── base-api.ts
│ │ │ ├── index.test.ts
│ │ │ ├── index.ts
│ │ │ └── urls.ts
│ │ ├── classes
│ │ │ ├── actions.ts
│ │ │ ├── assets.ts
│ │ │ ├── group.ts
│ │ │ ├── items.ts
│ │ │ ├── labels.ts
│ │ │ ├── locations.ts
│ │ │ ├── notifiers.ts
│ │ │ ├── reports.ts
│ │ │ ├── stats.ts
│ │ │ └── users.ts
│ │ ├── public.ts
│ │ ├── types
│ │ │ ├── data-contracts.ts
│ │ │ └── non-generated.ts
│ │ └── user.ts
│ ├── data
│ │ └── themes.ts
│ ├── datelib
│ │ ├── datelib.test.ts
│ │ └── datelib.ts
│ ├── passwords
│ │ ├── index.test.ts
│ │ └── index.ts
│ ├── requests
│ │ ├── index.ts
│ │ └── requests.ts
│ └── strings
│ │ ├── index.test.ts
│ │ └── index.ts
├── middleware
│ └── auth.ts
├── nuxt.config.ts
├── nuxt.proxyoverride.ts
├── package.json
├── pages
│ ├── [...all].vue
│ ├── a
│ │ └── [id].vue
│ ├── assets
│ │ └── [id].vue
│ ├── home
│ │ ├── index.vue
│ │ ├── statistics.ts
│ │ └── table.ts
│ ├── index.vue
│ ├── item
│ │ ├── [id]
│ │ │ ├── index.vue
│ │ │ └── index
│ │ │ │ ├── edit.vue
│ │ │ │ └── maintenance.vue
│ │ └── new.vue
│ ├── items.vue
│ ├── label
│ │ └── [id].vue
│ ├── location
│ │ └── [id].vue
│ ├── locations.vue
│ ├── profile.vue
│ ├── reports
│ │ └── label-generator.vue
│ └── tools.vue
├── plugins
│ └── scroll.client.ts
├── pnpm-lock.yaml
├── postcss.config.js
├── public
│ ├── favicon.svg
│ ├── no-image.jpg
│ ├── pwa-192x192.png
│ └── pwa-512x512.png
├── stores
│ ├── labels.ts
│ └── locations.ts
├── tailwind.config.js
├── test
│ ├── config.ts
│ ├── setup.ts
│ └── vitest.config.ts
└── tsconfig.json
├── pnpm-lock.yaml
└── renovate.json
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | # [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
2 | ARG VARIANT=16-bullseye
3 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
4 |
5 | RUN sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/typescript-node
3 | {
4 | "name": "Node.js & TypeScript",
5 | "build": {
6 | "dockerfile": "Dockerfile",
7 | // Update 'VARIANT' to pick a Node version: 18, 16, 14.
8 | // Append -bullseye or -buster to pin to an OS version.
9 | // Use -bullseye variants on local on arm64/Apple Silicon.
10 | "args": {
11 | "VARIANT": "18-bullseye"
12 | }
13 | },
14 |
15 | // Configure tool-specific properties.
16 | "customizations": {
17 | // Configure properties specific to VS Code.
18 | "vscode": {
19 | // Add the IDs of extensions you want installed when the container is created.
20 | "extensions": [
21 | "dbaeumer.vscode-eslint"
22 | ]
23 | }
24 | },
25 |
26 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
27 | "forwardPorts": [
28 | 7745,
29 | 3000
30 | ],
31 |
32 | // Use 'postCreateCommand' to run commands after the container is created.
33 | "postCreateCommand": "go install github.com/go-task/task/v3/cmd/task@latest && npm install -g pnpm && task setup",
34 |
35 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
36 | "remoteUser": "node",
37 | "features": {
38 | "golang": "1.21"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/.classpath
2 | **/.dockerignore
3 | **/.env
4 | **/.git
5 | **/.gitignore
6 | **/.project
7 | **/.settings
8 | **/.toolstarget
9 | **/.vs
10 | **/.vscode
11 | **/*.*proj.user
12 | **/*.dbmdl
13 | **/*.jfm
14 | **/bin
15 | **/charts
16 | **/docker-compose*
17 | **/compose*
18 | **/Dockerfile*
19 | **/node_modules
20 | **/npm-debug.log
21 | **/obj
22 | **/secrets.dev.yaml
23 | **/values.dev.yaml
24 | README.md
25 | !Dockerfile.rootless
26 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [hay-kot]
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: "Feature Request"
3 | description: "Submit a feature request for the current release"
4 | labels: ["feature-request"]
5 | body:
6 | - type: textarea
7 | id: problem-statement
8 | attributes:
9 | label: What is the problem you are trying to solve with this feature?
10 | placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 | - type: textarea
12 | id: feature-solution
13 | attributes:
14 | label: What is the solution you are proposing?
15 | placeholder: A clear and concise description of what you want to happen.
16 | - type: textarea
17 | id: feature-alternatives
18 | attributes:
19 | label: What alternatives have you considered?
20 | placeholder: A clear and concise description of any alternative solutions or features you've considered.
21 | - type: textarea
22 | id: feature-details
23 | attributes:
24 | label: Additional context
25 | placeholder: Add any other context or screenshots about the feature request here.
26 | - type: checkboxes
27 | id: checks
28 | attributes:
29 | label: Contributions
30 | description: Please confirm the following
31 | options:
32 | - label: I have searched through existing issues and feature requests to see if my idea has already been proposed.
33 | required: true
34 | - label: If this feature is accepted, I would be willing to help implement and maintain this feature.
35 | required: false
36 | - label: If this feature is accepted, I'm willing to sponsor the development of this feature.
37 | required: false
38 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | ## What type of PR is this?
8 |
9 | _(REQUIRED)_
10 |
11 |
14 |
15 | - bug
16 | - cleanup
17 | - documentation
18 | - feature
19 |
20 | ## What this PR does / why we need it:
21 |
22 | _(REQUIRED)_
23 |
24 |
31 |
32 | ## Which issue(s) this PR fixes:
33 |
34 | _(REQUIRED)_
35 |
36 |
42 |
43 | ## Special notes for your reviewer:
44 |
45 | _(fill-in or delete this section)_
46 |
47 |
51 |
52 | ## Testing
53 |
54 | _(fill-in or delete this section)_
55 |
56 |
59 |
60 | ## Release Notes
61 |
62 | _(REQUIRED)_
63 |
70 |
71 | ```release-note
72 | ```
--------------------------------------------------------------------------------
/.github/workflows/partial-backend.yaml:
--------------------------------------------------------------------------------
1 | name: Go Build/Test
2 |
3 | on:
4 | workflow_call:
5 |
6 | jobs:
7 | Go:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 |
12 | - name: Set up Go
13 | uses: actions/setup-go@v5
14 | with:
15 | go-version: "1.21"
16 |
17 | - name: Install Task
18 | uses: arduino/setup-task@v1
19 | with:
20 | repo-token: ${{ secrets.GITHUB_TOKEN }}
21 |
22 | - name: golangci-lint
23 | uses: golangci/golangci-lint-action@v4
24 | with:
25 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
26 | version: latest
27 |
28 | # Optional: working directory, useful for monorepos
29 | working-directory: backend
30 | args: --timeout=6m
31 |
32 | - name: Build API
33 | run: task go:build
34 |
35 | - name: Test
36 | run: task go:coverage
37 |
--------------------------------------------------------------------------------
/.github/workflows/partial-frontend.yaml:
--------------------------------------------------------------------------------
1 | name: Frontend / E2E
2 |
3 | on:
4 | workflow_call:
5 |
6 | jobs:
7 | lint:
8 | name: Lint
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v4
13 | with:
14 | fetch-depth: 0
15 |
16 | - uses: pnpm/action-setup@v3.0.0
17 | with:
18 | version: 6.0.2
19 |
20 | - name: Install dependencies
21 | run: pnpm install --shamefully-hoist
22 | working-directory: frontend
23 |
24 | - name: Run Lint
25 | run: pnpm run lint:ci
26 | working-directory: frontend
27 |
28 | - name: Run Typecheck
29 | run: pnpm run typecheck
30 | working-directory: frontend
31 |
32 | integration-tests:
33 | name: Integration Tests
34 | runs-on: ubuntu-latest
35 | steps:
36 | - name: Checkout
37 | uses: actions/checkout@v4
38 | with:
39 | fetch-depth: 0
40 |
41 | - name: Install Task
42 | uses: arduino/setup-task@v1
43 | with:
44 | repo-token: ${{ secrets.GITHUB_TOKEN }}
45 |
46 | - name: Set up Go
47 | uses: actions/setup-go@v5
48 | with:
49 | go-version: "1.21"
50 |
51 | - uses: actions/setup-node@v4
52 | with:
53 | node-version: 18
54 |
55 | - uses: pnpm/action-setup@v3.0.0
56 | with:
57 | version: 6.0.2
58 |
59 | - name: Install dependencies
60 | run: pnpm install
61 | working-directory: frontend
62 |
63 | - name: Run Integration Tests
64 | run: task test:ci
65 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yaml:
--------------------------------------------------------------------------------
1 | name: Publish Dockers
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | env:
9 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
10 |
11 | jobs:
12 | deploy:
13 | name: "Deploy Nightly to Fly.io"
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: superfly/flyctl-actions/setup-flyctl@master
18 | - run: flyctl deploy --remote-only
19 |
20 | publish-nightly:
21 | name: "Publish Nightly"
22 | if: github.event_name != 'release'
23 | uses: hay-kot/homebox/.github/workflows/partial-publish.yaml@main
24 | with:
25 | tag: nightly
26 | secrets:
27 | GH_TOKEN: ${{ secrets.CR_PAT }}
28 |
29 |
30 |
--------------------------------------------------------------------------------
/.github/workflows/pull-requests.yaml:
--------------------------------------------------------------------------------
1 | name: Pull Request CI
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | backend-tests:
10 | name: "Backend Server Tests"
11 | uses: ./.github/workflows/partial-backend.yaml
12 |
13 | frontend-tests:
14 | name: "Frontend and End-to-End Tests"
15 | uses: ./.github/workflows/partial-frontend.yaml
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Project Specific
2 | backend/.data/*
3 | config.yml
4 | homebox.db
5 | .idea
6 | .DS_Store
7 | test-mailer.json
8 | node_modules
9 |
10 | *.venv
11 | # If you prefer the allow list template instead of the deny list, see community template:
12 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
13 | #
14 | # Binaries for programs and plugins
15 | *.exe
16 | *.exe~
17 | *.dll
18 | *.so
19 | *.dylib
20 |
21 | # Test binary, built with `go test -c`
22 | *.test
23 |
24 | # Output of the go coverage tool, specifically when used with LiteIDE
25 | *.out
26 |
27 | # Dependency directories (remove the comment below to include it)
28 | # vendor/
29 |
30 | # Go workspace file
31 | go.work
32 | .task/
33 | backend/.env
34 | build/*
35 |
36 | # Output Directory for Nuxt/Frontend during build step
37 | backend/app/api/public/*
38 | !backend/app/api/public/.gitkeep
39 |
40 | node_modules
41 | *.log*
42 | .nuxt
43 | .nitro
44 | .cache
45 | .output
46 | .env
47 | dist
48 |
49 | .pnpm-store
50 | backend/app/api/app
51 | backend/app/api/__debug_bin
52 | dist/
53 |
54 | # Nuxt Publish Dir
55 | backend/app/api/static/public/*
56 | !backend/app/api/static/public/.gitkeep
57 | backend/api
--------------------------------------------------------------------------------
/.scaffold/model/scaffold.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=https://hay-kot.github.io/scaffold/schema.json
3 | messages:
4 | pre: |
5 | # Ent Model Generation
6 |
7 | With Boilerplate!
8 | post: |
9 | Complete!
10 |
11 | questions:
12 | - name: "model"
13 | prompt:
14 | message: "What is the name of the model? (PascalCase)"
15 | required: true
16 |
17 | - name: "by_group"
18 | prompt:
19 | confirm: "Include a Group Edge? (group_id -> id)"
20 | required: true
21 |
22 | rewrites:
23 | - from: 'templates/model.go'
24 | to: 'backend/internal/data/ent/schema/{{ lower .Scaffold.model }}.go'
25 |
26 | inject:
27 | - name: "Insert Groups Edge"
28 | path: 'backend/internal/data/ent/schema/group.go'
29 | at: // $scaffold_edge
30 | template: |
31 | {{- if .Scaffold.by_group -}}
32 | owned("{{ lower .Scaffold.model }}s", {{ .Scaffold.model }}.Type),
33 | {{- end -}}
34 |
--------------------------------------------------------------------------------
/.scaffold/model/templates/model.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "entgo.io/ent"
5 |
6 | "github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
7 | )
8 |
9 | type {{ .Scaffold.model }} struct {
10 | ent.Schema
11 | }
12 |
13 | func ({{ .Scaffold.model }}) Mixin() []ent.Mixin {
14 | return []ent.Mixin{
15 | mixins.BaseMixin{},
16 | {{- if .Scaffold.by_group }}
17 | GroupMixin{ref: "{{ snakecase .Scaffold.model }}s"},
18 | {{- end }}
19 | }
20 | }
21 |
22 | // Fields of the {{ .Scaffold.model }}.
23 | func ({{ .Scaffold.model }}) Fields() []ent.Field {
24 | return []ent.Field{
25 | // field.String("name").
26 | }
27 | }
28 |
29 | // Edges of the {{ .Scaffold.model }}.
30 | func ({{ .Scaffold.model }}) Edges() []ent.Edge {
31 | return []ent.Edge{
32 | // edge.From("group", Group.Type).
33 | }
34 | }
35 |
36 | func ({{ .Scaffold.model }}) Indexes() []ent.Index {
37 | return []ent.Index{
38 | // index.Fields("token"),
39 | }
40 | }
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "compounds": [
7 | {
8 | "name": "Full Stack",
9 | "configurations": ["Launch Backend", "Launch Frontend"],
10 | "stopAll": true
11 | }
12 | ],
13 | "configurations": [
14 | {
15 | "name": "Launch Backend",
16 | "type": "go",
17 | "request": "launch",
18 | "mode": "debug",
19 | "program": "${workspaceRoot}/backend/app/api/",
20 | "args": [],
21 | "env": {
22 | "HBOX_DEMO": "true",
23 | "HBOX_LOG_LEVEL": "debug",
24 | "HBOX_DEBUG_ENABLED": "true",
25 | "HBOX_STORAGE_DATA": "${workspaceRoot}/backend/.data",
26 | "HBOX_STORAGE_SQLITE_URL": "${workspaceRoot}/backend/.data/homebox.db?_fk=1"
27 | },
28 | },
29 | {
30 | "name": "Launch Frontend",
31 | "type": "node",
32 | "request": "launch",
33 | "runtimeExecutable": "pnpm",
34 | "runtimeArgs": [
35 | "run",
36 | "dev"
37 | ],
38 | "cwd": "${workspaceFolder}/frontend",
39 | "serverReadyAction": {
40 | "action": "debugWithChrome",
41 | "pattern": "Local: http://localhost:([0-9]+)",
42 | "uriFormat": "http://localhost:%s",
43 | "webRoot": "${workspaceFolder}/frontend"
44 | }
45 | }
46 | ]
47 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "yaml.schemas": {
3 | "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml"
4 | },
5 | "explorer.fileNesting.enabled": true,
6 | "explorer.fileNesting.patterns": {
7 | "package.json": "package-lock.json, yarn.lock, .eslintrc.js, tsconfig.json, .prettierrc, .editorconfig, pnpm-lock.yaml, postcss.config.js, tailwind.config.js",
8 | "docker-compose.yml": "Dockerfile, .dockerignore, docker-compose.dev.yml, docker-compose.yml",
9 | "README.md": "LICENSE, SECURITY.md"
10 | },
11 | "cSpell.words": [
12 | "debughandlers",
13 | "Homebox"
14 | ],
15 | // use ESLint to format code on save
16 | "editor.formatOnSave": false,
17 | "editor.defaultFormatter": "dbaeumer.vscode-eslint",
18 | "editor.codeActionsOnSave": {
19 | "source.fixAll.eslint": "explicit"
20 | },
21 | "[typescript]": {
22 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
23 | },
24 | "eslint.format.enable": true,
25 | "css.validate": false,
26 | "tailwindCSS.includeLanguages": {
27 | "vue": "html",
28 | "vue-html": "html"
29 | },
30 | "editor.quickSuggestions": {
31 | "strings": true
32 | },
33 | "tailwindCSS.experimental.configFile": "./frontend/tailwind.config.js"
34 | }
35 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 |
2 | # Build Nuxt
3 | FROM node:18-alpine as frontend-builder
4 | WORKDIR /app
5 | RUN npm install -g pnpm
6 | COPY frontend/package.json frontend/pnpm-lock.yaml ./
7 | RUN pnpm install --frozen-lockfile --shamefully-hoist
8 | COPY frontend .
9 | RUN pnpm build
10 |
11 | # Build API
12 | FROM golang:alpine AS builder
13 | ARG BUILD_TIME
14 | ARG COMMIT
15 | ARG VERSION
16 | RUN apk update && \
17 | apk upgrade && \
18 | apk add --update git build-base gcc g++
19 |
20 | WORKDIR /go/src/app
21 | COPY ./backend .
22 | RUN go get -d -v ./...
23 | RUN rm -rf ./app/api/public
24 | COPY --from=frontend-builder /app/.output/public ./app/api/static/public
25 | RUN CGO_ENABLED=0 GOOS=linux go build \
26 | -ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
27 | -o /go/bin/api \
28 | -v ./app/api/*.go
29 |
30 | # Production Stage
31 | FROM alpine:latest
32 |
33 | ENV HBOX_MODE=production
34 | ENV HBOX_STORAGE_DATA=/data/
35 | ENV HBOX_STORAGE_SQLITE_URL=/data/homebox.db?_pragma=busy_timeout=2000&_pragma=journal_mode=WAL&_fk=1
36 |
37 | RUN apk --no-cache add ca-certificates
38 | RUN mkdir /app
39 | COPY --from=builder /go/bin/api /app
40 |
41 | RUN chmod +x /app/api
42 |
43 | LABEL Name=homebox Version=0.0.1
44 | LABEL org.opencontainers.image.source="https://github.com/hay-kot/homebox"
45 | EXPOSE 7745
46 | WORKDIR /app
47 | VOLUME [ "/data" ]
48 |
49 | ENTRYPOINT [ "/app/api" ]
50 | CMD [ "/data/config.yml" ]
51 |
--------------------------------------------------------------------------------
/Dockerfile.rootless:
--------------------------------------------------------------------------------
1 |
2 | # Build Nuxt
3 | FROM node:17-alpine as frontend-builder
4 | WORKDIR /app
5 | RUN npm install -g pnpm
6 | COPY frontend/package.json frontend/pnpm-lock.yaml ./
7 | RUN pnpm install --frozen-lockfile --shamefully-hoist
8 | COPY frontend .
9 | RUN pnpm build
10 |
11 | # Build API
12 | FROM golang:alpine AS builder
13 | ARG BUILD_TIME
14 | ARG COMMIT
15 | ARG VERSION
16 | RUN apk update && \
17 | apk upgrade && \
18 | apk add --update git build-base gcc g++
19 |
20 | WORKDIR /go/src/app
21 | COPY ./backend .
22 | RUN go get -d -v ./...
23 | RUN rm -rf ./app/api/public
24 | COPY --from=frontend-builder /app/.output/public ./app/api/static/public
25 | RUN CGO_ENABLED=0 GOOS=linux go build \
26 | -ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \
27 | -o /go/bin/api \
28 | -v ./app/api/*.go && \
29 | chmod +x /go/bin/api && \
30 | # create a directory so that we can copy it in the next stage
31 | mkdir /data
32 |
33 | # Production Stage
34 | FROM gcr.io/distroless/static
35 |
36 | ENV HBOX_MODE=production
37 | ENV HBOX_STORAGE_DATA=/data/
38 | ENV HBOX_STORAGE_SQLITE_URL=/data/homebox.db?_fk=1
39 |
40 | # Copy the binary and the (empty) /data dir and
41 | # change the ownership to the low-privileged user
42 | COPY --from=builder --chown=nonroot /go/bin/api /app
43 | COPY --from=builder --chown=nonroot /data /data
44 |
45 | LABEL Name=homebox Version=0.0.1
46 | LABEL org.opencontainers.image.source="https://github.com/hay-kot/homebox"
47 | EXPOSE 7745
48 | VOLUME [ "/data" ]
49 |
50 | # Drop root and run as low-privileged user
51 | USER nonroot
52 | ENTRYPOINT [ "/app" ]
53 | CMD [ "/data/config.yml" ]
54 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | HomeBox
6 |
7 | Docs
8 | |
9 | Demo
10 | |
11 | Discord
12 |
13 |
14 | ## Quick Start
15 |
16 | [Configuration & Docker Compose](https://hay-kot.github.io/homebox/quick-start)
17 |
18 | ```bash
19 | # If using the rootless image, ensure data
20 | # folder has correct permissions
21 | mkdir -p /path/to/data/folder
22 | chown 65532:65532 -R /path/to/data/folder
23 | docker run -d \
24 | --name homebox \
25 | --restart unless-stopped \
26 | --publish 3100:7745 \
27 | --env TZ=Europe/Bucharest \
28 | --volume /path/to/data/folder/:/data \
29 | ghcr.io/hay-kot/homebox:latest
30 | # ghcr.io/hay-kot/homebox:latest-rootless
31 | ```
32 |
33 |
34 | ## Contributing
35 |
36 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
37 |
38 | 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.
39 |
40 |
41 | ## Credits
42 |
43 | - Logo by [@lakotelman](https://github.com/lakotelman)
44 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | Since this software is still considered beta/WIP support is always only given for the latest version.
6 |
7 | ## Reporting a Vulnerability
8 |
9 | Please open a normal public issue if you have any security related concerns.
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | dist/
3 |
--------------------------------------------------------------------------------
/backend/.golangci.yml:
--------------------------------------------------------------------------------
1 | run:
2 | timeout: 10m
3 | skip-dirs:
4 | - internal/data/ent.*
5 | linters-settings:
6 | goconst:
7 | min-len: 5
8 | min-occurrences: 5
9 | exhaustive:
10 | default-signifies-exhaustive: true
11 | revive:
12 | ignore-generated-header: false
13 | severity: warning
14 | confidence: 3
15 | depguard:
16 | rules:
17 | main:
18 | deny:
19 | - pkg: io/util
20 | desc: |
21 | Deprecated: As of Go 1.16, the same functionality is now provided by
22 | package io or package os, and those implementations should be
23 | preferred in new code. See the specific function documentation for
24 | details.
25 | gocritic:
26 | enabled-checks:
27 | - ruleguard
28 | testifylint:
29 | enable-all: true
30 | tagalign:
31 | order:
32 | - json
33 | - schema
34 | - yaml
35 | - yml
36 | - toml
37 | - validate
38 | linters:
39 | disable-all: true
40 | enable:
41 | - asciicheck
42 | - bodyclose
43 | - depguard
44 | - dogsled
45 | - errcheck
46 | - errorlint
47 | - exhaustive
48 | - exportloopref
49 | - gochecknoinits
50 | - goconst
51 | - gocritic
52 | - gocyclo
53 | - gofmt
54 | - goprintffuncname
55 | - gosimple
56 | - govet
57 | - ineffassign
58 | - misspell
59 | - nakedret
60 | - revive
61 | - staticcheck
62 | - stylecheck
63 | - tagalign
64 | - testifylint
65 | - typecheck
66 | - typecheck
67 | - unconvert
68 | - unused
69 | - whitespace
70 | - zerologlint
71 | - sqlclosecheck
72 | issues:
73 | exclude-use-default: false
74 | fix: true
75 |
--------------------------------------------------------------------------------
/backend/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | # This is an example .goreleaser.yml file with some sensible defaults.
2 | # Make sure to check the documentation at https://goreleaser.com
3 | before:
4 | hooks:
5 | # you may remove this if you don't need go generate
6 | - go generate ./...
7 | builds:
8 | - main: ./app/api
9 | env:
10 | - CGO_ENABLED=0
11 | goos:
12 | - linux
13 | - windows
14 | - darwin
15 | goarch:
16 | - amd64
17 | - "386"
18 | - arm
19 | - arm64
20 | ignore:
21 | - goos: windows
22 | goarch: arm
23 | - goos: windows
24 | goarch: "386"
25 |
26 | archives:
27 | - format: tar.gz
28 | # this name template makes the OS and Arch compatible with the results of uname.
29 | name_template: >-
30 | {{ .ProjectName }}_
31 | {{- title .Os }}_
32 | {{- if eq .Arch "amd64" }}x86_64
33 | {{- else if eq .Arch "386" }}i386
34 | {{- else }}{{ .Arch }}{{ end }}
35 | {{- if .Arm }}v{{ .Arm }}{{ end }}
36 | # use zip for windows archives
37 | format_overrides:
38 | - goos: windows
39 | format: zip
40 | checksum:
41 | name_template: 'checksums.txt'
42 | snapshot:
43 | name_template: "{{ incpatch .Version }}-next"
44 | changelog:
45 | sort: asc
46 | filters:
47 | exclude:
48 | - '^docs:'
49 | - '^test:'
50 |
51 | # The lines beneath this are called `modelines`. See `:help modeline`
52 | # Feel free to remove those if you don't want/use them.
53 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
54 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj
55 |
--------------------------------------------------------------------------------
/backend/app/api/app.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/hay-kot/homebox/backend/internal/core/services"
5 | "github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
6 | "github.com/hay-kot/homebox/backend/internal/data/ent"
7 | "github.com/hay-kot/homebox/backend/internal/data/repo"
8 | "github.com/hay-kot/homebox/backend/internal/sys/config"
9 | "github.com/hay-kot/homebox/backend/pkgs/mailer"
10 | )
11 |
12 | type app struct {
13 | conf *config.Config
14 | mailer mailer.Mailer
15 | db *ent.Client
16 | repos *repo.AllRepos
17 | services *services.AllServices
18 | bus *eventbus.EventBus
19 | }
20 |
21 | func new(conf *config.Config) *app {
22 | s := &app{
23 | conf: conf,
24 | }
25 |
26 | s.mailer = mailer.Mailer{
27 | Host: s.conf.Mailer.Host,
28 | Port: s.conf.Mailer.Port,
29 | Username: s.conf.Mailer.Username,
30 | Password: s.conf.Mailer.Password,
31 | From: s.conf.Mailer.From,
32 | }
33 |
34 | return s
35 | }
36 |
--------------------------------------------------------------------------------
/backend/app/api/bgrunner.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "time"
6 | )
7 |
8 | type BackgroundTask struct {
9 | name string
10 | Interval time.Duration
11 | Fn func(context.Context)
12 | }
13 |
14 | func (tsk *BackgroundTask) Name() string {
15 | return tsk.name
16 | }
17 |
18 | func NewTask(name string, interval time.Duration, fn func(context.Context)) *BackgroundTask {
19 | return &BackgroundTask{
20 | Interval: interval,
21 | Fn: fn,
22 | }
23 | }
24 |
25 | func (tsk *BackgroundTask) Start(ctx context.Context) error {
26 | timer := time.NewTimer(tsk.Interval)
27 |
28 | for {
29 | select {
30 | case <-ctx.Done():
31 | return nil
32 | case <-timer.C:
33 | timer.Reset(tsk.Interval)
34 | tsk.Fn(ctx)
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/backend/app/api/handlers/debughandlers/debug.go:
--------------------------------------------------------------------------------
1 | // Package debughandlers provides handlers for debugging.
2 | package debughandlers
3 |
4 | import (
5 | "expvar"
6 | "net/http"
7 | "net/http/pprof"
8 | )
9 |
10 | func New(mux *http.ServeMux) {
11 | mux.HandleFunc("/debug/pprof", pprof.Index)
12 | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
13 | mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
14 | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
15 | mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
16 | mux.Handle("/debug/vars", expvar.Handler())
17 | }
18 |
--------------------------------------------------------------------------------
/backend/app/api/handlers/v1/assets/QRIcon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hay-kot/homebox/6fd8457e5ac1cbe652a8e59230ca6b3b2e97d45b/backend/app/api/handlers/v1/assets/QRIcon.png
--------------------------------------------------------------------------------
/backend/app/api/handlers/v1/partials.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/go-chi/chi/v5"
7 | "github.com/google/uuid"
8 | "github.com/hay-kot/homebox/backend/internal/sys/validate"
9 | )
10 |
11 | // routeID extracts the ID from the request URL. If the ID is not in a valid
12 | // format, an error is returned. If a error is returned, it can be directly returned
13 | // from the handler. the validate.ErrInvalidID error is known by the error middleware
14 | // and will be handled accordingly.
15 | //
16 | // Example: /api/v1/ac614db5-d8b8-4659-9b14-6e913a6eb18a -> uuid.UUID{ac614db5-d8b8-4659-9b14-6e913a6eb18a}
17 | func (ctrl *V1Controller) routeID(r *http.Request) (uuid.UUID, error) {
18 | return ctrl.routeUUID(r, "id")
19 | }
20 |
21 | func (ctrl *V1Controller) routeUUID(r *http.Request, key string) (uuid.UUID, error) {
22 | ID, err := uuid.Parse(chi.URLParam(r, key))
23 | if err != nil {
24 | return uuid.Nil, validate.NewRouteKeyError(key)
25 | }
26 | return ID, nil
27 | }
28 |
--------------------------------------------------------------------------------
/backend/app/api/handlers/v1/query_params.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "net/url"
5 | "strconv"
6 |
7 | "github.com/google/uuid"
8 | )
9 |
10 | func queryUUIDList(params url.Values, key string) []uuid.UUID {
11 | var ids []uuid.UUID
12 | for _, id := range params[key] {
13 | uid, err := uuid.Parse(id)
14 | if err != nil {
15 | continue
16 | }
17 | ids = append(ids, uid)
18 | }
19 | return ids
20 | }
21 |
22 | func queryIntOrNegativeOne(s string) int {
23 | i, err := strconv.Atoi(s)
24 | if err != nil {
25 | return -1
26 | }
27 | return i
28 | }
29 |
30 | func queryBool(s string) bool {
31 | b, err := strconv.ParseBool(s)
32 | if err != nil {
33 | return false
34 | }
35 | return b
36 | }
37 |
--------------------------------------------------------------------------------
/backend/app/api/handlers/v1/v1_ctrl_qrcode.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "bytes"
5 | "image/png"
6 | "io"
7 | "net/http"
8 | "net/url"
9 |
10 | "github.com/hay-kot/homebox/backend/internal/web/adapters"
11 | "github.com/hay-kot/httpkit/errchain"
12 | "github.com/yeqown/go-qrcode/v2"
13 | "github.com/yeqown/go-qrcode/writer/standard"
14 |
15 | _ "embed"
16 | )
17 |
18 | //go:embed assets/QRIcon.png
19 | var qrcodeLogo []byte
20 |
21 | // HandleGenerateQRCode godoc
22 | //
23 | // @Summary Create QR Code
24 | // @Tags Items
25 | // @Produce json
26 | // @Param data query string false "data to be encoded into qrcode"
27 | // @Success 200 {string} string "image/jpeg"
28 | // @Router /v1/qrcode [GET]
29 | // @Security Bearer
30 | func (ctrl *V1Controller) HandleGenerateQRCode() errchain.HandlerFunc {
31 | type query struct {
32 | // 4,296 characters is the maximum length of a QR code
33 | Data string `schema:"data" validate:"required,max=4296"`
34 | }
35 |
36 | return func(w http.ResponseWriter, r *http.Request) error {
37 | q, err := adapters.DecodeQuery[query](r)
38 | if err != nil {
39 | return err
40 | }
41 |
42 | image, err := png.Decode(bytes.NewReader(qrcodeLogo))
43 | if err != nil {
44 | panic(err)
45 | }
46 |
47 | decodedStr, err := url.QueryUnescape(q.Data)
48 | if err != nil {
49 | return err
50 | }
51 |
52 | qrc, err := qrcode.New(decodedStr)
53 | if err != nil {
54 | return err
55 | }
56 |
57 | toWriteCloser := struct {
58 | io.Writer
59 | io.Closer
60 | }{
61 | Writer: w,
62 | Closer: io.NopCloser(nil),
63 | }
64 |
65 | qrwriter := standard.NewWithWriter(toWriteCloser, standard.WithLogoImage(image))
66 |
67 | // Return the QR code as a jpeg image
68 | w.Header().Set("Content-Type", "image/jpeg")
69 | w.Header().Set("Content-Disposition", "attachment; filename=qrcode.jpg")
70 | return qrc.Save(qrwriter)
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/backend/app/api/handlers/v1/v1_ctrl_reporting.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/hay-kot/homebox/backend/internal/core/services"
7 | "github.com/hay-kot/httpkit/errchain"
8 | )
9 |
10 | // HandleBillOfMaterialsExport godoc
11 | //
12 | // @Summary Export Bill of Materials
13 | // @Tags Reporting
14 | // @Produce json
15 | // @Success 200 {string} string "text/csv"
16 | // @Router /v1/reporting/bill-of-materials [GET]
17 | // @Security Bearer
18 | func (ctrl *V1Controller) HandleBillOfMaterialsExport() errchain.HandlerFunc {
19 | return func(w http.ResponseWriter, r *http.Request) error {
20 | actor := services.UseUserCtx(r.Context())
21 |
22 | csv, err := ctrl.svc.Items.ExportBillOfMaterialsTSV(r.Context(), actor.GroupID)
23 | if err != nil {
24 | return err
25 | }
26 |
27 | w.Header().Set("Content-Type", "text/tsv")
28 | w.Header().Set("Content-Disposition", "attachment; filename=bill-of-materials.tsv")
29 | _, err = w.Write(csv)
30 | return err
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/backend/app/api/logger.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/hay-kot/homebox/backend/internal/sys/config"
7 | "github.com/rs/zerolog"
8 | "github.com/rs/zerolog/log"
9 | )
10 |
11 | // setupLogger initializes the zerolog config
12 | // for the shared logger.
13 | func (a *app) setupLogger() {
14 | // Logger Init
15 | // zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
16 | if a.conf.Log.Format != config.LogFormatJSON {
17 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}).With().Caller().Logger()
18 | }
19 |
20 | level, err := zerolog.ParseLevel(a.conf.Log.Level)
21 | if err == nil {
22 | zerolog.SetGlobalLevel(level)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/backend/app/api/providers/doc.go:
--------------------------------------------------------------------------------
1 | // Package providers provides a authentication abstraction for the backend.
2 | package providers
3 |
--------------------------------------------------------------------------------
/backend/app/api/providers/extractors.go:
--------------------------------------------------------------------------------
1 | package providers
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 |
7 | "github.com/hay-kot/homebox/backend/internal/sys/validate"
8 | "github.com/hay-kot/httpkit/server"
9 | "github.com/rs/zerolog/log"
10 | )
11 |
12 | type LoginForm struct {
13 | Username string `json:"username"`
14 | Password string `json:"password"`
15 | StayLoggedIn bool `json:"stayLoggedIn"`
16 | }
17 |
18 | func getLoginForm(r *http.Request) (LoginForm, error) {
19 | loginForm := LoginForm{}
20 |
21 | switch r.Header.Get("Content-Type") {
22 | case "application/x-www-form-urlencoded":
23 | err := r.ParseForm()
24 | if err != nil {
25 | return loginForm, errors.New("failed to parse form")
26 | }
27 |
28 | loginForm.Username = r.PostFormValue("username")
29 | loginForm.Password = r.PostFormValue("password")
30 | loginForm.StayLoggedIn = r.PostFormValue("stayLoggedIn") == "true"
31 | case "application/json":
32 | err := server.Decode(r, &loginForm)
33 | if err != nil {
34 | log.Err(err).Msg("failed to decode login form")
35 | return loginForm, errors.New("failed to decode login form")
36 | }
37 | default:
38 | return loginForm, errors.New("invalid content type")
39 | }
40 |
41 | if loginForm.Username == "" || loginForm.Password == "" {
42 | return loginForm, validate.NewFieldErrors(
43 | validate.FieldError{
44 | Field: "username",
45 | Error: "username or password is empty",
46 | },
47 | validate.FieldError{
48 | Field: "password",
49 | Error: "username or password is empty",
50 | },
51 | )
52 | }
53 |
54 | return loginForm, nil
55 | }
56 |
--------------------------------------------------------------------------------
/backend/app/api/providers/local.go:
--------------------------------------------------------------------------------
1 | package providers
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/hay-kot/homebox/backend/internal/core/services"
7 | )
8 |
9 | type LocalProvider struct {
10 | service *services.UserService
11 | }
12 |
13 | func NewLocalProvider(service *services.UserService) *LocalProvider {
14 | return &LocalProvider{
15 | service: service,
16 | }
17 | }
18 |
19 | func (p *LocalProvider) Name() string {
20 | return "local"
21 | }
22 |
23 | func (p *LocalProvider) Authenticate(w http.ResponseWriter, r *http.Request) (services.UserAuthTokenDetail, error) {
24 | loginForm, err := getLoginForm(r)
25 | if err != nil {
26 | return services.UserAuthTokenDetail{}, err
27 | }
28 |
29 | return p.service.Login(r.Context(), loginForm.Username, loginForm.Password, loginForm.StayLoggedIn)
30 | }
31 |
--------------------------------------------------------------------------------
/backend/app/api/static/public/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hay-kot/homebox/6fd8457e5ac1cbe652a8e59230ca6b3b2e97d45b/backend/app/api/static/public/.gitkeep
--------------------------------------------------------------------------------
/backend/app/tools/migrations/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "os"
8 |
9 | "github.com/hay-kot/homebox/backend/internal/data/ent/migrate"
10 |
11 | atlas "ariga.io/atlas/sql/migrate"
12 | _ "ariga.io/atlas/sql/sqlite"
13 | "entgo.io/ent/dialect"
14 | "entgo.io/ent/dialect/sql/schema"
15 | _ "github.com/mattn/go-sqlite3"
16 | )
17 |
18 | func main() {
19 | ctx := context.Background()
20 | // Create a local migration directory able to understand Atlas migration file format for replay.
21 | dir, err := atlas.NewLocalDir("internal/data/migrations/migrations")
22 | if err != nil {
23 | log.Fatalf("failed creating atlas migration directory: %v", err)
24 | }
25 | // Migrate diff options.
26 | opts := []schema.MigrateOption{
27 | schema.WithDir(dir), // provide migration directory
28 | schema.WithMigrationMode(schema.ModeReplay), // provide migration mode
29 | schema.WithDialect(dialect.SQLite), // Ent dialect to use
30 | schema.WithFormatter(atlas.DefaultFormatter),
31 | schema.WithDropIndex(true),
32 | schema.WithDropColumn(true),
33 | }
34 | if len(os.Args) != 2 {
35 | log.Fatalln("migration name is required. Use: 'go run -mod=mod ent/migrate/main.go '")
36 | }
37 |
38 | // Generate migrations using Atlas support for MySQL (note the Ent dialect option passed above).
39 | err = migrate.NamedDiff(ctx, "sqlite://.data/homebox.migration.db?_fk=1", os.Args[1], opts...)
40 | if err != nil {
41 | log.Fatalf("failed generating migration file: %v", err)
42 | }
43 |
44 | fmt.Println("Migration file generated successfully.")
45 | }
46 |
--------------------------------------------------------------------------------
/backend/internal/core/services/all.go:
--------------------------------------------------------------------------------
1 | // Package services provides the core business logic for the application.
2 | package services
3 |
4 | import (
5 | "github.com/hay-kot/homebox/backend/internal/core/currencies"
6 | "github.com/hay-kot/homebox/backend/internal/data/repo"
7 | )
8 |
9 | type AllServices struct {
10 | User *UserService
11 | Group *GroupService
12 | Items *ItemService
13 | BackgroundService *BackgroundService
14 | Currencies *currencies.CurrencyRegistry
15 | }
16 |
17 | type OptionsFunc func(*options)
18 |
19 | type options struct {
20 | autoIncrementAssetID bool
21 | currencies []currencies.Currency
22 | }
23 |
24 | func WithAutoIncrementAssetID(v bool) func(*options) {
25 | return func(o *options) {
26 | o.autoIncrementAssetID = v
27 | }
28 | }
29 |
30 | func WithCurrencies(v []currencies.Currency) func(*options) {
31 | return func(o *options) {
32 | o.currencies = v
33 | }
34 | }
35 |
36 | func New(repos *repo.AllRepos, opts ...OptionsFunc) *AllServices {
37 | if repos == nil {
38 | panic("repos cannot be nil")
39 | }
40 |
41 | defaultCurrencies, err := currencies.CollectionCurrencies(
42 | currencies.CollectDefaults(),
43 | )
44 | if err != nil {
45 | panic("failed to collect default currencies")
46 | }
47 |
48 | options := &options{
49 | autoIncrementAssetID: true,
50 | currencies: defaultCurrencies,
51 | }
52 |
53 | for _, opt := range opts {
54 | opt(options)
55 | }
56 |
57 | return &AllServices{
58 | User: &UserService{repos},
59 | Group: &GroupService{repos},
60 | Items: &ItemService{
61 | repo: repos,
62 | autoIncrementAssetID: options.autoIncrementAssetID,
63 | },
64 | BackgroundService: &BackgroundService{repos},
65 | Currencies: currencies.NewCurrencyService(options.currencies),
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/backend/internal/core/services/contexts.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/google/uuid"
7 | "github.com/hay-kot/homebox/backend/internal/data/repo"
8 | )
9 |
10 | type contextKeys struct {
11 | name string
12 | }
13 |
14 | var (
15 | ContextUser = &contextKeys{name: "User"}
16 | ContextUserToken = &contextKeys{name: "UserToken"}
17 | )
18 |
19 | type Context struct {
20 | context.Context
21 |
22 | // UID is a unique identifier for the acting user.
23 | UID uuid.UUID
24 |
25 | // GID is a unique identifier for the acting users group.
26 | GID uuid.UUID
27 |
28 | // User is the acting user.
29 | User *repo.UserOut
30 | }
31 |
32 | // NewContext is a helper function that returns the service context from the context.
33 | // This extracts the users from the context and embeds it into the ServiceContext struct
34 | func NewContext(ctx context.Context) Context {
35 | user := UseUserCtx(ctx)
36 | return Context{
37 | Context: ctx,
38 | UID: user.ID,
39 | GID: user.GroupID,
40 | User: user,
41 | }
42 | }
43 |
44 | // SetUserCtx is a helper function that sets the ContextUser and ContextUserToken
45 | // values within the context of a web request (or any context).
46 | func SetUserCtx(ctx context.Context, user *repo.UserOut, token string) context.Context {
47 | ctx = context.WithValue(ctx, ContextUser, user)
48 | ctx = context.WithValue(ctx, ContextUserToken, token)
49 | return ctx
50 | }
51 |
52 | // UseUserCtx is a helper function that returns the user from the context.
53 | func UseUserCtx(ctx context.Context) *repo.UserOut {
54 | if val := ctx.Value(ContextUser); val != nil {
55 | return val.(*repo.UserOut)
56 | }
57 | return nil
58 | }
59 |
60 | // UseTokenCtx is a helper function that returns the user token from the context.
61 | func UseTokenCtx(ctx context.Context) string {
62 | if val := ctx.Value(ContextUserToken); val != nil {
63 | return val.(string)
64 | }
65 | return ""
66 | }
67 |
--------------------------------------------------------------------------------
/backend/internal/core/services/contexts_test.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/google/uuid"
8 | "github.com/hay-kot/homebox/backend/internal/data/repo"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func Test_SetAuthContext(t *testing.T) {
13 | user := &repo.UserOut{
14 | ID: uuid.New(),
15 | }
16 |
17 | token := uuid.New().String()
18 |
19 | ctx := SetUserCtx(context.Background(), user, token)
20 |
21 | ctxUser := UseUserCtx(ctx)
22 |
23 | assert.NotNil(t, ctxUser)
24 | assert.Equal(t, user.ID, ctxUser.ID)
25 |
26 | ctxUserToken := UseTokenCtx(ctx)
27 | assert.NotEmpty(t, ctxUserToken)
28 | }
29 |
30 | func Test_SetAuthContext_Nulls(t *testing.T) {
31 | ctx := SetUserCtx(context.Background(), nil, "")
32 |
33 | ctxUser := UseUserCtx(ctx)
34 |
35 | assert.Nil(t, ctxUser)
36 |
37 | ctxUserToken := UseTokenCtx(ctx)
38 | assert.Empty(t, ctxUserToken)
39 | }
40 |
--------------------------------------------------------------------------------
/backend/internal/core/services/reporting/.testdata/import/fields.csv:
--------------------------------------------------------------------------------
1 | HB.location,HB.name,HB.quantity,HB.description,HB.field.Custom Field 1,HB.field.Custom Field 2,HB.field.Custom Field 3
2 | loc,Item 1,1,Description 1,Value 1[1],Value 1[2],Value 1[3]
3 | loc,Item 2,2,Description 2,Value 2[1],Value 2[2],Value 2[3]
4 | loc,Item 3,3,Description 3,Value 3[1],Value 3[2],Value 3[3]
5 |
6 |
--------------------------------------------------------------------------------
/backend/internal/core/services/reporting/.testdata/import/minimal.csv:
--------------------------------------------------------------------------------
1 | HB.location,HB.name,HB.quantity,HB.description
2 | loc,Item 1,1,Description 1
3 | loc,Item 2,2,Description 2
4 | loc,Item 3,3,Description 3
--------------------------------------------------------------------------------
/backend/internal/core/services/reporting/.testdata/import/types.csv:
--------------------------------------------------------------------------------
1 | HB.name,HB.asset_id,HB.location,HB.labels
2 | Item 1,1,Path / To / Location 1,L1 ; L2 ; L3
3 | Item 2,000-002,Path /To/ Location 2,L1;L2;L3
4 | Item 3,1000-003,Path / To /Location 3 , L1;L2; L3
--------------------------------------------------------------------------------
/backend/internal/core/services/reporting/bill_of_materials.go:
--------------------------------------------------------------------------------
1 | package reporting
2 |
3 | import (
4 | "github.com/gocarina/gocsv"
5 | "github.com/hay-kot/homebox/backend/internal/data/repo"
6 | "github.com/hay-kot/homebox/backend/internal/data/types"
7 | )
8 |
9 | // =================================================================================================
10 |
11 | type BillOfMaterialsEntry struct {
12 | PurchaseDate types.Date `csv:"Purchase Date"`
13 | Name string `csv:"Name"`
14 | Description string `csv:"Description"`
15 | Manufacturer string `csv:"Manufacturer"`
16 | SerialNumber string `csv:"Serial Number"`
17 | ModelNumber string `csv:"Model Number"`
18 | Quantity int `csv:"Quantity"`
19 | Price float64 `csv:"Price"`
20 | TotalPrice float64 `csv:"Total Price"`
21 | }
22 |
23 | // BillOfMaterialsTSV returns a byte slice of the Bill of Materials for a given GID in TSV format
24 | // See BillOfMaterialsEntry for the format of the output
25 | func BillOfMaterialsTSV(entities []repo.ItemOut) ([]byte, error) {
26 | bomEntries := make([]BillOfMaterialsEntry, len(entities))
27 | for i, entity := range entities {
28 | bomEntries[i] = BillOfMaterialsEntry{
29 | PurchaseDate: entity.PurchaseTime,
30 | Name: entity.Name,
31 | Description: entity.Description,
32 | Manufacturer: entity.Manufacturer,
33 | SerialNumber: entity.SerialNumber,
34 | ModelNumber: entity.ModelNumber,
35 | Quantity: entity.Quantity,
36 | Price: entity.PurchasePrice,
37 | TotalPrice: entity.PurchasePrice * float64(entity.Quantity),
38 | }
39 | }
40 |
41 | return gocsv.MarshalBytes(&bomEntries)
42 | }
43 |
--------------------------------------------------------------------------------
/backend/internal/core/services/reporting/eventbus/eventbus.go:
--------------------------------------------------------------------------------
1 | // Package eventbus provides an interface for event bus.
2 | package eventbus
3 |
4 | import (
5 | "context"
6 | "sync"
7 |
8 | "github.com/google/uuid"
9 | )
10 |
11 | type Event string
12 |
13 | const (
14 | EventLabelMutation Event = "label.mutation"
15 | EventLocationMutation Event = "location.mutation"
16 | EventItemMutation Event = "item.mutation"
17 | )
18 |
19 | type GroupMutationEvent struct {
20 | GID uuid.UUID
21 | }
22 |
23 | type eventData struct {
24 | event Event
25 | data any
26 | }
27 |
28 | type EventBus struct {
29 | started bool
30 | ch chan eventData
31 |
32 | mu sync.RWMutex
33 | subscribers map[Event][]func(any)
34 | }
35 |
36 | func New() *EventBus {
37 | return &EventBus{
38 | ch: make(chan eventData, 100),
39 | subscribers: map[Event][]func(any){
40 | EventLabelMutation: {},
41 | EventLocationMutation: {},
42 | EventItemMutation: {},
43 | },
44 | }
45 | }
46 |
47 | func (e *EventBus) Run(ctx context.Context) error {
48 | if e.started {
49 | panic("event bus already started")
50 | }
51 |
52 | e.started = true
53 |
54 | for {
55 | select {
56 | case <-ctx.Done():
57 | return nil
58 | case event := <-e.ch:
59 | e.mu.RLock()
60 | arr, ok := e.subscribers[event.event]
61 | e.mu.RUnlock()
62 |
63 | if !ok {
64 | continue
65 | }
66 |
67 | for _, fn := range arr {
68 | fn(event.data)
69 | }
70 | }
71 | }
72 | }
73 |
74 | func (e *EventBus) Publish(event Event, data any) {
75 | e.ch <- eventData{
76 | event: event,
77 | data: data,
78 | }
79 | }
80 |
81 | func (e *EventBus) Subscribe(event Event, fn func(any)) {
82 | e.mu.Lock()
83 | defer e.mu.Unlock()
84 |
85 | arr, ok := e.subscribers[event]
86 | if !ok {
87 | panic("event not found")
88 | }
89 |
90 | e.subscribers[event] = append(arr, fn)
91 | }
92 |
--------------------------------------------------------------------------------
/backend/internal/core/services/reporting/value_parsers.go:
--------------------------------------------------------------------------------
1 | package reporting
2 |
3 | import (
4 | "strconv"
5 | "strings"
6 | )
7 |
8 | func parseSeparatedString(s string, sep string) ([]string, error) {
9 | list := strings.Split(s, sep)
10 |
11 | csf := make([]string, 0, len(list))
12 | for _, s := range list {
13 | trimmed := strings.TrimSpace(s)
14 | if trimmed != "" {
15 | csf = append(csf, trimmed)
16 | }
17 | }
18 |
19 | return csf, nil
20 | }
21 |
22 | func parseFloat(s string) float64 {
23 | if s == "" {
24 | return 0
25 | }
26 | f, _ := strconv.ParseFloat(s, 64)
27 | return f
28 | }
29 |
30 | func parseBool(s string) bool {
31 | b, _ := strconv.ParseBool(s)
32 | return b
33 | }
34 |
35 | func parseInt(s string) int {
36 | i, _ := strconv.Atoi(s)
37 | return i
38 | }
39 |
--------------------------------------------------------------------------------
/backend/internal/core/services/reporting/value_parsers_test.go:
--------------------------------------------------------------------------------
1 | package reporting
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func Test_parseSeparatedString(t *testing.T) {
9 | type args struct {
10 | s string
11 | sep string
12 | }
13 | tests := []struct {
14 | name string
15 | args args
16 | want []string
17 | wantErr bool
18 | }{
19 | {
20 | name: "comma",
21 | args: args{
22 | s: "a,b,c",
23 | sep: ",",
24 | },
25 | want: []string{"a", "b", "c"},
26 | wantErr: false,
27 | },
28 | {
29 | name: "trimmed comma",
30 | args: args{
31 | s: "a, b, c",
32 | sep: ",",
33 | },
34 | want: []string{"a", "b", "c"},
35 | },
36 | {
37 | name: "excessive whitespace",
38 | args: args{
39 | s: " a, b, c ",
40 | sep: ",",
41 | },
42 | want: []string{"a", "b", "c"},
43 | },
44 | {
45 | name: "empty",
46 | args: args{
47 | s: "",
48 | sep: ",",
49 | },
50 | want: []string{},
51 | },
52 | }
53 | for _, tt := range tests {
54 | t.Run(tt.name, func(t *testing.T) {
55 | got, err := parseSeparatedString(tt.args.s, tt.args.sep)
56 | if (err != nil) != tt.wantErr {
57 | t.Errorf("parseSeparatedString() error = %v, wantErr %v", err, tt.wantErr)
58 | return
59 | }
60 | if !reflect.DeepEqual(got, tt.want) {
61 | t.Errorf("parseSeparatedString() = %v, want %v", got, tt.want)
62 | }
63 | })
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/backend/internal/core/services/service_background.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "strings"
6 | "time"
7 |
8 | "github.com/containrrr/shoutrrr"
9 | "github.com/hay-kot/homebox/backend/internal/data/repo"
10 | "github.com/hay-kot/homebox/backend/internal/data/types"
11 | "github.com/rs/zerolog/log"
12 | )
13 |
14 | type BackgroundService struct {
15 | repos *repo.AllRepos
16 | }
17 |
18 | func (svc *BackgroundService) SendNotifiersToday(ctx context.Context) error {
19 | // Get All Groups
20 | groups, err := svc.repos.Groups.GetAllGroups(ctx)
21 | if err != nil {
22 | return err
23 | }
24 |
25 | today := types.DateFromTime(time.Now())
26 |
27 | for i := range groups {
28 | group := groups[i]
29 |
30 | entries, err := svc.repos.MaintEntry.GetScheduled(ctx, group.ID, today)
31 | if err != nil {
32 | return err
33 | }
34 |
35 | if len(entries) == 0 {
36 | log.Debug().
37 | Str("group_name", group.Name).
38 | Str("group_id", group.ID.String()).
39 | Msg("No scheduled maintenance for today")
40 | continue
41 | }
42 |
43 | notifiers, err := svc.repos.Notifiers.GetByGroup(ctx, group.ID)
44 | if err != nil {
45 | return err
46 | }
47 |
48 | urls := make([]string, len(notifiers))
49 | for i := range notifiers {
50 | urls[i] = notifiers[i].URL
51 | }
52 |
53 | bldr := strings.Builder{}
54 |
55 | bldr.WriteString("Homebox Maintenance for (")
56 | bldr.WriteString(today.String())
57 | bldr.WriteString("):\n")
58 |
59 | for i := range entries {
60 | entry := entries[i]
61 | bldr.WriteString(" - ")
62 | bldr.WriteString(entry.Name)
63 | bldr.WriteString("\n")
64 | }
65 |
66 | var sendErrs []error
67 | for i := range urls {
68 | err := shoutrrr.Send(urls[i], bldr.String())
69 |
70 | if err != nil {
71 | sendErrs = append(sendErrs, err)
72 | }
73 | }
74 |
75 | if len(sendErrs) > 0 {
76 | return sendErrs[0]
77 | }
78 | }
79 |
80 | return nil
81 | }
82 |
--------------------------------------------------------------------------------
/backend/internal/core/services/service_group.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "errors"
5 | "time"
6 |
7 | "github.com/hay-kot/homebox/backend/internal/data/repo"
8 | "github.com/hay-kot/homebox/backend/pkgs/hasher"
9 | )
10 |
11 | type GroupService struct {
12 | repos *repo.AllRepos
13 | }
14 |
15 | func (svc *GroupService) UpdateGroup(ctx Context, data repo.GroupUpdate) (repo.Group, error) {
16 | if data.Name == "" {
17 | data.Name = ctx.User.GroupName
18 | }
19 |
20 | if data.Currency == "" {
21 | return repo.Group{}, errors.New("currency cannot be empty")
22 | }
23 |
24 | return svc.repos.Groups.GroupUpdate(ctx.Context, ctx.GID, data)
25 | }
26 |
27 | func (svc *GroupService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) {
28 | token := hasher.GenerateToken()
29 |
30 | _, err := svc.repos.Groups.InvitationCreate(ctx, ctx.GID, repo.GroupInvitationCreate{
31 | Token: token.Hash,
32 | Uses: uses,
33 | ExpiresAt: expiresAt,
34 | })
35 | if err != nil {
36 | return "", err
37 | }
38 |
39 | return token.Raw, nil
40 | }
41 |
--------------------------------------------------------------------------------
/backend/internal/core/services/service_items_attachments_test.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "os"
6 | "path"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/hay-kot/homebox/backend/internal/data/repo"
11 | "github.com/stretchr/testify/assert"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func TestItemService_AddAttachment(t *testing.T) {
16 | temp := os.TempDir()
17 |
18 | svc := &ItemService{
19 | repo: tRepos,
20 | filepath: temp,
21 | }
22 |
23 | loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, repo.LocationCreate{
24 | Description: "test",
25 | Name: "test",
26 | })
27 | require.NoError(t, err)
28 | assert.NotNil(t, loc)
29 |
30 | itmC := repo.ItemCreate{
31 | Name: fk.Str(10),
32 | Description: fk.Str(10),
33 | LocationID: loc.ID,
34 | }
35 |
36 | itm, err := svc.repo.Items.Create(context.Background(), tGroup.ID, itmC)
37 | require.NoError(t, err)
38 | assert.NotNil(t, itm)
39 | t.Cleanup(func() {
40 | err := svc.repo.Items.Delete(context.Background(), itm.ID)
41 | require.NoError(t, err)
42 | })
43 |
44 | contents := fk.Str(1000)
45 | reader := strings.NewReader(contents)
46 |
47 | // Setup
48 | afterAttachment, err := svc.AttachmentAdd(tCtx, itm.ID, "testfile.txt", "attachment", reader)
49 | require.NoError(t, err)
50 | assert.NotNil(t, afterAttachment)
51 |
52 | // Check that the file exists
53 | storedPath := afterAttachment.Attachments[0].Document.Path
54 |
55 | // {root}/{group}/{item}/{attachment}
56 | assert.Equal(t, path.Join(temp, "homebox", tGroup.ID.String(), "documents"), path.Dir(storedPath))
57 |
58 | // Check that the file contents are correct
59 | bts, err := os.ReadFile(storedPath)
60 | require.NoError(t, err)
61 | assert.Equal(t, contents, string(bts))
62 | }
63 |
--------------------------------------------------------------------------------
/backend/internal/core/services/service_user_defaults.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "github.com/hay-kot/homebox/backend/internal/data/repo"
5 | )
6 |
7 | func defaultLocations() []repo.LocationCreate {
8 | return []repo.LocationCreate{
9 | {
10 | Name: "Living Room",
11 | },
12 | {
13 | Name: "Garage",
14 | },
15 | {
16 | Name: "Kitchen",
17 | },
18 | {
19 | Name: "Bedroom",
20 | },
21 | {
22 | Name: "Bathroom",
23 | },
24 | {
25 | Name: "Office",
26 | },
27 | {
28 | Name: "Attic",
29 | },
30 | {
31 | Name: "Basement",
32 | },
33 | }
34 | }
35 |
36 | func defaultLabels() []repo.LabelCreate {
37 | return []repo.LabelCreate{
38 | {
39 | Name: "Appliances",
40 | },
41 | {
42 | Name: "IOT",
43 | },
44 | {
45 | Name: "Electronics",
46 | },
47 | {
48 | Name: "Servers",
49 | },
50 | {
51 | Name: "General",
52 | },
53 | {
54 | Name: "Important",
55 | },
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/external.go:
--------------------------------------------------------------------------------
1 | package ent
2 |
3 | import (
4 | "database/sql"
5 |
6 | entsql "entgo.io/ent/dialect/sql"
7 | )
8 |
9 | // Sql exposes the underlying database connection in the ent client
10 | // so that we can use it to perform custom queries.
11 | func (c *Client) Sql() *sql.DB {
12 | return c.driver.(*entsql.Driver).DB()
13 | }
14 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/generate.go:
--------------------------------------------------------------------------------
1 | package ent
2 |
3 | //go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/versioned-migration ./schema --template=./schema/templates/has_id.tmpl
4 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/has_id.go:
--------------------------------------------------------------------------------
1 | // Code generated by ent, DO NOT EDIT.
2 |
3 | package ent
4 |
5 | import "github.com/google/uuid"
6 |
7 | func (a *Attachment) GetID() uuid.UUID {
8 | return a.ID
9 | }
10 |
11 | func (ar *AuthRoles) GetID() int {
12 | return ar.ID
13 | }
14 |
15 | func (at *AuthTokens) GetID() uuid.UUID {
16 | return at.ID
17 | }
18 |
19 | func (d *Document) GetID() uuid.UUID {
20 | return d.ID
21 | }
22 |
23 | func (gr *Group) GetID() uuid.UUID {
24 | return gr.ID
25 | }
26 |
27 | func (git *GroupInvitationToken) GetID() uuid.UUID {
28 | return git.ID
29 | }
30 |
31 | func (i *Item) GetID() uuid.UUID {
32 | return i.ID
33 | }
34 |
35 | func (_if *ItemField) GetID() uuid.UUID {
36 | return _if.ID
37 | }
38 |
39 | func (l *Label) GetID() uuid.UUID {
40 | return l.ID
41 | }
42 |
43 | func (l *Location) GetID() uuid.UUID {
44 | return l.ID
45 | }
46 |
47 | func (me *MaintenanceEntry) GetID() uuid.UUID {
48 | return me.ID
49 | }
50 |
51 | func (n *Notifier) GetID() uuid.UUID {
52 | return n.ID
53 | }
54 |
55 | func (u *User) GetID() uuid.UUID {
56 | return u.ID
57 | }
58 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/predicate/predicate.go:
--------------------------------------------------------------------------------
1 | // Code generated by ent, DO NOT EDIT.
2 |
3 | package predicate
4 |
5 | import (
6 | "entgo.io/ent/dialect/sql"
7 | )
8 |
9 | // Attachment is the predicate function for attachment builders.
10 | type Attachment func(*sql.Selector)
11 |
12 | // AuthRoles is the predicate function for authroles builders.
13 | type AuthRoles func(*sql.Selector)
14 |
15 | // AuthTokens is the predicate function for authtokens builders.
16 | type AuthTokens func(*sql.Selector)
17 |
18 | // Document is the predicate function for document builders.
19 | type Document func(*sql.Selector)
20 |
21 | // Group is the predicate function for group builders.
22 | type Group func(*sql.Selector)
23 |
24 | // GroupInvitationToken is the predicate function for groupinvitationtoken builders.
25 | type GroupInvitationToken func(*sql.Selector)
26 |
27 | // Item is the predicate function for item builders.
28 | type Item func(*sql.Selector)
29 |
30 | // ItemField is the predicate function for itemfield builders.
31 | type ItemField func(*sql.Selector)
32 |
33 | // Label is the predicate function for label builders.
34 | type Label func(*sql.Selector)
35 |
36 | // Location is the predicate function for location builders.
37 | type Location func(*sql.Selector)
38 |
39 | // MaintenanceEntry is the predicate function for maintenanceentry builders.
40 | type MaintenanceEntry func(*sql.Selector)
41 |
42 | // Notifier is the predicate function for notifier builders.
43 | type Notifier func(*sql.Selector)
44 |
45 | // User is the predicate function for user builders.
46 | type User func(*sql.Selector)
47 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/runtime/runtime.go:
--------------------------------------------------------------------------------
1 | // Code generated by ent, DO NOT EDIT.
2 |
3 | package runtime
4 |
5 | // The schema-stitching logic is generated in github.com/hay-kot/homebox/backend/internal/data/ent/runtime.go
6 |
7 | const (
8 | Version = "v0.12.5" // Version of ent codegen.
9 | Sum = "h1:KREM5E4CSoej4zeGa88Ou/gfturAnpUv0mzAjch1sj4=" // Sum of ent codegen.
10 | )
11 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/attachment.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "entgo.io/ent"
5 | "entgo.io/ent/schema/edge"
6 | "entgo.io/ent/schema/field"
7 | "github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
8 | )
9 |
10 | // Attachment holds the schema definition for the Attachment entity.
11 | type Attachment struct {
12 | ent.Schema
13 | }
14 |
15 | func (Attachment) Mixin() []ent.Mixin {
16 | return []ent.Mixin{
17 | mixins.BaseMixin{},
18 | }
19 | }
20 |
21 | // Fields of the Attachment.
22 | func (Attachment) Fields() []ent.Field {
23 | return []ent.Field{
24 | field.Enum("type").
25 | Values("photo", "manual", "warranty", "attachment", "receipt").
26 | Default("attachment"),
27 | field.Bool("primary").
28 | Default(false),
29 | }
30 | }
31 |
32 | // Edges of the Attachment.
33 | func (Attachment) Edges() []ent.Edge {
34 | return []ent.Edge{
35 | edge.From("item", Item.Type).
36 | Ref("attachments").
37 | Required().
38 | Unique(),
39 | edge.From("document", Document.Type).
40 | Ref("attachments").
41 | Required().
42 | Unique(),
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/auth_roles.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "entgo.io/ent"
5 | "entgo.io/ent/schema/edge"
6 | "entgo.io/ent/schema/field"
7 | )
8 |
9 | // AuthRoles holds the schema definition for the AuthRoles entity.
10 | type AuthRoles struct {
11 | ent.Schema
12 | }
13 |
14 | // Fields of the AuthRoles.
15 | func (AuthRoles) Fields() []ent.Field {
16 | return []ent.Field{
17 | field.Enum("role").
18 | Default("user").
19 | Values(
20 | "admin", // can do everything - currently unused
21 | "user", // default login role
22 | "attachments", // Read Attachments
23 | ),
24 | }
25 | }
26 |
27 | // Edges of the AuthRoles.
28 | func (AuthRoles) Edges() []ent.Edge {
29 | return []ent.Edge{
30 | edge.From("token", AuthTokens.Type).
31 | Ref("roles").
32 | Unique(),
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/auth_tokens.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "time"
5 |
6 | "entgo.io/ent"
7 | "entgo.io/ent/dialect/entsql"
8 | "entgo.io/ent/schema/edge"
9 | "entgo.io/ent/schema/field"
10 | "entgo.io/ent/schema/index"
11 | "github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
12 | )
13 |
14 | // AuthTokens holds the schema definition for the AuthTokens entity.
15 | type AuthTokens struct {
16 | ent.Schema
17 | }
18 |
19 | func (AuthTokens) Mixin() []ent.Mixin {
20 | return []ent.Mixin{
21 | mixins.BaseMixin{},
22 | }
23 | }
24 |
25 | // Fields of the AuthTokens.
26 | func (AuthTokens) Fields() []ent.Field {
27 | return []ent.Field{
28 | field.Bytes("token").
29 | Unique(),
30 | field.Time("expires_at").
31 | Default(func() time.Time { return time.Now().Add(time.Hour * 24 * 7) }),
32 | }
33 | }
34 |
35 | // Edges of the AuthTokens.
36 | func (AuthTokens) Edges() []ent.Edge {
37 | return []ent.Edge{
38 | edge.From("user", User.Type).
39 | Ref("auth_tokens").
40 | Unique(),
41 | edge.To("roles", AuthRoles.Type).
42 | Unique().
43 | Annotations(entsql.Annotation{
44 | OnDelete: entsql.Cascade,
45 | }),
46 | }
47 | }
48 |
49 | func (AuthTokens) Indexes() []ent.Index {
50 | return []ent.Index{
51 | index.Fields("token"),
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/document.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "entgo.io/ent"
5 | "entgo.io/ent/dialect/entsql"
6 | "entgo.io/ent/schema/edge"
7 | "entgo.io/ent/schema/field"
8 | "github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
9 | )
10 |
11 | // Document holds the schema definition for the Document entity.
12 | type Document struct {
13 | ent.Schema
14 | }
15 |
16 | func (Document) Mixin() []ent.Mixin {
17 | return []ent.Mixin{
18 | mixins.BaseMixin{},
19 | GroupMixin{ref: "documents"},
20 | }
21 | }
22 |
23 | // Fields of the Document.
24 | func (Document) Fields() []ent.Field {
25 | return []ent.Field{
26 | field.String("title").
27 | MaxLen(255).
28 | NotEmpty(),
29 | field.String("path").
30 | MaxLen(500).
31 | NotEmpty(),
32 | }
33 | }
34 |
35 | // Edges of the Document.
36 | func (Document) Edges() []ent.Edge {
37 | return []ent.Edge{
38 | edge.To("attachments", Attachment.Type).
39 | Annotations(entsql.Annotation{
40 | OnDelete: entsql.Cascade,
41 | }),
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/group_invitation_token.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "time"
5 |
6 | "entgo.io/ent"
7 | "entgo.io/ent/schema/edge"
8 | "entgo.io/ent/schema/field"
9 | "github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
10 | )
11 |
12 | // GroupInvitationToken holds the schema definition for the GroupInvitationToken entity.
13 | type GroupInvitationToken struct {
14 | ent.Schema
15 | }
16 |
17 | func (GroupInvitationToken) Mixin() []ent.Mixin {
18 | return []ent.Mixin{
19 | mixins.BaseMixin{},
20 | }
21 | }
22 |
23 | // Fields of the GroupInvitationToken.
24 | func (GroupInvitationToken) Fields() []ent.Field {
25 | return []ent.Field{
26 | field.Bytes("token").
27 | Unique(),
28 | field.Time("expires_at").
29 | Default(func() time.Time { return time.Now().Add(time.Hour * 24 * 7) }),
30 | field.Int("uses").
31 | Default(0),
32 | }
33 | }
34 |
35 | // Edges of the GroupInvitationToken.
36 | func (GroupInvitationToken) Edges() []ent.Edge {
37 | return []ent.Edge{
38 | edge.From("group", Group.Type).
39 | Ref("invitation_tokens").
40 | Unique(),
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/item_field.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "time"
5 |
6 | "entgo.io/ent"
7 | "entgo.io/ent/schema/edge"
8 | "entgo.io/ent/schema/field"
9 | "github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
10 | )
11 |
12 | // ItemField holds the schema definition for the ItemField entity.
13 | type ItemField struct {
14 | ent.Schema
15 | }
16 |
17 | func (ItemField) Mixin() []ent.Mixin {
18 | return []ent.Mixin{
19 | mixins.BaseMixin{},
20 | mixins.DetailsMixin{},
21 | }
22 | }
23 |
24 | // Fields of the ItemField.
25 | func (ItemField) Fields() []ent.Field {
26 | return []ent.Field{
27 | field.Enum("type").
28 | Values("text", "number", "boolean", "time"),
29 | field.String("text_value").
30 | MaxLen(500).
31 | Optional(),
32 | field.Int("number_value").
33 | Optional(),
34 | field.Bool("boolean_value").
35 | Default(false),
36 | field.Time("time_value").
37 | Default(time.Now),
38 | }
39 | }
40 |
41 | // Edges of the ItemField.
42 | func (ItemField) Edges() []ent.Edge {
43 | return []ent.Edge{
44 | edge.From("item", Item.Type).
45 | Ref("fields").
46 | Unique(),
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/label.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "entgo.io/ent"
5 | "entgo.io/ent/schema/edge"
6 | "entgo.io/ent/schema/field"
7 | "github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
8 | )
9 |
10 | // Label holds the schema definition for the Label entity.
11 | type Label struct {
12 | ent.Schema
13 | }
14 |
15 | func (Label) Mixin() []ent.Mixin {
16 | return []ent.Mixin{
17 | mixins.BaseMixin{},
18 | mixins.DetailsMixin{},
19 | GroupMixin{ref: "labels"},
20 | }
21 | }
22 |
23 | // Fields of the Label.
24 | func (Label) Fields() []ent.Field {
25 | return []ent.Field{
26 | field.String("color").
27 | MaxLen(255).
28 | Optional(),
29 | }
30 | }
31 |
32 | // Edges of the Label.
33 | func (Label) Edges() []ent.Edge {
34 | return []ent.Edge{
35 | edge.To("items", Item.Type),
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/location.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "entgo.io/ent"
5 | "entgo.io/ent/dialect/entsql"
6 | "entgo.io/ent/schema/edge"
7 | "github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
8 | )
9 |
10 | // Location holds the schema definition for the Location entity.
11 | type Location struct {
12 | ent.Schema
13 | }
14 |
15 | func (Location) Mixin() []ent.Mixin {
16 | return []ent.Mixin{
17 | mixins.BaseMixin{},
18 | mixins.DetailsMixin{},
19 | GroupMixin{ref: "locations"},
20 | }
21 | }
22 |
23 | // Fields of the Location.
24 | func (Location) Fields() []ent.Field {
25 | return nil
26 | }
27 |
28 | // Edges of the Location.
29 | func (Location) Edges() []ent.Edge {
30 | return []ent.Edge{
31 | edge.To("children", Location.Type).
32 | From("parent").
33 | Unique(),
34 | edge.To("items", Item.Type).
35 | Annotations(entsql.Annotation{
36 | OnDelete: entsql.Cascade,
37 | }),
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/maintenance_entry.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "entgo.io/ent"
5 | "entgo.io/ent/schema/edge"
6 | "entgo.io/ent/schema/field"
7 | "github.com/google/uuid"
8 | "github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
9 | )
10 |
11 | type MaintenanceEntry struct {
12 | ent.Schema
13 | }
14 |
15 | func (MaintenanceEntry) Mixin() []ent.Mixin {
16 | return []ent.Mixin{
17 | mixins.BaseMixin{},
18 | }
19 | }
20 |
21 | func (MaintenanceEntry) Fields() []ent.Field {
22 | return []ent.Field{
23 | field.UUID("item_id", uuid.UUID{}),
24 | field.Time("date").
25 | Optional(),
26 | field.Time("scheduled_date").
27 | Optional(),
28 | field.String("name").
29 | MaxLen(255).
30 | NotEmpty(),
31 | field.String("description").
32 | MaxLen(2500).
33 | Optional(),
34 | field.Float("cost").
35 | Default(0.0),
36 | }
37 | }
38 |
39 | // Edges of the ItemField.
40 | func (MaintenanceEntry) Edges() []ent.Edge {
41 | return []ent.Edge{
42 | edge.From("item", Item.Type).
43 | Field("item_id").
44 | Ref("maintenance_entries").
45 | Required().
46 | Unique(),
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/mixins/base.go:
--------------------------------------------------------------------------------
1 | package mixins
2 |
3 | import (
4 | "time"
5 |
6 | "entgo.io/ent"
7 | "entgo.io/ent/schema/field"
8 | "entgo.io/ent/schema/mixin"
9 | "github.com/google/uuid"
10 | )
11 |
12 | type BaseMixin struct {
13 | mixin.Schema
14 | }
15 |
16 | func (BaseMixin) Fields() []ent.Field {
17 | return []ent.Field{
18 | field.UUID("id", uuid.UUID{}).
19 | Default(uuid.New),
20 | field.Time("created_at").
21 | Immutable().
22 | Default(time.Now),
23 | field.Time("updated_at").
24 | Default(time.Now).
25 | UpdateDefault(time.Now),
26 | }
27 | }
28 |
29 | type DetailsMixin struct {
30 | mixin.Schema
31 | }
32 |
33 | func (DetailsMixin) Fields() []ent.Field {
34 | return []ent.Field{
35 | field.String("name").
36 | MaxLen(255).
37 | NotEmpty(),
38 | field.String("description").
39 | MaxLen(1000).
40 | Optional(),
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/notifier.go:
--------------------------------------------------------------------------------
1 | package schema
2 |
3 | import (
4 | "entgo.io/ent"
5 | "entgo.io/ent/schema/field"
6 | "entgo.io/ent/schema/index"
7 |
8 | "github.com/hay-kot/homebox/backend/internal/data/ent/schema/mixins"
9 | )
10 |
11 | type Notifier struct {
12 | ent.Schema
13 | }
14 |
15 | func (Notifier) Mixin() []ent.Mixin {
16 | return []ent.Mixin{
17 | mixins.BaseMixin{},
18 | GroupMixin{
19 | ref: "notifiers",
20 | field: "group_id",
21 | },
22 | UserMixin{
23 | ref: "notifiers",
24 | field: "user_id",
25 | },
26 | }
27 | }
28 |
29 | // Fields of the Notifier.
30 | func (Notifier) Fields() []ent.Field {
31 | return []ent.Field{
32 | field.String("name").
33 | MaxLen(255).
34 | NotEmpty(),
35 | field.String("url").
36 | Sensitive().
37 | MaxLen(2083). // supposed max length of URL
38 | NotEmpty(),
39 | field.Bool("is_active").
40 | Default(true),
41 | }
42 | }
43 |
44 | func (Notifier) Indexes() []ent.Index {
45 | return []ent.Index{
46 | index.Fields("user_id"),
47 | index.Fields("user_id", "is_active"),
48 | index.Fields("group_id"),
49 | index.Fields("group_id", "is_active"),
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/backend/internal/data/ent/schema/templates/has_id.tmpl:
--------------------------------------------------------------------------------
1 | {{/* The line below tells Intellij/GoLand to enable the autocompletion based on the *gen.Graph type. */}}
2 | {{/* gotype: entgo.io/ent/entc/gen.Graph */}}
3 |
4 | {{ define "has_id" }}
5 |
6 | {{/* Add the base header for the generated file */}}
7 | {{ $pkg := base $.Config.Package }}
8 | {{ template "header" $ }}
9 | import "github.com/google/uuid"
10 | {{/* Loop over all nodes and implement the "HasID" interface */}}
11 | {{ range $n := $.Nodes }}
12 | {{ if not $n.ID }}
13 | {{/* If the node doesn't have an ID field, we skip it. */}}
14 | {{ continue }}
15 | {{ end }}
16 | {{/* The "HasID" interface is implemented by the "ID" method. */}}
17 | {{ $receiver := $n.Receiver }}
18 | func ({{ $receiver }} *{{ $n.Name }}) GetID() {{ $n.ID.Type }} {
19 | return {{ $receiver }}.ID
20 | }
21 | {{ end }}
22 |
23 | {{ end }}
24 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations.go:
--------------------------------------------------------------------------------
1 | // Package migrations provides a way to embed the migrations into the binary.
2 | package migrations
3 |
4 | import (
5 | "embed"
6 | "os"
7 | "path"
8 | )
9 |
10 | //go:embed all:migrations
11 | var Files embed.FS
12 |
13 | // Write writes the embedded migrations to a temporary directory.
14 | // It returns an error and a cleanup function. The cleanup function
15 | // should be called when the migrations are no longer needed.
16 | func Write(temp string) error {
17 | err := os.MkdirAll(temp, 0o755)
18 | if err != nil {
19 | return err
20 | }
21 |
22 | fsDir, err := Files.ReadDir("migrations")
23 | if err != nil {
24 | return err
25 | }
26 |
27 | for _, f := range fsDir {
28 | if f.IsDir() {
29 | continue
30 | }
31 |
32 | b, err := Files.ReadFile(path.Join("migrations", f.Name()))
33 | if err != nil {
34 | return err
35 | }
36 |
37 | err = os.WriteFile(path.Join(temp, f.Name()), b, 0o644)
38 | if err != nil {
39 | return err
40 | }
41 | }
42 |
43 | return nil
44 | }
45 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/20221001210956_group_invitations.sql:
--------------------------------------------------------------------------------
1 | -- create "group_invitation_tokens" table
2 | CREATE TABLE `group_invitation_tokens` (`id` uuid NOT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `token` blob NOT NULL, `expires_at` datetime NOT NULL, `uses` integer NOT NULL DEFAULT 0, `group_invitation_tokens` uuid NULL, PRIMARY KEY (`id`), CONSTRAINT `group_invitation_tokens_groups_invitation_tokens` FOREIGN KEY (`group_invitation_tokens`) REFERENCES `groups` (`id`) ON DELETE CASCADE);
3 | -- create index "group_invitation_tokens_token_key" to table: "group_invitation_tokens"
4 | CREATE UNIQUE INDEX `group_invitation_tokens_token_key` ON `group_invitation_tokens` (`token`);
5 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/20221009173029_add_user_roles.sql:
--------------------------------------------------------------------------------
1 | -- disable the enforcement of foreign-keys constraints
2 | PRAGMA foreign_keys = off;
3 | -- create "new_users" table
4 | CREATE TABLE `new_users` (`id` uuid NOT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `name` text NOT NULL, `email` text NOT NULL, `password` text NOT NULL, `is_superuser` bool NOT NULL DEFAULT false, `role` text NOT NULL DEFAULT 'user', `superuser` bool NOT NULL DEFAULT false, `activated_on` datetime NULL, `group_users` uuid NOT NULL, PRIMARY KEY (`id`), CONSTRAINT `users_groups_users` FOREIGN KEY (`group_users`) REFERENCES `groups` (`id`) ON DELETE CASCADE);
5 | -- copy rows from old table "users" to new temporary table "new_users"
6 | INSERT INTO `new_users` (`id`, `created_at`, `updated_at`, `name`, `email`, `password`, `is_superuser`, `group_users`) SELECT `id`, `created_at`, `updated_at`, `name`, `email`, `password`, `is_superuser`, `group_users` FROM `users`;
7 | -- drop "users" table after copying rows
8 | DROP TABLE `users`;
9 | -- rename temporary table "new_users" to "users"
10 | ALTER TABLE `new_users` RENAME TO `users`;
11 | -- create index "users_email_key" to table: "users"
12 | CREATE UNIQUE INDEX `users_email_key` ON `users` (`email`);
13 | -- enable back the enforcement of foreign-keys constraints
14 | PRAGMA foreign_keys = on;
15 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/20221203053132_add_token_roles.sql:
--------------------------------------------------------------------------------
1 | -- create "auth_roles" table
2 | CREATE TABLE `auth_roles` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `role` text NOT NULL DEFAULT 'user', `auth_tokens_roles` uuid NULL, CONSTRAINT `auth_roles_auth_tokens_roles` FOREIGN KEY (`auth_tokens_roles`) REFERENCES `auth_tokens` (`id`) ON DELETE SET NULL);
3 | -- create index "auth_roles_auth_tokens_roles_key" to table: "auth_roles"
4 | CREATE UNIQUE INDEX `auth_roles_auth_tokens_roles_key` ON `auth_roles` (`auth_tokens_roles`);
5 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/20221205230404_drop_document_tokens.sql:
--------------------------------------------------------------------------------
1 | -- disable the enforcement of foreign-keys constraints
2 | PRAGMA foreign_keys = off;
3 | DROP TABLE `document_tokens`;
4 | -- enable back the enforcement of foreign-keys constraints
5 | PRAGMA foreign_keys = on;
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/20221205234214_add_maintenance_entries.sql:
--------------------------------------------------------------------------------
1 | -- create "maintenance_entries" table
2 | CREATE TABLE `maintenance_entries` (`id` uuid NOT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `date` datetime NOT NULL, `name` text NOT NULL, `description` text NULL, `cost` real NOT NULL DEFAULT 0, `item_id` uuid NOT NULL, PRIMARY KEY (`id`), CONSTRAINT `maintenance_entries_items_maintenance_entries` FOREIGN KEY (`item_id`) REFERENCES `items` (`id`) ON DELETE CASCADE);
3 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/20221205234812_cascade_delete_roles.sql:
--------------------------------------------------------------------------------
1 | -- disable the enforcement of foreign-keys constraints
2 | PRAGMA foreign_keys = off;
3 | -- create "new_auth_roles" table
4 | CREATE TABLE `new_auth_roles` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `role` text NOT NULL DEFAULT 'user', `auth_tokens_roles` uuid NULL, CONSTRAINT `auth_roles_auth_tokens_roles` FOREIGN KEY (`auth_tokens_roles`) REFERENCES `auth_tokens` (`id`) ON DELETE CASCADE);
5 | -- copy rows from old table "auth_roles" to new temporary table "new_auth_roles"
6 | INSERT INTO `new_auth_roles` (`id`, `role`, `auth_tokens_roles`) SELECT `id`, `role`, `auth_tokens_roles` FROM `auth_roles`;
7 | -- drop "auth_roles" table after copying rows
8 | DROP TABLE `auth_roles`;
9 | -- rename temporary table "new_auth_roles" to "auth_roles"
10 | ALTER TABLE `new_auth_roles` RENAME TO `auth_roles`;
11 | -- create index "auth_roles_auth_tokens_roles_key" to table: "auth_roles"
12 | CREATE UNIQUE INDEX `auth_roles_auth_tokens_roles_key` ON `auth_roles` (`auth_tokens_roles`);
13 | -- delete where tokens is null
14 | DELETE FROM `auth_roles` WHERE `auth_tokens_roles` IS NULL;
15 | -- enable back the enforcement of foreign-keys constraints
16 | PRAGMA foreign_keys = on;
17 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/20230227024134_add_scheduled_date.sql:
--------------------------------------------------------------------------------
1 | -- disable the enforcement of foreign-keys constraints
2 | PRAGMA foreign_keys = off;
3 | -- create "new_maintenance_entries" table
4 | CREATE TABLE `new_maintenance_entries` (`id` uuid NOT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `date` datetime NULL, `scheduled_date` datetime NULL, `name` text NOT NULL, `description` text NULL, `cost` real NOT NULL DEFAULT 0, `item_id` uuid NOT NULL, PRIMARY KEY (`id`), CONSTRAINT `maintenance_entries_items_maintenance_entries` FOREIGN KEY (`item_id`) REFERENCES `items` (`id`) ON DELETE CASCADE);
5 | -- copy rows from old table "maintenance_entries" to new temporary table "new_maintenance_entries"
6 | INSERT INTO `new_maintenance_entries` (`id`, `created_at`, `updated_at`, `date`, `name`, `description`, `cost`, `item_id`) SELECT `id`, `created_at`, `updated_at`, `date`, `name`, `description`, `cost`, `item_id` FROM `maintenance_entries`;
7 | -- drop "maintenance_entries" table after copying rows
8 | DROP TABLE `maintenance_entries`;
9 | -- rename temporary table "new_maintenance_entries" to "maintenance_entries"
10 | ALTER TABLE `new_maintenance_entries` RENAME TO `maintenance_entries`;
11 | -- enable back the enforcement of foreign-keys constraints
12 | PRAGMA foreign_keys = on;
13 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/20230305065819_add_notifier_types.sql:
--------------------------------------------------------------------------------
1 | -- create "notifiers" table
2 | CREATE TABLE `notifiers` (`id` uuid NOT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `name` text NOT NULL, `url` text NOT NULL, `is_active` bool NOT NULL DEFAULT true, `user_id` uuid NOT NULL, PRIMARY KEY (`id`), CONSTRAINT `notifiers_users_notifiers` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE);
3 | -- create index "notifier_user_id" to table: "notifiers"
4 | CREATE INDEX `notifier_user_id` ON `notifiers` (`user_id`);
5 | -- create index "notifier_user_id_is_active" to table: "notifiers"
6 | CREATE INDEX `notifier_user_id_is_active` ON `notifiers` (`user_id`, `is_active`);
7 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/20230305071524_add_group_id_to_notifiers.sql:
--------------------------------------------------------------------------------
1 | -- disable the enforcement of foreign-keys constraints
2 | PRAGMA foreign_keys = off;
3 | -- create "new_notifiers" table
4 | CREATE TABLE `new_notifiers` (`id` uuid NOT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `name` text NOT NULL, `url` text NOT NULL, `is_active` bool NOT NULL DEFAULT true, `group_id` uuid NOT NULL, `user_id` uuid NOT NULL, PRIMARY KEY (`id`), CONSTRAINT `notifiers_groups_notifiers` FOREIGN KEY (`group_id`) REFERENCES `groups` (`id`) ON DELETE CASCADE, CONSTRAINT `notifiers_users_notifiers` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE);
5 | -- copy rows from old table "notifiers" to new temporary table "new_notifiers"
6 | INSERT INTO `new_notifiers` (`id`, `created_at`, `updated_at`, `name`, `url`, `is_active`, `user_id`) SELECT `id`, `created_at`, `updated_at`, `name`, `url`, `is_active`, `user_id` FROM `notifiers`;
7 | -- drop "notifiers" table after copying rows
8 | DROP TABLE `notifiers`;
9 | -- rename temporary table "new_notifiers" to "notifiers"
10 | ALTER TABLE `new_notifiers` RENAME TO `notifiers`;
11 | -- create index "notifier_user_id" to table: "notifiers"
12 | CREATE INDEX `notifier_user_id` ON `notifiers` (`user_id`);
13 | -- create index "notifier_user_id_is_active" to table: "notifiers"
14 | CREATE INDEX `notifier_user_id_is_active` ON `notifiers` (`user_id`, `is_active`);
15 | -- create index "notifier_group_id" to table: "notifiers"
16 | CREATE INDEX `notifier_group_id` ON `notifiers` (`group_id`);
17 | -- create index "notifier_group_id_is_active" to table: "notifiers"
18 | CREATE INDEX `notifier_group_id_is_active` ON `notifiers` (`group_id`, `is_active`);
19 | -- enable back the enforcement of foreign-keys constraints
20 | PRAGMA foreign_keys = on;
21 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/20231006213457_add_primary_attachment_flag.sql:
--------------------------------------------------------------------------------
1 | -- Disable the enforcement of foreign-keys constraints
2 | PRAGMA foreign_keys = off;
3 | -- Create "new_attachments" table
4 | CREATE TABLE `new_attachments` (`id` uuid NOT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `type` text NOT NULL DEFAULT 'attachment', `primary` bool NOT NULL DEFAULT false, `document_attachments` uuid NOT NULL, `item_attachments` uuid NOT NULL, PRIMARY KEY (`id`), CONSTRAINT `attachments_documents_attachments` FOREIGN KEY (`document_attachments`) REFERENCES `documents` (`id`) ON DELETE CASCADE, CONSTRAINT `attachments_items_attachments` FOREIGN KEY (`item_attachments`) REFERENCES `items` (`id`) ON DELETE CASCADE);
5 | -- Copy rows from old table "attachments" to new temporary table "new_attachments"
6 | INSERT INTO `new_attachments` (`id`, `created_at`, `updated_at`, `type`, `document_attachments`, `item_attachments`) SELECT `id`, `created_at`, `updated_at`, `type`, `document_attachments`, `item_attachments` FROM `attachments`;
7 | -- Drop "attachments" table after copying rows
8 | DROP TABLE `attachments`;
9 | -- Rename temporary table "new_attachments" to "attachments"
10 | ALTER TABLE `new_attachments` RENAME TO `attachments`;
11 | -- Enable back the enforcement of foreign-keys constraints
12 | PRAGMA foreign_keys = on;
13 |
--------------------------------------------------------------------------------
/backend/internal/data/migrations/migrations/atlas.sum:
--------------------------------------------------------------------------------
1 | h1:sjJCTAqc9FG8BKBIzh5ZynYD/Ilz6vnLqM4XX83WQ4M=
2 | 20220929052825_init.sql h1:ZlCqm1wzjDmofeAcSX3jE4h4VcdTNGpRg2eabztDy9Q=
3 | 20221001210956_group_invitations.sql h1:YQKJFtE39wFOcRNbZQ/d+ZlHwrcfcsZlcv/pLEYdpjw=
4 | 20221009173029_add_user_roles.sql h1:vWmzAfgEWQeGk0Vn70zfVPCcfEZth3E0JcvyKTjpYyU=
5 | 20221020043305_allow_nesting_types.sql h1:4AyJpZ7l7SSJtJAQETYY802FHJ64ufYPJTqvwdiGn3M=
6 | 20221101041931_add_archived_field.sql h1:L2WxiOh1svRn817cNURgqnEQg6DIcodZ1twK4tvxW94=
7 | 20221113012312_add_asset_id_field.sql h1:DjD7e1PS8OfxGBWic8h0nO/X6CNnHEMqQjDCaaQ3M3Q=
8 | 20221203053132_add_token_roles.sql h1:wFTIh+KBoHfLfy/L0ZmJz4cNXKHdACG9ZK/yvVKjF0M=
9 | 20221205230404_drop_document_tokens.sql h1:9dCbNFcjtsT6lEhkxCn/vYaGRmQrl1LefdEJgvkfhGg=
10 | 20221205234214_add_maintenance_entries.sql h1:B56VzCuDsed1k3/sYUoKlOkP90DcdLufxFK0qYvoafU=
11 | 20221205234812_cascade_delete_roles.sql h1:VIiaImR48nCHF3uFbOYOX1E79Ta5HsUBetGaSAbh9Gk=
12 | 20230227024134_add_scheduled_date.sql h1:8qO5OBZ0AzsfYEQOAQQrYIjyhSwM+v1A+/ylLSoiyoc=
13 | 20230305065819_add_notifier_types.sql h1:r5xrgCKYQ2o9byBqYeAX1zdp94BLdaxf4vq9OmGHNl0=
14 | 20230305071524_add_group_id_to_notifiers.sql h1:xDShqbyClcFhvJbwclOHdczgXbdffkxXNWjV61hL/t4=
15 | 20231006213457_add_primary_attachment_flag.sql h1:J4tMSJQFa7vaj0jpnh8YKTssdyIjRyq6RXDXZIzDDu4=
16 |
--------------------------------------------------------------------------------
/backend/internal/data/repo/asset_id_type.go:
--------------------------------------------------------------------------------
1 | package repo
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "strconv"
7 | )
8 |
9 | type AssetID int
10 |
11 | func (aid AssetID) Nil() bool {
12 | return aid.Int() <= 0
13 | }
14 |
15 | func (aid AssetID) Int() int {
16 | return int(aid)
17 | }
18 |
19 | func ParseAssetIDBytes(d []byte) (AID AssetID, ok bool) {
20 | d = bytes.Replace(d, []byte(`"`), []byte(``), -1)
21 | d = bytes.Replace(d, []byte(`-`), []byte(``), -1)
22 |
23 | aidInt, err := strconv.Atoi(string(d))
24 | if err != nil {
25 | return AssetID(-1), false
26 | }
27 |
28 | return AssetID(aidInt), true
29 | }
30 |
31 | func ParseAssetID(s string) (AID AssetID, ok bool) {
32 | return ParseAssetIDBytes([]byte(s))
33 | }
34 |
35 | func (aid AssetID) String() string {
36 | if aid.Nil() {
37 | return ""
38 | }
39 |
40 | aidStr := fmt.Sprintf("%06d", aid)
41 | aidStr = fmt.Sprintf("%s-%s", aidStr[:3], aidStr[3:])
42 | return aidStr
43 | }
44 |
45 | func (aid AssetID) MarshalJSON() ([]byte, error) {
46 | return []byte(`"` + aid.String() + `"`), nil
47 | }
48 |
49 | func (aid *AssetID) UnmarshalJSON(d []byte) error {
50 | if len(d) == 0 || bytes.Equal(d, []byte(`""`)) {
51 | *aid = -1
52 | return nil
53 | }
54 |
55 | d = bytes.Replace(d, []byte(`"`), []byte(``), -1)
56 | d = bytes.Replace(d, []byte(`-`), []byte(``), -1)
57 |
58 | aidInt, err := strconv.Atoi(string(d))
59 | if err != nil {
60 | return err
61 | }
62 |
63 | *aid = AssetID(aidInt)
64 | return nil
65 | }
66 |
67 | func (aid AssetID) MarshalCSV() (string, error) {
68 | return aid.String(), nil
69 | }
70 |
71 | func (aid *AssetID) UnmarshalCSV(d string) error {
72 | return aid.UnmarshalJSON([]byte(d))
73 | }
74 |
--------------------------------------------------------------------------------
/backend/internal/data/repo/automappers.go:
--------------------------------------------------------------------------------
1 | package repo
2 |
3 | type MapFunc[T any, U any] func(T) U
4 |
5 | func (a MapFunc[T, U]) Map(v T) U {
6 | return a(v)
7 | }
8 |
9 | func (a MapFunc[T, U]) MapEach(v []T) []U {
10 | result := make([]U, len(v))
11 | for i, item := range v {
12 | result[i] = a(item)
13 | }
14 | return result
15 | }
16 |
17 | func (a MapFunc[T, U]) MapErr(v T, err error) (U, error) {
18 | if err != nil {
19 | var zero U
20 | return zero, err
21 | }
22 |
23 | return a(v), nil
24 | }
25 |
26 | func (a MapFunc[T, U]) MapEachErr(v []T, err error) ([]U, error) {
27 | if err != nil {
28 | return nil, err
29 | }
30 |
31 | return a.MapEach(v), nil
32 | }
33 |
--------------------------------------------------------------------------------
/backend/internal/data/repo/id_set.go:
--------------------------------------------------------------------------------
1 | package repo
2 |
3 | import (
4 | "github.com/google/uuid"
5 | "github.com/hay-kot/homebox/backend/pkgs/set"
6 | )
7 |
8 | // HasID is an interface to entities that have an ID uuid.UUID field and a GetID() method.
9 | // This interface is fulfilled by all entities generated by entgo.io/ent via a custom template
10 | type HasID interface {
11 | GetID() uuid.UUID
12 | }
13 |
14 | func newIDSet[T HasID](entities []T) set.Set[uuid.UUID] {
15 | uuids := make([]uuid.UUID, 0, len(entities))
16 | for _, e := range entities {
17 | uuids = append(uuids, e.GetID())
18 | }
19 |
20 | return set.New(uuids...)
21 | }
22 |
--------------------------------------------------------------------------------
/backend/internal/data/repo/main_test.go:
--------------------------------------------------------------------------------
1 | package repo
2 |
3 | import (
4 | "context"
5 | "log"
6 | "os"
7 | "testing"
8 |
9 | "github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
10 | "github.com/hay-kot/homebox/backend/internal/data/ent"
11 | "github.com/hay-kot/homebox/backend/pkgs/faker"
12 | _ "github.com/mattn/go-sqlite3"
13 | )
14 |
15 | var (
16 | fk = faker.NewFaker()
17 | tbus = eventbus.New()
18 |
19 | tClient *ent.Client
20 | tRepos *AllRepos
21 | tUser UserOut
22 | tGroup Group
23 | )
24 |
25 | func bootstrap() {
26 | var (
27 | err error
28 | ctx = context.Background()
29 | )
30 |
31 | tGroup, err = tRepos.Groups.GroupCreate(ctx, "test-group")
32 | if err != nil {
33 | log.Fatal(err)
34 | }
35 |
36 | tUser, err = tRepos.Users.Create(ctx, userFactory())
37 | if err != nil {
38 | log.Fatal(err)
39 | }
40 | }
41 |
42 | func TestMain(m *testing.M) {
43 | client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
44 | if err != nil {
45 | log.Fatalf("failed opening connection to sqlite: %v", err)
46 | }
47 |
48 | go func() {
49 | _ = tbus.Run(context.Background())
50 | }()
51 |
52 | err = client.Schema.Create(context.Background())
53 | if err != nil {
54 | log.Fatalf("failed creating schema resources: %v", err)
55 | }
56 |
57 | tClient = client
58 | tRepos = New(tClient, tbus, os.TempDir())
59 | defer func() { _ = client.Close() }()
60 |
61 | bootstrap()
62 |
63 | os.Exit(m.Run())
64 | }
65 |
--------------------------------------------------------------------------------
/backend/internal/data/repo/map_helpers.go:
--------------------------------------------------------------------------------
1 | package repo
2 |
3 | // mapTErrFunc is a factory function that returns a mapper function that
4 | // wraps the given mapper function but first will check for an error and
5 | // return the error if present.
6 | //
7 | // Helpful for wrapping database calls that return both a value and an error
8 | func mapTErrFunc[T any, Y any](fn func(T) Y) func(T, error) (Y, error) {
9 | return func(t T, err error) (Y, error) {
10 | if err != nil {
11 | var zero Y
12 | return zero, err
13 | }
14 |
15 | return fn(t), nil
16 | }
17 | }
18 |
19 | func mapTEachFunc[T any, Y any](fn func(T) Y) func([]T) []Y {
20 | return func(items []T) []Y {
21 | result := make([]Y, len(items))
22 | for i, item := range items {
23 | result[i] = fn(item)
24 | }
25 |
26 | return result
27 | }
28 | }
29 |
30 | func mapTEachErrFunc[T any, Y any](fn func(T) Y) func([]T, error) ([]Y, error) {
31 | return func(items []T, err error) ([]Y, error) {
32 | if err != nil {
33 | return nil, err
34 | }
35 |
36 | result := make([]Y, len(items))
37 | for i, item := range items {
38 | result[i] = fn(item)
39 | }
40 |
41 | return result, nil
42 | }
43 | }
44 |
45 | func mapEach[T any, U any](items []T, fn func(T) U) []U {
46 | result := make([]U, len(items))
47 | for i, item := range items {
48 | result[i] = fn(item)
49 | }
50 | return result
51 | }
52 |
--------------------------------------------------------------------------------
/backend/internal/data/repo/pagination.go:
--------------------------------------------------------------------------------
1 | package repo
2 |
3 | type PaginationResult[T any] struct {
4 | Page int `json:"page"`
5 | PageSize int `json:"pageSize"`
6 | Total int `json:"total"`
7 | Items []T `json:"items"`
8 | }
9 |
10 | func calculateOffset(page, pageSize int) int {
11 | return (page - 1) * pageSize
12 | }
13 |
--------------------------------------------------------------------------------
/backend/internal/data/repo/query_helpers.go:
--------------------------------------------------------------------------------
1 | package repo
2 |
3 | import "time"
4 |
5 | func sqliteDateFormat(t time.Time) string {
6 | return t.Format("2006-01-02 15:04:05")
7 | }
8 |
9 | // orDefault returns the value of the pointer if it is not nil, otherwise it returns the default value
10 | //
11 | // This is used for nullable or potentially nullable fields (or aggregates) in the database when running
12 | // queries. If the field is null, the pointer will be nil, so we return the default value instead.
13 | func orDefault[T any](v *T, def T) T {
14 | if v == nil {
15 | return def
16 | }
17 | return *v
18 | }
19 |
--------------------------------------------------------------------------------
/backend/internal/data/repo/repo_group_test.go:
--------------------------------------------------------------------------------
1 | package repo
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func Test_Group_Create(t *testing.T) {
12 | g, err := tRepos.Groups.GroupCreate(context.Background(), "test")
13 |
14 | require.NoError(t, err)
15 | assert.Equal(t, "test", g.Name)
16 |
17 | // Get by ID
18 | foundGroup, err := tRepos.Groups.GroupByID(context.Background(), g.ID)
19 | require.NoError(t, err)
20 | assert.Equal(t, g.ID, foundGroup.ID)
21 | }
22 |
23 | func Test_Group_Update(t *testing.T) {
24 | g, err := tRepos.Groups.GroupCreate(context.Background(), "test")
25 | require.NoError(t, err)
26 |
27 | g, err = tRepos.Groups.GroupUpdate(context.Background(), g.ID, GroupUpdate{
28 | Name: "test2",
29 | Currency: "eur",
30 | })
31 | require.NoError(t, err)
32 | assert.Equal(t, "test2", g.Name)
33 | assert.Equal(t, "EUR", g.Currency)
34 | }
35 |
36 | func Test_Group_GroupStatistics(t *testing.T) {
37 | useItems(t, 20)
38 | useLabels(t, 20)
39 |
40 | stats, err := tRepos.Groups.StatsGroup(context.Background(), tGroup.ID)
41 |
42 | require.NoError(t, err)
43 | assert.Equal(t, 20, stats.TotalItems)
44 | assert.Equal(t, 20, stats.TotalLabels)
45 | assert.Equal(t, 1, stats.TotalUsers)
46 | assert.Equal(t, 1, stats.TotalLocations)
47 | }
48 |
--------------------------------------------------------------------------------
/backend/internal/data/repo/repos_all.go:
--------------------------------------------------------------------------------
1 | // Package repo provides the data access layer for the application.
2 | package repo
3 |
4 | import (
5 | "github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
6 | "github.com/hay-kot/homebox/backend/internal/data/ent"
7 | )
8 |
9 | // AllRepos is a container for all the repository interfaces
10 | type AllRepos struct {
11 | Users *UserRepository
12 | AuthTokens *TokenRepository
13 | Groups *GroupRepository
14 | Locations *LocationRepository
15 | Labels *LabelRepository
16 | Items *ItemsRepository
17 | Docs *DocumentRepository
18 | Attachments *AttachmentRepo
19 | MaintEntry *MaintenanceEntryRepository
20 | Notifiers *NotifierRepository
21 | }
22 |
23 | func New(db *ent.Client, bus *eventbus.EventBus, root string) *AllRepos {
24 | return &AllRepos{
25 | Users: &UserRepository{db},
26 | AuthTokens: &TokenRepository{db},
27 | Groups: NewGroupRepository(db),
28 | Locations: &LocationRepository{db, bus},
29 | Labels: &LabelRepository{db, bus},
30 | Items: &ItemsRepository{db, bus},
31 | Docs: &DocumentRepository{db, root},
32 | Attachments: &AttachmentRepo{db},
33 | MaintEntry: &MaintenanceEntryRepository{db},
34 | Notifiers: NewNotifierRepository(db),
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/backend/internal/sys/config/conf_database.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | const (
4 | DriverSqlite3 = "sqlite3"
5 | )
6 |
7 | type Storage struct {
8 | // Data is the path to the root directory
9 | Data string `yaml:"data" conf:"default:./.data"`
10 | SqliteURL string `yaml:"sqlite-url" conf:"default:./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1"`
11 | }
12 |
--------------------------------------------------------------------------------
/backend/internal/sys/config/conf_logger.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | const (
4 | LogFormatJSON = "json"
5 | LogFormatText = "text"
6 | )
7 |
8 | type LoggerConf struct {
9 | Level string `conf:"default:info"`
10 | Format string `conf:"default:text"`
11 | }
12 |
--------------------------------------------------------------------------------
/backend/internal/sys/config/conf_mailer.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | type MailerConf struct {
4 | Host string `conf:""`
5 | Port int `conf:""`
6 | Username string `conf:""`
7 | Password string `conf:""`
8 | From string `conf:""`
9 | }
10 |
11 | // Ready is a simple check to ensure that the configuration is not empty.
12 | // or with it's default state.
13 | func (mc *MailerConf) Ready() bool {
14 | return mc.Host != "" && mc.Port != 0 && mc.Username != "" && mc.Password != "" && mc.From != ""
15 | }
16 |
--------------------------------------------------------------------------------
/backend/internal/sys/config/conf_mailer_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func Test_MailerReady_Success(t *testing.T) {
10 | mc := &MailerConf{
11 | Host: "host",
12 | Port: 1,
13 | Username: "username",
14 | Password: "password",
15 | From: "from",
16 | }
17 |
18 | assert.True(t, mc.Ready())
19 | }
20 |
21 | func Test_MailerReady_Failure(t *testing.T) {
22 | mc := &MailerConf{}
23 | assert.False(t, mc.Ready())
24 |
25 | mc.Host = "host"
26 | assert.False(t, mc.Ready())
27 |
28 | mc.Port = 1
29 | assert.False(t, mc.Ready())
30 |
31 | mc.Username = "username"
32 | assert.False(t, mc.Ready())
33 |
34 | mc.Password = "password"
35 | assert.False(t, mc.Ready())
36 |
37 | mc.From = "from"
38 | assert.True(t, mc.Ready())
39 | }
40 |
--------------------------------------------------------------------------------
/backend/internal/web/adapters/adapters.go:
--------------------------------------------------------------------------------
1 | package adapters
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/google/uuid"
7 | )
8 |
9 | type AdapterFunc[T any, Y any] func(*http.Request, T) (Y, error)
10 | type IDFunc[T any, Y any] func(*http.Request, uuid.UUID, T) (Y, error)
11 |
--------------------------------------------------------------------------------
/backend/internal/web/adapters/command.go:
--------------------------------------------------------------------------------
1 | package adapters
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/google/uuid"
7 | "github.com/hay-kot/httpkit/errchain"
8 | "github.com/hay-kot/httpkit/server"
9 | )
10 |
11 | type CommandFunc[T any] func(*http.Request) (T, error)
12 | type CommandIDFunc[T any] func(*http.Request, uuid.UUID) (T, error)
13 |
14 | // Command is an HandlerAdapter that returns a errchain.HandlerFunc that
15 | // The command adapters are used to handle commands that do not accept a body
16 | // or a query. You can think of them as a way to handle RPC style Rest Endpoints.
17 | //
18 | // Example:
19 | //
20 | // fn := func(r *http.Request) (interface{}, error) {
21 | // // do something
22 | // return nil, nil
23 | // }
24 | //
25 | // r.Get("/foo", adapters.Command(fn, http.NoContent))
26 | func Command[T any](f CommandFunc[T], ok int) errchain.HandlerFunc {
27 | return func(w http.ResponseWriter, r *http.Request) error {
28 | res, err := f(r)
29 | if err != nil {
30 | return err
31 | }
32 |
33 | return server.JSON(w, ok, res)
34 | }
35 | }
36 |
37 | // CommandID is the same as the Command adapter but it accepts a UUID as a parameter
38 | // in the URL. The parameter name is passed as the first argument.
39 | //
40 | // Example:
41 | //
42 | // fn := func(r *http.Request, id uuid.UUID) (interface{}, error) {
43 | // // do something
44 | // return nil, nil
45 | // }
46 | //
47 | // r.Get("/foo/{id}", adapters.CommandID("id", fn, http.NoContent))
48 | func CommandID[T any](param string, f CommandIDFunc[T], ok int) errchain.HandlerFunc {
49 | return func(w http.ResponseWriter, r *http.Request) error {
50 | ID, err := RouteUUID(r, param)
51 | if err != nil {
52 | return err
53 | }
54 |
55 | res, err := f(r, ID)
56 | if err != nil {
57 | return err
58 | }
59 |
60 | return server.JSON(w, ok, res)
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/backend/internal/web/adapters/decoders.go:
--------------------------------------------------------------------------------
1 | package adapters
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/pkg/errors"
7 |
8 | "github.com/go-chi/chi/v5"
9 | "github.com/google/uuid"
10 | "github.com/gorilla/schema"
11 | "github.com/hay-kot/homebox/backend/internal/sys/validate"
12 | "github.com/hay-kot/httpkit/server"
13 | )
14 |
15 | var queryDecoder = schema.NewDecoder()
16 |
17 | func DecodeQuery[T any](r *http.Request) (T, error) {
18 | var v T
19 | err := queryDecoder.Decode(&v, r.URL.Query())
20 | if err != nil {
21 | return v, errors.Wrap(err, "decoding error")
22 | }
23 |
24 | err = validate.Check(v)
25 | if err != nil {
26 | return v, errors.Wrap(err, "validation error")
27 | }
28 |
29 | return v, nil
30 | }
31 |
32 | type Validator interface {
33 | Validate() error
34 | }
35 |
36 | func DecodeBody[T any](r *http.Request) (T, error) {
37 | var val T
38 |
39 | err := server.Decode(r, &val)
40 | if err != nil {
41 | return val, errors.Wrap(err, "body decoding error")
42 | }
43 |
44 | err = validate.Check(val)
45 | if err != nil {
46 | return val, err
47 | }
48 |
49 | if v, ok := any(val).(Validator); ok {
50 | err = v.Validate()
51 | if err != nil {
52 | return val, errors.Wrap(err, "validation error")
53 | }
54 | }
55 |
56 | return val, nil
57 | }
58 |
59 | func RouteUUID(r *http.Request, key string) (uuid.UUID, error) {
60 | ID, err := uuid.Parse(chi.URLParam(r, key))
61 | if err != nil {
62 | return uuid.Nil, validate.NewRouteKeyError(key)
63 | }
64 | return ID, nil
65 | }
66 |
--------------------------------------------------------------------------------
/backend/internal/web/adapters/doc.go:
--------------------------------------------------------------------------------
1 | /*
2 | Package adapters offers common adapters for turing regular functions into HTTP Handlers
3 | There are three types of adapters
4 |
5 | - Query adapters
6 | - Action adapters
7 | - Command adapters
8 | */
9 | package adapters
10 |
--------------------------------------------------------------------------------
/backend/internal/web/adapters/query.go:
--------------------------------------------------------------------------------
1 | package adapters
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/hay-kot/httpkit/errchain"
7 | "github.com/hay-kot/httpkit/server"
8 | )
9 |
10 | // Query is a server.Handler that decodes a query from the request and calls the provided function.
11 | //
12 | // Example:
13 | //
14 | // type Query struct {
15 | // Foo string `schema:"foo"`
16 | // }
17 | //
18 | // fn := func(r *http.Request, q Query) (any, error) {
19 | // // do something with q
20 | // return nil, nil
21 | // }
22 | //
23 | // r.Get("/foo", adapters.Query(fn, http.StatusOK))
24 | func Query[T any, Y any](f AdapterFunc[T, Y], ok int) errchain.HandlerFunc {
25 | return func(w http.ResponseWriter, r *http.Request) error {
26 | q, err := DecodeQuery[T](r)
27 | if err != nil {
28 | return err
29 | }
30 |
31 | res, err := f(r, q)
32 | if err != nil {
33 | return err
34 | }
35 |
36 | return server.JSON(w, ok, res)
37 | }
38 | }
39 |
40 | // QueryID is a server.Handler that decodes a query and an ID from the request and calls the provided function.
41 | //
42 | // Example:
43 | //
44 | // type Query struct {
45 | // Foo string `schema:"foo"`
46 | // }
47 | //
48 | // fn := func(r *http.Request, ID uuid.UUID, q Query) (any, error) {
49 | // // do something with ID and q
50 | // return nil, nil
51 | // }
52 | //
53 | // r.Get("/foo/{id}", adapters.QueryID(fn, http.StatusOK))
54 | func QueryID[T any, Y any](param string, f IDFunc[T, Y], ok int) errchain.HandlerFunc {
55 | return func(w http.ResponseWriter, r *http.Request) error {
56 | ID, err := RouteUUID(r, param)
57 | if err != nil {
58 | return err
59 | }
60 |
61 | q, err := DecodeQuery[T](r)
62 | if err != nil {
63 | return err
64 | }
65 |
66 | res, err := f(r, ID, q)
67 | if err != nil {
68 | return err
69 | }
70 |
71 | return server.JSON(w, ok, res)
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/backend/internal/web/mid/doc.go:
--------------------------------------------------------------------------------
1 | // Package mid provides web middleware.
2 | package mid
3 |
--------------------------------------------------------------------------------
/backend/internal/web/mid/logger.go:
--------------------------------------------------------------------------------
1 | package mid
2 |
3 | import (
4 | "bufio"
5 | "errors"
6 | "net"
7 | "net/http"
8 |
9 | "github.com/go-chi/chi/v5/middleware"
10 | "github.com/rs/zerolog"
11 | )
12 |
13 | type spy struct {
14 | http.ResponseWriter
15 | status int
16 | }
17 |
18 | func (s *spy) WriteHeader(status int) {
19 | s.status = status
20 | s.ResponseWriter.WriteHeader(status)
21 | }
22 |
23 | func (s *spy) Hijack() (net.Conn, *bufio.ReadWriter, error) {
24 | hj, ok := s.ResponseWriter.(http.Hijacker)
25 | if !ok {
26 | return nil, nil, errors.New("response writer does not support hijacking")
27 | }
28 | return hj.Hijack()
29 | }
30 |
31 | func Logger(l zerolog.Logger) func(http.Handler) http.Handler {
32 | return func(h http.Handler) http.Handler {
33 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34 | reqID := r.Context().Value(middleware.RequestIDKey).(string)
35 |
36 | l.Info().Str("method", r.Method).Str("path", r.URL.Path).Str("rid", reqID).Msg("request received")
37 |
38 | s := &spy{ResponseWriter: w}
39 | h.ServeHTTP(s, r)
40 |
41 | l.Info().Str("method", r.Method).Str("path", r.URL.Path).Int("status", s.status).Str("rid", reqID).Msg("request finished")
42 | })
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/backend/pkgs/cgofreesqlite/sqlite.go:
--------------------------------------------------------------------------------
1 | // Package cgofreesqlite package provides a CGO free implementation of the sqlite3 driver. This wraps the
2 | // modernc.org/sqlite driver and adds the PRAGMA foreign_keys = ON; statement to the connection
3 | // initialization as well as registering the driver with the sql package as "sqlite3" for compatibility
4 | // with entgo.io
5 | //
6 | // NOTE: This does come with around a 30% performance hit compared to the CGO version of the driver.
7 | // however it greatly simplifies the build process and allows for cross compilation.
8 | package cgofreesqlite
9 |
10 | import (
11 | "database/sql"
12 | "database/sql/driver"
13 |
14 | "modernc.org/sqlite"
15 | )
16 |
17 | type CGOFreeSqliteDriver struct {
18 | *sqlite.Driver
19 | }
20 |
21 | type sqlite3DriverConn interface {
22 | Exec(string, []driver.Value) (driver.Result, error)
23 | }
24 |
25 | func (d CGOFreeSqliteDriver) Open(name string) (conn driver.Conn, err error) {
26 | conn, err = d.Driver.Open(name)
27 | if err != nil {
28 | return nil, err
29 | }
30 | _, err = conn.(sqlite3DriverConn).Exec("PRAGMA foreign_keys = ON;", nil)
31 | if err != nil {
32 | _ = conn.Close()
33 | return nil, err
34 | }
35 | return conn, err
36 | }
37 |
38 | func init() { //nolint:gochecknoinits
39 | sql.Register("sqlite3", CGOFreeSqliteDriver{Driver: &sqlite.Driver{}})
40 | }
41 |
--------------------------------------------------------------------------------
/backend/pkgs/faker/random.go:
--------------------------------------------------------------------------------
1 | // Package faker provides a simple interface for generating fake data for testing.
2 | package faker
3 |
4 | import (
5 | "math/rand"
6 | "time"
7 | )
8 |
9 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
10 |
11 | type Faker struct{}
12 |
13 | func NewFaker() *Faker {
14 | return &Faker{}
15 | }
16 |
17 | func (f *Faker) Time() time.Time {
18 | return time.Now().Add(time.Duration(f.Num(1, 100)) * time.Hour)
19 | }
20 |
21 | func (f *Faker) Str(length int) string {
22 | b := make([]rune, length)
23 | for i := range b {
24 | b[i] = letters[rand.Intn(len(letters))]
25 | }
26 | return string(b)
27 | }
28 |
29 | func (f *Faker) Path() string {
30 | return "/" + f.Str(10) + "/" + f.Str(10) + "/" + f.Str(10)
31 | }
32 |
33 | func (f *Faker) Email() string {
34 | return f.Str(10) + "@example.com"
35 | }
36 |
37 | func (f *Faker) Bool() bool {
38 | return rand.Intn(2) == 1
39 | }
40 |
41 | func (f *Faker) Num(min, max int) int {
42 | return rand.Intn(max-min) + min
43 | }
44 |
--------------------------------------------------------------------------------
/backend/pkgs/hasher/doc.go:
--------------------------------------------------------------------------------
1 | // Package hasher provides a simple interface for hashing and verifying passwords.
2 | package hasher
3 |
--------------------------------------------------------------------------------
/backend/pkgs/hasher/password.go:
--------------------------------------------------------------------------------
1 | package hasher
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "golang.org/x/crypto/bcrypt"
8 | )
9 |
10 | var enabled = true
11 |
12 | func init() { // nolint: gochecknoinits
13 | disableHas := os.Getenv("UNSAFE_DISABLE_PASSWORD_PROJECTION") == "yes_i_am_sure"
14 |
15 | if disableHas {
16 | fmt.Println("WARNING: Password protection is disabled. This is unsafe in production.")
17 | enabled = false
18 | }
19 | }
20 |
21 | func HashPassword(password string) (string, error) {
22 | if !enabled {
23 | return password, nil
24 | }
25 |
26 | bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
27 | return string(bytes), err
28 | }
29 |
30 | func CheckPasswordHash(password, hash string) bool {
31 | if !enabled {
32 | return password == hash
33 | }
34 |
35 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
36 | return err == nil
37 | }
38 |
--------------------------------------------------------------------------------
/backend/pkgs/hasher/password_test.go:
--------------------------------------------------------------------------------
1 | package hasher
2 |
3 | import "testing"
4 |
5 | func TestHashPassword(t *testing.T) {
6 | t.Parallel()
7 | type args struct {
8 | password string
9 | }
10 | tests := []struct {
11 | name string
12 | args args
13 | wantErr bool
14 | }{
15 | {
16 | name: "letters_and_numbers",
17 | args: args{
18 | password: "password123456788",
19 | },
20 | },
21 | {
22 | name: "letters_number_and_special",
23 | args: args{
24 | password: "!2afj3214pofajip3142j;fa",
25 | },
26 | },
27 | }
28 | for _, tt := range tests {
29 | t.Run(tt.name, func(t *testing.T) {
30 | got, err := HashPassword(tt.args.password)
31 | if (err != nil) != tt.wantErr {
32 | t.Errorf("HashPassword() error = %v, wantErr %v", err, tt.wantErr)
33 | return
34 | }
35 | if !CheckPasswordHash(tt.args.password, got) {
36 | t.Errorf("CheckPasswordHash() failed to validate password=%v against hash=%v", tt.args.password, got)
37 | }
38 | })
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/backend/pkgs/hasher/token.go:
--------------------------------------------------------------------------------
1 | package hasher
2 |
3 | import (
4 | "crypto/rand"
5 | "crypto/sha256"
6 | "encoding/base32"
7 | )
8 |
9 | type Token struct {
10 | Raw string
11 | Hash []byte
12 | }
13 |
14 | func GenerateToken() Token {
15 | randomBytes := make([]byte, 16)
16 | _, _ = rand.Read(randomBytes)
17 |
18 | plainText := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
19 | hash := HashToken(plainText)
20 |
21 | return Token{
22 | Raw: plainText,
23 | Hash: hash,
24 | }
25 | }
26 |
27 | func HashToken(plainTextToken string) []byte {
28 | hash := sha256.Sum256([]byte(plainTextToken))
29 | return hash[:]
30 | }
31 |
--------------------------------------------------------------------------------
/backend/pkgs/hasher/token_test.go:
--------------------------------------------------------------------------------
1 | package hasher
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | const ITERATIONS = 200
10 |
11 | func Test_NewToken(t *testing.T) {
12 | t.Parallel()
13 | tokens := make([]Token, ITERATIONS)
14 | for i := 0; i < ITERATIONS; i++ {
15 | tokens[i] = GenerateToken()
16 | }
17 |
18 | // Check if they are unique
19 | for i := 0; i < 5; i++ {
20 | for j := i + 1; j < 5; j++ {
21 | if tokens[i].Raw == tokens[j].Raw {
22 | t.Errorf("NewToken() failed to generate unique tokens")
23 | }
24 | }
25 | }
26 | }
27 |
28 | func Test_HashToken_CheckTokenHash(t *testing.T) {
29 | t.Parallel()
30 | for i := 0; i < ITERATIONS; i++ {
31 | token := GenerateToken()
32 |
33 | // Check raw text is reltively random
34 | for j := 0; j < 5; j++ {
35 | assert.NotEqual(t, token.Raw, GenerateToken().Raw)
36 | }
37 |
38 | // Check token length is less than 32 characters
39 | assert.Less(t, len(token.Raw), 32)
40 |
41 | // Check hash is the same
42 | assert.Equal(t, token.Hash, HashToken(token.Raw))
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/backend/pkgs/mailer/mailer.go:
--------------------------------------------------------------------------------
1 | // Package mailer provides a simple mailer for sending emails.
2 | package mailer
3 |
4 | import (
5 | "encoding/base64"
6 | "fmt"
7 | "mime"
8 | "net/smtp"
9 | "strconv"
10 | )
11 |
12 | type Mailer struct {
13 | Host string `json:"host,omitempty"`
14 | Port int `json:"port,omitempty"`
15 | Username string `json:"username,omitempty"`
16 | Password string `json:"password,omitempty"`
17 | From string `json:"from,omitempty"`
18 | }
19 |
20 | func (m *Mailer) Ready() bool {
21 | return m.Host != "" && m.Port != 0 && m.Username != "" && m.Password != "" && m.From != ""
22 | }
23 |
24 | func (m *Mailer) server() string {
25 | return m.Host + ":" + strconv.Itoa(m.Port)
26 | }
27 |
28 | func (m *Mailer) Send(msg *Message) error {
29 | server := m.server()
30 |
31 | header := make(map[string]string)
32 | header["From"] = msg.From.String()
33 | header["To"] = msg.To.String()
34 | header["Subject"] = mime.QEncoding.Encode("UTF-8", msg.Subject)
35 | header["MIME-Version"] = "1.0"
36 | header["Content-Type"] = "text/html; charset=\"utf-8\""
37 | header["Content-Transfer-Encoding"] = "base64"
38 |
39 | message := ""
40 | for k, v := range header {
41 | message += fmt.Sprintf("%s: %s\r\n", k, v)
42 | }
43 | message += "\r\n" + base64.StdEncoding.EncodeToString([]byte(msg.Body))
44 |
45 | return smtp.SendMail(
46 | server,
47 | smtp.PlainAuth("", m.Username, m.Password, m.Host),
48 | m.From,
49 | []string{msg.To.Address},
50 | []byte(message),
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/backend/pkgs/mailer/mailer_test.go:
--------------------------------------------------------------------------------
1 | package mailer
2 |
3 | import (
4 | "encoding/json"
5 | "os"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | const (
12 | TestMailerConfig = "test-mailer.json"
13 | )
14 |
15 | func GetTestMailer() (*Mailer, error) {
16 | // Read JSON File
17 | bytes, err := os.ReadFile(TestMailerConfig)
18 |
19 | mailer := &Mailer{}
20 |
21 | if err != nil {
22 | return nil, err
23 | }
24 |
25 | // Unmarshal JSON
26 | err = json.Unmarshal(bytes, mailer)
27 |
28 | if err != nil {
29 | return nil, err
30 | }
31 |
32 | return mailer, nil
33 | }
34 |
35 | func Test_Mailer(t *testing.T) {
36 | t.Parallel()
37 |
38 | mailer, err := GetTestMailer()
39 | if err != nil {
40 | t.Skip("Error Reading Test Mailer Config - Skipping")
41 | }
42 |
43 | if !mailer.Ready() {
44 | t.Skip("Mailer not ready - Skipping")
45 | }
46 |
47 | message, err := RenderWelcome()
48 | if err != nil {
49 | t.Error(err)
50 | }
51 |
52 | mb := NewMessageBuilder().
53 | SetBody(message).
54 | SetSubject("Hello").
55 | SetTo("John Doe", "john@doe.com").
56 | SetFrom("Jane Doe", "jane@doe.com")
57 |
58 | msg := mb.Build()
59 |
60 | err = mailer.Send(msg)
61 |
62 | require.NoError(t, err)
63 | }
64 |
--------------------------------------------------------------------------------
/backend/pkgs/mailer/message.go:
--------------------------------------------------------------------------------
1 | package mailer
2 |
3 | import "net/mail"
4 |
5 | type Message struct {
6 | Subject string
7 | To mail.Address
8 | From mail.Address
9 | Body string
10 | }
11 |
12 | type MessageBuilder struct {
13 | subject string
14 | to mail.Address
15 | from mail.Address
16 | body string
17 | }
18 |
19 | func NewMessageBuilder() *MessageBuilder {
20 | return &MessageBuilder{}
21 | }
22 |
23 | func (mb *MessageBuilder) Build() *Message {
24 | return &Message{
25 | Subject: mb.subject,
26 | To: mb.to,
27 | From: mb.from,
28 | Body: mb.body,
29 | }
30 | }
31 |
32 | func (mb *MessageBuilder) SetSubject(subject string) *MessageBuilder {
33 | mb.subject = subject
34 | return mb
35 | }
36 |
37 | func (mb *MessageBuilder) SetTo(name, to string) *MessageBuilder {
38 | mb.to = mail.Address{
39 | Name: name,
40 | Address: to,
41 | }
42 | return mb
43 | }
44 |
45 | func (mb *MessageBuilder) SetFrom(name, from string) *MessageBuilder {
46 | mb.from = mail.Address{
47 | Name: name,
48 | Address: from,
49 | }
50 | return mb
51 | }
52 |
53 | func (mb *MessageBuilder) SetBody(body string) *MessageBuilder {
54 | mb.body = body
55 | return mb
56 | }
57 |
--------------------------------------------------------------------------------
/backend/pkgs/mailer/message_test.go:
--------------------------------------------------------------------------------
1 | package mailer
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func Test_MessageBuilder(t *testing.T) {
10 | t.Parallel()
11 |
12 | mb := NewMessageBuilder().
13 | SetBody("Hello World!").
14 | SetSubject("Hello").
15 | SetTo("John Doe", "john@doe.com").
16 | SetFrom("Jane Doe", "jane@doe.com")
17 |
18 | msg := mb.Build()
19 |
20 | assert.Equal(t, "Hello", msg.Subject)
21 | assert.Equal(t, "Hello World!", msg.Body)
22 | assert.Equal(t, "John Doe", msg.To.Name)
23 | assert.Equal(t, "john@doe.com", msg.To.Address)
24 | assert.Equal(t, "Jane Doe", msg.From.Name)
25 | assert.Equal(t, "jane@doe.com", msg.From.Address)
26 | }
27 |
--------------------------------------------------------------------------------
/backend/pkgs/mailer/templates.go:
--------------------------------------------------------------------------------
1 | package mailer
2 |
3 | import (
4 | "bytes"
5 | _ "embed"
6 | "html/template"
7 | )
8 |
9 | //go:embed templates/welcome.html
10 | var templatesWelcome string
11 |
12 | type TemplateDefaults struct {
13 | CompanyName string
14 | CompanyAddress string
15 | CompanyURL string
16 | ActivateAccountURL string
17 | UnsubscribeURL string
18 | }
19 |
20 | type TemplateProps struct {
21 | Defaults TemplateDefaults
22 | Data map[string]string
23 | }
24 |
25 | func (tp *TemplateProps) Set(key, value string) {
26 | tp.Data[key] = value
27 | }
28 |
29 | func DefaultTemplateData() TemplateProps {
30 | return TemplateProps{
31 | Defaults: TemplateDefaults{
32 | CompanyName: "Haybytes.com",
33 | CompanyAddress: "123 Main St, Anytown, CA 12345",
34 | CompanyURL: "https://haybytes.com",
35 | ActivateAccountURL: "https://google.com",
36 | UnsubscribeURL: "https://google.com",
37 | },
38 | Data: make(map[string]string),
39 | }
40 | }
41 |
42 | func render(tpl string, data TemplateProps) (string, error) {
43 | tmpl, err := template.New("name").Parse(tpl)
44 | if err != nil {
45 | return "", err
46 | }
47 |
48 | var tplBuffer bytes.Buffer
49 |
50 | err = tmpl.Execute(&tplBuffer, data)
51 |
52 | if err != nil {
53 | return "", err
54 | }
55 |
56 | return tplBuffer.String(), nil
57 | }
58 |
59 | func RenderWelcome() (string, error) {
60 | return render(templatesWelcome, DefaultTemplateData())
61 | }
62 |
--------------------------------------------------------------------------------
/backend/pkgs/mailer/test-mailer-template.json:
--------------------------------------------------------------------------------
1 | {
2 | "host": "",
3 | "port": 465,
4 | "username": "",
5 | "password": "",
6 | "from": ""
7 | }
--------------------------------------------------------------------------------
/backend/pkgs/pathlib/pathlib.go:
--------------------------------------------------------------------------------
1 | // Package pathlib provides a way to safely create a file path without overwriting any existing files.
2 | package pathlib
3 |
4 | import (
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 | )
10 |
11 | type dirReaderFunc func(name string) []string
12 |
13 | var dirReader dirReaderFunc = func(directory string) []string {
14 | f, err := os.Open(directory)
15 | if err != nil {
16 | return nil
17 | }
18 | defer func() { _ = f.Close() }()
19 |
20 | names, err := f.Readdirnames(-1)
21 | if err != nil {
22 | return nil
23 | }
24 | return names
25 | }
26 |
27 | func hasConflict(path string, neighbors []string) bool {
28 | filename := strings.ToLower(filepath.Base(path))
29 |
30 | for _, n := range neighbors {
31 | if strings.ToLower(n) == filename {
32 | return true
33 | }
34 | }
35 | return false
36 | }
37 |
38 | // Safe will take a destination path and return a validated path that is safe to use.
39 | // without overwriting any existing files. If a conflict exists, it will append a number
40 | // to the end of the file name. If the parent directory does not exist this function will
41 | // return the original path.
42 | func Safe(path string) string {
43 | parent := filepath.Dir(path)
44 |
45 | neighbors := dirReader(parent)
46 | if neighbors == nil {
47 | return path
48 | }
49 |
50 | if hasConflict(path, neighbors) {
51 | ext := filepath.Ext(path)
52 |
53 | name := strings.TrimSuffix(filepath.Base(path), ext)
54 |
55 | for i := 1; i < 1000; i++ {
56 | newName := fmt.Sprintf("%s (%d)%s", name, i, ext)
57 | newPath := filepath.Join(parent, newName)
58 | if !hasConflict(newPath, neighbors) {
59 | return newPath
60 | }
61 | }
62 | }
63 |
64 | return path
65 | }
66 |
--------------------------------------------------------------------------------
/backend/pkgs/set/set.go:
--------------------------------------------------------------------------------
1 | // Package set provides a simple set implementation.
2 | package set
3 |
4 | type key interface {
5 | comparable
6 | }
7 |
8 | type Set[T key] struct {
9 | mp map[T]struct{}
10 | }
11 |
12 | func Make[T key](size int) Set[T] {
13 | return Set[T]{
14 | mp: make(map[T]struct{}, size),
15 | }
16 | }
17 |
18 | func New[T key](v ...T) Set[T] {
19 | mp := make(map[T]struct{}, len(v))
20 |
21 | s := Set[T]{mp}
22 |
23 | s.Insert(v...)
24 | return s
25 | }
26 |
27 | func (s Set[T]) Insert(v ...T) {
28 | for _, e := range v {
29 | s.mp[e] = struct{}{}
30 | }
31 | }
32 |
33 | func (s Set[T]) Remove(v ...T) {
34 | for _, e := range v {
35 | delete(s.mp, e)
36 | }
37 | }
38 |
39 | func (s Set[T]) Contains(v T) bool {
40 | _, ok := s.mp[v]
41 | return ok
42 | }
43 |
44 | func (s Set[T]) ContainsAll(v ...T) bool {
45 | for _, e := range v {
46 | if !s.Contains(e) {
47 | return false
48 | }
49 | }
50 | return true
51 | }
52 |
53 | func (s Set[T]) Slice() []T {
54 | slice := make([]T, 0, len(s.mp))
55 | for k := range s.mp {
56 | slice = append(slice, k)
57 | }
58 | return slice
59 | }
60 |
61 | func (s Set[T]) Len() int {
62 | return len(s.mp)
63 | }
64 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | homebox:
3 | image: homebox
4 | build:
5 | context: .
6 | dockerfile: ./Dockerfile
7 | args:
8 | - COMMIT=head
9 | - BUILD_TIME=0001-01-01T00:00:00Z
10 | ports:
11 | - 3100:7745
12 |
--------------------------------------------------------------------------------
/docs/docs/assets/img/homebox-email-banner.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hay-kot/homebox/6fd8457e5ac1cbe652a8e59230ca6b3b2e97d45b/docs/docs/assets/img/homebox-email-banner.jpg
--------------------------------------------------------------------------------
/docs/docs/assets/stylesheets/extras.css:
--------------------------------------------------------------------------------
1 | [data-md-color-scheme="homebox"] {
2 | --md-primary-fg-color: #5b7f67;
3 | --md-primary-fg-color--light: #5b7f67;
4 | --md-primary-fg-color--dark: #90030c;
5 | }
6 |
7 |
8 |
9 | /* Site width etc.*/
10 | .md-grid {
11 | max-width: 64rem !important;
12 | }
13 |
14 | .md-typeset table:not([class]) th {
15 | color: white;
16 | background-color: var(--md-primary-fg-color--light);
17 | }
18 |
19 | th {
20 | font-weight: bold;
21 | }
22 |
23 | .md-button {
24 | padding: 0.2rem 0.75rem !important;
25 | }
--------------------------------------------------------------------------------
/docs/docs/build.md:
--------------------------------------------------------------------------------
1 | # Building The Binary
2 |
3 | This document describes how to build the project from source code.
4 |
5 | ## Prerequisites
6 |
7 | TODO
8 |
9 | ## Building
10 |
11 | TODO
12 |
13 | ## Running
14 |
15 | TODO
--------------------------------------------------------------------------------
/docs/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Homebox
2 | site_url: https://hay-kot.github.io/homebox/
3 | repo_name: Homebox
4 | repo_url: https://github.com/hay-kot/homebox
5 | use_directory_urls: true
6 | theme:
7 | name: material
8 | palette:
9 | # Palette toggle for light mode
10 | - scheme: homebox
11 | toggle:
12 | icon: material/brightness-7
13 | name: Switch to dark mode
14 |
15 | # Palette toggle for dark mode
16 | - scheme: slate
17 | toggle:
18 | icon: material/brightness-4
19 | name: Switch to light mode
20 |
21 | features:
22 | - content.code.annotate
23 | - navigation.instant
24 | - navigation.expand
25 | - navigation.sections
26 | - navigation.tabs.sticky
27 | - navigation.tabs
28 | favicon: assets/img/favicon.svg
29 | logo: assets/img/favicon.svg
30 |
31 | plugins:
32 | - tags
33 |
34 | extra_css:
35 | - assets/stylesheets/extras.css
36 |
37 | markdown_extensions:
38 | - pymdownx.emoji:
39 | emoji_index: !!python/name:materialx.emoji.twemoji
40 | emoji_generator: !!python/name:materialx.emoji.to_svg
41 | - def_list
42 | - pymdownx.highlight
43 | - pymdownx.superfences
44 | - pymdownx.tasklist:
45 | custom_checkbox: true
46 | - admonition
47 | - attr_list
48 | - pymdownx.superfences
49 |
50 | nav:
51 | - Home:
52 | - Home: index.md
53 | - Quick Start: quick-start.md
54 | - Tips and Tricks: tips-tricks.md
55 | - Import and Export: import-csv.md
56 | - Building The Binary: build.md
57 | - API: "https://redocly.github.io/redoc/?url=https://hay-kot.github.io/homebox/api/openapi-2.0.json"
58 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | mkdocs-material==9.5.12
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml file generated for homebox on 2022-09-08T16:00:08-08:00
2 |
3 | app = "homebox"
4 | kill_signal = "SIGINT"
5 | kill_timeout = 5
6 | processes = []
7 |
8 | [build.args]
9 | COMMIT = "HEAD"
10 | VERSION = "nightly"
11 |
12 | [env]
13 | PORT = "7745"
14 | HBOX_DEMO = "true"
15 |
16 | [experimental]
17 | allowed_public_ports = []
18 | auto_rollback = true
19 |
20 | [[services]]
21 | http_checks = []
22 | internal_port = 7745
23 | processes = ["app"]
24 | protocol = "tcp"
25 | script_checks = []
26 | [services.concurrency]
27 | hard_limit = 25
28 | soft_limit = 20
29 | type = "connections"
30 |
31 | [[services.ports]]
32 | force_https = true
33 | handlers = ["http"]
34 | port = 80
35 |
36 | [[services.ports]]
37 | handlers = ["tls", "http"]
38 | port = 443
39 |
40 | [[services.tcp_checks]]
41 | grace_period = "1s"
42 | interval = "15s"
43 | restart_limit = 0
44 | timeout = "2s"
45 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | node: true,
6 | },
7 | extends: [
8 | "eslint:recommended",
9 | "plugin:vue/essential",
10 | "plugin:@typescript-eslint/recommended",
11 | "@nuxtjs/eslint-config-typescript",
12 | "plugin:vue/vue3-recommended",
13 | "plugin:prettier/recommended",
14 | ],
15 | parserOptions: {
16 | ecmaVersion: "latest",
17 | parser: "@typescript-eslint/parser",
18 | sourceType: "module",
19 | },
20 | plugins: ["vue", "@typescript-eslint"],
21 | rules: {
22 | "no-console": 0,
23 | "no-unused-vars": "off",
24 | "vue/multi-word-component-names": "off",
25 | "vue/no-setup-props-destructure": 0,
26 | "vue/no-multiple-template-root": 0,
27 | "vue/no-v-model-argument": 0,
28 | "@typescript-eslint/consistent-type-imports": "error",
29 | "@typescript-eslint/ban-ts-comment": 0,
30 | "@typescript-eslint/no-unused-vars": [
31 | "error",
32 | {
33 | ignoreRestSiblings: true,
34 | destructuredArrayIgnorePattern: "_",
35 | caughtErrors: "none",
36 | },
37 | ],
38 | "prettier/prettier": [
39 | "warn",
40 | {
41 | arrowParens: "avoid",
42 | semi: true,
43 | tabWidth: 2,
44 | useTabs: false,
45 | vueIndentScriptAndStyle: true,
46 | singleQuote: false,
47 | trailingComma: "es5",
48 | printWidth: 120,
49 | },
50 | ],
51 | },
52 | };
53 |
--------------------------------------------------------------------------------
/frontend/.nuxtignore:
--------------------------------------------------------------------------------
1 | pages/**/*.ts
--------------------------------------------------------------------------------
/frontend/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
16 |
--------------------------------------------------------------------------------
/frontend/assets/css/main.css:
--------------------------------------------------------------------------------
1 | .text-no-transform {
2 | text-transform: none !important;
3 | }
4 |
5 | .btn {
6 | text-transform: none !important;
7 | }
8 |
9 | /* transparent subtle scrollbar */
10 | ::-webkit-scrollbar {
11 | width: 0.2em;
12 | background-color: #F5F5F5;
13 | }
14 |
15 | ::-webkit-scrollbar-thumb {
16 | background-color: rgba(0,0,0,.2);
17 | }
18 |
19 | ::-webkit-scrollbar-track {
20 | -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
21 | background-color: #F5F5F5;
22 | }
23 |
24 | ::-webkit-scrollbar-thumb:hover {
25 | background-color: #9B9B9B;
26 | }
--------------------------------------------------------------------------------
/frontend/components/Base/Button.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
17 |
18 |
19 |
36 |
37 |
38 |
78 |
--------------------------------------------------------------------------------
/frontend/components/Base/Card.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
31 |
32 |
33 |
34 |
35 |
36 |
50 |
--------------------------------------------------------------------------------
/frontend/components/Base/Container.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/frontend/components/Base/Modal.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
60 |
--------------------------------------------------------------------------------
/frontend/components/Base/SectionHeader.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
29 |
--------------------------------------------------------------------------------
/frontend/components/DetailAction.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/frontend/components/Form/Checkbox.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
14 |
15 |
16 |
17 |
18 |
36 |
--------------------------------------------------------------------------------
/frontend/components/Form/Password.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
13 |
14 |
15 |
37 |
--------------------------------------------------------------------------------
/frontend/components/Form/TextField.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
12 |
13 |
14 |
15 |
16 |
57 |
--------------------------------------------------------------------------------
/frontend/components/Item/AttachmentsList.vue:
--------------------------------------------------------------------------------
1 |
2 |
22 |
23 |
24 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/frontend/components/Item/View/Table.types.ts:
--------------------------------------------------------------------------------
1 | import type { ItemSummary } from "~~/lib/api/types/data-contracts";
2 |
3 | export type TableHeader = {
4 | text: string;
5 | value: keyof ItemSummary;
6 | sortable?: boolean;
7 | align?: "left" | "center" | "right";
8 | };
9 |
10 | export type TableData = Record;
11 |
--------------------------------------------------------------------------------
/frontend/components/Label/Chip.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
36 |
40 | {{ label.name }}
41 |
42 |
43 |
--------------------------------------------------------------------------------
/frontend/components/Location/Card.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
14 |
15 |
19 |
20 | {{ location.name }}
21 |
22 |
23 | {{ count }}
24 |
25 |
26 |
27 |
28 |
29 |
30 |
62 |
--------------------------------------------------------------------------------
/frontend/components/Location/Tree/Root.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/frontend/components/Location/Tree/tree-state.ts:
--------------------------------------------------------------------------------
1 | import type { Ref } from "vue";
2 |
3 | type TreeState = Record;
4 |
5 | const store: Record> = {};
6 |
7 | export function newTreeKey(): string {
8 | return Math.random().toString(36).substring(2);
9 | }
10 |
11 | export function useTreeState(key: string): Ref {
12 | if (!store[key]) {
13 | store[key] = ref({});
14 | }
15 |
16 | return store[key];
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/components/ModalConfirm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Confirm
4 |
7 |
8 | Confirm
9 |
10 |
11 |
12 |
13 |
16 |
--------------------------------------------------------------------------------
/frontend/components/global/CopyText.vue:
--------------------------------------------------------------------------------
1 |
2 |
25 |
26 |
27 |
55 |
--------------------------------------------------------------------------------
/frontend/components/global/Currency.vue:
--------------------------------------------------------------------------------
1 |
2 | {{ value }}
3 |
4 |
5 |
22 |
--------------------------------------------------------------------------------
/frontend/components/global/DateTime.vue:
--------------------------------------------------------------------------------
1 |
2 | {{ value }}
3 |
4 |
5 |
28 |
--------------------------------------------------------------------------------
/frontend/components/global/DetailsSection/types.ts:
--------------------------------------------------------------------------------
1 | export type StringLike = string | number | boolean;
2 |
3 | type BaseDetail = {
4 | name: string;
5 | slot?: string;
6 | };
7 |
8 | type DateDetail = BaseDetail & {
9 | type: "date";
10 | text: Date | string;
11 | date: boolean;
12 | };
13 |
14 | type CurrencyDetail = BaseDetail & {
15 | type: "currency";
16 | text: string;
17 | };
18 |
19 | type LinkDetail = BaseDetail & {
20 | type: "link";
21 | text: string;
22 | href: string;
23 | };
24 |
25 | type MarkdownDetail = BaseDetail & {
26 | type: "markdown";
27 | text: string;
28 | };
29 |
30 | export type Detail = BaseDetail & {
31 | text: StringLike;
32 | type?: "text";
33 | copyable?: boolean;
34 | };
35 |
36 | export type AnyDetail = DateDetail | CurrencyDetail | LinkDetail | MarkdownDetail | Detail;
37 |
38 | export type Details = Array;
39 |
40 | export function filterZeroValues(details: Details): Details {
41 | return details.filter(detail => {
42 | switch (detail.type) {
43 | case "date":
44 | return validDate(detail.text);
45 | case "currency":
46 | return !!detail.text;
47 | case "link":
48 | return !!detail.text && !!detail.href;
49 | case undefined:
50 | case "text":
51 | case "markdown":
52 | return detail.text !== null && detail.text !== "" && detail.text !== undefined;
53 | default:
54 | console.warn("Unknown detail type (this should never happen)", detail);
55 | return false;
56 | }
57 | });
58 | }
59 |
--------------------------------------------------------------------------------
/frontend/components/global/DropZone.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
11 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/frontend/components/global/Markdown.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 |
30 |
84 |
--------------------------------------------------------------------------------
/frontend/components/global/PageQRCode.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
10 |
Page URL
11 |
![]()
12 |
13 |
14 |
15 |
16 |
17 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/frontend/components/global/PasswordScore.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Password Strength: {{ message }}
4 |
14 |
15 |
16 |
17 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/frontend/components/global/Spacer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/components/global/StatCard/StatCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{ title }}
5 |
6 |
7 | {{ value }}
8 |
9 |
{{ subtitle }}
10 |
11 |
12 |
13 |
14 |
29 |
--------------------------------------------------------------------------------
/frontend/components/global/StatCard/types.ts:
--------------------------------------------------------------------------------
1 | export type StatsFormat = "currency" | "number" | "percent";
2 |
--------------------------------------------------------------------------------
/frontend/components/global/Subtitle.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/frontend/components/global/Table.types.ts:
--------------------------------------------------------------------------------
1 | export type TableHeader = {
2 | text: string;
3 | value: string;
4 | sortable?: boolean;
5 | align?: "left" | "center" | "right";
6 | };
7 |
8 | export type TableData = Record;
9 |
--------------------------------------------------------------------------------
/frontend/composables/use-api.ts:
--------------------------------------------------------------------------------
1 | import { PublicApi } from "~~/lib/api/public";
2 | import { UserClient } from "~~/lib/api/user";
3 | import { Requests } from "~~/lib/requests";
4 |
5 | export type Observer = {
6 | handler: (r: Response, req?: RequestInit) => void;
7 | };
8 |
9 | export type RemoveObserver = () => void;
10 |
11 | const observers: Record = {};
12 |
13 | export function defineObserver(key: string, observer: Observer): RemoveObserver {
14 | observers[key] = observer;
15 |
16 | return () => {
17 | delete observers[key];
18 | };
19 | }
20 |
21 | function logger(r: Response) {
22 | console.log(`${r.status} ${r.url} ${r.statusText}`);
23 | }
24 |
25 | export function usePublicApi(): PublicApi {
26 | const requests = new Requests("", "", {});
27 | return new PublicApi(requests);
28 | }
29 |
30 | export function useUserApi(): UserClient {
31 | const authCtx = useAuthContext();
32 |
33 | const requests = new Requests("", "", {});
34 | requests.addResponseInterceptor(logger);
35 | requests.addResponseInterceptor(r => {
36 | if (r.status === 401) {
37 | console.error("unauthorized request, invalidating session");
38 | authCtx.invalidateSession();
39 | }
40 | });
41 |
42 | for (const [_, observer] of Object.entries(observers)) {
43 | requests.addResponseInterceptor(observer.handler);
44 | }
45 |
46 | return new UserClient(requests, authCtx.attachmentToken || "");
47 | }
48 |
--------------------------------------------------------------------------------
/frontend/composables/use-confirm.ts:
--------------------------------------------------------------------------------
1 | import type { UseConfirmDialogRevealResult, UseConfirmDialogReturn } from "@vueuse/core";
2 | import type { Ref } from "vue";
3 |
4 | type Store = UseConfirmDialogReturn & {
5 | text: Ref;
6 | setup: boolean;
7 | open: (text: string) => Promise>;
8 | };
9 |
10 | const store: Partial = {
11 | text: ref("Are you sure you want to delete this item? "),
12 | setup: false,
13 | };
14 |
15 | /**
16 | * This function is used to wrap the ModalConfirmation which is a "Singleton" component
17 | * that is used to confirm actions. It's mounded once on the root of the page and reused
18 | * for every confirmation action that is required.
19 | *
20 | * This is in an experimental phase of development and may have unknown or unexpected side effects.
21 | */
22 | export function useConfirm(): Store {
23 | if (!store.setup) {
24 | store.setup = true;
25 |
26 | const { isRevealed, reveal, confirm, cancel } = useConfirmDialog();
27 | store.isRevealed = isRevealed;
28 | store.reveal = reveal;
29 | store.confirm = confirm;
30 | store.cancel = cancel;
31 | }
32 |
33 | async function openDialog(msg: string): Promise> {
34 | if (!store.reveal) {
35 | throw new Error("reveal is not defined");
36 | }
37 | if (!store.text) {
38 | throw new Error("text is not defined");
39 | }
40 |
41 | store.text.value = msg;
42 | return await store.reveal();
43 | }
44 |
45 | return {
46 | ...(store as Store),
47 | open: openDialog,
48 | };
49 | }
50 |
--------------------------------------------------------------------------------
/frontend/composables/use-defer.ts:
--------------------------------------------------------------------------------
1 | type DeferFunction = (...args: TArgs) => TReturn;
2 |
3 | // useDefer is a function that takes a function and returns a function that
4 | // calls the original function and then calls the onComplete function.
5 | export function useDefer(
6 | onComplete: (...args: TArgs) => void,
7 | func: DeferFunction
8 | ): DeferFunction {
9 | return (...args: TArgs) => {
10 | let result: TReturn;
11 | try {
12 | result = func(...args);
13 | } finally {
14 | onComplete(...args);
15 | }
16 |
17 | return result;
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/composables/use-ids.ts:
--------------------------------------------------------------------------------
1 | function slugify(text: string) {
2 | return text
3 | .toString()
4 | .toLowerCase()
5 | .replace(/\s+/g, "-") // Replace spaces with -
6 | .replace(/[^\w-]+/g, "") // Remove all non-word chars
7 | .replace(/--+/g, "-") // Replace multiple - with single -
8 | .replace(/^-+/, "") // Trim - from start of text
9 | .replace(/-+$/, ""); // Trim - from end of text
10 | }
11 |
12 | function idGenerator(): string {
13 | const id = Math.random().toString(32).substring(2, 6) + Math.random().toString(36).substring(2, 6);
14 | return slugify(id);
15 | }
16 |
17 | /**
18 | * useFormIds uses the provided label to generate a unique id for the
19 | * form element. If no label is provided the id is generated using a
20 | * random string.
21 | */
22 | export function useFormIds(label: string): string {
23 | const slug = label ? slugify(label) : idGenerator();
24 | return `${slug}-${idGenerator()}`;
25 | }
26 |
27 | export function useId(): string {
28 | return idGenerator();
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/composables/use-item-search.ts:
--------------------------------------------------------------------------------
1 | import type { ItemSummary, LabelSummary, LocationSummary } from "~~/lib/api/types/data-contracts";
2 | import type { UserClient } from "~~/lib/api/user";
3 |
4 | type SearchOptions = {
5 | immediate?: boolean;
6 | };
7 |
8 | export function useItemSearch(client: UserClient, opts?: SearchOptions) {
9 | const query = ref("");
10 | const locations = ref([]);
11 | const labels = ref([]);
12 | const results = ref([]);
13 | const includeArchived = ref(false);
14 |
15 | watchDebounced(query, search, { debounce: 250, maxWait: 1000 });
16 | async function search() {
17 | const locIds = locations.value.map(l => l.id);
18 | const labelIds = labels.value.map(l => l.id);
19 |
20 | const { data, error } = await client.items.getAll({
21 | q: query.value,
22 | locations: locIds,
23 | labels: labelIds,
24 | includeArchived: includeArchived.value,
25 | });
26 | if (error) {
27 | return;
28 | }
29 | results.value = data.items;
30 | }
31 |
32 | if (opts?.immediate) {
33 | search();
34 | }
35 |
36 | return {
37 | query,
38 | results,
39 | locations,
40 | labels,
41 | };
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/composables/use-location-helpers.ts:
--------------------------------------------------------------------------------
1 | import type { Ref } from "vue";
2 | import type { TreeItem } from "~~/lib/api/types/data-contracts";
3 |
4 | export interface FlatTreeItem {
5 | id: string;
6 | name: string;
7 | treeString: string;
8 | }
9 |
10 | function flatTree(tree: TreeItem[]): FlatTreeItem[] {
11 | const v = [] as FlatTreeItem[];
12 |
13 | // turns the nested items into a flat items array where
14 | // the display is a string of the tree hierarchy separated by breadcrumbs
15 |
16 | function flatten(items: TreeItem[], display: string) {
17 | if (!items) {
18 | return;
19 | }
20 |
21 | for (const item of items) {
22 | v.push({
23 | id: item.id,
24 | name: item.name,
25 | treeString: display + item.name,
26 | });
27 | if (item.children) {
28 | flatten(item.children, display + item.name + " > ");
29 | }
30 | }
31 | }
32 |
33 | flatten(tree, "");
34 |
35 | return v;
36 | }
37 |
38 | export function useFlatLocations(): Ref {
39 | const locations = useLocationStore();
40 |
41 | if (locations.tree === null) {
42 | locations.refreshTree();
43 | }
44 |
45 | return computed(() => {
46 | if (locations.tree === null) {
47 | return [];
48 | }
49 |
50 | return flatTree(locations.tree);
51 | });
52 | }
53 |
--------------------------------------------------------------------------------
/frontend/composables/use-min-loader.ts:
--------------------------------------------------------------------------------
1 | import type { WritableComputedRef } from "vue";
2 |
3 | export function useMinLoader(ms = 500): WritableComputedRef {
4 | const loading = ref(false);
5 |
6 | const locked = ref(false);
7 |
8 | const minLoading = computed({
9 | get: () => loading.value,
10 | set: value => {
11 | if (value) {
12 | loading.value = true;
13 |
14 | if (!locked.value) {
15 | locked.value = true;
16 | setTimeout(() => {
17 | locked.value = false;
18 | }, ms);
19 | }
20 | }
21 |
22 | if (!value && !locked.value) {
23 | loading.value = false;
24 | } else if (!value && locked.value) {
25 | setTimeout(() => {
26 | loading.value = false;
27 | }, ms);
28 | }
29 | },
30 | });
31 | return minLoading;
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/composables/use-notifier.ts:
--------------------------------------------------------------------------------
1 | import { useId } from "./use-ids";
2 |
3 | interface Notification {
4 | id: string;
5 | message: string;
6 | type: "success" | "error" | "info";
7 | }
8 |
9 | const notifications = ref([]);
10 |
11 | function addNotification(notification: Notification) {
12 | notifications.value.unshift(notification);
13 |
14 | if (notifications.value.length > 4) {
15 | notifications.value.pop();
16 | } else {
17 | setTimeout(() => {
18 | // Remove notification with ID
19 | notifications.value = notifications.value.filter(n => n.id !== notification.id);
20 | }, 5000);
21 | }
22 | }
23 |
24 | export function useNotifications() {
25 | return {
26 | notifications,
27 | dropNotification: (idx: number) => notifications.value.splice(idx, 1),
28 | };
29 | }
30 |
31 | export function useNotifier() {
32 | return {
33 | success: (message: string) => {
34 | addNotification({
35 | id: useId(),
36 | message,
37 | type: "success",
38 | });
39 | },
40 | error: (message: string) => {
41 | addNotification({
42 | id: useId(),
43 | message,
44 | type: "error",
45 | });
46 | },
47 | info: (message: string) => {
48 | addNotification({
49 | id: useId(),
50 | message,
51 | type: "info",
52 | });
53 | },
54 | };
55 | }
56 |
--------------------------------------------------------------------------------
/frontend/composables/use-password-score.ts:
--------------------------------------------------------------------------------
1 | import type { ComputedRef, Ref } from "vue";
2 | import { scorePassword } from "~~/lib/passwords";
3 |
4 | export interface PasswordScore {
5 | score: ComputedRef;
6 | message: ComputedRef;
7 | isValid: ComputedRef;
8 | }
9 |
10 | export function usePasswordScore(pw: Ref, min = 30): PasswordScore {
11 | const score = computed(() => {
12 | return scorePassword(pw.value) || 0;
13 | });
14 |
15 | const message = computed(() => {
16 | if (score.value < 20) {
17 | return "Very weak";
18 | } else if (score.value < 40) {
19 | return "Weak";
20 | } else if (score.value < 60) {
21 | return "Good";
22 | } else if (score.value < 80) {
23 | return "Strong";
24 | }
25 | return "Very strong";
26 | });
27 |
28 | const isValid = computed(() => {
29 | return score.value >= min;
30 | });
31 |
32 | return {
33 | score,
34 | isValid,
35 | message,
36 | };
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/composables/use-preferences.ts:
--------------------------------------------------------------------------------
1 | import type { Ref } from "vue";
2 | import type { DaisyTheme } from "~~/lib/data/themes";
3 |
4 | export type ViewType = "table" | "card" | "tree";
5 |
6 | export type LocationViewPreferences = {
7 | showDetails: boolean;
8 | showEmpty: boolean;
9 | editorAdvancedView: boolean;
10 | itemDisplayView: ViewType;
11 | theme: DaisyTheme;
12 | };
13 |
14 | /**
15 | * useViewPreferences loads the view preferences from local storage and hydrates
16 | * them. These are reactive and will update the local storage when changed.
17 | */
18 | export function useViewPreferences(): Ref {
19 | const results = useLocalStorage(
20 | "homebox/preferences/location",
21 | {
22 | showDetails: true,
23 | showEmpty: true,
24 | editorAdvancedView: false,
25 | itemDisplayView: "card",
26 | theme: "homebox",
27 | },
28 | { mergeDefaults: true }
29 | );
30 |
31 | // casting is required because the type returned is removable, however since we
32 | // use `mergeDefaults` the result _should_ always be present.
33 | return results as unknown as Ref;
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/composables/use-theme.ts:
--------------------------------------------------------------------------------
1 | import type { ComputedRef } from "vue";
2 | import type { DaisyTheme } from "~~/lib/data/themes";
3 |
4 | export interface UseTheme {
5 | theme: ComputedRef;
6 | setTheme: (theme: DaisyTheme) => void;
7 | }
8 |
9 | const themeRef = ref("garden");
10 |
11 | export function useTheme(): UseTheme {
12 | const preferences = useViewPreferences();
13 | themeRef.value = preferences.value.theme;
14 |
15 | const setTheme = (newTheme: DaisyTheme) => {
16 | preferences.value.theme = newTheme;
17 |
18 | if (htmlEl) {
19 | htmlEl.value?.setAttribute("data-theme", newTheme);
20 | }
21 |
22 | themeRef.value = newTheme;
23 | };
24 |
25 | const htmlEl = ref();
26 |
27 | onMounted(() => {
28 | if (htmlEl.value) {
29 | return;
30 | }
31 |
32 | htmlEl.value = document.querySelector("html");
33 | });
34 |
35 | const theme = computed(() => {
36 | return themeRef.value;
37 | });
38 |
39 | return { theme, setTheme };
40 | }
41 |
42 | export function useIsDark() {
43 | const theme = useTheme();
44 |
45 | const darkthemes = [
46 | "synthwave",
47 | "retro",
48 | "cyberpunk",
49 | "valentine",
50 | "halloween",
51 | "forest",
52 | "aqua",
53 | "black",
54 | "luxury",
55 | "dracula",
56 | "business",
57 | "night",
58 | "coffee",
59 | ];
60 |
61 | return computed(() => {
62 | return darkthemes.includes(theme.theme.value);
63 | });
64 | }
65 |
--------------------------------------------------------------------------------
/frontend/composables/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 | import { maybeUrl } from "./utils";
3 |
4 | describe("maybeURL works as expected", () => {
5 | test("basic valid URL case", () => {
6 | const result = maybeUrl("https://example.com");
7 | expect(result.isUrl).toBe(true);
8 | expect(result.url).toBe("https://example.com");
9 | expect(result.text).toBe("Link");
10 | });
11 |
12 | test("special URL syntax", () => {
13 | const result = maybeUrl("[My Text](http://example.com)");
14 | expect(result.isUrl).toBe(true);
15 | expect(result.url).toBe("http://example.com");
16 | expect(result.text).toBe("My Text");
17 | });
18 |
19 | test("not a url", () => {
20 | const result = maybeUrl("not a url");
21 | expect(result.isUrl).toBe(false);
22 | expect(result.url).toBe("");
23 | expect(result.text).toBe("");
24 | });
25 |
26 | test("malformed special syntax", () => {
27 | const result = maybeUrl("[My Text(http://example.com)");
28 | expect(result.isUrl).toBe(false);
29 | expect(result.url).toBe("");
30 | expect(result.text).toBe("");
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/frontend/composables/utils.ts:
--------------------------------------------------------------------------------
1 | export function validDate(dt: Date | string | null | undefined): boolean {
2 | if (!dt) {
3 | return false;
4 | }
5 |
6 | // If it's a string, try to parse it
7 | if (typeof dt === "string") {
8 | if (dt.startsWith("0001")) {
9 | return false;
10 | }
11 |
12 | const parsed = new Date(dt);
13 | if (isNaN(parsed.getTime())) {
14 | return false;
15 | }
16 | }
17 |
18 | // If it's a date, check if it's valid
19 | if (dt instanceof Date) {
20 | if (dt.getFullYear() < 1000) {
21 | return false;
22 | }
23 | }
24 |
25 | return true;
26 | }
27 |
28 | export function fmtCurrency(value: number | string, currency = "USD", locale = "en-Us"): string {
29 | if (typeof value === "string") {
30 | value = parseFloat(value);
31 | }
32 |
33 | const formatter = new Intl.NumberFormat(locale, {
34 | style: "currency",
35 | currency,
36 | minimumFractionDigits: 2,
37 | });
38 | return formatter.format(value);
39 | }
40 |
41 | export type MaybeUrlResult = {
42 | isUrl: boolean;
43 | url: string;
44 | text: string;
45 | };
46 |
47 | export function maybeUrl(str: string): MaybeUrlResult {
48 | const result: MaybeUrlResult = {
49 | isUrl: str.startsWith("http://") || str.startsWith("https://"),
50 | url: "",
51 | text: "",
52 | };
53 |
54 | if (!result.isUrl && !str.startsWith("[")) {
55 | return result;
56 | }
57 |
58 | if (str.startsWith("[")) {
59 | const match = str.match(/\[(.*)\]\((.*)\)/);
60 | if (match && match.length === 3) {
61 | result.isUrl = true;
62 | result.text = match[1];
63 | result.url = match[2];
64 | }
65 | } else {
66 | result.url = str;
67 | result.text = "Link";
68 | }
69 |
70 | return result;
71 | }
72 |
--------------------------------------------------------------------------------
/frontend/global.d.ts:
--------------------------------------------------------------------------------
1 | ///
--------------------------------------------------------------------------------
/frontend/layouts/404.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/frontend/layouts/empty.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
--------------------------------------------------------------------------------
/frontend/lib/api/__test__/public.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from "vitest";
2 | import { factories } from "./factories";
3 |
4 | describe("[GET] /api/v1/status", () => {
5 | test("server should respond", async () => {
6 | const api = factories.client.public();
7 | const { response, data } = await api.status();
8 | expect(response.status).toBe(200);
9 | expect(data.health).toBe(true);
10 | });
11 | });
12 |
13 | describe("first time user workflow (register, login, join group)", () => {
14 | const api = factories.client.public();
15 | const userData = factories.user();
16 |
17 | test("user should be able to register", async () => {
18 | const { response } = await api.register(userData);
19 | expect(response.status).toBe(204);
20 | });
21 |
22 | test("user should be able to login", async () => {
23 | const { response, data } = await api.login(userData.email, userData.password);
24 | expect(response.status).toBe(200);
25 | expect(data.token).toBeTruthy();
26 |
27 | // Cleanup
28 | const userApi = factories.client.user(data.token);
29 | {
30 | const { response } = await userApi.user.delete();
31 | expect(response.status).toBe(204);
32 | }
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/frontend/lib/api/__test__/test-utils.ts:
--------------------------------------------------------------------------------
1 | import { beforeAll, expect } from "vitest";
2 | import { faker } from "@faker-js/faker";
3 | import type { UserClient } from "../user";
4 | import { factories } from "./factories";
5 |
6 | const cache = {
7 | token: "",
8 | };
9 |
10 | /*
11 | * Shared UserApi token for tests where the creation of a user is _not_ import
12 | * to the test. This is useful for tests that are testing the user API itself.
13 | */
14 | export async function sharedUserClient(): Promise {
15 | if (cache.token) {
16 | return factories.client.user(cache.token);
17 | }
18 | const testUser = {
19 | email: faker.internet.email(),
20 | name: faker.person.fullName(),
21 | password: faker.internet.password(),
22 | token: "",
23 | };
24 |
25 | const api = factories.client.public();
26 | const { response: tryLoginResp, data } = await api.login(testUser.email, testUser.password);
27 |
28 | if (tryLoginResp.status === 200) {
29 | cache.token = data.token;
30 | return factories.client.user(cache.token);
31 | }
32 |
33 | const { response: registerResp } = await api.register(testUser);
34 | expect(registerResp.status).toBe(204);
35 |
36 | const { response: loginResp, data: loginData } = await api.login(testUser.email, testUser.password);
37 | expect(loginResp.status).toBe(200);
38 |
39 | cache.token = loginData.token;
40 | return factories.client.user(data.token);
41 | }
42 |
43 | beforeAll(async () => {
44 | await sharedUserClient();
45 | });
46 |
--------------------------------------------------------------------------------
/frontend/lib/api/__test__/user/user.test.ts:
--------------------------------------------------------------------------------
1 | import { faker } from "@faker-js/faker";
2 | import { describe, expect, test } from "vitest";
3 | import { factories } from "../factories";
4 |
5 | describe("basic user workflows", () => {
6 | test("user should be able to change password", async () => {
7 | const { client, user } = await factories.client.singleUse();
8 | const password = faker.internet.password();
9 |
10 | // Change Password
11 | {
12 | const response = await client.user.changePassword(user.password, password);
13 | expect(response.error).toBeFalsy();
14 | expect(response.status).toBe(204);
15 | }
16 |
17 | // Ensure New Login is Valid
18 | {
19 | const pub = factories.client.public();
20 | const response = await pub.login(user.email, password);
21 | expect(response.error).toBeFalsy();
22 | expect(response.status).toBe(200);
23 | }
24 |
25 | await client.user.delete();
26 | }, 20000);
27 | });
28 |
--------------------------------------------------------------------------------
/frontend/lib/api/base/base-api.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "vitest";
2 | import { hasKey, parseDate } from "./base-api";
3 |
4 | describe("hasKey works as expected", () => {
5 | test("hasKey returns true if the key exists", () => {
6 | const obj = { createdAt: "2021-01-01" };
7 | expect(hasKey(obj, "createdAt")).toBe(true);
8 | });
9 |
10 | test("hasKey returns false if the key does not exist", () => {
11 | const obj = { createdAt: "2021-01-01" };
12 | expect(hasKey(obj, "updatedAt")).toBe(false);
13 | });
14 | });
15 |
16 | describe("parseDate should work as expected", () => {
17 | test("parseDate should set defaults", () => {
18 | const obj = { createdAt: "2021-01-01", updatedAt: "2021-01-01" };
19 | const result = parseDate(obj);
20 | expect(result.createdAt).toBeInstanceOf(Date);
21 | expect(result.updatedAt).toBeInstanceOf(Date);
22 | });
23 |
24 | test("parseDate should set passed in types", () => {
25 | const obj = { key1: "2021-01-01", key2: "2021-01-01" };
26 | const result = parseDate(obj, ["key1", "key2"]);
27 | expect(result.key1).toBeInstanceOf(Date);
28 | expect(result.key2).toBeInstanceOf(Date);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/frontend/lib/api/base/index.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { route } from ".";
3 |
4 | describe("UrlBuilder", () => {
5 | it("basic query parameter", () => {
6 | const result = route("/test", { a: "b" });
7 | expect(result).toBe("/api/v1/test?a=b");
8 | });
9 |
10 | it("multiple query parameters", () => {
11 | const result = route("/test", { a: "b", c: "d" });
12 | expect(result).toBe("/api/v1/test?a=b&c=d");
13 | });
14 |
15 | it("no query parameters", () => {
16 | const result = route("/test");
17 | expect(result).toBe("/api/v1/test");
18 | });
19 |
20 | it("list-like query parameters", () => {
21 | const result = route("/test", { a: ["b", "c"] });
22 | expect(result).toBe("/api/v1/test?a=b&a=c");
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/frontend/lib/api/base/index.ts:
--------------------------------------------------------------------------------
1 | export { BaseAPI } from "./base-api";
2 | export { route } from "./urls";
3 |
--------------------------------------------------------------------------------
/frontend/lib/api/base/urls.ts:
--------------------------------------------------------------------------------
1 | const parts = {
2 | host: "http://localhost.com",
3 | prefix: "/api/v1",
4 | };
5 |
6 | export function overrideParts(host: string, prefix: string) {
7 | parts.host = host;
8 | parts.prefix = prefix;
9 | }
10 |
11 | export type QueryValue = string | string[] | number | number[] | boolean | null | undefined;
12 |
13 | /**
14 | * route is the main URL builder for the API. It will use a predefined host and prefix (global)
15 | * in the urls.ts file and then append the passed-in path parameter using the `URL` class from the
16 | * browser. It will also append any query parameters passed in as the second parameter.
17 | *
18 | * The default host `http://localhost.com` is removed from the path if it is present. This allows us
19 | * to bootstrap the API with different hosts as needed (like for testing) but still allows us to use
20 | * relative URLs in production because the API and client bundle are served from the same server/host.
21 | */
22 | export function route(rest: string, params: Record = {}): string {
23 | const url = new URL(parts.prefix + rest, parts.host);
24 |
25 | for (const [key, value] of Object.entries(params)) {
26 | if (Array.isArray(value)) {
27 | for (const item of value) {
28 | url.searchParams.append(key, String(item));
29 | }
30 | } else {
31 | url.searchParams.append(key, String(value));
32 | }
33 | }
34 |
35 | return url.toString().replace("http://localhost.com", "");
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/lib/api/classes/actions.ts:
--------------------------------------------------------------------------------
1 | import { BaseAPI, route } from "../base";
2 | import type { ActionAmountResult } from "../types/data-contracts";
3 |
4 | export class ActionsAPI extends BaseAPI {
5 | ensureAssetIDs() {
6 | return this.http.post({
7 | url: route("/actions/ensure-asset-ids"),
8 | });
9 | }
10 |
11 | resetItemDateTimes() {
12 | return this.http.post({
13 | url: route("/actions/zero-item-time-fields"),
14 | });
15 | }
16 |
17 | ensureImportRefs() {
18 | return this.http.post({
19 | url: route("/actions/ensure-import-refs"),
20 | });
21 | }
22 |
23 | setPrimaryPhotos() {
24 | return this.http.post({
25 | url: route("/actions/set-primary-photos"),
26 | });
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/lib/api/classes/assets.ts:
--------------------------------------------------------------------------------
1 | import { BaseAPI, route } from "../base";
2 | import type { ItemSummary } from "../types/data-contracts";
3 | import type { PaginationResult } from "../types/non-generated";
4 |
5 | export class AssetsApi extends BaseAPI {
6 | async get(id: string, page = 1, pageSize = 50) {
7 | return await this.http.get>({
8 | url: route(`/assets/${id}`, { page, pageSize }),
9 | });
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/lib/api/classes/group.ts:
--------------------------------------------------------------------------------
1 | import { BaseAPI, route } from "../base";
2 | import type {
3 | CurrenciesCurrency,
4 | Group,
5 | GroupInvitation,
6 | GroupInvitationCreate,
7 | GroupUpdate,
8 | } from "../types/data-contracts";
9 |
10 | export class GroupApi extends BaseAPI {
11 | createInvitation(data: GroupInvitationCreate) {
12 | return this.http.post({
13 | url: route("/groups/invitations"),
14 | body: data,
15 | });
16 | }
17 |
18 | update(data: GroupUpdate) {
19 | return this.http.put({
20 | url: route("/groups"),
21 | body: data,
22 | });
23 | }
24 |
25 | get() {
26 | return this.http.get({
27 | url: route("/groups"),
28 | });
29 | }
30 |
31 | currencies() {
32 | return this.http.get({
33 | url: route("/currencies"),
34 | });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/lib/api/classes/labels.ts:
--------------------------------------------------------------------------------
1 | import { BaseAPI, route } from "../base";
2 | import type { LabelCreate, LabelOut } from "../types/data-contracts";
3 |
4 | export class LabelsApi extends BaseAPI {
5 | getAll() {
6 | return this.http.get({ url: route("/labels") });
7 | }
8 |
9 | create(body: LabelCreate) {
10 | return this.http.post({ url: route("/labels"), body });
11 | }
12 |
13 | get(id: string) {
14 | return this.http.get({ url: route(`/labels/${id}`) });
15 | }
16 |
17 | delete(id: string) {
18 | return this.http.delete({ url: route(`/labels/${id}`) });
19 | }
20 |
21 | update(id: string, body: LabelCreate) {
22 | return this.http.put({ url: route(`/labels/${id}`), body });
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/lib/api/classes/locations.ts:
--------------------------------------------------------------------------------
1 | import { BaseAPI, route } from "../base";
2 | import type { LocationOutCount, LocationCreate, LocationOut, LocationUpdate, TreeItem } from "../types/data-contracts";
3 |
4 | export type LocationsQuery = {
5 | filterChildren: boolean;
6 | };
7 |
8 | export type TreeQuery = {
9 | withItems: boolean;
10 | };
11 |
12 | export class LocationsApi extends BaseAPI {
13 | getAll(q: LocationsQuery = { filterChildren: false }) {
14 | return this.http.get({ url: route("/locations", q) });
15 | }
16 |
17 | getTree(tq = { withItems: false }) {
18 | return this.http.get({ url: route("/locations/tree", tq) });
19 | }
20 |
21 | create(body: LocationCreate) {
22 | return this.http.post({ url: route("/locations"), body });
23 | }
24 |
25 | get(id: string) {
26 | return this.http.get({ url: route(`/locations/${id}`) });
27 | }
28 |
29 | delete(id: string) {
30 | return this.http.delete({ url: route(`/locations/${id}`) });
31 | }
32 |
33 | update(id: string, body: LocationUpdate) {
34 | return this.http.put({ url: route(`/locations/${id}`), body });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/lib/api/classes/notifiers.ts:
--------------------------------------------------------------------------------
1 | import { BaseAPI, route } from "../base";
2 | import type { NotifierCreate, NotifierOut, NotifierUpdate } from "../types/data-contracts";
3 |
4 | export class NotifiersAPI extends BaseAPI {
5 | getAll() {
6 | return this.http.get({ url: route("/notifiers") });
7 | }
8 |
9 | create(body: NotifierCreate) {
10 | return this.http.post({ url: route("/notifiers"), body });
11 | }
12 |
13 | update(id: string, body: NotifierUpdate) {
14 | if (body.url === "") {
15 | body.url = null;
16 | }
17 |
18 | return this.http.put({ url: route(`/notifiers/${id}`), body });
19 | }
20 |
21 | delete(id: string) {
22 | return this.http.delete({ url: route(`/notifiers/${id}`) });
23 | }
24 |
25 | test(url: string) {
26 | return this.http.post<{ url: string }, null>({ url: route(`/notifiers/test`), body: { url } });
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/lib/api/classes/reports.ts:
--------------------------------------------------------------------------------
1 | import { BaseAPI, route } from "../base";
2 |
3 | export class ReportsAPI extends BaseAPI {
4 | billOfMaterialsURL(): string {
5 | return route("/reporting/bill-of-materials");
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/lib/api/classes/stats.ts:
--------------------------------------------------------------------------------
1 | import { BaseAPI, route } from "../base";
2 | import type { GroupStatistics, TotalsByOrganizer, ValueOverTime } from "../types/data-contracts";
3 |
4 | function YYYY_MM_DD(date?: Date): string {
5 | if (!date) {
6 | return "";
7 | }
8 | // with leading zeros
9 | const year = date.getFullYear();
10 | const month = (date.getMonth() + 1).toString().padStart(2, "0");
11 | const day = date.getDate().toString().padStart(2, "0");
12 | return `${year}-${month}-${day}`;
13 | }
14 | export class StatsAPI extends BaseAPI {
15 | totalPriceOverTime(start?: Date, end?: Date) {
16 | return this.http.get({
17 | url: route("/groups/statistics/purchase-price", { start: YYYY_MM_DD(start), end: YYYY_MM_DD(end) }),
18 | });
19 | }
20 |
21 | /**
22 | * Returns ths general statistics for the group. This mostly just
23 | * includes the totals for various group properties.
24 | */
25 | group() {
26 | return this.http.get({
27 | url: route("/groups/statistics"),
28 | });
29 | }
30 |
31 | labels() {
32 | return this.http.get({
33 | url: route("/groups/statistics/labels"),
34 | });
35 | }
36 |
37 | locations() {
38 | return this.http.get({
39 | url: route("/groups/statistics/locations"),
40 | });
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/lib/api/classes/users.ts:
--------------------------------------------------------------------------------
1 | import { BaseAPI, route } from "../base";
2 | import type { ChangePassword, UserOut } from "../types/data-contracts";
3 | import type { Result } from "../types/non-generated";
4 |
5 | export class UserApi extends BaseAPI {
6 | public self() {
7 | return this.http.get>({ url: route("/users/self") });
8 | }
9 |
10 | public logout() {
11 | return this.http.post