diff --git a/apps/api/internal/service/transfer_service.go b/apps/api/internal/service/transfer_service.go index ac2a1e9..28aaec4 100644 --- a/apps/api/internal/service/transfer_service.go +++ b/apps/api/internal/service/transfer_service.go @@ -230,10 +230,10 @@ func (s *Service) CreateTransfer(ctx context.Context, clientKey string, request // RegisterFiles validates encrypted file metadata before upload URLs are issued. func (s *Service) RegisterFiles(ctx context.Context, transferID string, token string, files []RegisterFileRequest) error { if len(files) == 0 { - return &HTTPError{Status: 400, Code: "empty_files", Message: "at least one file must be registered"} + return &HTTPError{Status: 400, Code: "empty_files", Message: "At least one file must be registered"} } if len(files) > s.cfg.MaxFileCount { - return &HTTPError{Status: 400, Code: "too_many_files", Message: "file count exceeds configured maximum"} + return &HTTPError{Status: 400, Code: "too_many_files", Message: "File count exceeds configured maximum"} } transfer, err := s.authorizeManage(ctx, transferID, token) @@ -241,14 +241,14 @@ func (s *Service) RegisterFiles(ctx context.Context, transferID string, token st return err } if isExpired(transfer) { - return &HTTPError{Status: 410, Code: "expired", Message: "transfer has expired"} + return &HTTPError{Status: 410, Code: "expired", Message: "Transfer has expired"} } totalBytes := int64(0) modelFiles := make([]models.TransferFile, 0, len(files)) for _, file := range files { if strings.TrimSpace(file.FileID) == "" || file.TotalChunks <= 0 || file.ChunkSize <= 0 || file.CiphertextBytes <= 0 { - return &HTTPError{Status: 400, Code: "invalid_file_registration", Message: "file registration contains invalid values"} + return &HTTPError{Status: 400, Code: "invalid_file_registration", Message: "File registration contains invalid values"} } totalBytes += file.CiphertextBytes modelFiles = append(modelFiles, models.TransferFile{ @@ -263,7 +263,7 @@ func (s *Service) RegisterFiles(ctx context.Context, transferID string, token st } if totalBytes > s.cfg.MaxTransferBytes { - return &HTTPError{Status: 400, Code: "transfer_too_large", Message: "transfer exceeds configured size limit"} + return &HTTPError{Status: 400, Code: "transfer_too_large", Message: "Transfer exceeds configured size limit"} } if err := s.repo.RegisterFiles(ctx, transferID, modelFiles); err != nil { @@ -280,7 +280,7 @@ func (s *Service) CreateUploadURLs(ctx context.Context, transferID string, token return nil, err } if isExpired(transfer) { - return nil, &HTTPError{Status: 410, Code: "expired", Message: "transfer has expired"} + return nil, &HTTPError{Status: 410, Code: "expired", Message: "Transfer has expired"} } files, err := s.repo.ListFiles(ctx, transferID) @@ -296,7 +296,7 @@ func (s *Service) CreateUploadURLs(ctx context.Context, transferID string, token for _, chunk := range request.Chunks { file, ok := fileIndex[chunk.FileID] if !ok || chunk.ChunkIndex < 0 || chunk.ChunkIndex >= file.TotalChunks { - return nil, &HTTPError{Status: 400, Code: "invalid_chunk_request", Message: "upload chunk request is invalid"} + return nil, &HTTPError{Status: 400, Code: "invalid_chunk_request", Message: "Upload chunk request is invalid"} } objectKey := chunkObjectKey(transferID, chunk.FileID, chunk.ChunkIndex) @@ -323,13 +323,13 @@ func (s *Service) CompleteChunks(ctx context.Context, transferID string, token s return err } if isExpired(transfer) { - return &HTTPError{Status: 410, Code: "expired", Message: "transfer has expired"} + return &HTTPError{Status: 410, Code: "expired", Message: "Transfer has expired"} } modelChunks := make([]models.TransferChunk, 0, len(chunks)) for _, chunk := range chunks { if strings.TrimSpace(chunk.FileID) == "" || chunk.ChunkIndex < 0 || chunk.CiphertextSize <= 0 || strings.TrimSpace(chunk.ChecksumSHA256) == "" { - return &HTTPError{Status: 400, Code: "invalid_chunk_completion", Message: "chunk completion payload is invalid"} + return &HTTPError{Status: 400, Code: "invalid_chunk_completion", Message: "Chunk completion payload is invalid"} } modelChunks = append(modelChunks, models.TransferChunk{ TransferID: transferID, @@ -355,12 +355,12 @@ func (s *Service) PutManifest(ctx context.Context, transferID string, token stri return err } if isExpired(transfer) { - return &HTTPError{Status: 410, Code: "expired", Message: "transfer has expired"} + return &HTTPError{Status: 410, Code: "expired", Message: "Transfer has expired"} } payload, err := base64.StdEncoding.DecodeString(request.CiphertextBase64) if err != nil || len(payload) == 0 { - return &HTTPError{Status: 400, Code: "invalid_manifest", Message: "manifest payload must be valid base64 ciphertext"} + return &HTTPError{Status: 400, Code: "invalid_manifest", Message: "Manifest payload must be valid base64 ciphertext"} } objectKey := manifestObjectKey(transferID) @@ -381,10 +381,10 @@ func (s *Service) FinalizeTransfer(ctx context.Context, transferID string, token return err } if isExpired(transfer) { - return &HTTPError{Status: 410, Code: "expired", Message: "transfer has expired"} + return &HTTPError{Status: 410, Code: "expired", Message: "Transfer has expired"} } if strings.TrimSpace(request.WrappedRootKey) == "" || request.TotalFiles <= 0 || request.TotalCiphertextBytes <= 0 { - return &HTTPError{Status: 400, Code: "invalid_finalize", Message: "finalize payload is invalid"} + return &HTTPError{Status: 400, Code: "invalid_finalize", Message: "Finalize payload is invalid"} } if err := s.repo.FinalizeTransfer(ctx, transferID, request.WrappedRootKey, request.TotalFiles, request.TotalCiphertextBytes); err != nil { @@ -443,7 +443,7 @@ func (s *Service) UpdateTransfer(ctx context.Context, transferID string, token s return err } if publicStatus(transfer) == models.TransferStatusDeleted { - return &HTTPError{Status: 410, Code: "deleted", Message: "transfer has been deleted"} + return &HTTPError{Status: 410, Code: "deleted", Message: "Transfer has been deleted"} } params := models.UpdateTransferParams{} @@ -457,7 +457,7 @@ func (s *Service) UpdateTransfer(ctx context.Context, transferID string, token s if strings.TrimSpace(request.CiphertextBase64) != "" { payload, err := base64.StdEncoding.DecodeString(request.CiphertextBase64) if err != nil || len(payload) == 0 { - return &HTTPError{Status: 400, Code: "invalid_manifest", Message: "updated manifest must be valid base64 ciphertext"} + return &HTTPError{Status: 400, Code: "invalid_manifest", Message: "Updated manifest must be valid base64 ciphertext"} } objectKey := transfer.ManifestObjectKey if objectKey == "" { @@ -500,7 +500,7 @@ func (s *Service) GetPublicTransfer(ctx context.Context, clientKey string, trans transfer, err := s.repo.GetTransfer(ctx, transferID) if errors.Is(err, repo.ErrNotFound) { - return PublicTransferResponse{}, &HTTPError{Status: 404, Code: "not_found", Message: "transfer not found"} + return PublicTransferResponse{}, &HTTPError{Status: 404, Code: "not_found", Message: "Transfer not found"} } if err != nil { return PublicTransferResponse{}, err @@ -538,13 +538,13 @@ func (s *Service) CreateDownloadURLs(ctx context.Context, clientKey string, tran resume, err := s.repo.GetResumeState(ctx, transferID) if errors.Is(err, repo.ErrNotFound) { - return nil, &HTTPError{Status: 404, Code: "not_found", Message: "transfer not found"} + return nil, &HTTPError{Status: 404, Code: "not_found", Message: "Transfer not found"} } if err != nil { return nil, err } if publicStatus(resume.Transfer) != models.TransferStatusReady { - return nil, &HTTPError{Status: 409, Code: "transfer_unavailable", Message: "transfer is not available for download"} + return nil, &HTTPError{Status: 409, Code: "transfer_unavailable", Message: "Transfer is not available for download"} } uploaded := map[string]map[int]struct{}{} @@ -559,10 +559,10 @@ func (s *Service) CreateDownloadURLs(ctx context.Context, clientKey string, tran for _, chunk := range request.Chunks { chunkSet, ok := uploaded[chunk.FileID] if !ok { - return nil, &HTTPError{Status: 400, Code: "invalid_download_request", Message: "requested file is unavailable"} + return nil, &HTTPError{Status: 400, Code: "invalid_download_request", Message: "Requested file is unavailable"} } if _, ok = chunkSet[chunk.ChunkIndex]; !ok { - return nil, &HTTPError{Status: 400, Code: "invalid_download_request", Message: "requested chunk is unavailable"} + return nil, &HTTPError{Status: 400, Code: "invalid_download_request", Message: "Requested chunk is unavailable"} } url, err := s.storage.PresignDownload(ctx, chunkObjectKey(transferID, chunk.FileID, chunk.ChunkIndex), s.cfg.PresignTTL) @@ -601,12 +601,12 @@ func (s *Service) CleanupExpired(ctx context.Context) error { // authorizeManage validates the owner token using a constant-time hash comparison. func (s *Service) authorizeManage(ctx context.Context, transferID string, token string) (models.Transfer, error) { if strings.TrimSpace(token) == "" { - return models.Transfer{}, &HTTPError{Status: 401, Code: "missing_manage_token", Message: "manage token is required"} + return models.Transfer{}, &HTTPError{Status: 401, Code: "missing_manage_token", Message: "Manage token is required"} } transfer, err := s.repo.GetTransfer(ctx, transferID) if errors.Is(err, repo.ErrNotFound) { - return models.Transfer{}, &HTTPError{Status: 404, Code: "not_found", Message: "transfer not found"} + return models.Transfer{}, &HTTPError{Status: 404, Code: "not_found", Message: "Transfer not found"} } if err != nil { return models.Transfer{}, err @@ -614,7 +614,7 @@ func (s *Service) authorizeManage(ctx context.Context, transferID string, token givenHash := hashToken(token) if subtle.ConstantTimeCompare([]byte(givenHash), []byte(transfer.ManageTokenHash)) != 1 { - return models.Transfer{}, &HTTPError{Status: 403, Code: "invalid_manage_token", Message: "manage token is invalid"} + return models.Transfer{}, &HTTPError{Status: 403, Code: "invalid_manage_token", Message: "Manage token is invalid"} } return transfer, nil @@ -627,7 +627,7 @@ func (s *Service) enforceRateLimit(ctx context.Context, key string, limit int, w return fmt.Errorf("rate limit check: %w", err) } if !allowed { - return &HTTPError{Status: 429, Code: "rate_limited", Message: "too many requests"} + return &HTTPError{Status: 429, Code: "rate_limited", Message: "Too many requests"} } return nil @@ -694,7 +694,7 @@ func requestedUpdateExpiry(request UpdateTransferRequest) (time.Duration, bool, func validateExpiryDuration(duration time.Duration) (time.Duration, error) { if !config.IsAllowedExpiry(duration) { - return 0, &HTTPError{Status: 400, Code: "invalid_expiry", Message: "expiry must match a supported option"} + return 0, &HTTPError{Status: 400, Code: "invalid_expiry", Message: "Expiry must match a supported option"} } return duration, nil diff --git a/apps/api/internal/service/transfer_service_edge_test.go b/apps/api/internal/service/transfer_service_edge_test.go index 969f04f..593b4d1 100644 --- a/apps/api/internal/service/transfer_service_edge_test.go +++ b/apps/api/internal/service/transfer_service_edge_test.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/base64" + "strings" "testing" "time" @@ -272,6 +273,7 @@ func TestGetPublicTransferReturnsNotFoundAndExpiredStatuses(t *testing.T) { _, err := svc.GetPublicTransfer(context.Background(), "198.51.100.20", "missing-transfer") requireHTTPError(t, err, 404, "not_found") + requireHTTPErrorMessage(t, err, "Transfer not found") create := createTransferForTest(t, svc) transfer, err := repository.GetTransfer(context.Background(), create.TransferID) @@ -289,6 +291,59 @@ func TestGetPublicTransferReturnsNotFoundAndExpiredStatuses(t *testing.T) { require.Empty(t, response.WrappedRootKey) } +func TestHTTPErrorMessagesAreSentenceCased(t *testing.T) { + t.Parallel() + + repository := newMemoryRepository() + svc := newTestService(repository) + create := createTransferForTest(t, svc) + + testCases := []struct { + name string + err error + message string + }{ + { + name: "missing manage token", + err: svc.RegisterFiles(context.Background(), create.TransferID, "", []RegisterFileRequest{{ + FileID: "file-a", + TotalChunks: 1, + CiphertextBytes: 1, + ChunkSize: 1, + }}), + message: "Manage token is required", + }, + { + name: "empty files", + err: svc.RegisterFiles(context.Background(), create.TransferID, create.ManageToken, nil), + message: "At least one file must be registered", + }, + { + name: "transfer unavailable", + err: func() error { + _, err := svc.CreateDownloadURLs( + context.Background(), + "198.51.100.20", + create.TransferID, + DownloadURLRequest{Chunks: []UploadChunkRequest{{FileID: "file-a", ChunkIndex: 0}}}, + ) + return err + }(), + message: "Transfer is not available for download", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + httpErr := requireHTTPErrorMessage(t, tc.err, tc.message) + require.Equal(t, strings.ToUpper(httpErr.Message[:1]), httpErr.Message[:1]) + }) + } +} + func TestCreateDownloadURLsValidatesAvailabilityAndChunkPresence(t *testing.T) { t.Parallel() @@ -406,3 +461,15 @@ func requireHTTPError(t *testing.T, err error, status int, code string) { require.Equal(t, status, httpErr.Status) require.Equal(t, code, httpErr.Code) } + +func requireHTTPErrorMessage(t *testing.T, err error, message string) *HTTPError { + t.Helper() + + require.Error(t, err) + + httpErr, ok := err.(*HTTPError) + require.True(t, ok) + require.Equal(t, message, httpErr.Message) + + return httpErr +} diff --git a/apps/web/src/app/routes/HistoryPage.test.tsx b/apps/web/src/app/routes/HistoryPage.test.tsx index 843a2f8..431f9fd 100644 --- a/apps/web/src/app/routes/HistoryPage.test.tsx +++ b/apps/web/src/app/routes/HistoryPage.test.tsx @@ -27,8 +27,10 @@ describe('HistoryPage', () => { expect(usePageMetadataMock).toHaveBeenCalledWith( expect.objectContaining({ + description: + 'Manage Xdrop end-to-end encrypted file transfers stored in this browser on this device. Plaintext file names, contents, and keys stay off the server.', exposeUrl: false, - title: 'Manage Transfers on This Device | Xdrop', + title: 'Manage End-to-End Encrypted Transfers on This Device | Xdrop', }), ) expect(screen.getByText('History board')).toBeInTheDocument() diff --git a/apps/web/src/app/routes/SharePage.test.tsx b/apps/web/src/app/routes/SharePage.test.tsx index eec4a32..246f69d 100644 --- a/apps/web/src/app/routes/SharePage.test.tsx +++ b/apps/web/src/app/routes/SharePage.test.tsx @@ -47,8 +47,10 @@ describe('SharePage', () => { expect(usePageMetadataMock).toHaveBeenCalledWith( expect.objectContaining({ + description: + 'Share an Xdrop end-to-end encrypted file transfer from this browser on this device while keeping plaintext file names, contents, and keys off the server.', exposeUrl: false, - title: 'Share This Transfer | Xdrop', + title: 'Share an End-to-End Encrypted Transfer | Xdrop', }), ) expect(screen.getByText('Share t1')).toBeInTheDocument() diff --git a/apps/web/src/lib/seo/site.test.ts b/apps/web/src/lib/seo/site.test.ts index b2562ab..974c1f5 100644 --- a/apps/web/src/lib/seo/site.test.ts +++ b/apps/web/src/lib/seo/site.test.ts @@ -1,6 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { getConfiguredSiteUrl, getHomeStructuredData, getSiteOrigin, toAbsoluteUrl } from './site' +import { + HISTORY_PAGE_DESCRIPTION, + HISTORY_PAGE_TITLE, + NOT_FOUND_PAGE_DESCRIPTION, + RECEIVE_PAGE_DESCRIPTION, + RECEIVE_PAGE_TITLE, + SHARE_PAGE_DESCRIPTION, + SHARE_PAGE_TITLE, + getConfiguredSiteUrl, + getHomeStructuredData, + getSiteOrigin, + toAbsoluteUrl, +} from './site' describe('site helpers', () => { afterEach(() => { @@ -50,4 +62,22 @@ describe('site helpers', () => { value: originalWindow, }) }) + + it('keeps page-level seo copy aligned with the messaging guide', () => { + expect(HISTORY_PAGE_TITLE).toBe('Manage End-to-End Encrypted Transfers on This Device | Xdrop') + expect(HISTORY_PAGE_DESCRIPTION).toBe( + 'Manage Xdrop end-to-end encrypted file transfers stored in this browser on this device. Plaintext file names, contents, and keys stay off the server.', + ) + expect(SHARE_PAGE_TITLE).toBe('Share an End-to-End Encrypted Transfer | Xdrop') + expect(SHARE_PAGE_DESCRIPTION).toBe( + 'Share an Xdrop end-to-end encrypted file transfer from this browser on this device while keeping plaintext file names, contents, and keys off the server.', + ) + expect(RECEIVE_PAGE_TITLE).toBe('Receive an End-to-End Encrypted Transfer | Xdrop') + expect(RECEIVE_PAGE_DESCRIPTION).toBe( + 'Receive an Xdrop end-to-end encrypted file transfer and decrypt it locally in the browser, keeping plaintext file names, contents, and keys off the server.', + ) + expect(NOT_FOUND_PAGE_DESCRIPTION).toBe( + 'This page was not found in Xdrop. If this came from an end-to-end encrypted transfer link, ask the sender to resend the complete share details.', + ) + }) }) diff --git a/apps/web/src/lib/seo/site.ts b/apps/web/src/lib/seo/site.ts index 31604a5..08ac5d6 100644 --- a/apps/web/src/lib/seo/site.ts +++ b/apps/web/src/lib/seo/site.ts @@ -11,18 +11,18 @@ export const TERMINAL_SUPPORT_BLURB = export const DEFAULT_SEO_TITLE = 'Open Source End-to-End Encrypted File Transfer for Humans and Agents | Xdrop' export const DEFAULT_SEO_DESCRIPTION = PROJECT_ONE_LINER -export const HISTORY_PAGE_TITLE = 'Manage Transfers on This Device | Xdrop' +export const HISTORY_PAGE_TITLE = 'Manage End-to-End Encrypted Transfers on This Device | Xdrop' export const HISTORY_PAGE_DESCRIPTION = - 'Manage encrypted transfers stored in this browser on this device. There is no account or cross-device history.' -export const SHARE_PAGE_TITLE = 'Share This Transfer | Xdrop' + 'Manage Xdrop end-to-end encrypted file transfers stored in this browser on this device. Plaintext file names, contents, and keys stay off the server.' +export const SHARE_PAGE_TITLE = 'Share an End-to-End Encrypted Transfer | Xdrop' export const SHARE_PAGE_DESCRIPTION = - 'Review upload status and share a transfer staged in this browser on this device without exposing its address on the page.' -export const RECEIVE_PAGE_TITLE = 'Download and Decrypt in the Browser | Xdrop' + 'Share an Xdrop end-to-end encrypted file transfer from this browser on this device while keeping plaintext file names, contents, and keys off the server.' +export const RECEIVE_PAGE_TITLE = 'Receive an End-to-End Encrypted Transfer | Xdrop' export const RECEIVE_PAGE_DESCRIPTION = - 'Download files from this transfer and decrypt them in the browser. The decryption key stays in the browser and never reaches the server.' + 'Receive an Xdrop end-to-end encrypted file transfer and decrypt it locally in the browser, keeping plaintext file names, contents, and keys off the server.' export const NOT_FOUND_PAGE_TITLE = 'Page Not Found | Xdrop' export const NOT_FOUND_PAGE_DESCRIPTION = - 'The address does not map to a page in Xdrop. If this came from a shared transfer, ask the sender to resend the complete share details.' + 'This page was not found in Xdrop. If this came from an end-to-end encrypted transfer link, ask the sender to resend the complete share details.' export const DEFAULT_OG_IMAGE_PATH = '/brand-lockup-horizontal.png' export const DEFAULT_OG_IMAGE_ALT = 'Open source end-to-end encrypted file transfer for humans and agents. Plaintext file names, contents, and keys stay off the server.'