diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index cd9f27c3..29f45fb5 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -github: [tankerkiller125,katosdev] +github: [tankerkiller125,katosdev,tonyaellie] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5a68cd6a..7ae658b3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -3,6 +3,7 @@ name: "Bug Report" description: "Submit a bug report for the current release" labels: ["🕷️ bug"] projects: ["sysadminsmedia/2"] +type: "Bug" body: - type: checkboxes id: checks diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 3fa4d802..7be76a99 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -3,6 +3,7 @@ name: "Feature Request" description: "Submit a feature request for the current release" labels: ["⬆️ enhancement"] projects: ["sysadminsmedia/2"] +type: "Enhancement" body: - type: textarea id: problem-statement diff --git a/.github/workflows/docker-publish-arm.yaml b/.github/workflows/docker-publish-arm.yaml index 9e43e473..0147fa1c 100644 --- a/.github/workflows/docker-publish-arm.yaml +++ b/.github/workflows/docker-publish-arm.yaml @@ -30,7 +30,6 @@ env: # github.repository as / IMAGE_NAME: ${{ github.repository }} - jobs: build: @@ -38,36 +37,35 @@ jobs: permissions: contents: read packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio when running outside of PRs. attestations: write id-token: write steps: + # Step 1: Checkout repository - name: Checkout repository uses: actions/checkout@v4 - # Set up BuildKit Docker container builder to be able to build - # multi-platform images and export cache - # https://github.com/docker/setup-buildx-action + # Step 2: Set up Buildx without specifying driver + # Let it use default settings to avoid the 'no remote endpoint' issue - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.0.0 # v3.0.0 + uses: docker/setup-buildx-action@v3.0.0 + with: + install: true # Ensure Buildx is installed and set up properly + use: true # Use Buildx instance directly for this job - # Login against a Docker registry except on PR - # https://github.com/docker/login-action + # Step 3: Login against Docker registry except on PR - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@v3.0.0 # v3.0.0 + uses: docker/login-action@v3.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action + # Step 4: Extract metadata for Docker images - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v5.0.0 # v5.0.0 + uses: docker/metadata-action@v5.0.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -80,11 +78,10 @@ jobs: flavor: | suffix=-arm,onlatest=true - # Build and push Docker image with Buildx (don't push on PR) - # https://github.com/docker/build-push-action + # Step 5: Build and push the Docker image - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@v5.0.0 # v5.0.0 + uses: docker/build-push-action@v5.0.0 with: context: . push: ${{ github.event_name != 'pull_request' }} @@ -95,8 +92,9 @@ jobs: cache-to: type=gha,mode=max build-args: | VERSION=${{ github.ref_name }} - COMMIT=${{ github.sha }} - + COMMIT=${{ github.sha }} + + # Step 6: Attest built image to prove build provenance - name: Attest uses: actions/attest-build-provenance@v1 id: attest diff --git a/.github/workflows/docker-publish-rootless-arm.yaml b/.github/workflows/docker-publish-rootless-arm.yaml index fde1e86a..c40cd55b 100644 --- a/.github/workflows/docker-publish-rootless-arm.yaml +++ b/.github/workflows/docker-publish-rootless-arm.yaml @@ -24,14 +24,12 @@ on: - '.dockerignore' - '.github/workflows' - env: # Use docker.io for Docker Hub if empty REGISTRY: ghcr.io # github.repository as / IMAGE_NAME: ${{ github.repository }} - jobs: build-rootless: @@ -39,36 +37,34 @@ jobs: permissions: contents: read packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio when running outside of PRs. attestations: write id-token: write steps: + # Step 1: Checkout repository - name: Checkout repository uses: actions/checkout@v4 - # Set up BuildKit Docker container builder to be able to build - # multi-platform images and export cache - # https://github.com/docker/setup-buildx-action + # Step 2: Set up Buildx without specifying driver - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.0.0 # v3.0.0 + uses: docker/setup-buildx-action@v3.0.0 + with: + install: true # Ensure Buildx is installed and set up properly + use: true # Use Buildx instance directly for this job - # Login against a Docker registry except on PR - # https://github.com/docker/login-action + # Step 3: Login to Docker registry except on PR - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@v3.0.0 # v3.0.0 + uses: docker/login-action@v3.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action + # Step 4: Extract metadata for Docker images - name: Extract Docker metadata id: metadata - uses: docker/metadata-action@v5.0.0 # v5.0.0 + uses: docker/metadata-action@v5.0.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -80,12 +76,11 @@ jobs: type=schedule,pattern=nightly flavor: | suffix=-rootless-arm,onlatest=true - - # Build and push Docker image with Buildx (don't push on PR) - # https://github.com/docker/build-push-action + + # Step 5: Build and push the Docker image - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@v5.0.0 # v5.0.0 + uses: docker/build-push-action@v5.0.0 with: context: . push: ${{ github.event_name != 'pull_request' }} @@ -98,6 +93,7 @@ jobs: VERSION=${{ github.ref_name }} COMMIT=${{ github.sha }} + # Step 6: Attest built image to prove build provenance - name: Attest uses: actions/attest-build-provenance@v1 id: attest diff --git a/.github/workflows/partial-frontend.yaml b/.github/workflows/partial-frontend.yaml index f8494064..c793c62a 100644 --- a/.github/workflows/partial-frontend.yaml +++ b/.github/workflows/partial-frontend.yaml @@ -15,7 +15,7 @@ jobs: - uses: pnpm/action-setup@v3.0.0 with: - version: 6.0.2 + version: 9.12.2 - name: Install dependencies run: pnpm install --shamefully-hoist @@ -54,7 +54,7 @@ jobs: - uses: pnpm/action-setup@v3.0.0 with: - version: 6.0.2 + version: 9.12.2 - name: Install dependencies run: pnpm install diff --git a/Dockerfile b/Dockerfile index 85097bd7..878a1231 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,69 +1,91 @@ -# Node dependencies -FROM node:18-alpine AS frontend-dependencies +# Node dependencies stage +FROM --platform=$TARGETPLATFORM node:18-alpine AS frontend-dependencies WORKDIR /app + +# Install pnpm globally (caching layer) RUN npm install -g pnpm + +# Copy package.json and lockfile to leverage caching COPY frontend/package.json frontend/pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile --shamefully-hoist -# Build Nuxt -FROM node:18-alpine AS frontend-builder -WORKDIR /app +# Build Nuxt (frontend) stage +FROM --platform=$TARGETPLATFORM node:18-alpine AS frontend-builder +WORKDIR /app + +# Install pnpm globally again (it can reuse the cache if not changed) RUN npm install -g pnpm -COPY frontend . + +# Copy over source files and node_modules from dependencies stage +COPY frontend . COPY --from=frontend-dependencies /app/node_modules ./node_modules RUN pnpm build -FROM golang:alpine AS builder-dependencies +# Go dependencies stage +FROM --platform=$TARGETPLATFORM golang:alpine AS builder-dependencies WORKDIR /go/src/app -COPY ./backend . + +# Copy go.mod and go.sum for better caching +COPY ./backend/go.mod ./backend/go.sum ./ RUN go mod download -# Build API -FROM golang:alpine AS builder +# Build API stage +FROM --platform=$TARGETPLATFORM golang:alpine AS builder ARG BUILD_TIME ARG COMMIT ARG VERSION + +# Install necessary build tools RUN apk update && \ apk upgrade && \ - apk add --update git build-base gcc g++ + apk add --no-cache git build-base gcc g++ WORKDIR /go/src/app + +# Copy Go modules (from dependencies stage) and source code +COPY --from=builder-dependencies /go/pkg/mod /go/pkg/mod COPY ./backend . + +# Clear old public files and copy new ones from frontend build RUN rm -rf ./app/api/public COPY --from=frontend-builder /app/.output/public ./app/api/static/public -COPY --from=builder-dependencies /go/pkg/mod /go/pkg/mod -RUN --mount=type=cache,target=/root/.cache/go-build \ + +# Use cache for Go build artifacts +RUN --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build \ - -ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \ + -ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \ -o /go/bin/api \ -v ./app/api/*.go -FROM gcr.io/distroless/java:latest - -# Production Stage -FROM alpine:latest - +# Production stage +FROM --platform=$TARGETPLATFORM alpine:latest ENV HBOX_MODE=production ENV HBOX_STORAGE_DATA=/data/ ENV HBOX_STORAGE_SQLITE_URL=/data/homebox.db?_pragma=busy_timeout=2000&_pragma=journal_mode=WAL&_fk=1 -RUN apk --no-cache add ca-certificates +# Install necessary runtime dependencies +RUN apk --no-cache add ca-certificates wget + +# Create application directory and copy over built Go binary RUN mkdir /app COPY --from=builder /go/bin/api /app - RUN chmod +x /app/api -RUN apk add --no-cache wget +# Labels and configuration for the final image LABEL Name=homebox Version=0.0.1 LABEL org.opencontainers.image.source="https://github.com/sysadminsmedia/homebox" + +# Expose necessary ports EXPOSE 7745 WORKDIR /app -HEALTHCHECK --interval=30s \ - --timeout=5s \ - --start-period=5s \ - --retries=3 \ - CMD [ "/usr/bin/wget", "--no-verbose", "--tries=1", "-O -", "http://localhost:7745/api/v1/status" ] + +# Healthcheck configuration +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD [ "wget", "--no-verbose", "--tries=1", "-O", "-", "http://localhost:7745/api/v1/status" ] + +# Persist volume VOLUME [ "/data" ] +# Entrypoint and CMD ENTRYPOINT [ "/app/api" ] CMD [ "/data/config.yml" ] diff --git a/Dockerfile.rootless b/Dockerfile.rootless index 3a0799d8..91ac930e 100644 --- a/Dockerfile.rootless +++ b/Dockerfile.rootless @@ -7,15 +7,15 @@ RUN pnpm install --frozen-lockfile --shamefully-hoist # Build Nuxt FROM node:18-alpine AS frontend-builder -WORKDIR /app -RUN npm install -g pnpm -COPY frontend . +WORKDIR /app +COPY frontend ./ COPY --from=frontend-dependencies /app/node_modules ./node_modules RUN pnpm build +# Build Go dependencies FROM golang:alpine AS builder-dependencies WORKDIR /go/src/app -COPY ./backend . +COPY ./backend/go.mod ./backend/go.sum ./ RUN go mod download # Build API @@ -23,48 +23,51 @@ FROM golang:alpine AS builder ARG BUILD_TIME ARG COMMIT ARG VERSION -RUN apk update && \ - apk upgrade && \ - apk add --update git build-base gcc g++ + +RUN apk update && apk upgrade && apk add --no-cache git build-base gcc g++ WORKDIR /go/src/app COPY ./backend . RUN rm -rf ./app/api/public COPY --from=frontend-builder /app/.output/public ./app/api/static/public COPY --from=builder-dependencies /go/pkg/mod /go/pkg/mod -RUN --mount=type=cache,target=/root/.cache/go-build \ + +# Use cache for Go build +RUN --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build \ - -ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \ - -o /go/bin/api \ - -v ./app/api/*.go + -ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \ + -o /go/bin/api ./app/api/*.go -FROM gcr.io/distroless/java:latest - -# Production Stage +# Production stage with distroless FROM gcr.io/distroless/static:latest ENV HBOX_MODE=production ENV HBOX_STORAGE_DATA=/data/ ENV HBOX_STORAGE_SQLITE_URL=/data/homebox.db?_fk=1 -# Copy the binary and the (empty) /data dir and -# change the ownership to the low-privileged user +# Copy the binary and data directory, change ownership COPY --from=builder --chown=nonroot /go/bin/api /app COPY --from=builder --chown=nonroot /data /data -RUN apk add --no-cache wget +# Add wget to the image +# Note: If using distroless, this may not be applicable +# as distroless images do not include package managers. +# This line may be omitted if you're relying on another way to handle healthchecks. +COPY --from=alpine:latest /bin/wget /usr/bin/wget LABEL Name=homebox Version=0.0.1 LABEL org.opencontainers.image.source="https://github.com/sysadminsmedia/homebox" EXPOSE 7745 + HEALTHCHECK --interval=30s \ --timeout=5s \ --start-period=5s \ --retries=3 \ - CMD [ "/usr/bin/wget", "--no-verbose", "--tries=1", "-O -", "http://localhost:7745/api/v1/status" ] -VOLUME [ "/data" ] + CMD ["/usr/bin/wget", "--no-verbose", "--tries=1", "-O", "-", "http://localhost:7745/api/v1/status"] -# Drop root and run as low-privileged user +VOLUME ["/data"] + +# Drop root and run as a low-privileged user USER nonroot -ENTRYPOINT [ "/app" ] -CMD [ "/data/config.yml" ] +ENTRYPOINT ["/app"] +CMD ["/data/config.yml"] diff --git a/backend/internal/data/repo/repo_item_attachments.go b/backend/internal/data/repo/repo_item_attachments.go index 7be4d7fe..d4f8e568 100644 --- a/backend/internal/data/repo/repo_item_attachments.go +++ b/backend/internal/data/repo/repo_item_attachments.go @@ -87,11 +87,11 @@ func (r *AttachmentRepo) Get(ctx context.Context, id uuid.UUID) (*ent.Attachment Only(ctx) } -func (r *AttachmentRepo) Update(ctx context.Context, itemID uuid.UUID, data *ItemAttachmentUpdate) (*ent.Attachment, error) { +func (r *AttachmentRepo) Update(ctx context.Context, id uuid.UUID, data *ItemAttachmentUpdate) (*ent.Attachment, error) { // TODO: execute within Tx typ := attachment.Type(data.Type) - bldr := r.db.Attachment.UpdateOneID(itemID). + bldr := r.db.Attachment.UpdateOneID(id). SetType(typ) // Primary only applies to photos @@ -101,7 +101,12 @@ func (r *AttachmentRepo) Update(ctx context.Context, itemID uuid.UUID, data *Ite bldr = bldr.SetPrimary(false) } - itm, err := bldr.Save(ctx) + updatedAttachment, err := bldr.Save(ctx) + if err != nil { + return nil, err + } + + attachmentItem, err := updatedAttachment.QueryItem().Only(ctx) if err != nil { return nil, err } @@ -109,8 +114,8 @@ func (r *AttachmentRepo) Update(ctx context.Context, itemID uuid.UUID, data *Ite // Ensure all other attachments are not primary err = r.db.Attachment.Update(). Where( - attachment.HasItemWith(item.ID(itemID)), - attachment.IDNEQ(itm.ID), + attachment.HasItemWith(item.ID(attachmentItem.ID)), + attachment.IDNEQ(updatedAttachment.ID), ). SetPrimary(false). Exec(ctx) @@ -118,7 +123,7 @@ func (r *AttachmentRepo) Update(ctx context.Context, itemID uuid.UUID, data *Ite return nil, err } - return r.Get(ctx, itm.ID) + return r.Get(ctx, updatedAttachment.ID) } func (r *AttachmentRepo) Delete(ctx context.Context, id uuid.UUID) error { diff --git a/backend/internal/data/repo/repo_item_attachments_test.go b/backend/internal/data/repo/repo_item_attachments_test.go index 22a2256c..1dd922b9 100644 --- a/backend/internal/data/repo/repo_item_attachments_test.go +++ b/backend/internal/data/repo/repo_item_attachments_test.go @@ -133,3 +133,25 @@ func TestAttachmentRepo_Delete(t *testing.T) { _, err = tRepos.Attachments.Get(context.Background(), entity.ID) require.Error(t, err) } + +func TestAttachmentRepo_EnsureSinglePrimaryAttachment(t *testing.T) { + ctx := context.Background() + attachments := useAttachments(t, 2) + + setAndVerifyPrimary := func(primaryAttachmentID, nonPrimaryAttachmentID uuid.UUID) { + primaryAttachment, err := tRepos.Attachments.Update(ctx, primaryAttachmentID, &ItemAttachmentUpdate{ + Type: attachment.TypePhoto.String(), + Primary: true, + }) + require.NoError(t, err) + + nonPrimaryAttachment, err := tRepos.Attachments.Get(ctx, nonPrimaryAttachmentID) + require.NoError(t, err) + + assert.True(t, primaryAttachment.Primary) + assert.False(t, nonPrimaryAttachment.Primary) + } + + setAndVerifyPrimary(attachments[0].ID, attachments[1].ID) + setAndVerifyPrimary(attachments[1].ID, attachments[0].ID) +} diff --git a/docs/en/contribute/get-started.md b/docs/en/contribute/get-started.md index d335e7ad..5ae6d3eb 100644 --- a/docs/en/contribute/get-started.md +++ b/docs/en/contribute/get-started.md @@ -35,7 +35,7 @@ swagger update command `task swag` ### Frontend Development Notes -start command `task: ui:dev` +start command `task ui:dev` 1. The frontend is a Vue 3 app with Nuxt.js that uses Tailwind and DaisyUI for styling. 2. We're using Vitest for our automated testing. You can run these with `task ui:watch`. diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index 8a9d8fa7..185c47f5 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -28,3 +28,61 @@ ::-webkit-scrollbar-thumb:hover { background-color: #9B9B9B; } + +.scroll-bg::-webkit-scrollbar { + width: 0.5rem; +} + +.scroll-bg::-webkit-scrollbar-thumb { + border-radius: 0.25rem; + @apply bg-base-300; +} + +.markdown > :first-child { + margin-top: 0px !important; +} + +.markdown :where(p, ul, ol, dl, blockquote, h1, h2, h3, h4, h5, h6) { + margin-top: var(--y-gap); + margin-bottom: var(--y-gap); +} + +.markdown :where(ul) { + list-style: disc; + margin-left: 2rem; +} + +.markdown :where(ol) { + list-style: decimal; + margin-left: 2rem; +} +/* Heading Styles */ +.markdown :where(h1) { + font-size: 2rem; + font-weight: 700; +} + +.markdown :where(h2) { + font-size: 1.5rem; + font-weight: 700; +} + +.markdown :where(h3) { + font-size: 1.25rem; + font-weight: 700; +} + +.markdown :where(h4) { + font-size: 1rem; + font-weight: 700; +} + +.markdown :where(h5) { + font-size: 0.875rem; + font-weight: 700; +} + +.markdown :where(h6) { + font-size: 0.75rem; + font-weight: 700; +} diff --git a/frontend/components/Base/SectionHeader.vue b/frontend/components/Base/SectionHeader.vue index 387d160f..9f1a86f2 100644 --- a/frontend/components/Base/SectionHeader.vue +++ b/frontend/components/Base/SectionHeader.vue @@ -4,7 +4,6 @@ class="flex items-center text-3xl font-bold tracking-tight" :class="{ 'text-neutral-content': dark, - 'text-content': !dark, }" > diff --git a/frontend/components/Form/Autocomplete2.vue b/frontend/components/Form/Autocomplete2.vue index 0bda7dd6..552c5da5 100644 --- a/frontend/components/Form/Autocomplete2.vue +++ b/frontend/components/Form/Autocomplete2.vue @@ -85,7 +85,10 @@ type Props = { label: string; modelValue: SupportValues | null | undefined; - items: string[] | object[]; + items: { + id: string; + treeString: string; + }[]; display?: string; multiple?: boolean; }; @@ -156,11 +159,28 @@ const matches = index.value.search("*" + search.value + "*"); + const resultIDs = []; for (let i = 0; i < matches.length; i++) { const match = matches[i]; const item = props.items[parseInt(match.ref)]; const display = extractDisplay(item); list.push({ id: i, display, value: item }); + resultIDs.push(item.id); + } + + /** + * Supplementary search, + * Resolve the issue of language not being supported + */ + for (let i = 0; i < props.items.length; i++) { + const item = props.items[i]; + if (resultIDs.find(item_ => item_ === item.id) !== undefined) { + continue; + } + if (item.treeString.includes(search.value)) { + const display = extractDisplay(item); + list.push({ id: i, display, value: item }); + } } return list; diff --git a/frontend/components/Form/TextArea.vue b/frontend/components/Form/TextArea.vue index f208b327..87207415 100644 --- a/frontend/components/Form/TextArea.vue +++ b/frontend/components/Form/TextArea.vue @@ -6,10 +6,10 @@ :class="{ 'text-red-600': typeof value === 'string' && - ((maxLength && value.length > maxLength) || (minLength && value.length < minLength)), + ((maxLength !== -1 && value.length > maxLength) || (minLength !== -1 && value.length < minLength)), }" > - {{ typeof value === "string" && (maxLength || minLength) ? `${value.length}/${maxLength}` : "" }} + {{ typeof value === "string" && (maxLength !== -1 || minLength !== -1) ? `${value.length}/${maxLength}` : "" }}