diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index efd5bed3..760a916e 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -39,7 +39,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: "1.24.3" + go-version: "1.25.0" - name: Set up Node.js uses: actions/setup-node@v3 diff --git a/.github/workflows/go-build-cache.yml b/.github/workflows/go-build-cache.yml new file mode 100644 index 00000000..70044d5e --- /dev/null +++ b/.github/workflows/go-build-cache.yml @@ -0,0 +1,50 @@ +name: Go build cache + +on: + push: + branches: + - main + paths: + - go.mod + - go.sum + - .github/workflows/go-build-cache.yml + workflow_dispatch: + +jobs: + warm-cache: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + cache: false + + - name: Go build cache + id: cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: release-go-${{ runner.os }}-${{ hashFiles('go.sum') }} + + - name: Compile all release targets + if: steps.cache.outputs.cache-hit != 'true' + env: + CGO_ENABLED: "0" + run: | + for target in \ + darwin/amd64 darwin/arm64 \ + linux/amd64 linux/arm64 linux/arm/6 linux/arm/7 \ + windows/amd64 windows/arm64 windows/arm/6 windows/arm/7 \ + freebsd/amd64 freebsd/arm64 freebsd/arm/6 freebsd/arm/7 \ + netbsd/amd64 netbsd/arm64 netbsd/arm/6 netbsd/arm/7 \ + openbsd/amd64 openbsd/arm64 openbsd/arm/6 openbsd/arm/7; do + IFS=/ read -r os arch arm <<< "$target" + echo "→ $target" + GOOS=$os GOARCH=$arch GOARM=$arm go build -o /dev/null ./cmd + done diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 62ddb1f8..ab32da70 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.24.3" + go-version: "1.25.0" - name: Install dependencies run: go get -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58f46c34..fec17274 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,8 +40,17 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.24.3" - cache: true + go-version: "1.25.0" + cache: false + + - name: Restore Go build cache + uses: actions/cache/restore@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: release-go-${{ runner.os }}-${{ hashFiles('go.sum') }} + restore-keys: release-go-${{ runner.os }}- - name: Set up Node.js uses: actions/setup-node@v3 @@ -55,7 +64,7 @@ jobs: uses: goreleaser/goreleaser-action@v5 with: version: latest - args: release --parallelism 1 --clean + args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DOCKER_ORG: libredesk diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 88d3d2b0..2916ec3c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -28,7 +28,7 @@ builds: goarm: - 6 - 7 - binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}' + binary: libredesk ldflags: - -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}" hooks: diff --git a/Makefile b/Makefile index 589f6e6d..51a08865 100644 --- a/Makefile +++ b/Makefile @@ -102,10 +102,21 @@ demo-build: @echo "→ Building in demo mode..." @export VITE_DEMO_BUILD="true" && $(MAKE) build -# Run tests. +# Run tests. Integration tests need a Postgres; start one with `make test-db` first, else they skip. .PHONY: test test: @echo "→ Running Go tests..." go test -count=1 ./... @echo "→ Running frontend tests..." cd ${FRONTEND_DIR} && npx pnpm install --frozen-lockfile && npx pnpm test:run + +# Start a throwaway Postgres for integration tests. Remove it with `make test-db-down`. +.PHONY: test-db +test-db: + docker run -d --name libredesk-test-db -p 127.0.0.1:5433:5432 \ + -e POSTGRES_USER=libredesk -e POSTGRES_PASSWORD=libredesk -e POSTGRES_DB=libredesk \ + postgres:17-alpine + +.PHONY: test-db-down +test-db-down: + docker rm -f libredesk-test-db diff --git a/cmd/chat.go b/cmd/chat.go index e30f6b42..2ab7e4ad 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -795,7 +795,7 @@ func resolveOrCreateExternalContact(app *App, claims Claims) (int, error) { ExternalUserID: null.NewString(claims.ExternalUserID, true), CustomAttributes: marshalCustomAttributes(claims.ContactCustomAttributes, app), } - if err := app.user.CreateContact(&user); err != nil { + if err := app.user.ResolveContact(&user, umodels.ContactSync); err != nil { return 0, err } return user.ID, nil @@ -899,7 +899,7 @@ func resolveUserFromClaims(app *App, claims Claims) (umodels.User, error) { case claims.UserID > 0: user, err = app.user.Get(claims.UserID, "", []string{umodels.UserTypeContact, umodels.UserTypeVisitor}) case claims.ExternalUserID != "": - user, err = app.user.GetByExternalID(claims.ExternalUserID) + user, err = app.user.GetContactByExternalID(claims.ExternalUserID) default: return umodels.User{}, errors.New("error fetching user") } diff --git a/cmd/conversation.go b/cmd/conversation.go index a9226cd0..9f8712d5 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "mime" "slices" "strconv" "strings" @@ -52,6 +53,7 @@ type createConversationRequest struct { FirstName string `json:"first_name"` LastName string `json:"last_name"` ExternalUserID string `json:"external_user_id"` + ReuseContact bool `json:"reuse_contact"` Subject string `json:"subject"` Content string `json:"content"` Attachments []int `json:"attachments"` @@ -357,7 +359,7 @@ func handleDownloadConversationTranscript(r *fastglue.Request) error { transcript := app.conversation.BuildTranscript(*conversation, messages, time.Now()) safeRef := stringutil.SanitizeFilename(conversation.ReferenceNumber) filename := fmt.Sprintf("transcript-%s.txt", safeRef) - r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + r.RequestCtx.Response.Header.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff") r.RequestCtx.SetContentType("text/plain; charset=utf-8") r.RequestCtx.SetBody(transcript) @@ -797,7 +799,6 @@ func handleCreateConversation(r *fastglue.Request) error { req.Email = strings.ToLower(strings.TrimSpace(req.Email)) - // Validate the request if err := validateCreateConversationRequest(req, app); err != nil { return sendErrorEnvelope(r, err) } @@ -816,17 +817,21 @@ func handleCreateConversation(r *fastglue.Request) error { ExternalUserID: null.NewString(req.ExternalUserID, req.ExternalUserID != ""), CustomAttributes: json.RawMessage(`{}`), } - // Reuse an existing contact as-is; this endpoint is gated only on conversations:write and must never rename a contact. - existing, err := app.user.GetContactByEmail(email) + canWriteContacts, err := app.authz.Enforce(user, "contacts", "write") if err != nil { - if envErr, ok := err.(envelope.Error); !ok || envErr.ErrorType != envelope.NotFoundError { - return sendErrorEnvelope(r, err) - } - if err := app.user.CreateContact(&contact); err != nil { - return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) - } - } else { - contact.ID = existing.ID + app.lo.Error("error checking permission", "error", err) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + policy := umodels.ContactReuse + if canWriteContacts && !req.ReuseContact { + policy = umodels.ContactSync + } + if err := app.user.ResolveContact(&contact, policy); err != nil { + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + // A contact matched by external ID keeps its stored email as the recipient. + if policy == umodels.ContactReuse && contact.Email.String != "" { + to = []string{contact.Email.String} } // Create conversation first. @@ -893,7 +898,6 @@ func handleCreateConversation(r *fastglue.Request) error { return r.SendEnvelope(conversation) } -// validateCreateConversationRequest validates the create conversation request fields. func validateCreateConversationRequest(req createConversationRequest, app *App) error { if req.InboxID <= 0 { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil) diff --git a/cmd/media.go b/cmd/media.go index fa4308af..b9d073be 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "mime" "net/http" "path/filepath" "strings" @@ -262,7 +263,7 @@ func serveMediaFile(r *fastglue.Request, app *App, uuid string, media *mmodels.M } r.RequestCtx.Response.Header.Set("Content-Type", media.ContentType) - r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, media.Filename)) + r.RequestCtx.Response.Header.Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": media.Filename})) r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff") r.RequestCtx.Response.Header.Set("Cache-Control", fmt.Sprintf("%s, max-age=%d, immutable", cacheVisibility(media.Private), int(mediaCacheTTL.Seconds()))) diff --git a/frontend/apps/main/src/features/conversation/CreateConversation.vue b/frontend/apps/main/src/features/conversation/CreateConversation.vue index 7b71c844..e2363c31 100644 --- a/frontend/apps/main/src/features/conversation/CreateConversation.vue +++ b/frontend/apps/main/src/features/conversation/CreateConversation.vue @@ -243,6 +243,7 @@ :handleFileUpload="handleFileUpload" @emojiSelect="handleEmojiSelect" :showSendButton="false" + :showGenerateReply="false" />