fix: align transfer copy with product messaging
This commit is contained in:
1 parent
5145f36dea
commit
ce0be8e0e3
6 files changed
+136
-35
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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.'
|
||||
|
||||
Reference in new issue
Block a user