The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | <!--
 2 |   This template provides some ideas of things to include in your PR description.
 3 |   To start, try providing a short summary of your changes in the Title above.
 4 |   If a section of the PR template does not apply to this PR, then delete that section.
 5 |  -->
 6 | 
 7 | ## What type of PR is this?
 8 | 
 9 | _(REQUIRED)_
10 | 
11 | <!--
12 |   Delete any of the following that do not apply:
13 |  -->
14 | 
15 | - bug
16 | - cleanup
17 | - documentation
18 | - feature
19 | 
20 | ## What this PR does / why we need it:
21 | 
22 | _(REQUIRED)_
23 | 
24 | <!--
25 |   What goal is this change working towards?
26 |   Provide a bullet pointed summary of how each file was changed.
27 |   Briefly explain any decisions you made with respect to the changes.
28 |   Include anything here that you didn't include in *Release Notes*
29 |   above, such as changes to CI or changes to internal methods.
30 | -->
31 | 
32 | ## Which issue(s) this PR fixes:
33 | 
34 | _(REQUIRED)_
35 | 
36 | <!--
37 | If this PR fixes one of more issues, list them here.
38 | One line each, like so:
39 | Fixes #123
40 | Fixes #39
41 | -->
42 | 
43 | ## Special notes for your reviewer:
44 | 
45 | _(fill-in or delete this section)_
46 | 
47 | <!--
48 |    Is there any particular feedback you would / wouldn't like?
49 |    Which parts of the code should reviewers focus on?
50 | -->
51 | 
52 | ## Testing
53 | 
54 | _(fill-in or delete this section)_
55 | 
56 | <!--
57 |   Describe how you tested this change.
58 | -->
59 | 
60 | ## Release Notes
61 | 
62 | _(REQUIRED)_
63 | <!--
64 |   If this PR makes user facing changes, please describe them here. This
65 |   description will be copied into the release notes/changelog, whenever the
66 |   next version is released. Keep this section short, and focus on high level
67 |   changes.
68 |   Put your text between the block. To omit notes, use NONE within the block.
69 | -->
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 | <div align="center">
 2 |   <img src="/docs/docs/assets/img/lilbox.svg" height="200"/>
 3 | </div>
 4 | 
 5 | <h1 align="center" style="margin-top: -10px"> HomeBox </h1>
 6 | <p align="center" style="width: 100;">
 7 |    <a href="https://hay-kot.github.io/homebox/">Docs</a>
 8 |    |
 9 |    <a href="https://homebox.fly.dev">Demo</a>
10 |    |
11 |    <a href="https://discord.gg/tuncmNrE4z">Discord</a>
12 | </p>
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 | <!-- CONTRIBUTING -->
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 | <a href="https://www.buymeacoffee.com/haykot" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-green.png" alt="Buy Me A Coffee" style="height: 30px !important;width: 107px !important;" ></a>
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 <name>'")
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 | <template>
 2 |   <NuxtLayout>
 3 |     <Html lang="en" :data-theme="theme || 'homebox'" />
 4 |     <Link rel="icon" type="image/svg" href="/favicon.svg"></Link>
 5 |     <Link rel="apple-touch-icon" href="/apple-touch-icon.png" size="180x180" />
 6 |     <Link rel="mask-icon" href="/mask-icon.svg" color="#5b7f67" />
 7 |     <Meta name="theme-color" content="#5b7f67" />
 8 |     <Link rel="manifest" href="/manifest.webmanifest" />
 9 |     <NuxtPage />
10 |   </NuxtLayout>
11 | </template>
12 | 
13 | <script lang="ts" setup>
14 |   const { theme } = useTheme();
15 | </script>
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 | <template>
 2 |   <NuxtLink
 3 |     v-if="to"
 4 |     v-bind="attributes"
 5 |     ref="submitBtn"
 6 |     class="btn"
 7 |     :class="{
 8 |       loading: loading,
 9 |       'btn-sm': size === 'sm',
10 |       'btn-lg': size === 'lg',
11 |     }"
12 |     :style="upper ? '' : 'text-transform: none'"
13 |   >
14 |     <label v-if="$slots.icon" class="swap swap-rotate mr-2" :class="{ 'swap-active': isHover }">
15 |       <slot name="icon" />
16 |     </label>
17 |     <slot />
18 |   </NuxtLink>
19 |   <button
20 |     v-else
21 |     v-bind="attributes"
22 |     ref="submitBtn"
23 |     class="btn"
24 |     :class="{
25 |       loading: loading,
26 |       'btn-sm': size === 'sm',
27 |       'btn-lg': size === 'lg',
28 |     }"
29 |     :style="upper ? '' : 'text-transform: none'"
30 |   >
31 |     <label v-if="$slots.icon" class="swap swap-rotate mr-2" :class="{ 'swap-active': isHover }">
32 |       <slot name="icon" />
33 |     </label>
34 |     <slot />
35 |   </button>
36 | </template>
37 | 
38 | <script setup lang="ts">
39 |   type Sizes = "sm" | "md" | "lg";
40 | 
41 |   const props = defineProps({
42 |     upper: {
43 |       type: Boolean,
44 |       default: false,
45 |     },
46 |     loading: {
47 |       type: Boolean,
48 |       default: false,
49 |     },
50 |     disabled: {
51 |       type: Boolean,
52 |       default: false,
53 |     },
54 |     size: {
55 |       type: String as () => Sizes,
56 |       default: "md",
57 |     },
58 |     to: {
59 |       type: String as () => string | null,
60 |       default: null,
61 |     },
62 |   });
63 | 
64 |   const attributes = computed(() => {
65 |     if (props.to) {
66 |       return {
67 |         to: props.to,
68 |       };
69 |     }
70 |     return {
71 |       disabled: props.disabled || props.loading,
72 |     };
73 |   });
74 | 
75 |   const submitBtn = ref(null);
76 |   const isHover = useElementHover(submitBtn);
77 | </script>
78 | 


--------------------------------------------------------------------------------
/frontend/components/Base/Card.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div class="card bg-base-100 shadow-xl sm:rounded-lg">
 3 |     <div v-if="$slots.title" class="px-4 py-5 sm:px-6">
 4 |       <component :is="collapsable ? 'button' : 'div'" v-on="collapsable ? { click: toggle } : {}">
 5 |         <h3 class="text-lg font-medium leading-6 flex items-center">
 6 |           <slot name="title"></slot>
 7 |           <template v-if="collapsable">
 8 |             <span class="ml-2 swap swap-rotate" :class="`${collapsed ? 'swap-active' : ''}`">
 9 |               <MdiChevronRight class="h-6 w-6 swap-on" />
10 |               <MdiChevronDown class="h-6 w-6 swap-off" />
11 |             </span>
12 |           </template>
13 |         </h3>
14 |       </component>
15 |       <div>
16 |         <p v-if="$slots.subtitle" class="mt-1 max-w-2xl text-sm text-gray-500">
17 |           <slot name="subtitle"></slot>
18 |         </p>
19 |         <template v-if="$slots['title-actions']">
20 |           <slot name="title-actions"></slot>
21 |         </template>
22 |       </div>
23 |     </div>
24 |     <div
25 |       :class="{
26 |         'max-h-[9000px]': collapsable && !collapsed,
27 |         'max-h-0 overflow-hidden': collapsed,
28 |       }"
29 |       class="transition-[max-height] duration-200"
30 |     >
31 |       <slot />
32 |     </div>
33 |   </div>
34 | </template>
35 | 
36 | <script setup lang="ts">
37 |   import MdiChevronDown from "~icons/mdi/chevron-down";
38 |   import MdiChevronRight from "~icons/mdi/chevron-right";
39 | 
40 |   defineProps<{
41 |     collapsable?: boolean;
42 |   }>();
43 | 
44 |   function toggle() {
45 |     collapsed.value = !collapsed.value;
46 |   }
47 | 
48 |   const collapsed = ref(false);
49 | </script>
50 | 


--------------------------------------------------------------------------------
/frontend/components/Base/Container.vue:
--------------------------------------------------------------------------------
 1 | <script lang="ts" setup>
 2 |   defineProps({
 3 |     cmp: {
 4 |       type: String,
 5 |       default: "div",
 6 |     },
 7 |   });
 8 | </script>
 9 | 
10 | <template>
11 |   <component :is="cmp" class="container max-w-6xl mx-auto px-3">
12 |     <slot />
13 |   </component>
14 | </template>
15 | 


--------------------------------------------------------------------------------
/frontend/components/Base/Modal.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div class="z-[999]">
 3 |     <input :id="modalId" v-model="modal" type="checkbox" class="modal-toggle" />
 4 |     <div class="modal modal-bottom sm:modal-middle overflow-visible">
 5 |       <div class="modal-box overflow-visible relative">
 6 |         <button :for="modalId" class="btn btn-sm btn-circle absolute right-2 top-2" @click="close">✕</button>
 7 | 
 8 |         <h3 class="font-bold text-lg">
 9 |           <slot name="title"></slot>
10 |         </h3>
11 |         <slot> </slot>
12 |       </div>
13 |     </div>
14 |   </div>
15 | </template>
16 | 
17 | <script setup lang="ts">
18 |   const emit = defineEmits(["cancel", "update:modelValue"]);
19 |   const props = defineProps({
20 |     modelValue: {
21 |       type: Boolean,
22 |       required: true,
23 |     },
24 |     /**
25 |      * in readonly mode the modal only `emits` a "cancel" event to indicate
26 |      * that the modal was closed via the "x" button. The parent component is
27 |      * responsible for closing the modal.
28 |      */
29 |     readonly: {
30 |       type: Boolean,
31 |       default: false,
32 |     },
33 |   });
34 | 
35 |   function escClose(e: KeyboardEvent) {
36 |     if (e.key === "Escape") {
37 |       close();
38 |     }
39 |   }
40 | 
41 |   function close() {
42 |     if (props.readonly) {
43 |       emit("cancel");
44 |       return;
45 |     }
46 |     modal.value = false;
47 |   }
48 | 
49 |   const modalId = useId();
50 |   const modal = useVModel(props, "modelValue", emit);
51 | 
52 |   watchEffect(() => {
53 |     if (modal.value) {
54 |       document.addEventListener("keydown", escClose);
55 |     } else {
56 |       document.removeEventListener("keydown", escClose);
57 |     }
58 |   });
59 | </script>
60 | 


--------------------------------------------------------------------------------
/frontend/components/Base/SectionHeader.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div class="pb-3">
 3 |     <h3
 4 |       class="text-3xl font-bold tracking-tight flex items-center"
 5 |       :class="{
 6 |         'text-neutral-content': dark,
 7 |         'text-content': !dark,
 8 |       }"
 9 |     >
10 |       <slot />
11 |     </h3>
12 |     <p v-if="$slots.description" class="mt-2 max-w-4xl text-sm text-base-content">
13 |       <slot name="description" />
14 |     </p>
15 |     <div v-if="$slots.after">
16 |       <slot name="after" />
17 |     </div>
18 |   </div>
19 | </template>
20 | 
21 | <script lang="ts" setup>
22 |   defineProps({
23 |     dark: {
24 |       type: Boolean,
25 |       default: false,
26 |     },
27 |   });
28 | </script>
29 | 


--------------------------------------------------------------------------------
/frontend/components/DetailAction.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div class="grid grid-cols-1 md:grid-cols-4 gap-10 py-6">
 3 |     <div class="col-span-3">
 4 |       <h4 class="mb-1 text-lg font-semibold">
 5 |         <slot name="title"></slot>
 6 |       </h4>
 7 |       <p class="text-sm">
 8 |         <slot></slot>
 9 |       </p>
10 |     </div>
11 |     <BaseButton class="btn-primary mt-auto" @click="$emit('action')">
12 |       <slot name="button">
13 |         <slot name="title"></slot>
14 |       </slot>
15 |     </BaseButton>
16 |   </div>
17 | </template>
18 | 


--------------------------------------------------------------------------------
/frontend/components/Form/Checkbox.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div v-if="!inline" class="form-control w-full">
 3 |     <label class="label cursor-pointer">
 4 |       <input v-model="value" type="checkbox" class="checkbox checkbox-primary" />
 5 |       <span class="label-text"> {{ label }}</span>
 6 |     </label>
 7 |   </div>
 8 |   <div v-else class="label cursor-pointer sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
 9 |     <label>
10 |       <span class="label-text">
11 |         {{ label }}
12 |       </span>
13 |     </label>
14 |     <input v-model="value" type="checkbox" class="checkbox checkbox-primary" />
15 |   </div>
16 | </template>
17 | 
18 | <script setup lang="ts">
19 |   const props = defineProps({
20 |     modelValue: {
21 |       type: Boolean,
22 |       default: false,
23 |     },
24 |     inline: {
25 |       type: Boolean,
26 |       default: false,
27 |     },
28 |     label: {
29 |       type: String,
30 |       default: "",
31 |     },
32 |   });
33 | 
34 |   const value = useVModel(props, "modelValue");
35 | </script>
36 | 


--------------------------------------------------------------------------------
/frontend/components/Form/Password.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div class="relative">
 3 |     <FormTextField v-model="value" placeholder="Password" :label="label" :type="inputType"> </FormTextField>
 4 |     <button
 5 |       type="button"
 6 |       class="inline-flex p-1 ml-1 justify-center mt-auto mb-3 tooltip absolute top-11 right-3"
 7 |       data-tip="Toggle Password Show"
 8 |       @click="toggle()"
 9 |     >
10 |       <MdiEye name="mdi-eye" class="h-5 w-5" />
11 |     </button>
12 |   </div>
13 | </template>
14 | 
15 | <script setup lang="ts">
16 |   import MdiEye from "~icons/mdi/eye";
17 | 
18 |   type Props = {
19 |     modelValue: string;
20 |     placeholder?: string;
21 |     label: string;
22 |   };
23 | 
24 |   const props = withDefaults(defineProps<Props>(), {
25 |     placeholder: "Password",
26 |     label: "Password",
27 |   });
28 | 
29 |   const [hide, toggle] = useToggle(true);
30 | 
31 |   const inputType = computed(() => {
32 |     return hide.value ? "password" : "text";
33 |   });
34 | 
35 |   const value = useVModel(props, "modelValue");
36 | </script>
37 | 


--------------------------------------------------------------------------------
/frontend/components/Form/TextField.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div v-if="!inline" class="form-control w-full">
 3 |     <label class="label">
 4 |       <span class="label-text">{{ label }}</span>
 5 |     </label>
 6 |     <input ref="input" v-model="value" :placeholder="placeholder" :type="type" class="input input-bordered w-full" />
 7 |   </div>
 8 |   <div v-else class="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
 9 |     <label class="label">
10 |       <span class="label-text">{{ label }}</span>
11 |     </label>
12 |     <input v-model="value" :placeholder="placeholder" class="input input-bordered col-span-3 w-full mt-2" />
13 |   </div>
14 | </template>
15 | 
16 | <script lang="ts" setup>
17 |   const props = defineProps({
18 |     label: {
19 |       type: String,
20 |       default: "",
21 |     },
22 |     modelValue: {
23 |       type: [String, Number],
24 |       default: null,
25 |     },
26 |     type: {
27 |       type: String,
28 |       default: "text",
29 |     },
30 |     triggerFocus: {
31 |       type: Boolean,
32 |       default: null,
33 |     },
34 |     inline: {
35 |       type: Boolean,
36 |       default: false,
37 |     },
38 |     placeholder: {
39 |       type: String,
40 |       default: "",
41 |     },
42 |   });
43 | 
44 |   const input = ref<HTMLElement | null>(null);
45 | 
46 |   whenever(
47 |     () => props.triggerFocus,
48 |     () => {
49 |       if (input.value) {
50 |         input.value.focus();
51 |       }
52 |     }
53 |   );
54 | 
55 |   const value = useVModel(props, "modelValue");
56 | </script>
57 | 


--------------------------------------------------------------------------------
/frontend/components/Item/AttachmentsList.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
 3 |     <li
 4 |       v-for="attachment in attachments"
 5 |       :key="attachment.id"
 6 |       class="flex items-center justify-between py-3 pl-3 pr-4 text-sm"
 7 |     >
 8 |       <div class="flex w-0 flex-1 items-center">
 9 |         <MdiPaperclip class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
10 |         <span class="ml-2 w-0 flex-1 truncate"> {{ attachment.document.title }}</span>
11 |       </div>
12 |       <div class="ml-4 flex-shrink-0">
13 |         <a class="tooltip mr-2" data-tip="Download" :href="attachmentURL(attachment.id)" target="_blank">
14 |           <MdiDownload class="h-5 w-5" />
15 |         </a>
16 |         <a class="tooltip" data-tip="Open" :href="attachmentURL(attachment.id)" target="_blank">
17 |           <MdiOpenInNew class="h-5 w-5" />
18 |         </a>
19 |       </div>
20 |     </li>
21 |   </ul>
22 | </template>
23 | 
24 | <script setup lang="ts">
25 |   import type { ItemAttachment } from "~~/lib/api/types/data-contracts";
26 |   import MdiPaperclip from "~icons/mdi/paperclip";
27 |   import MdiDownload from "~icons/mdi/download";
28 |   import MdiOpenInNew from "~icons/mdi/open-in-new";
29 | 
30 |   const props = defineProps({
31 |     attachments: {
32 |       type: Object as () => ItemAttachment[],
33 |       required: true,
34 |     },
35 |     itemId: {
36 |       type: String,
37 |       required: true,
38 |     },
39 |   });
40 | 
41 |   const api = useUserApi();
42 | 
43 |   function attachmentURL(attachmentId: string) {
44 |     return api.authURL(`/items/${props.itemId}/attachments/${attachmentId}`);
45 |   }
46 | </script>
47 | 
48 | <style scoped></style>
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<string, any>;
11 | 


--------------------------------------------------------------------------------
/frontend/components/Label/Chip.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 |   import type { LabelOut, LabelSummary } from "~~/lib/api/types/data-contracts";
 3 |   import MdiArrowRight from "~icons/mdi/arrow-right";
 4 |   import MdiTagOutline from "~icons/mdi/tag-outline";
 5 | 
 6 |   export type sizes = "sm" | "md" | "lg" | "xl";
 7 |   defineProps({
 8 |     label: {
 9 |       type: Object as () => LabelOut | LabelSummary,
10 |       required: true,
11 |     },
12 |     size: {
13 |       type: String as () => sizes,
14 |       default: "md",
15 |     },
16 |   });
17 | 
18 |   const badge = ref(null);
19 |   const isHover = useElementHover(badge);
20 |   const { focused } = useFocus(badge);
21 | 
22 |   const isActive = computed(() => isHover.value || focused.value);
23 | </script>
24 | 
25 | <template>
26 |   <NuxtLink
27 |     ref="badge"
28 |     class="badge badge-secondary text-secondary-content"
29 |     :class="{
30 |       'badge-lg p-4': size === 'lg',
31 |       'p-3': size !== 'sm' && size !== 'lg',
32 |       'p-2 badge-sm': size === 'sm',
33 |     }"
34 |     :to="`/label/${label.id}`"
35 |   >
36 |     <label class="swap swap-rotate" :class="isActive ? 'swap-active' : ''">
37 |       <MdiArrowRight class="mr-2 swap-on" />
38 |       <MdiTagOutline class="mr-2 swap-off" />
39 |     </label>
40 |     {{ label.name }}
41 |   </NuxtLink>
42 | </template>
43 | 


--------------------------------------------------------------------------------
/frontend/components/Location/Card.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <NuxtLink
 3 |     ref="card"
 4 |     :to="`/location/${location.id}`"
 5 |     class="card bg-base-100 text-base-content rounded-md transition duration-300 shadow-md"
 6 |   >
 7 |     <div
 8 |       class="card-body"
 9 |       :class="{
10 |         'p-4': !dense,
11 |         'py-2 px-3': dense,
12 |       }"
13 |     >
14 |       <h2 class="flex items-center justify-between gap-2">
15 |         <label class="swap swap-rotate" :class="isActive ? 'swap-active' : ''">
16 |           <MdiArrowRight class="swap-on h-6 w-6" />
17 |           <MdiMapMarkerOutline class="swap-off h-6 w-6" />
18 |         </label>
19 |         <span class="mx-auto">
20 |           {{ location.name }}
21 |         </span>
22 |         <span class="badge badge-primary h-6 badge-lg" :class="{ 'opacity-0': !hasCount }">
23 |           {{ count }}
24 |         </span>
25 |       </h2>
26 |     </div>
27 |   </NuxtLink>
28 | </template>
29 | 
30 | <script lang="ts" setup>
31 |   import type { LocationOut, LocationOutCount, LocationSummary } from "~~/lib/api/types/data-contracts";
32 |   import MdiArrowRight from "~icons/mdi/arrow-right";
33 |   import MdiMapMarkerOutline from "~icons/mdi/map-marker-outline";
34 | 
35 |   const props = defineProps({
36 |     location: {
37 |       type: Object as () => LocationOutCount | LocationOut | LocationSummary,
38 |       required: true,
39 |     },
40 |     dense: {
41 |       type: Boolean,
42 |       default: false,
43 |     },
44 |   });
45 | 
46 |   const hasCount = computed(() => {
47 |     return !!(props.location as LocationOutCount).itemCount;
48 |   });
49 | 
50 |   const count = computed(() => {
51 |     if (hasCount.value) {
52 |       return (props.location as LocationOutCount).itemCount;
53 |     }
54 |   });
55 | 
56 |   const card = ref(null);
57 |   const isHover = useElementHover(card);
58 |   const { focused } = useFocus(card);
59 | 
60 |   const isActive = computed(() => isHover.value || focused.value);
61 | </script>
62 | 


--------------------------------------------------------------------------------
/frontend/components/Location/Tree/Root.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 |   import type { TreeItem } from "~~/lib/api/types/data-contracts";
 3 | 
 4 |   type Props = {
 5 |     locs: TreeItem[];
 6 |     treeId: string;
 7 |   };
 8 | 
 9 |   defineProps<Props>();
10 | </script>
11 | 
12 | <template>
13 |   <div class="p-4 border-2 root">
14 |     <LocationTreeNode v-for="item in locs" :key="item.id" :item="item" :tree-id="treeId" />
15 |   </div>
16 | </template>
17 | 
18 | <style></style>
19 | 


--------------------------------------------------------------------------------
/frontend/components/Location/Tree/tree-state.ts:
--------------------------------------------------------------------------------
 1 | import type { Ref } from "vue";
 2 | 
 3 | type TreeState = Record<string, boolean>;
 4 | 
 5 | const store: Record<string, Ref<TreeState>> = {};
 6 | 
 7 | export function newTreeKey(): string {
 8 |   return Math.random().toString(36).substring(2);
 9 | }
10 | 
11 | export function useTreeState(key: string): Ref<TreeState> {
12 |   if (!store[key]) {
13 |     store[key] = ref({});
14 |   }
15 | 
16 |   return store[key];
17 | }
18 | 


--------------------------------------------------------------------------------
/frontend/components/ModalConfirm.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <BaseModal v-model="isRevealed" readonly @cancel="cancel(false)">
 3 |     <template #title> Confirm </template>
 4 |     <div>
 5 |       <p>{{ text }}</p>
 6 |     </div>
 7 |     <div class="modal-action">
 8 |       <BaseButton type="submit" @click="confirm(true)"> Confirm </BaseButton>
 9 |     </div>
10 |   </BaseModal>
11 | </template>
12 | 
13 | <script setup lang="ts">
14 |   const { text, isRevealed, confirm, cancel } = useConfirm();
15 | </script>
16 | 


--------------------------------------------------------------------------------
/frontend/components/global/CopyText.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <button class="" @click="copyText">
 3 |     <label
 4 |       class="swap swap-rotate"
 5 |       :class="{
 6 |         'swap-active': copied,
 7 |       }"
 8 |     >
 9 |       <MdiContentCopy
10 |         class="swap-off"
11 |         :style="{
12 |           height: `${iconSize}px`,
13 |           width: `${iconSize}px`,
14 |         }"
15 |       />
16 |       <MdiClipboard
17 |         class="swap-on"
18 |         :style="{
19 |           height: `${iconSize}px`,
20 |           width: `${iconSize}px`,
21 |         }"
22 |       />
23 |     </label>
24 |   </button>
25 | </template>
26 | 
27 | <script setup lang="ts">
28 |   import MdiContentCopy from "~icons/mdi/content-copy";
29 |   import MdiClipboard from "~icons/mdi/clipboard";
30 | 
31 |   const props = defineProps({
32 |     text: {
33 |       type: String as () => string,
34 |       default: "",
35 |     },
36 |     iconSize: {
37 |       type: Number as () => number,
38 |       default: 20,
39 |     },
40 |   });
41 | 
42 |   const copied = ref(false);
43 | 
44 |   const { copy } = useClipboard();
45 | 
46 |   function copyText() {
47 |     copy(props.text);
48 |     copied.value = true;
49 | 
50 |     setTimeout(() => {
51 |       copied.value = false;
52 |     }, 1000);
53 |   }
54 | </script>
55 | 


--------------------------------------------------------------------------------
/frontend/components/global/Currency.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   {{ value }}
 3 | </template>
 4 | 
 5 | <script setup lang="ts">
 6 |   type Props = {
 7 |     amount: string | number;
 8 |   };
 9 | 
10 |   const props = defineProps<Props>();
11 | 
12 |   const fmt = await useFormatCurrency();
13 | 
14 |   const value = computed(() => {
15 |     if (!props.amount || props.amount === "0") {
16 |       return fmt(0);
17 |     }
18 | 
19 |     return fmt(props.amount);
20 |   });
21 | </script>
22 | 


--------------------------------------------------------------------------------
/frontend/components/global/DateTime.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   {{ value }}
 3 | </template>
 4 | 
 5 | <script setup lang="ts">
 6 |   import type { DateTimeFormat, DateTimeType } from "~~/composables/use-formatters";
 7 | 
 8 |   type Props = {
 9 |     date?: Date | string;
10 |     format?: DateTimeFormat;
11 |     datetimeType?: DateTimeType;
12 |   };
13 | 
14 |   const props = withDefaults(defineProps<Props>(), {
15 |     date: undefined,
16 |     format: "relative",
17 |     datetimeType: "date",
18 |   });
19 | 
20 |   const value = computed(() => {
21 |     if (!props.date || !validDate(props.date)) {
22 |       return "";
23 |     }
24 | 
25 |     return fmtDate(props.date, props.format);
26 |   });
27 | </script>
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<Detail | AnyDetail>;
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 | <template>
 2 |   <div
 3 |     ref="el"
 4 |     class="h-24 w-full border-2 border-primary border-dashed grid place-content-center"
 5 |     :class="isOverDropZone ? 'bg-primary bg-opacity-10' : ''"
 6 |   >
 7 |     <slot />
 8 |   </div>
 9 | </template>
10 | 
11 | <script setup lang="ts">
12 |   defineProps({
13 |     modelValue: {
14 |       type: Boolean,
15 |       required: false,
16 |     },
17 |   });
18 | 
19 |   const emit = defineEmits(["update:modelValue", "drop"]);
20 | 
21 |   const el = ref<HTMLDivElement>();
22 |   const { isOverDropZone } = useDropZone(el, files => {
23 |     emit("drop", files);
24 |   });
25 | 
26 |   watch(isOverDropZone, () => {
27 |     emit("update:modelValue", isOverDropZone.value);
28 |   });
29 | </script>
30 | 
31 | <style scoped></style>
32 | 


--------------------------------------------------------------------------------
/frontend/components/global/Markdown.vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 |   import MarkdownIt from "markdown-it";
 3 |   import DOMPurify from "dompurify";
 4 | 
 5 |   type Props = {
 6 |     source: string | null | undefined;
 7 |   };
 8 | 
 9 |   const props = withDefaults(defineProps<Props>(), {
10 |     source: null,
11 |   });
12 | 
13 |   const md = new MarkdownIt({
14 |     breaks: true,
15 |     html: true,
16 |     linkify: true,
17 |     typographer: true,
18 |   });
19 | 
20 |   const raw = computed(() => {
21 |     const html = md.render(props.source || "");
22 |     return DOMPurify.sanitize(html);
23 |   });
24 | </script>
25 | 
26 | <template>
27 |   <div class="markdown" v-html="raw"></div>
28 | </template>
29 | 
30 | <style scoped>
31 |   * {
32 |     --y-gap: 0.65rem;
33 |   }
34 | 
35 |   .markdown > :first-child {
36 |     margin-top: 0px !important;
37 |   }
38 | 
39 |   .markdown :where(p, ul, ol, dl, blockquote, h1, h2, h3, h4, h5, h6) {
40 |     margin-top: var(--y-gap);
41 |     margin-bottom: var(--y-gap);
42 |   }
43 | 
44 |   .markdown :where(ul) {
45 |     list-style: disc;
46 |     margin-left: 2rem;
47 |   }
48 | 
49 |   .markdown :where(ol) {
50 |     list-style: decimal;
51 |     margin-left: 2rem;
52 |   }
53 |   /* Heading Styles */
54 |   .markdown :where(h1) {
55 |     font-size: 2rem;
56 |     font-weight: 700;
57 |   }
58 | 
59 |   .markdown :where(h2) {
60 |     font-size: 1.5rem;
61 |     font-weight: 700;
62 |   }
63 | 
64 |   .markdown :where(h3) {
65 |     font-size: 1.25rem;
66 |     font-weight: 700;
67 |   }
68 | 
69 |   .markdown :where(h4) {
70 |     font-size: 1rem;
71 |     font-weight: 700;
72 |   }
73 | 
74 |   .markdown :where(h5) {
75 |     font-size: 0.875rem;
76 |     font-weight: 700;
77 |   }
78 | 
79 |   .markdown :where(h6) {
80 |     font-size: 0.75rem;
81 |     font-weight: 700;
82 |   }
83 | </style>
84 | 


--------------------------------------------------------------------------------
/frontend/components/global/PageQRCode.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div class="dropdown dropdown-left">
 3 |     <slot>
 4 |       <label tabindex="0" class="btn btn-circle btn-sm">
 5 |         <MdiQrcode />
 6 |       </label>
 7 |     </slot>
 8 |     <div tabindex="0" class="card compact dropdown-content shadow-lg bg-base-100 rounded-box w-64">
 9 |       <div class="card-body">
10 |         <h2 class="text-center">Page URL</h2>
11 |         <img :src="getQRCodeUrl()" />
12 |       </div>
13 |     </div>
14 |   </div>
15 | </template>
16 | 
17 | <script setup lang="ts">
18 |   import { route } from "../../lib/api/base";
19 |   import MdiQrcode from "~icons/mdi/qrcode";
20 | 
21 |   function getQRCodeUrl(): string {
22 |     const currentURL = window.location.href;
23 | 
24 |     return route(`/qrcode`, { data: encodeURIComponent(currentURL) });
25 |   }
26 | </script>
27 | 
28 | <style lang="scss" scoped></style>
29 | 


--------------------------------------------------------------------------------
/frontend/components/global/PasswordScore.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div class="py-4">
 3 |     <p class="text-sm">Password Strength: {{ message }}</p>
 4 |     <progress
 5 |       class="progress w-full progress-bar"
 6 |       :value="score"
 7 |       max="100"
 8 |       :class="{
 9 |         'progress-success': score > 50,
10 |         'progress-warning': score > 25 && score < 50,
11 |         'progress-error': score < 25,
12 |       }"
13 |     />
14 |   </div>
15 | </template>
16 | 
17 | <script setup lang="ts">
18 |   const props = defineProps({
19 |     password: {
20 |       type: String,
21 |       required: true,
22 |     },
23 |     valid: {
24 |       type: Boolean,
25 |       required: false,
26 |     },
27 |   });
28 | 
29 |   const emits = defineEmits(["update:valid"]);
30 | 
31 |   const { password } = toRefs(props);
32 | 
33 |   const { score, message, isValid } = usePasswordScore(password);
34 | 
35 |   watchEffect(() => {
36 |     emits("update:valid", isValid.value);
37 |   });
38 | </script>
39 | 
40 | <style scoped></style>
41 | 


--------------------------------------------------------------------------------
/frontend/components/global/Spacer.vue:
--------------------------------------------------------------------------------
1 | <template>
2 |   <div class="grow-1 max-w-full"></div>
3 | </template>
4 | 


--------------------------------------------------------------------------------
/frontend/components/global/StatCard/StatCard.vue:
--------------------------------------------------------------------------------
 1 | <template>
 2 |   <div class="stats bg-neutral shadow rounded-md">
 3 |     <div class="stat text-neutral-content text-center space-y-1 p-3">
 4 |       <div class="stat-title">{{ title }}</div>
 5 |       <div class="stat-value text-2xl">
 6 |         <Currency v-if="type === 'currency'" :amount="value" />
 7 |         <template v-if="type === 'number'">{{ value }}</template>
 8 |       </div>
 9 |       <div v-if="subtitle" class="stat-desc">{{ subtitle }}</div>
10 |     </div>
11 |   </div>
12 | </template>
13 | 
14 | <script setup lang="ts">
15 |   import type { StatsFormat } from "./types";
16 | 
17 |   type Props = {
18 |     title: string;
19 |     value: number;
20 |     subtitle?: string;
21 |     type?: StatsFormat;
22 |   };
23 | 
24 |   withDefaults(defineProps<Props>(), {
25 |     type: "number",
26 |     subtitle: undefined,
27 |   });
28 | </script>
29 | 


--------------------------------------------------------------------------------
/frontend/components/global/StatCard/types.ts:
--------------------------------------------------------------------------------
1 | export type StatsFormat = "currency" | "number" | "percent";
2 | 


--------------------------------------------------------------------------------
/frontend/components/global/Subtitle.vue:
--------------------------------------------------------------------------------
1 | <template>
2 |   <h3 class="flex gap-2 items-center mb-3 pl-1 text-lg">
3 |     <slot />
4 |   </h3>
5 | </template>
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<string, any>;
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<string, Observer> = {};
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<any, boolean, boolean> & {
 5 |   text: Ref<string>;
 6 |   setup: boolean;
 7 |   open: (text: string) => Promise<UseConfirmDialogRevealResult<boolean, boolean>>;
 8 | };
 9 | 
10 | const store: Partial<Store> = {
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<any, boolean, boolean>();
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<UseConfirmDialogRevealResult<boolean, boolean>> {
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<TArgs extends any[], TReturn> = (...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<TArgs extends any[], TReturn>(
 6 |   onComplete: (...args: TArgs) => void,
 7 |   func: DeferFunction<TArgs, TReturn>
 8 | ): DeferFunction<TArgs, TReturn> {
 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<LocationSummary[]>([]);
11 |   const labels = ref<LabelSummary[]>([]);
12 |   const results = ref<ItemSummary[]>([]);
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<FlatTreeItem[]> {
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<boolean> {
 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<Notification[]>([]);
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<number>;
 6 |   message: ComputedRef<string>;
 7 |   isValid: ComputedRef<boolean>;
 8 | }
 9 | 
10 | export function usePasswordScore(pw: Ref<string>, 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<LocationViewPreferences> {
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<LocationViewPreferences>;
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<DaisyTheme>;
 6 |   setTheme: (theme: DaisyTheme) => void;
 7 | }
 8 | 
 9 | const themeRef = ref<DaisyTheme>("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<HTMLElement | null>();
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 | /// <reference types="unplugin-icons/types/vue" />


--------------------------------------------------------------------------------
/frontend/layouts/404.vue:
--------------------------------------------------------------------------------
1 | <template>
2 |   <main class="w-full min-h-screen bg-blue-100 grid place-items-center">
3 |     <slot></slot>
4 |   </main>
5 | </template>
6 | 


--------------------------------------------------------------------------------
/frontend/layouts/empty.vue:
--------------------------------------------------------------------------------
1 | <script setup lang="ts"></script>
2 | <template>
3 |   <div>
4 |     <AppToast />
5 |     <slot />
6 |   </div>
7 | </template>
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<UserClient> {
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, QueryValue> = {}): 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<void, ActionAmountResult>({
 7 |       url: route("/actions/ensure-asset-ids"),
 8 |     });
 9 |   }
10 | 
11 |   resetItemDateTimes() {
12 |     return this.http.post<void, ActionAmountResult>({
13 |       url: route("/actions/zero-item-time-fields"),
14 |     });
15 |   }
16 | 
17 |   ensureImportRefs() {
18 |     return this.http.post<void, ActionAmountResult>({
19 |       url: route("/actions/ensure-import-refs"),
20 |     });
21 |   }
22 | 
23 |   setPrimaryPhotos() {
24 |     return this.http.post<void, ActionAmountResult>({
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<PaginationResult<ItemSummary>>({
 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<GroupInvitationCreate, GroupInvitation>({
13 |       url: route("/groups/invitations"),
14 |       body: data,
15 |     });
16 |   }
17 | 
18 |   update(data: GroupUpdate) {
19 |     return this.http.put<GroupUpdate, Group>({
20 |       url: route("/groups"),
21 |       body: data,
22 |     });
23 |   }
24 | 
25 |   get() {
26 |     return this.http.get<Group>({
27 |       url: route("/groups"),
28 |     });
29 |   }
30 | 
31 |   currencies() {
32 |     return this.http.get<CurrenciesCurrency[]>({
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<LabelOut[]>({ url: route("/labels") });
 7 |   }
 8 | 
 9 |   create(body: LabelCreate) {
10 |     return this.http.post<LabelCreate, LabelOut>({ url: route("/labels"), body });
11 |   }
12 | 
13 |   get(id: string) {
14 |     return this.http.get<LabelOut>({ url: route(`/labels/${id}`) });
15 |   }
16 | 
17 |   delete(id: string) {
18 |     return this.http.delete<void>({ url: route(`/labels/${id}`) });
19 |   }
20 | 
21 |   update(id: string, body: LabelCreate) {
22 |     return this.http.put<LabelCreate, LabelOut>({ 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<LocationOutCount[]>({ url: route("/locations", q) });
15 |   }
16 | 
17 |   getTree(tq = { withItems: false }) {
18 |     return this.http.get<TreeItem[]>({ url: route("/locations/tree", tq) });
19 |   }
20 | 
21 |   create(body: LocationCreate) {
22 |     return this.http.post<LocationCreate, LocationOut>({ url: route("/locations"), body });
23 |   }
24 | 
25 |   get(id: string) {
26 |     return this.http.get<LocationOut>({ url: route(`/locations/${id}`) });
27 |   }
28 | 
29 |   delete(id: string) {
30 |     return this.http.delete<void>({ url: route(`/locations/${id}`) });
31 |   }
32 | 
33 |   update(id: string, body: LocationUpdate) {
34 |     return this.http.put<LocationUpdate, LocationOut>({ 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<NotifierOut[]>({ url: route("/notifiers") });
 7 |   }
 8 | 
 9 |   create(body: NotifierCreate) {
10 |     return this.http.post<NotifierCreate, NotifierOut>({ 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<NotifierUpdate, NotifierOut>({ url: route(`/notifiers/${id}`), body });
19 |   }
20 | 
21 |   delete(id: string) {
22 |     return this.http.delete<void>({ 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<ValueOverTime>({
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<GroupStatistics>({
27 |       url: route("/groups/statistics"),
28 |     });
29 |   }
30 | 
31 |   labels() {
32 |     return this.http.get<TotalsByOrganizer[]>({
33 |       url: route("/groups/statistics/labels"),
34 |     });
35 |   }
36 | 
37 |   locations() {
38 |     return this.http.get<TotalsByOrganizer[]>({
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<Result<UserOut>>({ url: route("/users/self") });
 8 |   }
 9 | 
10 |   public logout() {
11 |     return this.http.post<object, void>({ url: route("/users/logout") });
12 |   }
13 | 
14 |   public delete() {
15 |     return this.http.delete<void>({ url: route("/users/self") });
16 |   }
17 | 
18 |   public changePassword(current: string, newPassword: string) {
19 |     return this.http.put<ChangePassword, void>({
20 |       url: route("/users/self/change-password"),
21 |       body: {
22 |         current,
23 |         new: newPassword,
24 |       },
25 |     });
26 |   }
27 | }
28 | 


--------------------------------------------------------------------------------
/frontend/lib/api/public.ts:
--------------------------------------------------------------------------------
 1 | import { BaseAPI, route } from "./base";
 2 | import type { APISummary, LoginForm, TokenResponse, UserRegistration } from "./types/data-contracts";
 3 | 
 4 | export type StatusResult = {
 5 |   health: boolean;
 6 |   versions: string[];
 7 |   title: string;
 8 |   message: string;
 9 | };
10 | 
11 | export class PublicApi extends BaseAPI {
12 |   public status() {
13 |     return this.http.get<APISummary>({ url: route("/status") });
14 |   }
15 | 
16 |   public login(username: string, password: string, stayLoggedIn = false) {
17 |     return this.http.post<LoginForm, TokenResponse>({
18 |       url: route("/users/login"),
19 |       body: {
20 |         username,
21 |         password,
22 |         stayLoggedIn,
23 |       },
24 |     });
25 |   }
26 | 
27 |   public register(body: UserRegistration) {
28 |     return this.http.post<UserRegistration, TokenResponse>({ url: route("/users/register"), body });
29 |   }
30 | }
31 | 


--------------------------------------------------------------------------------
/frontend/lib/api/types/non-generated.ts:
--------------------------------------------------------------------------------
 1 | export enum AttachmentTypes {
 2 |   Photo = "photo",
 3 |   Manual = "manual",
 4 |   Warranty = "warranty",
 5 |   Attachment = "attachment",
 6 |   Receipt = "receipt",
 7 | }
 8 | 
 9 | export type Result<T> = {
10 |   item: T;
11 | };
12 | 
13 | export interface PaginationResult<T> {
14 |   items: T[];
15 |   page: number;
16 |   pageSize: number;
17 |   total: number;
18 | }
19 | 


--------------------------------------------------------------------------------
/frontend/lib/api/user.ts:
--------------------------------------------------------------------------------
 1 | import { BaseAPI } from "./base";
 2 | import { ItemsApi } from "./classes/items";
 3 | import { LabelsApi } from "./classes/labels";
 4 | import { LocationsApi } from "./classes/locations";
 5 | import { GroupApi } from "./classes/group";
 6 | import { UserApi } from "./classes/users";
 7 | import { ActionsAPI } from "./classes/actions";
 8 | import { StatsAPI } from "./classes/stats";
 9 | import { AssetsApi } from "./classes/assets";
10 | import { ReportsAPI } from "./classes/reports";
11 | import { NotifiersAPI } from "./classes/notifiers";
12 | import type { Requests } from "~~/lib/requests";
13 | 
14 | export class UserClient extends BaseAPI {
15 |   locations: LocationsApi;
16 |   labels: LabelsApi;
17 |   items: ItemsApi;
18 |   group: GroupApi;
19 |   user: UserApi;
20 |   actions: ActionsAPI;
21 |   stats: StatsAPI;
22 |   assets: AssetsApi;
23 |   reports: ReportsAPI;
24 |   notifiers: NotifiersAPI;
25 | 
26 |   constructor(requests: Requests, attachmentToken: string) {
27 |     super(requests, attachmentToken);
28 | 
29 |     this.locations = new LocationsApi(requests);
30 |     this.labels = new LabelsApi(requests);
31 |     this.items = new ItemsApi(requests, attachmentToken);
32 |     this.group = new GroupApi(requests);
33 |     this.user = new UserApi(requests);
34 |     this.actions = new ActionsAPI(requests);
35 |     this.stats = new StatsAPI(requests);
36 |     this.assets = new AssetsApi(requests);
37 |     this.reports = new ReportsAPI(requests);
38 |     this.notifiers = new NotifiersAPI(requests);
39 | 
40 |     Object.freeze(this);
41 |   }
42 | }
43 | 


--------------------------------------------------------------------------------
/frontend/lib/datelib/datelib.test.ts:
--------------------------------------------------------------------------------
 1 | import { describe, test, expect } from "vitest";
 2 | import { format, zeroTime, factorRange, parse } from "./datelib";
 3 | 
 4 | describe("format", () => {
 5 |   test("should format a date as a string", () => {
 6 |     const date = new Date(2020, 1, 1);
 7 |     expect(format(date)).toBe("2020-02-01");
 8 |   });
 9 | 
10 |   test("should return the string if a string is passed in", () => {
11 |     expect(format("2020-02-01")).toBe("2020-02-01");
12 |   });
13 | });
14 | 
15 | describe("zeroTime", () => {
16 |   test("should zero out the time", () => {
17 |     const date = new Date(2020, 1, 1, 12, 30, 30);
18 |     const zeroed = zeroTime(date);
19 |     expect(zeroed.getHours()).toBe(0);
20 |     expect(zeroed.getMinutes()).toBe(0);
21 |     expect(zeroed.getSeconds()).toBe(0);
22 |   });
23 | });
24 | 
25 | describe("factorRange", () => {
26 |   test("should return a range of dates", () => {
27 |     const [start, end] = factorRange(10);
28 |     // Start should be today
29 |     expect(start).toBeInstanceOf(Date);
30 |     expect(start.getFullYear()).toBe(new Date().getFullYear());
31 | 
32 |     // End should be 10 days from now
33 |     expect(end).toBeInstanceOf(Date);
34 |     expect(end.getFullYear()).toBe(new Date().getFullYear());
35 |   });
36 | });
37 | 
38 | describe("parse", () => {
39 |   test("should parse a date string", () => {
40 |     const date = parse("2020-02-01");
41 |     expect(date).toBeInstanceOf(Date);
42 |   });
43 | });
44 | 


--------------------------------------------------------------------------------
/frontend/lib/datelib/datelib.ts:
--------------------------------------------------------------------------------
 1 | import { addDays } from "date-fns";
 2 | 
 3 | /*
 4 |  * Formats a date as a string
 5 |  * */
 6 | export function format(date: Date | string): string {
 7 |   if (typeof date === "string") {
 8 |     return date;
 9 |   }
10 |   return date.toISOString().split("T")[0];
11 | }
12 | 
13 | export function zeroTime(date: Date): Date {
14 |   return new Date(date.getFullYear(), date.getMonth(), date.getDate());
15 | }
16 | 
17 | export function factorRange(offset: number = 7): [Date, Date] {
18 |   const date = zeroTime(new Date());
19 | 
20 |   return [date, addDays(date, offset)];
21 | }
22 | 
23 | export function factory(offset = 0): Date {
24 |   if (offset) {
25 |     return addDays(zeroTime(new Date()), offset);
26 |   }
27 | 
28 |   return zeroTime(new Date());
29 | }
30 | 
31 | export function parse(yyyyMMdd: string): Date {
32 |   const parts = yyyyMMdd.split("-");
33 |   return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
34 | }
35 | 


--------------------------------------------------------------------------------
/frontend/lib/passwords/index.test.ts:
--------------------------------------------------------------------------------
 1 | import { describe, test, expect } from "vitest";
 2 | import { scorePassword } from ".";
 3 | 
 4 | describe("scorePassword tests", () => {
 5 |   test("flagged words should return negative number", () => {
 6 |     const flaggedWords = ["password", "homebox", "admin", "qwerty", "login"];
 7 | 
 8 |     for (const word of flaggedWords) {
 9 |       expect(scorePassword(word)).toBe(0);
10 |     }
11 |   });
12 | 
13 |   test("should return 0 for empty string", () => {
14 |     expect(scorePassword("")).toBe(0);
15 |   });
16 | 
17 |   test("should return 0 for strings less than 6", () => {
18 |     expect(scorePassword("12345")).toBe(0);
19 |   });
20 | 
21 |   test("should return positive number for long string", () => {
22 |     const result = expect(scorePassword("123456"));
23 |     result.toBeGreaterThan(0);
24 |     result.toBeLessThan(31);
25 |   });
26 | 
27 |   test("should return max number for long string with all variations", () => {
28 |     expect(scorePassword("3bYWcfYOwqxljqeOmQXTLlBwkrH6HV")).toBe(100);
29 |   });
30 | });
31 | 


--------------------------------------------------------------------------------
/frontend/lib/passwords/index.ts:
--------------------------------------------------------------------------------
 1 | const flaggedWords = ["password", "homebox", "admin", "qwerty", "login"];
 2 | 
 3 | /**
 4 |  * scorePassword returns a score for a given password between 0 and 100.
 5 |  * if a password contains a flagged word, it returns 0.
 6 |  * @param pass
 7 |  * @returns
 8 |  */
 9 | export function scorePassword(pass: string): number {
10 |   let score = 0;
11 |   if (!pass) return score;
12 | 
13 |   if (pass.length < 6) return score;
14 | 
15 |   // Check for flagged words
16 |   for (const word of flaggedWords) {
17 |     if (pass.toLowerCase().includes(word)) {
18 |       return 0;
19 |     }
20 |   }
21 | 
22 |   // award every unique letter until 5 repetitions
23 |   const letters: { [key: string]: number } = {};
24 | 
25 |   for (let i = 0; i < pass.length; i++) {
26 |     letters[pass[i]] = (letters[pass[i]] || 0) + 1;
27 |     score += 5.0 / letters[pass[i]];
28 |   }
29 | 
30 |   // bonus points for mixing it up
31 |   const variations: { [key: string]: boolean } = {
32 |     digits: /\d/.test(pass),
33 |     lower: /[a-z]/.test(pass),
34 |     upper: /[A-Z]/.test(pass),
35 |     nonWords: /\W/.test(pass),
36 |   };
37 | 
38 |   let variationCount = 0;
39 |   for (const check in variations) {
40 |     variationCount += variations[check] === true ? 1 : 0;
41 |   }
42 |   score += (variationCount - 1) * 10;
43 | 
44 |   return Math.max(Math.min(score, 100), 0);
45 | }
46 | 


--------------------------------------------------------------------------------
/frontend/lib/requests/index.ts:
--------------------------------------------------------------------------------
1 | export { Requests, type TResponse } from "./requests";
2 | 


--------------------------------------------------------------------------------
/frontend/lib/strings/index.ts:
--------------------------------------------------------------------------------
 1 | export function titlecase(str: string) {
 2 |   return str
 3 |     .split(" ")
 4 |     .map(word => word[0].toUpperCase() + word.slice(1))
 5 |     .join(" ");
 6 | }
 7 | 
 8 | export function capitalize(str: string) {
 9 |   return str[0].toUpperCase() + str.slice(1);
10 | }
11 | 
12 | export function truncate(str: string, length: number) {
13 |   return str.length > length ? str.substring(0, length) + "..." : str;
14 | }
15 | 


--------------------------------------------------------------------------------
/frontend/middleware/auth.ts:
--------------------------------------------------------------------------------
 1 | export default defineNuxtRouteMiddleware(async () => {
 2 |   const ctx = useAuthContext();
 3 |   const api = useUserApi();
 4 | 
 5 |   if (!ctx.isAuthorized()) {
 6 |     if (window.location.pathname !== "/") {
 7 |       console.debug("[middleware/auth] isAuthorized returned false, redirecting to /");
 8 |       return navigateTo("/");
 9 |     }
10 |   }
11 | 
12 |   if (!ctx.user) {
13 |     console.log("Fetching user data");
14 |     const { data, error } = await api.user.self();
15 |     if (error) {
16 |       if (window.location.pathname !== "/") {
17 |         console.debug("[middleware/user] user is null and fetch failed, redirecting to /");
18 |         return navigateTo("/");
19 |       }
20 |     }
21 | 
22 |     ctx.user = data.item;
23 |   }
24 | });
25 | 


--------------------------------------------------------------------------------
/frontend/nuxt.config.ts:
--------------------------------------------------------------------------------
 1 | import { defineNuxtConfig } from "nuxt/config";
 2 | 
 3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config
 4 | export default defineNuxtConfig({
 5 |   ssr: false,
 6 |   modules: [
 7 |     "@nuxtjs/tailwindcss",
 8 |     "@pinia/nuxt",
 9 |     "@vueuse/nuxt",
10 |     "@vite-pwa/nuxt",
11 |     "./nuxt.proxyoverride.ts",
12 |     "unplugin-icons/nuxt",
13 |   ],
14 |   nitro: {
15 |     devProxy: {
16 |       "/api": {
17 |         target: "http://localhost:7745/api",
18 |         ws: true,
19 |         changeOrigin: true,
20 |       },
21 |     },
22 |   },
23 |   css: ["@/assets/css/main.css"],
24 |   pwa: {
25 |     workbox: {
26 |       navigateFallbackDenylist: [/^\/api/],
27 |     },
28 |     injectRegister: "script",
29 |     injectManifest: {
30 |       swSrc: "sw.js",
31 |     },
32 |     devOptions: {
33 |       // Enable to troubleshoot during development
34 |       enabled: false,
35 |     },
36 |     manifest: {
37 |       name: "Homebox",
38 |       short_name: "Homebox",
39 |       description: "Home Inventory App",
40 |       theme_color: "#5b7f67",
41 |       start_url: "/home",
42 |       icons: [
43 |         {
44 |           src: "pwa-192x192.png",
45 |           sizes: "192x192",
46 |           type: "image/png",
47 |         },
48 |         {
49 |           src: "pwa-512x512.png",
50 |           sizes: "512x512",
51 |           type: "image/png",
52 |         },
53 |         {
54 |           src: "pwa-512x512.png",
55 |           sizes: "512x512",
56 |           type: "image/png",
57 |           purpose: "any maskable",
58 |         },
59 |       ],
60 |     },
61 |   },
62 | });
63 | 


--------------------------------------------------------------------------------
/frontend/nuxt.proxyoverride.ts:
--------------------------------------------------------------------------------
 1 | // https://gist.github.com/ucw/67f7291c64777fb24341e8eae72bcd24
 2 | import type { IncomingMessage } from "http";
 3 | import type internal from "stream";
 4 | import { defineNuxtModule, logger } from "@nuxt/kit";
 5 | // Related To
 6 | // - https://github.com/nuxt/nuxt/issues/15417
 7 | // - https://github.com/nuxt/cli/issues/107
 8 | //
 9 | // fix from
10 | // - https://gist.github.com/ucw/67f7291c64777fb24341e8eae72bcd24
11 | // eslint-disable-next-line
12 | import { createProxyServer } from "http-proxy";
13 | 
14 | export default defineNuxtModule({
15 |   defaults: {
16 |     target: "ws://localhost:7745",
17 |     path: "/api/v1/ws",
18 |   },
19 |   meta: {
20 |     configKey: "websocketProxy",
21 |     name: "Websocket proxy",
22 |   },
23 |   setup(resolvedOptions, nuxt) {
24 |     if (!nuxt.options.dev || !resolvedOptions.target) {
25 |       return;
26 |     }
27 | 
28 |     nuxt.hook("listen", server => {
29 |       const proxy = createProxyServer({
30 |         ws: true,
31 |         secure: false,
32 |         changeOrigin: true,
33 |         target: resolvedOptions.target,
34 |       });
35 | 
36 |       const proxyFn = (req: IncomingMessage, socket: internal.Duplex, head: Buffer) => {
37 |         if (req.url && req.url.startsWith(resolvedOptions.path)) {
38 |           proxy.ws(req, socket, head);
39 |         }
40 |       };
41 | 
42 |       server.on("upgrade", proxyFn);
43 | 
44 |       nuxt.hook("close", () => {
45 |         server.off("upgrade", proxyFn);
46 |         proxy.close();
47 |       });
48 | 
49 |       logger.info(`Websocket dev proxy started on ${resolvedOptions.path}`);
50 |     });
51 |   },
52 | });
53 | 


--------------------------------------------------------------------------------
/frontend/pages/[...all].vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 |   useHead({
 3 |     title: "404. Not Found",
 4 |   });
 5 |   definePageMeta({
 6 |     layout: "404",
 7 |   });
 8 | </script>
 9 | 
10 | <template>
11 |   <h1 class="text-blue-500 font-extrabold flex flex-col text-center">
12 |     <span class="text-7xl">404.</span>
13 |     <span class="text-5xl mt-5"> Page Not Found </span>
14 |   </h1>
15 | </template>
16 | 


--------------------------------------------------------------------------------
/frontend/pages/a/[id].vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 |   definePageMeta({
 3 |     middleware: ["auth"],
 4 |   });
 5 | 
 6 |   const route = useRoute();
 7 |   const assetId = computed<string>(() => route.params.id as string);
 8 |   await navigateTo("/assets/" + assetId.value, { replace: true, redirectCode: 301 });
 9 | </script>
10 | 


--------------------------------------------------------------------------------
/frontend/pages/assets/[id].vue:
--------------------------------------------------------------------------------
 1 | <script setup lang="ts">
 2 |   definePageMeta({
 3 |     middleware: ["auth"],
 4 |   });
 5 | 
 6 |   const route = useRoute();
 7 |   const api = useUserApi();
 8 |   const toast = useNotifier();
 9 | 
10 |   const assetId = computed<string>(() => route.params.id as string);
11 | 
12 |   const { pending, data: items } = useLazyAsyncData(`asset/${assetId.value}`, async () => {
13 |     const { data, error } = await api.assets.get(assetId.value);
14 |     if (error) {
15 |       toast.error("Failed to load asset");
16 |       navigateTo("/home");
17 |       return;
18 |     }
19 |     switch (data.total) {
20 |       case 0:
21 |         toast.error("Asset not found");
22 |         navigateTo("/home");
23 |         break;
24 |       case 1:
25 |         navigateTo(`/item/${data.items[0].id}`, { replace: true, redirectCode: 302 });
26 |         break;
27 |       default:
28 |         return data.items;
29 |     }
30 |   });
31 | </script>
32 | 
33 | <template>
34 |   <BaseContainer>
35 |     <section v-if="!pending">
36 |       <BaseSectionHeader class="mb-5"> This Asset Id is associated with multiple items</BaseSectionHeader>
37 |       <div class="grid gap-2 grid-cols-1 sm:grid-cols-2">
38 |         <ItemCard v-for="item in items" :key="item.id" :item="item" />
39 |       </div>
40 |     </section>
41 |   </BaseContainer>
42 | </template>
43 | 


--------------------------------------------------------------------------------
/frontend/pages/home/statistics.ts:
--------------------------------------------------------------------------------
 1 | import type { UserClient } from "~~/lib/api/user";
 2 | 
 3 | type StatCard = {
 4 |   label: string;
 5 |   value: number;
 6 |   type: "currency" | "number";
 7 | };
 8 | 
 9 | export function statCardData(api: UserClient) {
10 |   const { data: statistics } = useAsyncData(async () => {
11 |     const { data } = await api.stats.group();
12 |     return data;
13 |   });
14 | 
15 |   return computed(() => {
16 |     return [
17 |       {
18 |         label: "Total Value",
19 |         value: statistics.value?.totalItemPrice || 0,
20 |         type: "currency",
21 |       },
22 |       {
23 |         label: "Total Items",
24 |         value: statistics.value?.totalItems || 0,
25 |         type: "number",
26 |       },
27 |       {
28 |         label: "Total Locations",
29 |         value: statistics.value?.totalLocations || 0,
30 |         type: "number",
31 |       },
32 |       {
33 |         label: "Total Labels",
34 |         value: statistics.value?.totalLabels || 0,
35 |         type: "number",
36 |       },
37 |     ] as StatCard[];
38 |   });
39 | }
40 | 


--------------------------------------------------------------------------------
/frontend/pages/home/table.ts:
--------------------------------------------------------------------------------
 1 | import type { UserClient } from "~~/lib/api/user";
 2 | 
 3 | export function itemsTable(api: UserClient) {
 4 |   const { data: items, refresh } = useAsyncData(async () => {
 5 |     const { data } = await api.items.getAll({
 6 |       page: 1,
 7 |       pageSize: 5,
 8 |       orderBy: "createdAt",
 9 |     });
10 |     return data.items;
11 |   });
12 | 
13 |   onServerEvent(ServerEvent.ItemMutation, () => {
14 |     console.log("item mutation");
15 |     refresh();
16 |   });
17 | 
18 |   return computed(() => {
19 |     return {
20 |       items: items.value || [],
21 |     };
22 |   });
23 | }
24 | 


--------------------------------------------------------------------------------
/frontend/plugins/scroll.client.ts:
--------------------------------------------------------------------------------
1 | export default defineNuxtPlugin(nuxtApp => {
2 |   nuxtApp.hook("page:finish", () => {
3 |     document.body.scrollTo({ top: 0 });
4 |   });
5 | });
6 | 


--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 |   plugins: {
3 |     tailwindcss: {},
4 |     autoprefixer: {},
5 |   },
6 | };
7 | 


--------------------------------------------------------------------------------
/frontend/public/no-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hay-kot/homebox/6fd8457e5ac1cbe652a8e59230ca6b3b2e97d45b/frontend/public/no-image.jpg


--------------------------------------------------------------------------------
/frontend/public/pwa-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hay-kot/homebox/6fd8457e5ac1cbe652a8e59230ca6b3b2e97d45b/frontend/public/pwa-192x192.png


--------------------------------------------------------------------------------
/frontend/public/pwa-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hay-kot/homebox/6fd8457e5ac1cbe652a8e59230ca6b3b2e97d45b/frontend/public/pwa-512x512.png


--------------------------------------------------------------------------------
/frontend/stores/labels.ts:
--------------------------------------------------------------------------------
 1 | import { defineStore } from "pinia";
 2 | import type { LabelOut } from "~~/lib/api/types/data-contracts";
 3 | 
 4 | export const useLabelStore = defineStore("labels", {
 5 |   state: () => ({
 6 |     allLabels: null as LabelOut[] | null,
 7 |     client: useUserApi(),
 8 |   }),
 9 |   getters: {
10 |     /**
11 |      * labels represents the labels that are currently in the store. The store is
12 |      * synched with the server by intercepting the API calls and updating on the
13 |      * response.
14 |      */
15 |     labels(state): LabelOut[] {
16 |       if (state.allLabels === null) {
17 |         this.client.labels.getAll().then(result => {
18 |           if (result.error) {
19 |             console.error(result.error);
20 |           }
21 | 
22 |           this.allLabels = result.data;
23 |         });
24 |       }
25 |       return state.allLabels ?? [];
26 |     },
27 |   },
28 |   actions: {
29 |     async refresh() {
30 |       const result = await this.client.labels.getAll();
31 |       if (result.error) {
32 |         return result;
33 |       }
34 | 
35 |       this.allLabels = result.data;
36 |       return result;
37 |     },
38 |   },
39 | });
40 | 


--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
 1 | module.exports = {
 2 |   content: ["./app.vue", "./{components,pages,layouts}/**/*.{vue,js,ts,jsx,tsx}"],
 3 |   darkMode: "class", // or 'media' or 'class'
 4 |   theme: {
 5 |     extend: {},
 6 |   },
 7 |   daisyui: {
 8 |     themes: [
 9 |       {
10 |         homebox: {
11 |           primary: "#5C7F67",
12 |           secondary: "#ECF4E7",
13 |           accent: "#FFDA56",
14 |           neutral: "#2C2E27",
15 |           "base-100": "#FFFFFF",
16 |           info: "#3ABFF8",
17 |           success: "#36D399",
18 |           warning: "#FBBD23",
19 |           error: "#F87272",
20 |         },
21 |       },
22 |       "light",
23 |       "dark",
24 |       "cupcake",
25 |       "bumblebee",
26 |       "emerald",
27 |       "corporate",
28 |       "synthwave",
29 |       "retro",
30 |       "cyberpunk",
31 |       "valentine",
32 |       "halloween",
33 |       "garden",
34 |       "forest",
35 |       "aqua",
36 |       "lofi",
37 |       "pastel",
38 |       "fantasy",
39 |       "wireframe",
40 |       "black",
41 |       "luxury",
42 |       "dracula",
43 |       "cmyk",
44 |       "autumn",
45 |       "business",
46 |       "acid",
47 |       "lemonade",
48 |       "night",
49 |       "coffee",
50 |       "winter",
51 |     ],
52 |   },
53 |   variants: {
54 |     extend: {},
55 |   },
56 |   plugins: [require("@tailwindcss/aspect-ratio"), require("@tailwindcss/typography"), require("daisyui")],
57 | };
58 | 


--------------------------------------------------------------------------------
/frontend/test/config.ts:
--------------------------------------------------------------------------------
1 | export const PORT = "7745";
2 | export const HOST = "http://127.0.0.1";
3 | export const BASE_URL = HOST + ":" + PORT;
4 | 


--------------------------------------------------------------------------------
/frontend/test/setup.ts:
--------------------------------------------------------------------------------
 1 | import { exec } from "child_process";
 2 | import * as config from "./config";
 3 | 
 4 | export const setup = () => {
 5 |   console.log("Starting Client Tests");
 6 |   console.log({
 7 |     PORT: config.PORT,
 8 |     HOST: config.HOST,
 9 |     BASE_URL: config.BASE_URL,
10 |   });
11 | };
12 | 
13 | export const teardown = () => {
14 |   if (process.env.TEST_SHUTDOWN_API_SERVER) {
15 |     const pc = exec("pkill -SIGTERM api"); // Kill background API process
16 |     pc.stdout?.on("data", data => {
17 |       console.log(`stdout: ${data}`);
18 |     });
19 |   }
20 | };
21 | 


--------------------------------------------------------------------------------
/frontend/test/vitest.config.ts:
--------------------------------------------------------------------------------
 1 | import path from "path";
 2 | import { defineConfig } from "vite";
 3 | 
 4 | export default defineConfig({
 5 |   test: {
 6 |     globalSetup: "./test/setup.ts",
 7 |   },
 8 |   resolve: {
 9 |     alias: {
10 |       "@": path.resolve(__dirname, ".."),
11 |       "~~": path.resolve(__dirname, ".."),
12 |     },
13 |   },
14 | });
15 | 


--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 |   // https://v3.nuxtjs.org/concepts/typescript
3 |   "extends": "./.nuxt/tsconfig.json"
4 | }
5 | 


--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 |   "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 |   "extends": [
4 |     "config:base"
5 |   ]
6 | }
7 | 


--------------------------------------------------------------------------------