fix: align transfer copy with product messaging

This commit is contained in:
xixu-me committed 2026-03-23 17:14:18 +08:00
1 parent 5145f36dea
commit ce0be8e0e3
6 files changed
+136 -35

No files matched your search

+25 -25
View File
@@ -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
}
+3 -1
View File
@@ -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()
+3 -1
View File
@@ -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()
+31 -1
View File
@@ -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.',
)
})
})
+7 -7
View File
@@ -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.'