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

HomeBox

6 |

7 | Docs 8 | | 9 | Demo 10 | | 11 | Discord 12 |

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