feat: connect curated shares to public profiles (#519)

* feat: connect curated shares to public profiles

Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5

* chore: retry CI after tunnel failure

Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5

---------

Co-authored-by: Marina Zhou <marina-zhou@mails.agent-kanban.dev>
This commit is contained in:
agent-kanban[bot]
2026-07-24 00:39:25 -04:00
committed by GitHub
parent 9ccea6c846
commit 526d237a4e
41 changed files with 6579 additions and 497 deletions
+318 -152
View File
@@ -1891,6 +1891,7 @@ type CreatedShare struct {
DownloadLimit *int `json:"downloadLimit"`
ExpiresAt *string `json:"expiresAt"`
Kind string `json:"kind"`
ListedAt *string `json:"listedAt"`
Token string `json:"token"`
Urls struct {
Direct *string `json:"direct,omitempty"`
@@ -2450,15 +2451,18 @@ type PublicOrigin = string
// PublicProfile defines model for PublicProfile.
type PublicProfile struct {
Shares []*interface{} `json:"shares"`
User PublicUser `json:"user"`
}
// PublicUser defines model for PublicUser.
type PublicUser struct {
Image *string `json:"image"`
Name string `json:"name"`
Username string `json:"username"`
Shares []struct {
IsFolder bool `json:"isFolder"`
Name string `json:"name"`
Size *int `json:"size"`
Token string `json:"token"`
Type string `json:"type"`
} `json:"shares"`
User struct {
Image *string `json:"image"`
Name string `json:"name"`
Username string `json:"username"`
} `json:"user"`
}
// QuotaEntitlement defines model for QuotaEntitlement.
@@ -2573,6 +2577,7 @@ type ShareListItem struct {
ExpiresAt *string `json:"expiresAt"`
Id string `json:"id"`
Kind string `json:"kind"`
ListedAt *string `json:"listedAt"`
Matter struct {
Dirtype int `json:"dirtype"`
Name string `json:"name"`
@@ -2592,10 +2597,21 @@ type ShareObjects struct {
Name string `json:"name"`
Path string `json:"path"`
} `json:"breadcrumb"`
Items []*interface{} `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
Items []struct {
IsFolder bool `json:"isFolder"`
Name string `json:"name"`
Ref string `json:"ref"`
Size *int `json:"size"`
Type string `json:"type"`
} `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
}
// ShareProfileListing defines model for ShareProfileListing.
type ShareProfileListing struct {
ListedAt *string `json:"listedAt"`
}
// ShareView defines model for ShareView.
@@ -2617,14 +2633,20 @@ type ShareView struct {
Size *int `json:"size"`
Type string `json:"type"`
} `json:"matter"`
MatterId *string `json:"matterId,omitempty"`
OrgId *string `json:"orgId,omitempty"`
Recipients *[]*interface{} `json:"recipients,omitempty"`
RequiresPassword bool `json:"requiresPassword"`
RootRef string `json:"rootRef"`
Status string `json:"status"`
Token string `json:"token"`
Views int `json:"views"`
MatterId *string `json:"matterId,omitempty"`
OrgId *string `json:"orgId,omitempty"`
Recipients *[]struct {
CreatedAt string `json:"createdAt"`
Id string `json:"id"`
RecipientEmail *string `json:"recipientEmail"`
RecipientUserId *string `json:"recipientUserId"`
ShareId string `json:"shareId"`
} `json:"recipients,omitempty"`
RequiresPassword bool `json:"requiresPassword"`
RootRef string `json:"rootRef"`
Status string `json:"status"`
Token string `json:"token"`
Views int `json:"views"`
}
// SignupMode defines model for SignupMode.
@@ -4040,6 +4062,7 @@ type CreateShareJSONBody struct {
RecipientEmail *openapi_types.Email `json:"recipientEmail,omitempty"`
RecipientUserId *string `json:"recipientUserId,omitempty"`
} `json:"recipients,omitempty"`
ShowOnProfile *bool `json:"showOnProfile,omitempty"`
}
// CreateShareJSONBodyKind defines parameters for CreateShare.
@@ -5579,6 +5602,12 @@ type ClientInterface interface {
SaveShare(ctx context.Context, token string, body SaveShareJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// DeleteShareProfileListing request
DeleteShareProfileListing(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error)
// PutShareProfileListing request
PutShareProfileListing(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error)
// VerifySharePasswordWithBody request with any body
VerifySharePasswordWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -5893,9 +5922,6 @@ type ClientInterface interface {
// GetUserProfile request
GetUserProfile(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error)
// ListUserObjects request
ListUserObjects(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error)
}
func (c *Client) GetApiAuthAccountInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
@@ -8430,6 +8456,30 @@ func (c *Client) SaveShare(ctx context.Context, token string, body SaveShareJSON
return c.Client.Do(req)
}
func (c *Client) DeleteShareProfileListing(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewDeleteShareProfileListingRequest(c.Server, token)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) PutShareProfileListing(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPutShareProfileListingRequest(c.Server, token)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) VerifySharePasswordWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewVerifySharePasswordRequestWithBody(c.Server, token, contentType, body)
if err != nil {
@@ -9810,18 +9860,6 @@ func (c *Client) GetUserProfile(ctx context.Context, username string, reqEditors
return c.Client.Do(req)
}
func (c *Client) ListUserObjects(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListUserObjectsRequest(c.Server, username)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
// NewGetApiAuthAccountInfoRequest generates requests for GetApiAuthAccountInfo
func NewGetApiAuthAccountInfoRequest(server string) (*http.Request, error) {
var err error
@@ -15695,6 +15733,74 @@ func NewSaveShareRequestWithBody(server string, token string, contentType string
return req, nil
}
// NewDeleteShareProfileListingRequest generates requests for DeleteShareProfileListing
func NewDeleteShareProfileListingRequest(server string, token string) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "token", token, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/shares/%s/profile-listing", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewPutShareProfileListingRequest generates requests for PutShareProfileListing
func NewPutShareProfileListingRequest(server string, token string) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "token", token, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/shares/%s/profile-listing", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPut, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewVerifySharePasswordRequest calls the generic VerifySharePassword builder with application/json body
func NewVerifySharePasswordRequest(server string, token string, body VerifySharePasswordJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
@@ -19162,40 +19268,6 @@ func NewGetUserProfileRequest(server string, username string) (*http.Request, er
return req, nil
}
// NewListUserObjectsRequest generates requests for ListUserObjects
func NewListUserObjectsRequest(server string, username string) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "username", username, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/users/%s/objects", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error {
for _, r := range c.RequestEditors {
if err := r(ctx, req); err != nil {
@@ -19794,6 +19866,12 @@ type ClientWithResponsesInterface interface {
SaveShareWithResponse(ctx context.Context, token string, body SaveShareJSONRequestBody, reqEditors ...RequestEditorFn) (*SaveShareResponse, error)
// DeleteShareProfileListingWithResponse request
DeleteShareProfileListingWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*DeleteShareProfileListingResponse, error)
// PutShareProfileListingWithResponse request
PutShareProfileListingWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*PutShareProfileListingResponse, error)
// VerifySharePasswordWithBodyWithResponse request with any body
VerifySharePasswordWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*VerifySharePasswordResponse, error)
@@ -20108,9 +20186,6 @@ type ClientWithResponsesInterface interface {
// GetUserProfileWithResponse request
GetUserProfileWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*GetUserProfileResponse, error)
// ListUserObjectsWithResponse request
ListUserObjectsWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*ListUserObjectsResponse, error)
}
type GetApiAuthAccountInfoResponse struct {
@@ -26316,6 +26391,71 @@ func (r SaveShareResponse) ContentType() string {
return ""
}
type DeleteShareProfileListingResponse struct {
Body []byte
HTTPResponse *http.Response
JSON400 *Error
JSON403 *Error
JSON404 *Error
}
// Status returns HTTPResponse.Status
func (r DeleteShareProfileListingResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r DeleteShareProfileListingResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r DeleteShareProfileListingResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type PutShareProfileListingResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *ShareProfileListing
JSON400 *Error
JSON403 *Error
JSON404 *Error
}
// Status returns HTTPResponse.Status
func (r PutShareProfileListingResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r PutShareProfileListingResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r PutShareProfileListingResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type VerifySharePasswordResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -29013,40 +29153,6 @@ func (r GetUserProfileResponse) ContentType() string {
return ""
}
type ListUserObjectsResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *struct {
Breadcrumb []*interface{} `json:"breadcrumb"`
Items []*interface{} `json:"items"`
}
JSON404 *Error
}
// Status returns HTTPResponse.Status
func (r ListUserObjectsResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r ListUserObjectsResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r ListUserObjectsResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
// GetApiAuthAccountInfoWithResponse request returning *GetApiAuthAccountInfoResponse
func (c *ClientWithResponses) GetApiAuthAccountInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiAuthAccountInfoResponse, error) {
rsp, err := c.GetApiAuthAccountInfo(ctx, reqEditors...)
@@ -30868,6 +30974,24 @@ func (c *ClientWithResponses) SaveShareWithResponse(ctx context.Context, token s
return ParseSaveShareResponse(rsp)
}
// DeleteShareProfileListingWithResponse request returning *DeleteShareProfileListingResponse
func (c *ClientWithResponses) DeleteShareProfileListingWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*DeleteShareProfileListingResponse, error) {
rsp, err := c.DeleteShareProfileListing(ctx, token, reqEditors...)
if err != nil {
return nil, err
}
return ParseDeleteShareProfileListingResponse(rsp)
}
// PutShareProfileListingWithResponse request returning *PutShareProfileListingResponse
func (c *ClientWithResponses) PutShareProfileListingWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*PutShareProfileListingResponse, error) {
rsp, err := c.PutShareProfileListing(ctx, token, reqEditors...)
if err != nil {
return nil, err
}
return ParsePutShareProfileListingResponse(rsp)
}
// VerifySharePasswordWithBodyWithResponse request with arbitrary body returning *VerifySharePasswordResponse
func (c *ClientWithResponses) VerifySharePasswordWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*VerifySharePasswordResponse, error) {
rsp, err := c.VerifySharePasswordWithBody(ctx, token, contentType, body, reqEditors...)
@@ -31873,15 +31997,6 @@ func (c *ClientWithResponses) GetUserProfileWithResponse(ctx context.Context, us
return ParseGetUserProfileResponse(rsp)
}
// ListUserObjectsWithResponse request returning *ListUserObjectsResponse
func (c *ClientWithResponses) ListUserObjectsWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*ListUserObjectsResponse, error) {
rsp, err := c.ListUserObjects(ctx, username, reqEditors...)
if err != nil {
return nil, err
}
return ParseListUserObjectsResponse(rsp)
}
// ParseGetApiAuthAccountInfoResponse parses an HTTP response from a GetApiAuthAccountInfoWithResponse call
func ParseGetApiAuthAccountInfoResponse(rsp *http.Response) (*GetApiAuthAccountInfoResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
@@ -41141,6 +41256,93 @@ func ParseSaveShareResponse(rsp *http.Response) (*SaveShareResponse, error) {
return response, nil
}
// ParseDeleteShareProfileListingResponse parses an HTTP response from a DeleteShareProfileListingWithResponse call
func ParseDeleteShareProfileListingResponse(rsp *http.Response) (*DeleteShareProfileListingResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &DeleteShareProfileListingResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON400 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON403 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON404 = &dest
}
return response, nil
}
// ParsePutShareProfileListingResponse parses an HTTP response from a PutShareProfileListingWithResponse call
func ParsePutShareProfileListingResponse(rsp *http.Response) (*PutShareProfileListingResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &PutShareProfileListingResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest ShareProfileListing
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON400 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON403 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON404 = &dest
}
return response, nil
}
// ParseVerifySharePasswordResponse parses an HTTP response from a VerifySharePasswordWithResponse call
func ParseVerifySharePasswordResponse(rsp *http.Response) (*VerifySharePasswordResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
@@ -44172,39 +44374,3 @@ func ParseGetUserProfileResponse(rsp *http.Response) (*GetUserProfileResponse, e
return response, nil
}
// ParseListUserObjectsResponse parses an HTTP response from a ListUserObjectsWithResponse call
func ParseListUserObjectsResponse(rsp *http.Response) (*ListUserObjectsResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &ListUserObjectsResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest struct {
Breadcrumb []*interface{} `json:"breadcrumb"`
Items []*interface{} `json:"items"`
}
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON404 = &dest
}
return response, nil
}
+73
View File
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test'
const folderShare = {
token: 'profile-folder',
kind: 'landing',
status: 'active',
expiresAt: null,
downloadLimit: null,
matter: { name: 'Photos', type: 'folder', size: 0, isFolder: true },
creatorName: 'Alice',
requiresPassword: false,
expired: false,
exhausted: false,
accessibleByUser: false,
downloads: 0,
views: 0,
rootRef: 'profile-folder-root',
}
test('anonymous profile opens curated files and folders through the landing-share flow @desktop [spec: profile/share-flow]', async ({
page,
}) => {
await page.route('**/api/users/alice', (route) =>
route.fulfill({
json: {
user: { username: 'alice', name: 'Alice', image: null },
shares: [
{ token: 'profile-file', name: 'Brief.txt', type: 'text/plain', size: 50, isFolder: false },
{ token: folderShare.token, name: 'Photos', type: 'folder', size: 0, isFolder: true },
],
},
}),
)
await page.route(`**/api/shares/${folderShare.token}`, (route) => route.fulfill({ json: folderShare }))
await page.route(`**/api/shares/${folderShare.token}/objects?*`, (route) => {
const parent = new URL(route.request().url()).searchParams.get('parent') ?? ''
return route.fulfill({
json:
parent === 'Albums'
? {
items: [{ ref: 'summer-ref', name: 'summer.jpg', type: 'image/jpeg', size: 128, isFolder: false }],
total: 1,
page: 1,
pageSize: 50,
breadcrumb: [
{ name: 'Photos', path: '' },
{ name: 'Albums', path: 'Albums' },
],
}
: {
items: [{ ref: 'albums-ref', name: 'Albums', type: 'folder', size: 0, isFolder: true }],
total: 1,
page: 1,
pageSize: 50,
breadcrumb: [{ name: 'Photos', path: '' }],
},
})
})
await page.goto('/u/alice')
await expect(page.getByText('@alice')).toBeVisible()
await expect(page.locator('a[href="/s/profile-file"]')).toContainText('Brief.txt')
const folderLink = page.locator(`a[href="/s/${folderShare.token}"]`)
await expect(folderLink).toContainText('Photos')
await folderLink.click()
await expect(page).toHaveURL(`/s/${folderShare.token}`)
await expect(page.getByTestId('page-header')).toContainText('Photos')
await page.getByRole('button', { name: 'Albums' }).dblclick()
await expect(page.getByTestId('page-header')).toContainText('Albums')
await expect(page.getByText('summer.jpg')).toBeVisible()
})
@@ -0,0 +1,2 @@
ALTER TABLE `shares` ADD `listed_at` integer;--> statement-breakpoint
CREATE INDEX `shares_creator_listed_idx` ON `shares` (`creator_id`,`listed_at`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -484,6 +484,13 @@
"when": 1784842525068,
"tag": "0069_storage-health-status-vocabulary",
"breakpoints": true
},
{
"idx": 70,
"version": "6",
"when": 1784850571453,
"tag": "0070_profile-share-listing",
"breakpoints": true
}
]
}
+55
View File
@@ -34,6 +34,12 @@ export function createShareRepo(db: Database): ShareRepo {
if (input.kind === 'direct' && input.password) throw new CreateShareError('DIRECT_NO_PASSWORD')
if (input.kind === 'direct' && input.recipients && input.recipients.length > 0)
throw new CreateShareError('DIRECT_NO_RECIPIENTS')
if (
input.showOnProfile &&
(input.kind !== 'landing' || (input.recipients != null && input.recipients.length > 0))
) {
throw new CreateShareError('PROFILE_LISTING_INELIGIBLE')
}
const matter = await db
.select()
@@ -59,6 +65,7 @@ export function createShareRepo(db: Database): ShareRepo {
views: 0,
downloads: 0,
status: 'active',
listedAt: input.showOnProfile ? now : null,
createdAt: now,
}
@@ -175,6 +182,52 @@ export function createShareRepo(db: Database): ShareRepo {
return result.length > 0
},
async setProfileListing(token: string, creatorId: string, listedAt: Date | null): Promise<boolean> {
const result = await db
.update(shares)
.set({ listedAt })
.where(and(eq(shares.token, token), eq(shares.creatorId, creatorId)))
.returning({ id: shares.id })
return result.length > 0
},
async listPublicProfileShares(username: string, now: Date) {
const nowSecs = Math.floor(now.getTime() / 1000)
const rows = await db
.select({
token: shares.token,
name: matters.name,
type: matters.type,
size: matters.size,
dirtype: matters.dirtype,
})
.from(shares)
.innerJoin(user, eq(shares.creatorId, user.id))
.innerJoin(matters, eq(shares.matterId, matters.id))
.where(
and(
eq(user.username, username),
isNotNull(shares.listedAt),
eq(shares.kind, 'landing'),
eq(shares.status, 'active'),
or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`),
or(isNull(shares.downloadLimit), sql`${shares.downloads} < ${shares.downloadLimit}`),
eq(matters.status, 'active'),
isNull(matters.trashedAt),
isNull(matters.purgedAt),
sql`NOT EXISTS (
SELECT 1
FROM ${shareRecipients}
WHERE ${shareRecipients.shareId} = ${shares.id}
)`,
),
)
.orderBy(desc(shares.listedAt), desc(shares.id))
return rows.map(({ dirtype, ...row }) => ({ ...row, isFolder: dirtype !== DirType.FILE }))
},
async listForApi(
creatorId: string,
opts: { page: number; pageSize: number; status?: string },
@@ -200,6 +253,7 @@ export function createShareRepo(db: Database): ShareRepo {
views: shares.views,
downloads: shares.downloads,
status: shares.status,
listedAt: shares.listedAt,
createdAt: shares.createdAt,
matterName: matters.name,
matterType: matters.type,
@@ -257,6 +311,7 @@ export function createShareRepo(db: Database): ShareRepo {
views: shares.views,
downloads: shares.downloads,
status: shares.status,
listedAt: shares.listedAt,
createdAt: shares.createdAt,
matterName: matters.name,
matterType: matters.type,
+2
View File
@@ -565,10 +565,12 @@ export const shares = sqliteTable(
views: integer('views').notNull().default(0),
downloads: integer('downloads').notNull().default(0),
status: text('status').notNull().default('active'), // 'active' | 'revoked'
listedAt: integer('listed_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
},
(t) => [
index('shares_creator_status_created_idx').on(t.creatorId, t.status, t.createdAt),
index('shares_creator_listed_idx').on(t.creatorId, t.listedAt),
index('shares_created_idx').on(t.createdAt),
],
)
+125
View File
@@ -257,6 +257,59 @@ describe('POST /api/shares', () => {
expect(body.downloadLimit).toBe(5)
})
it('atomically lists an eligible landing share at creation time [spec: shares/create-profile-listing]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'profile-create', name: 'homepage.txt' })
const res = await createShare(app, headers, {
matterId: 'profile-create',
kind: 'landing',
showOnProfile: true,
})
expect(res.status).toBe(201)
const body = (await res.json()) as { token: string; listedAt: string | null }
expect(body.listedAt).not.toBeNull()
const rows = await db
.select({ listedAt: shares.listedAt, status: shares.status })
.from(shares)
.where(eq(shares.token, body.token))
expect(rows[0]?.listedAt).toBeInstanceOf(Date)
expect(rows[0]?.status).toBe('active')
})
it('rejects forged profile listing on direct and recipient-targeted shares', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'profile-ineligible', name: 'private.txt' })
const direct = await createShare(app, headers, {
matterId: 'profile-ineligible',
kind: 'direct',
showOnProfile: true,
})
expect(direct.status).toBe(400)
expect(((await direct.json()) as { error: { details: Array<{ reason: string }> } }).error.details[0]?.reason).toBe(
'PROFILE_LISTING_INELIGIBLE',
)
const targeted = await createShare(app, headers, {
matterId: 'profile-ineligible',
kind: 'landing',
recipients: [{ recipientEmail: 'private@example.com' }],
showOnProfile: true,
})
expect(targeted.status).toBe(400)
expect(
((await targeted.json()) as { error: { details: Array<{ reason: string }> } }).error.details[0]?.reason,
).toBe('PROFILE_LISTING_INELIGIBLE')
})
it('returns 400 with DIRECT_NO_RECIPIENTS when creating direct share with recipients [spec: shares/direct-no-recipients]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
@@ -306,6 +359,78 @@ describe('POST /api/shares', () => {
})
})
// ─── PUT/DELETE /api/shares/:token/profile-listing ───────────────────────────
function profileListingRequest(
app: TestApp,
token: string,
method: 'PUT' | 'DELETE',
headers?: Record<string, string>,
) {
return app.request(`/api/shares/${token}/profile-listing`, { method, headers })
}
describe('share profile listing mutation', () => {
it('requires authentication', async () => {
const { app } = await createTestApp()
expect((await profileListingRequest(app, 'unknown', 'PUT')).status).toBe(401)
expect((await profileListingRequest(app, 'unknown', 'DELETE')).status).toBe(401)
})
it('lets only the owner list and unlist, without revoking the landing share [spec: shares/profile-listing-owner] [spec: shares/profile-listing-authorization] [spec: shares/profile-unlist-preserves-access]', async () => {
const { app, db } = await createTestApp()
const ownerHeaders = await authedHeaders(app, `profile-owner-${nanoid()}@example.com`)
await insertStorage(db)
const ownerOrgId = await getOrgId(db)
await insertFile(db, ownerOrgId, { id: 'profile-toggle', name: 'toggle.txt' })
const created = await createShare(app, ownerHeaders, { matterId: 'profile-toggle', kind: 'landing' })
const token = ((await created.json()) as { token: string }).token
const otherHeaders = await authedHeaders(app, `profile-other-${nanoid()}@example.com`)
expect((await profileListingRequest(app, token, 'PUT', otherHeaders)).status).toBe(403)
const listed = await profileListingRequest(app, token, 'PUT', ownerHeaders)
expect(listed.status).toBe(200)
expect(((await listed.json()) as { listedAt: string | null }).listedAt).not.toBeNull()
const unlisted = await profileListingRequest(app, token, 'DELETE', ownerHeaders)
expect(unlisted.status).toBe(204)
const rows = await db
.select({ listedAt: shares.listedAt, status: shares.status })
.from(shares)
.where(eq(shares.token, token))
expect(rows[0]).toEqual({ listedAt: null, status: 'active' })
const landing = await app.request(`/api/shares/${token}`)
expect(landing.status).toBe(200)
})
it('rejects forged listing mutations for direct and recipient-targeted shares [spec: shares/profile-listing-ineligible]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app, `profile-ineligible-owner-${nanoid()}@example.com`)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'profile-direct-toggle', name: 'direct.txt' })
await insertFile(db, orgId, { id: 'profile-targeted-toggle', name: 'targeted.txt' })
const directCreated = await createShare(app, headers, { matterId: 'profile-direct-toggle', kind: 'direct' })
const directToken = ((await directCreated.json()) as { token: string }).token
const targetedCreated = await createShare(app, headers, {
matterId: 'profile-targeted-toggle',
kind: 'landing',
recipients: [{ recipientEmail: 'recipient@example.com' }],
})
const targetedToken = ((await targetedCreated.json()) as { token: string }).token
for (const token of [directToken, targetedToken]) {
const res = await profileListingRequest(app, token, 'PUT', headers)
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0]?.reason).toBe('PROFILE_LISTING_INELIGIBLE')
}
})
})
// ─── GET /api/shares auth guard ───────────────────────────────────────────────
describe('GET /api/shares (auth guard)', () => {
+73 -13
View File
@@ -3,7 +3,13 @@ import type { Context } from 'hono'
import { getCookie, setCookie } from 'hono/cookie'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { pageSchema } from '../../shared/schemas'
import { createShareRequestSchema, listSharesQuerySchema, saveShareRequestSchema } from '../../shared/schemas/share'
import {
createShareRequestSchema,
listSharesQuerySchema,
saveShareRequestSchema,
shareObjectsResponseSchema,
shareRecipientViewSchema,
} from '../../shared/schemas/share'
import { transferAuditActor } from '../middleware/audit-transfers'
import { requireAuth, requireTeamRole } from '../middleware/auth'
import type { Env } from '../middleware/platform'
@@ -17,6 +23,7 @@ import {
type ShareCreatorDto,
type ShareViewerDto,
saveShare,
setProfileListing,
verifySharePassword,
viewShare,
} from '../usecases/share'
@@ -59,7 +66,7 @@ const shareViewSchema = z
orgId: z.string().optional(),
creatorId: z.string().optional(),
createdAt: z.string().optional(),
recipients: z.array(z.unknown()).optional(),
recipients: z.array(shareRecipientViewSchema).optional(),
})
.openapi('ShareView')
@@ -88,7 +95,10 @@ function toShareViewDTO(dto: ShareViewerDto | ShareCreatorDto): z.infer<typeof s
orgId: dto.orgId,
creatorId: dto.creatorId,
createdAt: dto.createdAt.toISOString(),
recipients: dto.recipients,
recipients: dto.recipients.map((recipient) => ({
...recipient,
createdAt: recipient.createdAt.toISOString(),
})),
}
}
return base
@@ -107,6 +117,7 @@ const shareListItemSchema = z
views: z.number().int(),
downloads: z.number().int(),
status: z.string(),
listedAt: z.string().nullable(),
createdAt: z.string(),
matter: z.object({ name: z.string(), type: z.string(), dirtype: z.number().int() }),
recipientCount: z.number().int(),
@@ -115,20 +126,17 @@ const shareListItemSchema = z
.openapi('ShareListItem')
function toShareListItemDTO(s: ShareListItem): z.infer<typeof shareListItemSchema> {
return { ...s, expiresAt: s.expiresAt ? s.expiresAt.toISOString() : null, createdAt: s.createdAt.toISOString() }
return {
...s,
expiresAt: s.expiresAt ? s.expiresAt.toISOString() : null,
listedAt: s.listedAt ? s.listedAt.toISOString() : null,
createdAt: s.createdAt.toISOString(),
}
}
const shareListSchema = pageSchema(shareListItemSchema, 'ShareList')
const shareObjectsSchema = z
.object({
items: z.array(z.unknown()),
total: z.number().int(),
page: z.number().int(),
pageSize: z.number().int(),
breadcrumb: z.array(z.object({ name: z.string(), path: z.string() })),
})
.openapi('ShareObjects')
const shareObjectsSchema = shareObjectsResponseSchema.openapi('ShareObjects')
const createdShareSchema = z
.object({
@@ -137,6 +145,7 @@ const createdShareSchema = z
urls: z.object({ landing: z.string().optional(), direct: z.string().optional() }),
expiresAt: z.string().nullable(),
downloadLimit: z.number().int().nullable(),
listedAt: z.string().nullable(),
})
.openapi('CreatedShare')
@@ -378,6 +387,38 @@ const revokeShareRoute = createRoute({
},
})
const profileListingSchema = z.object({ listedAt: z.string().nullable() }).openapi('ShareProfileListing')
const putProfileListingRoute = createRoute({
operationId: 'putShareProfileListing',
summary: 'Show a share on the owner public profile',
tags: ['Shares'],
method: 'put',
path: '/{token}/profile-listing',
request: { params: z.object({ token: z.string() }) },
responses: {
200: jsonContent(profileListingSchema, 'Profile listing'),
400: errorResponse('Share is not eligible for profile listing'),
403: errorResponse('Forbidden'),
404: errorResponse('Not found'),
},
})
const deleteProfileListingRoute = createRoute({
operationId: 'deleteShareProfileListing',
summary: 'Remove a share from the owner public profile',
tags: ['Shares'],
method: 'delete',
path: '/{token}/profile-listing',
request: { params: z.object({ token: z.string() }) },
responses: {
204: { description: 'Profile listing removed' },
400: errorResponse('Share is not eligible for profile listing'),
403: errorResponse('Forbidden'),
404: errorResponse('Not found'),
},
})
const saveShareRoute = createRoute({
operationId: 'saveShare',
summary: 'Save a share to my drive',
@@ -420,12 +461,31 @@ export const authedShares = authedApp
urls: shareUrls(out.share.kind, out.share.token),
expiresAt: out.share.expiresAt ? out.share.expiresAt.toISOString() : null,
downloadLimit: out.share.downloadLimit,
listedAt: out.share.listedAt ? out.share.listedAt.toISOString() : null,
},
201,
)
}
throw out.error
})
.openapi(putProfileListingRoute, async (c) => {
const out = await setProfileListing(c.get('deps'), {
token: c.req.valid('param').token,
userId: c.get('userId')!,
listed: true,
})
if (out.ok) return c.json({ listedAt: out.listedAt?.toISOString() ?? null }, 200)
throw out.error
})
.openapi(deleteProfileListingRoute, async (c) => {
const out = await setProfileListing(c.get('deps'), {
token: c.req.valid('param').token,
userId: c.get('userId')!,
listed: false,
})
if (out.ok) return c.body(null, 204)
throw out.error
})
.openapi(revokeShareRoute, async (c) => {
const out = await revokeShare(c.get('deps'), {
token: c.req.valid('param').token,
+272
View File
@@ -0,0 +1,272 @@
import { env } from 'cloudflare:workers'
import { sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { createShareRepo } from '../adapters/repos/share'
import { createApp } from '../app'
import { createAuth } from '../auth'
import { createCloudflarePlatform } from '../platform/cloudflare'
type TestApp = ReturnType<typeof createApp>
type TestDb = ReturnType<typeof createCloudflarePlatform>['db']
async function buildApp() {
const platform = createCloudflarePlatform(env)
const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET)
return { app: createApp(platform, auth), db: platform.db }
}
async function signUp(app: TestApp, db: TestDb, username: string) {
const emailLocalPart = `${username}-${nanoid(6)}`.toLowerCase()
const email = `${emailLocalPart}@example.com`
const response = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: username, email, password: 'password123456' }),
})
expect(response.status).toBe(200)
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email} LIMIT 1`)
const userId = users[0]?.id
if (!userId) throw new Error(`Missing signed-up user for ${email}`)
await db.run(sql`UPDATE user SET username = ${username}, display_username = ${username} WHERE id = ${userId}`)
const organizations = await db.all<{ id: string }>(sql`
SELECT organization.id
FROM organization
INNER JOIN member ON member.organization_id = organization.id
WHERE member.user_id = ${userId}
AND COALESCE(organization.metadata, '') LIKE '%"type":"personal"%'
LIMIT 1
`)
const orgId = organizations[0]?.id
if (!orgId) throw new Error(`Missing personal organization for ${email}`)
return {
headers: { Cookie: response.headers.getSetCookie().join('; ') },
orgId,
userId,
username,
}
}
async function insertMatter(
db: TestDb,
orgId: string,
name: string,
options: { dirtype?: number; status?: string } = {},
) {
const id = `matter-${nanoid()}`
const now = Date.now()
const dirtype = options.dirtype ?? 0
await db.run(sql`
INSERT INTO matters
(id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
VALUES
(
${id},
${orgId},
${`alias-${nanoid()}`},
${name},
${dirtype === 0 ? 'text/plain' : 'folder'},
${dirtype === 0 ? 128 : 0},
${dirtype},
'',
${dirtype === 0 ? `objects/${id}` : ''},
'profile-cf-storage',
${options.status ?? 'active'},
${now},
${now}
)
`)
return id
}
function createShare(app: TestApp, headers: Record<string, string>, body: Record<string, unknown>) {
return app.request('/api/shares', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('[CF] public profile shares', () => {
it('lists a landing share selected at creation and unlisting leaves the share usable', async () => {
const { app, db } = await buildApp()
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
const matterId = await insertMatter(db, owner.orgId, 'Public guide.txt')
const creation = await createShare(app, owner.headers, {
matterId,
kind: 'landing',
showOnProfile: true,
})
expect(creation.status).toBe(201)
const created = (await creation.json()) as { token: string; listedAt: string | null }
expect(created.listedAt).toEqual(expect.any(String))
const profile = await app.request(`/api/users/${owner.username}`)
expect(profile.status).toBe(200)
expect(await profile.json()).toMatchObject({
user: { username: owner.username },
shares: [
{
token: created.token,
name: 'Public guide.txt',
type: 'text/plain',
size: 128,
isFolder: false,
},
],
})
const unlisted = await app.request(`/api/shares/${created.token}/profile-listing`, {
method: 'DELETE',
headers: owner.headers,
})
expect(unlisted.status).toBe(204)
const afterUnlisting = await app.request(`/api/users/${owner.username}`)
expect(((await afterUnlisting.json()) as { shares: unknown[] }).shares).toEqual([])
const underlyingShare = await app.request(`/api/shares/${created.token}`)
expect(underlyingShare.status).toBe(200)
})
it('requires authentication and ownership to change a profile listing', async () => {
const { app, db } = await buildApp()
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
const other = await signUp(app, db, `profile-other-${nanoid(5)}`)
const matterId = await insertMatter(db, owner.orgId, 'Owner only.txt')
const share = await createShareRepo(db).create({
matterId,
orgId: owner.orgId,
creatorId: owner.userId,
kind: 'landing',
})
const anonymous = await app.request(`/api/shares/${share.token}/profile-listing`, { method: 'PUT' })
expect(anonymous.status).toBe(401)
const nonOwner = await app.request(`/api/shares/${share.token}/profile-listing`, {
method: 'PUT',
headers: other.headers,
})
expect(nonOwner.status).toBe(403)
const ownerMutation = await app.request(`/api/shares/${share.token}/profile-listing`, {
method: 'PUT',
headers: owner.headers,
})
expect(ownerMutation.status).toBe(200)
const profile = await app.request(`/api/users/${owner.username}`)
expect(((await profile.json()) as { shares: Array<{ token: string }> }).shares.map((item) => item.token)).toEqual([
share.token,
])
})
it('rejects forged listing requests and never exposes direct or recipient-targeted shares', async () => {
const { app, db } = await buildApp()
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
const matterId = await insertMatter(db, owner.orgId, 'Privacy boundary.txt')
const repo = createShareRepo(db)
const visible = await repo.create({
matterId,
orgId: owner.orgId,
creatorId: owner.userId,
kind: 'landing',
showOnProfile: true,
})
const direct = await repo.create({
matterId,
orgId: owner.orgId,
creatorId: owner.userId,
kind: 'direct',
})
const targeted = await repo.create({
matterId,
orgId: owner.orgId,
creatorId: owner.userId,
kind: 'landing',
recipients: [{ recipientEmail: 'recipient@example.com' }],
})
const forgedListedAt = Date.now()
await db.run(
sql`UPDATE shares SET listed_at = ${forgedListedAt} WHERE token IN (${direct.token}, ${targeted.token})`,
)
for (const token of [direct.token, targeted.token]) {
const mutation = await app.request(`/api/shares/${token}/profile-listing`, {
method: 'PUT',
headers: owner.headers,
})
expect(mutation.status).toBe(400)
}
for (const body of [
{ matterId, kind: 'direct', showOnProfile: true },
{
matterId,
kind: 'landing',
recipients: [{ recipientEmail: 'recipient@example.com' }],
showOnProfile: true,
},
]) {
const creation = await createShare(app, owner.headers, body)
expect(creation.status).toBe(400)
}
const profile = await app.request(`/api/users/${owner.username}`)
expect(((await profile.json()) as { shares: Array<{ token: string }> }).shares.map((item) => item.token)).toEqual([
visible.token,
])
})
it('filters revoked, expired, exhausted, trashed, inactive, missing, and unlisted targets at read time', async () => {
const { app, db } = await buildApp()
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
const repo = createShareRepo(db)
async function listedShare(name: string, options: { expiresAt?: Date; downloadLimit?: number } = {}) {
const matterId = await insertMatter(db, owner.orgId, name)
const share = await repo.create({
matterId,
orgId: owner.orgId,
creatorId: owner.userId,
kind: 'landing',
showOnProfile: true,
...options,
})
return { matterId, share }
}
const available = await listedShare('Available.txt')
const revoked = await listedShare('Revoked.txt')
await listedShare('Expired.txt', { expiresAt: new Date(Date.now() - 60_000) })
const exhausted = await listedShare('Exhausted.txt', { downloadLimit: 1 })
const trashed = await listedShare('Trashed.txt')
const inactive = await listedShare('Inactive.txt')
const missing = await listedShare('Missing.txt')
const unlistedMatterId = await insertMatter(db, owner.orgId, 'Unlisted.txt')
await repo.create({
matterId: unlistedMatterId,
orgId: owner.orgId,
creatorId: owner.userId,
kind: 'landing',
})
await db.run(sql`UPDATE shares SET status = 'revoked' WHERE id = ${revoked.share.id}`)
await db.run(sql`UPDATE shares SET downloads = 1 WHERE id = ${exhausted.share.id}`)
await db.run(sql`UPDATE matters SET trashed_at = ${Date.now()} WHERE id = ${trashed.matterId}`)
await db.run(sql`UPDATE matters SET status = 'processing' WHERE id = ${inactive.matterId}`)
await db.run(sql`DELETE FROM matters WHERE id = ${missing.matterId}`)
const profile = await app.request(`/api/users/${owner.username}`)
expect(profile.status).toBe(200)
expect(((await profile.json()) as { shares: Array<{ token: string }> }).shares.map((item) => item.token)).toEqual([
available.share.token,
])
})
})
+147 -35
View File
@@ -1,6 +1,5 @@
import { sql } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildBreadcrumb } from '../domain/breadcrumb.js'
import { authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup.js'
async function adminHeaders(app: ReturnType<typeof import('../app')['createApp']>) {
@@ -574,6 +573,69 @@ async function insertUser(
return { orgId: `org-${opts.id}` }
}
type ProfileTestDb = Awaited<ReturnType<typeof createTestApp>>['db']
async function insertProfileMatter(
db: ProfileTestDb,
orgId: string,
id: string,
opts: {
name?: string
status?: string
dirtype?: number
trashedAt?: number | null
purgedAt?: number | null
} = {},
) {
const now = Math.floor(Date.now() / 1000)
const dirtype = opts.dirtype ?? 0
await db.run(sql`
INSERT INTO matters (
id, org_id, alias, name, type, size, dirtype, parent, object, storage_id,
status, trashed_at, purged_at, created_at, updated_at
)
VALUES (
${id}, ${orgId}, ${`${id}-alias`}, ${opts.name ?? `${id}.txt`},
${dirtype === 0 ? 'text/plain' : 'folder'}, ${dirtype === 0 ? 100 : 0}, ${dirtype},
'', ${dirtype === 0 ? `objects/${id}` : ''}, 'profile-storage',
${opts.status ?? 'active'}, ${opts.trashedAt ?? null}, ${opts.purgedAt ?? null}, ${now}, ${now}
)
`)
}
async function insertProfileShare(
db: ProfileTestDb,
creatorId: string,
orgId: string,
matterId: string,
opts: {
id?: string
token?: string
kind?: 'landing' | 'direct'
status?: 'active' | 'revoked'
listed?: boolean
expiresAt?: number | null
downloadLimit?: number | null
downloads?: number
} = {},
) {
const now = Math.floor(Date.now() / 1000)
const id = opts.id ?? `share-${matterId}`
const token = opts.token ?? `token-${matterId}`
await db.run(sql`
INSERT INTO shares (
id, token, kind, matter_id, org_id, creator_id, password_hash, expires_at,
download_limit, views, downloads, status, listed_at, created_at
)
VALUES (
${id}, ${token}, ${opts.kind ?? 'landing'}, ${matterId}, ${orgId}, ${creatorId},
NULL, ${opts.expiresAt ?? null}, ${opts.downloadLimit ?? null}, 0, ${opts.downloads ?? 0},
${opts.status ?? 'active'}, ${opts.listed === false ? null : now}, ${now}
)
`)
return { id, token }
}
describe('GET /api/users/:username', () => {
it('returns 404 when user does not exist [spec: profile/user-not-found]', async () => {
const { app } = await createTestApp()
@@ -616,43 +678,93 @@ describe('GET /api/users/:username', () => {
expect(body.user.username).toBe('orphanuser')
expect(body.shares).toEqual([])
})
})
describe('GET /api/users/:username/objects', () => {
it('returns 404 for unknown username [spec: profile/unknown-username]', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/users/nonexistent/objects')
expect(res.status).toBe(404)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('User not found')
})
it('returns empty items and breadcrumb for known user [spec: profile/empty-listing]', async () => {
it('returns exactly the selected public landing shares without authentication [spec: profile/curated-shares]', async () => {
const { app, db } = await createTestApp()
await insertUser(db, { id: 'user-1', username: 'testuser', email: 'test@example.com' })
const { orgId } = await insertUser(db, {
id: 'curated-user',
username: 'curated',
email: 'curated@example.com',
})
await insertProfileMatter(db, orgId, 'curated-file', { name: 'Public.txt' })
await insertProfileMatter(db, orgId, 'curated-folder', { name: 'Photos', dirtype: 1 })
await insertProfileMatter(db, orgId, 'unlisted-file', { name: 'Hidden.txt' })
await insertProfileShare(db, 'curated-user', orgId, 'curated-file', { token: 'public-file' })
await insertProfileShare(db, 'curated-user', orgId, 'curated-folder', { token: 'public-folder' })
await insertProfileShare(db, 'curated-user', orgId, 'unlisted-file', {
token: 'hidden-file',
listed: false,
})
const res = await app.request('/api/users/curated')
const res = await app.request('/api/users/testuser/objects')
expect(res.status).toBe(200)
const body = (await res.json()) as { items: unknown[]; breadcrumb: string[] }
expect(body.items).toEqual([])
expect(body.breadcrumb).toEqual([])
})
})
describe('buildBreadcrumb', () => {
it('returns empty array for empty string', () => {
expect(buildBreadcrumb('')).toEqual([])
})
it('returns single segment for a simple name', () => {
expect(buildBreadcrumb('photos')).toEqual(['photos'])
})
it('splits nested path into segments [spec: profile/breadcrumb-segments]', () => {
expect(buildBreadcrumb('a/b/c')).toEqual(['a', 'b', 'c'])
})
it('returns two segments for one-level-deep path', () => {
expect(buildBreadcrumb('Parent/Child')).toEqual(['Parent', 'Child'])
const body = (await res.json()) as {
shares: Array<{ token: string; name: string; type: string; size: number | null; isFolder: boolean }>
}
expect(body.shares).toEqual([
{ token: 'public-folder', name: 'Photos', type: 'folder', size: 0, isFolder: true },
{ token: 'public-file', name: 'Public.txt', type: 'text/plain', size: 100, isFolder: false },
])
})
it('never leaks forged listed direct or recipient-targeted shares [spec: profile/privacy-boundaries]', async () => {
const { app, db } = await createTestApp()
const { orgId } = await insertUser(db, {
id: 'privacy-user',
username: 'privacy',
email: 'privacy@example.com',
})
await insertProfileMatter(db, orgId, 'private-direct')
await insertProfileMatter(db, orgId, 'private-targeted')
const direct = await insertProfileShare(db, 'privacy-user', orgId, 'private-direct', { kind: 'direct' })
const targeted = await insertProfileShare(db, 'privacy-user', orgId, 'private-targeted')
await db.run(sql`
INSERT INTO share_recipients (id, share_id, recipient_email, created_at)
VALUES ('private-recipient', ${targeted.id}, 'recipient@example.com', ${Math.floor(Date.now() / 1000)})
`)
const res = await app.request('/api/users/privacy')
expect(res.status).toBe(200)
expect(((await res.json()) as { shares: unknown[] }).shares).toEqual([])
expect(direct.token).toBeTruthy()
})
it('filters every unavailable selected share at read time [spec: profile/availability-filtering]', async () => {
const { app, db } = await createTestApp()
const { orgId } = await insertUser(db, {
id: 'availability-user',
username: 'availability',
email: 'availability@example.com',
})
const now = Math.floor(Date.now() / 1000)
const matters = [
['available', {}],
['revoked', {}],
['expired', {}],
['exhausted', {}],
['trashed', { trashedAt: now }],
['purged', { purgedAt: now }],
['draft', { status: 'draft' }],
] as const
for (const [id, options] of matters) await insertProfileMatter(db, orgId, id, options)
await insertProfileShare(db, 'availability-user', orgId, 'available', { token: 'only-available' })
await insertProfileShare(db, 'availability-user', orgId, 'revoked', { status: 'revoked' })
await insertProfileShare(db, 'availability-user', orgId, 'expired', { expiresAt: now - 1 })
await insertProfileShare(db, 'availability-user', orgId, 'exhausted', {
downloadLimit: 2,
downloads: 2,
})
await insertProfileShare(db, 'availability-user', orgId, 'trashed')
await insertProfileShare(db, 'availability-user', orgId, 'purged')
await insertProfileShare(db, 'availability-user', orgId, 'draft')
await insertProfileShare(db, 'availability-user', orgId, 'missing-target', { token: 'missing-target' })
const res = await app.request('/api/users/availability')
expect(res.status).toBe(200)
expect(((await res.json()) as { shares: Array<{ token: string }> }).shares.map((share) => share.token)).toEqual([
'only-available',
])
})
})
+10 -26
View File
@@ -1,4 +1,5 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
import { publicProfileSchema } from '@shared/schemas/profile'
import { requireAdmin, requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import {
@@ -11,6 +12,7 @@ import {
unsupportedMediaType,
} from '../usecases/ports'
import { getUserQuota } from '../usecases/quota'
import { listPublicProfileShares } from '../usecases/share'
import {
getPublicProfile,
grantUserEntitlement,
@@ -34,11 +36,7 @@ import { errorResponse, jsonBody, jsonContent } from './openapi'
// lookup, the authenticated user's own avatar — plus the admin storage
// entitlement grants, which live in our own quota domain rather than better-auth.
const publicUserSchema = z
.object({ username: z.string(), name: z.string(), image: z.string().nullable() })
.openapi('PublicUser')
const publicProfileSchema = z.object({ user: publicUserSchema, shares: z.array(z.unknown()) }).openapi('PublicProfile')
const publicProfileResponseSchema = publicProfileSchema.openapi('PublicProfile')
const grantEntitlementSchema = z.object({
resourceType: z.literal('storage'),
@@ -104,20 +102,7 @@ const getUserRoute = createRoute({
path: '/{username}',
request: { params: z.object({ username: z.string() }) },
responses: {
200: jsonContent(publicProfileSchema, 'User'),
404: errorResponse('User not found'),
},
})
const userObjectsRoute = createRoute({
operationId: 'listUserObjects',
summary: "List a user's public objects",
tags: ['Users'],
method: 'get',
path: '/{username}/objects',
request: { params: z.object({ username: z.string() }) },
responses: {
200: jsonContent(z.object({ items: z.array(z.unknown()), breadcrumb: z.array(z.unknown()) }), 'Objects'),
200: jsonContent(publicProfileResponseSchema, 'User'),
404: errorResponse('User not found'),
},
})
@@ -220,14 +205,13 @@ export const users = new OpenAPIHono<Env>()
return c.body(null, 204)
})
.openapi(getUserRoute, async (c) => {
const user = await getPublicProfile(c.get('deps'), c.req.valid('param').username)
const username = c.req.valid('param').username
const [user, shares] = await Promise.all([
getPublicProfile(c.get('deps'), username),
listPublicProfileShares(c.get('deps'), username),
])
if (!user) throw notFound('User not found')
return c.json({ user, shares: [] }, 200)
})
.openapi(userObjectsRoute, async (c) => {
const user = await getPublicProfile(c.get('deps'), c.req.valid('param').username)
if (!user) throw notFound('User not found')
return c.json({ items: [], breadcrumb: [] }, 200)
return c.json({ user, shares }, 200)
})
.openapi(getUserQuotaRoute, async (c) => {
const quota = await getUserQuota(c.get('deps'), { userId: c.req.valid('param').userId })
+65
View File
@@ -65,6 +65,71 @@ describe('global OpenAPI document', () => {
expect(events?.description).toContain('?downloadTasks=1')
})
it('documents the concrete public profile contract without the removed objects placeholder', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const res = await app.request('/api/openapi.json')
const doc = (await res.json()) as {
paths: Record<
string,
{
get?: {
responses?: Record<string, { content?: { 'application/json'?: { schema?: { $ref?: string } } } }>
}
}
>
components?: {
schemas?: Record<
string,
{
properties?: Record<
string,
{
type?: string
properties?: Record<string, { type?: string; nullable?: boolean }>
items?: {
type?: string
properties?: Record<string, { type?: string; nullable?: boolean }>
required?: string[]
}
}
>
required?: string[]
}
>
}
}
expect(doc.paths['/api/users/{username}']?.get?.responses?.['200']?.content?.['application/json']?.schema).toEqual({
$ref: '#/components/schemas/PublicProfile',
})
expect(doc.paths['/api/users/{username}/objects']).toBeUndefined()
const profile = doc.components?.schemas?.PublicProfile
expect(profile?.required).toEqual(['user', 'shares'])
expect(profile?.properties?.user).toMatchObject({
type: 'object',
properties: {
username: { type: 'string' },
name: { type: 'string' },
image: { type: 'string', nullable: true },
},
})
expect(profile?.properties?.shares).toMatchObject({
type: 'array',
items: {
type: 'object',
properties: {
token: { type: 'string' },
name: { type: 'string' },
type: { type: 'string' },
size: { type: 'integer', nullable: true },
isFolder: { type: 'boolean' },
},
required: ['token', 'name', 'type', 'size', 'isFolder'],
},
})
})
it("merges better-auth's auto-generated schema (incl. the device flow) into the same doc", async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const res = await app.request('/api/openapi.json')
+2
View File
@@ -368,9 +368,11 @@ const APP_SCHEMA_SQL = `
views INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
listed_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS shares_creator_status_created_idx ON shares(creator_id, status, created_at);
CREATE INDEX IF NOT EXISTS shares_creator_listed_idx ON shares(creator_id, listed_at);
CREATE TABLE IF NOT EXISTS share_recipients (
id TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
+3 -5
View File
@@ -1,8 +1,6 @@
export interface PublicUser {
username: string
name: string
image: string | null
}
import type { PublicUser } from '@shared/schemas/profile'
export type { PublicUser } from '@shared/schemas/profile'
export interface ProfileRepo {
getUserByUsername(username: string): Promise<PublicUser | null>
+13 -1
View File
@@ -1,3 +1,4 @@
import type { PublicProfileShare } from '@shared/schemas/profile'
import type { CreateShareInput } from '@shared/schemas/share'
import type { Matter } from './matter'
@@ -18,6 +19,7 @@ export interface ShareRecord {
views: number
downloads: number
status: string
listedAt: Date | null
createdAt: Date
}
@@ -41,6 +43,7 @@ export interface ShareListItem {
views: number
downloads: number
status: string
listedAt: Date | null
createdAt: Date
matter: { name: string; type: string; dirtype: number }
recipientCount: number
@@ -58,7 +61,14 @@ export type ShareResolution =
// Thrown by createShare on invalid share-shape combinations. Carries a stable
// code the http layer maps to a 400/404.
export class CreateShareError extends Error {
constructor(public code: 'MATTER_NOT_FOUND' | 'DIRECT_NO_FOLDER' | 'DIRECT_NO_PASSWORD' | 'DIRECT_NO_RECIPIENTS') {
constructor(
public code:
| 'MATTER_NOT_FOUND'
| 'DIRECT_NO_FOLDER'
| 'DIRECT_NO_PASSWORD'
| 'DIRECT_NO_RECIPIENTS'
| 'PROFILE_LISTING_INELIGIBLE',
) {
super(code)
}
}
@@ -73,6 +83,8 @@ export interface ShareRepo {
listRecipientUserIds(shareId: string): Promise<string[]>
revokeByMatter(matterId: string): Promise<void>
revokeByToken(token: string, creatorId: string): Promise<boolean>
setProfileListing(token: string, creatorId: string, listedAt: Date | null): Promise<boolean>
listPublicProfileShares(username: string, now: Date): Promise<PublicProfileShare[]>
listForApi(
creatorId: string,
opts: { page: number; pageSize: number; status?: string },
+69 -11
View File
@@ -11,7 +11,7 @@
import { createHmac } from 'node:crypto'
import { DirType } from '@shared/constants'
import type { CreateShareRequest } from '@shared/schemas/share'
import type { CreateShareRequest, ShareObjectsResponse } from '@shared/schemas/share'
import { isAccessibleByUser } from '../domain/share'
import { verifyPassword as verifyPasswordHash } from '../lib/password'
import type { Platform } from '../platform/interface'
@@ -222,15 +222,7 @@ export type ListShareObjectsParams = {
now?: Date
}
export type ShareObjectItem = { ref: string; name: string; type: string; size: number | null; isFolder: boolean }
export type ListShareObjectsResult = {
items: ShareObjectItem[]
total: number
page: number
pageSize: number
breadcrumb: Array<{ name: string; path: string }>
}
export type ListShareObjectsResult = ShareObjectsResponse
export type ListShareObjectsOutcome = { ok: true; result: ListShareObjectsResult } | { ok: false; error: AppError }
@@ -451,6 +443,7 @@ export type CreatedShare = {
kind: string
expiresAt: Date | null
downloadLimit: number | null
listedAt: Date | null
}
export type CreateShareOutcome = { ok: true; share: CreatedShare } | { ok: false; error: AppError }
@@ -462,6 +455,10 @@ const CREATE_SHARE_ERRORS: Record<CreateShareError['code'], AppError> = {
DIRECT_NO_FOLDER: badRequest('Direct shares cannot be folders', 'DIRECT_NO_FOLDER'),
DIRECT_NO_PASSWORD: badRequest('Direct shares cannot have a password', 'DIRECT_NO_PASSWORD'),
DIRECT_NO_RECIPIENTS: badRequest('Direct shares cannot have recipients', 'DIRECT_NO_RECIPIENTS'),
PROFILE_LISTING_INELIGIBLE: badRequest(
'Only untargeted landing shares can be shown on a profile',
'PROFILE_LISTING_INELIGIBLE',
),
}
export async function createShare(
@@ -490,6 +487,7 @@ export async function createShare(
expiresAt,
downloadLimit: input.downloadLimit,
recipients: input.recipients,
showOnProfile: input.showOnProfile,
})
} catch (err) {
if (err instanceof CreateShareError) return { ok: false, error: CREATE_SHARE_ERRORS[err.code] }
@@ -512,10 +510,70 @@ export async function createShare(
return {
ok: true,
share: { token: share.token, kind: share.kind, expiresAt: share.expiresAt, downloadLimit: share.downloadLimit },
share: {
token: share.token,
kind: share.kind,
expiresAt: share.expiresAt,
downloadLimit: share.downloadLimit,
listedAt: share.listedAt,
},
}
}
// ─── PUT/DELETE /:token/profile-listing — owner-curated profile state ───────
export type SetProfileListingParams = {
token: string
userId: string
listed: boolean
now?: Date
}
export type SetProfileListingOutcome = { ok: true; listedAt: Date | null } | { ok: false; error: AppError }
export async function setProfileListing(
deps: ShareDeps,
params: SetProfileListingParams,
): Promise<SetProfileListingOutcome> {
const { token, userId, listed, now = new Date() } = params
const resolved = await deps.share.resolveByToken(token)
if (resolved.status === 'not_found' || resolved.status === 'revoked') {
return { ok: false, error: notFound() }
}
const { share, matter, recipients } = resolved
if (share.creatorId !== userId) return { ok: false, error: forbidden() }
if (share.kind !== 'landing' || recipients.length > 0) {
return {
ok: false,
error: badRequest('Only untargeted landing shares can be shown on a profile', 'PROFILE_LISTING_INELIGIBLE'),
}
}
if (
listed &&
(resolved.status === 'matter_trashed' ||
matter.status !== 'active' ||
matter.purgedAt != null ||
(share.expiresAt != null && share.expiresAt <= now) ||
(share.downloadLimit != null && share.downloads >= share.downloadLimit))
) {
return {
ok: false,
error: badRequest('Unavailable shares cannot be shown on a profile', 'PROFILE_LISTING_UNAVAILABLE'),
}
}
const listedAt = listed ? now : null
const updated = await deps.share.setProfileListing(token, userId, listedAt)
if (!updated) return { ok: false, error: notFound() }
return { ok: true, listedAt }
}
export function listPublicProfileShares(deps: ShareDeps, username: string, now = new Date()) {
return deps.share.listPublicProfileShares(username, now)
}
// ─── PUT /:token/status — revoke (ownership-scoped) ──────────────────────────
export type RevokeShareParams = { token: string; userId: string; now?: Date }
+12 -1
View File
@@ -132,13 +132,24 @@ export type { ListNotificationsQuery } from './notification'
export { listNotificationsQuerySchema } from './notification'
export type { Page, PageQuery } from './pagination'
export { pageQuerySchema, pageSchema } from './pagination'
export type { CreateShareInput, CreateShareRequest, ShareKind } from './share'
export type { PublicProfile, PublicProfileShare, PublicUser } from './profile'
export { publicProfileSchema, publicProfileShareSchema, publicUserSchema } from './profile'
export type {
CreateShareInput,
CreateShareRequest,
ShareKind,
ShareObjectItem,
ShareObjectsResponse,
} from './share'
export {
createShareRequestSchema,
createShareSchema,
listSharesQuerySchema,
shareKindSchema,
shareObjectItemSchema,
shareObjectsResponseSchema,
shareRecipientSchema,
shareRecipientViewSchema,
} from './share'
export type {
SiteBranding,
+24
View File
@@ -0,0 +1,24 @@
import { z } from 'zod'
export const publicUserSchema = z.object({
username: z.string(),
name: z.string(),
image: z.string().nullable(),
})
export const publicProfileShareSchema = z.object({
token: z.string(),
name: z.string(),
type: z.string(),
size: z.number().int().nullable(),
isFolder: z.boolean(),
})
export const publicProfileSchema = z.object({
user: publicUserSchema,
shares: z.array(publicProfileShareSchema),
})
export type PublicUser = z.infer<typeof publicUserSchema>
export type PublicProfileShare = z.infer<typeof publicProfileShareSchema>
export type PublicProfile = z.infer<typeof publicProfileSchema>
+29
View File
@@ -9,6 +9,14 @@ export const shareRecipientSchema = z.object({
recipientEmail: z.string().email().optional(),
})
export const shareRecipientViewSchema = z.object({
id: z.string(),
shareId: z.string(),
recipientUserId: z.string().nullable(),
recipientEmail: z.string().nullable(),
createdAt: z.string(),
})
export const createShareSchema = z.object({
matterId: z.string().min(1),
orgId: z.string().min(1),
@@ -18,6 +26,7 @@ export const createShareSchema = z.object({
expiresAt: z.date().optional(),
downloadLimit: z.number().int().positive().optional(),
recipients: z.array(shareRecipientSchema).optional(),
showOnProfile: z.boolean().optional(),
})
export type CreateShareInput = z.infer<typeof createShareSchema>
@@ -36,10 +45,30 @@ export const createShareRequestSchema = z.object({
expiresAt: z.string().datetime({ offset: true }).optional(),
downloadLimit: z.number().int().positive().optional(),
recipients: z.array(shareRecipientSchema).optional(),
showOnProfile: z.boolean().optional(),
})
export type CreateShareRequest = z.infer<typeof createShareRequestSchema>
export const shareObjectItemSchema = z.object({
ref: z.string(),
name: z.string(),
type: z.string(),
size: z.number().int().nullable(),
isFolder: z.boolean(),
})
export const shareObjectsResponseSchema = z.object({
items: z.array(shareObjectItemSchema),
total: z.number().int(),
page: z.number().int(),
pageSize: z.number().int(),
breadcrumb: z.array(z.object({ name: z.string(), path: z.string() })),
})
export type ShareObjectItem = z.infer<typeof shareObjectItemSchema>
export type ShareObjectsResponse = z.infer<typeof shareObjectsResponseSchema>
export const saveShareRequestSchema = z.object({
targetOrgId: z.string().min(1),
targetParent: z.string().default(''),
+1
View File
@@ -476,6 +476,7 @@ export interface Share {
views: number
downloads: number
status: 'active' | 'revoked'
listedAt: string | null
createdAt: string
}
+23 -17
View File
@@ -1,6 +1,6 @@
Feature: Public profiles
Each user has a public profile page listing their public shares, reachable
without authentication. Profile paths render as navigable breadcrumbs.
Each user has a public profile page listing owner-curated public landing
shares, reachable without authentication.
@profile/user-not-found @api
Scenario: An unknown user id has no profile
@@ -26,20 +26,26 @@ Feature: Public profiles
When their profile is requested
Then their user info is returned
@profile/unknown-username @api
Scenario: An unknown username has no public listing
Given a username that does not exist
When its public listing is requested
Then the API responds 404
@profile/curated-shares @api
Scenario: A profile returns only selected public landing shares
Given selected and unselected public landing shares owned by a user
When their profile is requested without authentication
Then exactly the selected shares are returned
@profile/empty-listing @api
Scenario: A known user with no public files lists nothing
Given a known user with no public files
When their public listing is requested
Then an empty item list and breadcrumb are returned
@profile/privacy-boundaries @api
Scenario: Private share modes never appear on a profile
Given forged selected direct and recipient-targeted shares
When the owner's profile is requested
Then neither private share is returned
@profile/breadcrumb-segments @domain
Scenario: A profile path splits into breadcrumb segments
Given a nested profile path
When it is split into breadcrumb segments
Then each path level becomes one ordered segment
@profile/availability-filtering @api
Scenario: Unavailable selected shares disappear at read time
Given selected revoked, expired, exhausted, trashed, purged, draft, and missing-target shares
When the owner's profile is requested
Then none of the unavailable shares are returned
@profile/share-flow @journey
Scenario: Listed files and folders use the landing-share flow
Given selected public file and folder landing shares
When a visitor opens either item from the profile
Then the existing share landing page handles file access and folder navigation
+30
View File
@@ -69,6 +69,36 @@ Feature: Shares
When the share is created
Then the limit is stored
@shares/create-profile-listing @api
Scenario: An eligible landing share is listed at creation time
Given an authenticated user creating an untargeted landing share
When they choose to show it on their personal homepage
Then the share and its profile listing are created atomically
@shares/profile-listing-owner @api
Scenario: An owner lists and unlists an eligible share
Given an owner's untargeted landing share
When they add and remove its profile listing
Then only the listing state changes
@shares/profile-listing-authorization @api
Scenario: Another user cannot change a profile listing
Given a landing share owned by another user
When a non-owner tries to change its profile listing
Then the API responds 403
@shares/profile-listing-ineligible @api
Scenario: Direct and recipient-targeted shares cannot be listed
Given direct and recipient-targeted shares
When forged profile-listing requests are submitted
Then the API responds 400 PROFILE_LISTING_INELIGIBLE
@shares/profile-unlist-preserves-access @api
Scenario: Unlisting does not revoke a share
Given a listed public landing share
When its owner removes the profile listing
Then its original landing URL remains usable
@shares/create-notify-best-effort @api
Scenario: Share creation succeeds even if notification fails
Given share-created notification dispatch rejects
+47
View File
@@ -0,0 +1,47 @@
# Feature: Curated profile shares
## Requirements
- While a user creates an untargeted landing share, when they select “Show on personal homepage”, the system shall atomically create the share with a profile listing.
- While an authenticated owner views sent shares, when they list or unlist an eligible landing share, the system shall update only its profile-listing state.
- While an anonymous visitor requests a public profile, the system shall return only that owners currently listed and accessible untargeted landing shares.
- While a visitor opens a listed item, the system shall use the existing `/s/:token` landing and folder-navigation flow.
## Architecture
### Frontend
- Add an optional homepage switch to the existing share-creation dialog. It is available only for untargeted landing shares and resets when another mode is selected.
- Add list/unlist controls to eligible rows on the authenticated sent Shares page.
- Render concrete file/folder cards on `/u/:username`, linked only to `/s/:token`.
- Add a public-homepage link to the secondary profile settings page.
- Surface mutation failures through the existing toast error pattern.
### Backend
- Add nullable `shares.listedAt`; existing shares remain unlisted.
- Generate the migration with `pnpm db:generate`.
- Extend share creation with `showOnProfile?: boolean`, persisted atomically with the share.
- Model the owner-controlled selection as `PUT` and `DELETE /api/shares/:token/profile-listing`.
- Return the updated listing state from `PUT`; make `DELETE` idempotent for an owned eligible share.
- Remove the unused `GET /api/users/:username/objects` placeholder.
- Define the public profile response with a shared Zod schema and inferred wire types.
- Query curated shares using an inner join to the target and read-time predicates for listing, owner, landing kind, no recipients, active status, unexpired limit, remaining downloads, active target, and non-trashed/non-purged target.
### Security
- Require authentication on listing mutations and scope writes by both token and creator ID.
- Revalidate landing kind and the absence of recipients on the server; forged direct or targeted requests return a stable validation error.
- Never expose password hashes, internal IDs, organization IDs, recipient data, or raw object locations in public profile output.
- Select public response columns explicitly and order listings deterministically by `listedAt` then share ID.
- Unlisting changes only `listedAt`; it never revokes the share.
- Existing share landing/password/download/folder checks remain the sole public object-access path.
## Implementation Plan
- [x] Add schema field, generated migration, port/repository operations, and shared schemas.
- [x] Add creation and profile-listing use cases plus REST routes.
- [x] Replace profile placeholders with the curated read model and remove `/objects`.
- [x] Add dialog, Shares page, public profile, settings-link, and locale changes.
- [x] Add Node, Cloudflare, wrapper, React, OpenAPI, and browser acceptance tests.
- [ ] Run focused verification, full quality gates, CI, and branch-preview journeys.
@@ -0,0 +1,114 @@
import { DirType } from '@shared/constants'
import type { StorageObject } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createShare } from '@/lib/api'
import { ShareDialog } from './share-dialog'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, unknown>) => (values ? `${key}:${Object.values(values).join('/')}` : key),
}),
}))
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('@/hooks/use-clipboard', () => ({
useClipboard: () => ({ copy: vi.fn() }),
}))
vi.mock('@/lib/api', () => ({
createShare: vi.fn(),
}))
const item: StorageObject = {
id: 'matter-1',
orgId: 'org-1',
alias: '',
name: 'release-notes.pdf',
type: 'application/pdf',
size: 1024,
dirtype: DirType.FILE,
parent: '',
object: 'release-notes.pdf',
storageId: 'storage-1',
status: 'active',
trashedAt: null,
createdAt: '2026-07-23T00:00:00.000Z',
updatedAt: '2026-07-23T00:00:00.000Z',
}
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterAll(() => {
vi.unstubAllGlobals()
})
function renderDialog() {
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
return render(
<QueryClientProvider client={queryClient}>
<ShareDialog open item={item} onOpenChange={vi.fn()} />
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.mocked(createShare).mockResolvedValue({
token: 'share-token',
kind: 'landing',
urls: { landing: '/s/share-token' },
expiresAt: null,
downloadLimit: null,
listedAt: '2026-07-23T00:00:00.000Z',
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('ShareDialog profile listing selection', () => {
it('sends showOnProfile when the owner enables it for a landing share', async () => {
renderDialog()
fireEvent.click(screen.getByRole('switch', { name: 'share.showOnProfile' }))
fireEvent.click(screen.getByRole('button', { name: 'share.createButton' }))
await waitFor(() =>
expect(createShare).toHaveBeenCalledWith(
expect.objectContaining({
matterId: 'matter-1',
kind: 'landing',
showOnProfile: true,
}),
expect.anything(),
),
)
})
it('omits showOnProfile unless the owner enables the control', async () => {
renderDialog()
fireEvent.click(screen.getByRole('button', { name: 'share.createButton' }))
await waitFor(() => expect(createShare).toHaveBeenCalled())
expect(vi.mocked(createShare).mock.calls[0]?.[0]).not.toHaveProperty('showOnProfile')
})
})
@@ -118,6 +118,7 @@ const makeResult = (
urls: { landing: '/s/tok' },
expiresAt: null,
downloadLimit: null,
listedAt: null,
...overrides,
})
+22 -1
View File
@@ -2,7 +2,7 @@ import { DirType } from '@shared/constants'
import type { CreateShareRequest } from '@shared/schemas'
import type { StorageObject } from '@shared/types'
import { useMutation } from '@tanstack/react-query'
import { CheckCircle2, Copy, File, Folder, KeyRound, Share2, TriangleAlert, X } from 'lucide-react'
import { CheckCircle2, Copy, File, Folder, House, KeyRound, Share2, TriangleAlert, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -79,6 +79,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
const [customExpires, setCustomExpires] = useState('')
const [limitOption, setLimitOption] = useState('unlimited')
const [customLimit, setCustomLimit] = useState('')
const [showOnProfile, setShowOnProfile] = useState(false)
const [result, setResult] = useState<CreateShareResult | null>(null)
useEffect(() => {
@@ -92,6 +93,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
setCustomExpires('')
setLimitOption('unlimited')
setCustomLimit('')
setShowOnProfile(false)
setResult(null)
}, [open])
@@ -145,6 +147,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
if (isTargetedMode(mode) && chips.length > 0) {
body.recipients = chips.filter((c) => c.valid).map((c) => ({ recipientEmail: c.value }))
}
if (mode === 'page' && showOnProfile) body.showOnProfile = true
mutation.mutate(body)
}
@@ -183,6 +186,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
isFolder={isFolder}
onChange={(next) => {
setMode(next)
setShowOnProfile(false)
setPasswordEnabled(false)
setPassword('')
if (!isTargetedMode(next)) {
@@ -212,6 +216,23 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
)}
{mode === 'page' && <PasswordField enabled={passwordEnabled} onToggle={handlePasswordToggle} />}
{mode === 'page' && (
<div className="flex min-h-11 items-start justify-between gap-4 rounded-md border bg-background p-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<House className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="share-profile-listing">{t('share.showOnProfile')}</Label>
</div>
<p className="text-xs leading-5 text-muted-foreground">{t('share.showOnProfileHint')}</p>
</div>
<Switch
id="share-profile-listing"
className="mt-0.5"
checked={showOnProfile}
onCheckedChange={setShowOnProfile}
/>
</div>
)}
<ExpiresField
option={expiresOption}
+3 -8
View File
@@ -1,4 +1,5 @@
import { DirType } from '@shared/constants'
import type { ShareObjectItem } from '@shared/schemas'
import type { ShareView, StorageObject } from '@shared/types'
import { UserRound } from 'lucide-react'
import { useState } from 'react'
@@ -16,13 +17,7 @@ interface ShareLandingProps {
onPasswordRequired?: () => void
}
function toStorageObject(item: {
ref: string
name: string
type: string
size: number
isFolder: boolean
}): StorageObject {
function toStorageObject(item: ShareObjectItem): StorageObject {
const now = new Date().toISOString()
return {
id: item.ref,
@@ -30,7 +25,7 @@ function toStorageObject(item: {
alias: item.ref,
name: item.name,
type: item.type,
size: item.size,
size: item.size ?? 0,
dirtype: item.isFolder ? DirType.USER_FOLDER : DirType.FILE,
parent: '',
object: item.ref,
+10
View File
@@ -1115,6 +1115,7 @@
"settings.profile.displayName.hint": "Please use 100 characters at maximum.",
"settings.profile.username.description": "Your account username.",
"settings.profile.username.hint": "Username cannot be changed.",
"settings.profile.publicHomepage": "View public homepage",
"settings.profile.email.description": "Used for sign-in and notifications.",
"settings.profile.password.description": "Use a strong password you don't use elsewhere.",
"settings.profile.password.hint": "At least 8 characters.",
@@ -1474,6 +1475,11 @@
"shares.revokeConfirm": "Revoke share for {{name}}? Anyone with the link will lose access immediately. This cannot be undone.",
"shares.revokeSuccess": "Share revoked",
"shares.revokeError": "Failed to revoke share",
"shares.listOnProfile": "Show on public profile",
"shares.unlistFromProfile": "Remove from public profile",
"shares.profileListSuccess": "Share added to your public profile",
"shares.profileUnlistSuccess": "Share removed from your public profile",
"shares.profileListingError": "Failed to update public profile listing",
"shares.emptyState": "No shares yet",
"shares.emptyStateHint": "Right-click a file to create your first share",
"shares.boxSent": "My shares",
@@ -1509,6 +1515,8 @@
"share.recipientsHint": "These email addresses get targeted access, still subject to expiry and download limits",
"share.password": "Password",
"share.passwordHint": "Only access pages support password protection. Anyone with the link must enter the password first.",
"share.showOnProfile": "Show on personal homepage",
"share.showOnProfileHint": "Add this public access page to your curated profile.",
"share.generatePassword": "Generate",
"share.expires": "Expires",
"share.expires1d": "1 day",
@@ -1540,6 +1548,8 @@
"share.done": "Done",
"share.invalidExpiry": "Custom expiry must be a future date",
"share.invalidLimit": "Download limit must be a positive number",
"profile.noShares": "No shared files yet",
"profile.notFound": "User not found",
"files.share": "Share",
"share.notFound": "Share Not Found",
"share.notFoundDesc": "This share link doesn't exist or has been removed.",
+10
View File
@@ -1115,6 +1115,7 @@
"settings.profile.displayName.hint": "最多 100 个字符。",
"settings.profile.username.description": "你的账户用户名。",
"settings.profile.username.hint": "用户名无法修改。",
"settings.profile.publicHomepage": "查看公开主页",
"settings.profile.email.description": "用于登录和接收通知。",
"settings.profile.password.description": "使用一个你不会在其他地方重复使用的强密码。",
"settings.profile.password.hint": "至少 8 个字符。",
@@ -1474,6 +1475,11 @@
"shares.revokeConfirm": "撤销 {{name}} 的分享?任何人将立即失去访问权限,此操作不可撤回。",
"shares.revokeSuccess": "分享已撤销",
"shares.revokeError": "撤销分享失败",
"shares.listOnProfile": "显示在公开主页",
"shares.unlistFromProfile": "从公开主页移除",
"shares.profileListSuccess": "分享已添加到公开主页",
"shares.profileUnlistSuccess": "分享已从公开主页移除",
"shares.profileListingError": "更新公开主页展示失败",
"shares.emptyState": "暂无分享",
"shares.emptyStateHint": "右键点击文件即可创建第一个分享",
"shares.boxSent": "我的分享",
@@ -1509,6 +1515,8 @@
"share.recipientsHint": "系统会为这些邮箱创建定向访问入口,仍受有效期和下载限额约束",
"share.password": "密码",
"share.passwordHint": "仅访问页支持密码保护。拿到链接的人需要输入密码后才能访问。",
"share.showOnProfile": "显示在个人主页",
"share.showOnProfileHint": "将这个公开访问页添加到你的精选主页。",
"share.generatePassword": "生成",
"share.expires": "过期时间",
"share.expires1d": "1天",
@@ -1540,6 +1548,8 @@
"share.done": "完成",
"share.invalidExpiry": "自定义过期时间必须是未来日期",
"share.invalidLimit": "下载限额必须为正整数",
"profile.noShares": "暂无公开分享",
"profile.notFound": "用户不存在",
"files.share": "分享",
"share.notFound": "分享不存在",
"share.notFoundDesc": "此分享链接不存在或已被删除。",
+54 -22
View File
@@ -94,6 +94,7 @@ import {
listQuotas,
listReceivedShares,
listShareObjects,
listShareOnProfile,
listShares,
listSiteInvitations,
listStorages,
@@ -130,6 +131,7 @@ import {
serverEventsUrl,
testEmail,
transferObject,
unlistShareFromProfile,
updateAnnouncement,
updateDownloader,
updateDownloaderCreditBilling,
@@ -2330,32 +2332,26 @@ describe('api', () => {
})
describe('getProfile', () => {
it('fetches public profile by username', async () => {
it('gets the exact public profile path and returns its concrete share items', async () => {
const payload = {
user: { username: 'alice', name: 'Alice', image: null },
shares: [],
shares: [{ token: 'share-1', name: 'photo.jpg', type: 'image/jpeg', size: 42, isFolder: false }],
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await getProfile('alice')
expect(result).toEqual(payload)
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
expect(url).toContain('/api/users/alice')
})
it('returns shares with download URLs', async () => {
const matter = { id: 'm1', name: 'photo.jpg', dirtype: 0, downloadUrl: 'https://s3/photo.jpg' }
const payload = {
user: { username: 'bob', name: 'Bob', image: null },
shares: [matter],
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await getProfile('bob')
expect(result.shares).toHaveLength(1)
expect(result.shares[0].downloadUrl).toBe('https://s3/photo.jpg')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/users/alice')
expect(init.method).toBe('GET')
expect(result.shares[0]).toEqual({
token: 'share-1',
name: 'photo.jpg',
type: 'image/jpeg',
size: 42,
isFolder: false,
})
})
it('throws on 404 response', async () => {
@@ -2712,25 +2708,61 @@ describe('api', () => {
})
})
describe('profile share listing', () => {
it('lists a share with PUT on the exact resource path', async () => {
const payload = { listedAt: '2026-07-23T12:00:00.000Z' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
await expect(listShareOnProfile('tok123')).resolves.toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/shares/tok123/profile-listing')
expect(init.method).toBe('PUT')
expect(init.body).toBeUndefined()
})
it('surfaces listing errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(listShareOnProfile('tok123')).rejects.toBeInstanceOf(ApiError)
})
it('unlists a share with DELETE on the exact resource path', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await expect(unlistShareFromProfile('tok123')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/shares/tok123/profile-listing')
expect(init.method).toBe('DELETE')
expect(init.body).toBeUndefined()
})
it('surfaces unlisting errors', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Not found' }, false, 404))
await expect(unlistShareFromProfile('missing')).rejects.toBeInstanceOf(ApiError)
})
})
describe('createShare', () => {
it('posts share data to /api/shares and returns created share result', async () => {
it('posts the profile selection flag to the exact share collection path', async () => {
const payload = {
token: 'tok123',
kind: 'landing' as const,
urls: { landing: 'https://zpan.io/s/tok123' },
expiresAt: null,
downloadLimit: null,
listedAt: '2026-07-23T12:00:00.000Z',
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await createShare({ matterId: 'obj-1', kind: 'landing' })
const result = await createShare({ matterId: 'obj-1', kind: 'landing', showOnProfile: true })
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/shares')
expect(url).toBe('/api/shares')
expect(init.method).toBe('POST')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ matterId: 'obj-1', kind: 'landing' })
expect(body).toEqual({ matterId: 'obj-1', kind: 'landing', showOnProfile: true })
const headers =
init.headers instanceof Headers ? init.headers : new Headers(init.headers as Record<string, string>)
expect(headers.get('Content-Type')).toContain('application/json')
+17 -25
View File
@@ -16,8 +16,12 @@ import type {
DownloadTaskActionInput,
PatchStorageInput,
PresignObjectUploadPartsInput,
PublicProfile,
PublicUser,
RedeemGiftCardResponse,
ReplaceStorageInput,
ShareObjectItem,
ShareObjectsResponse,
SiteConfig,
SiteSettings,
UpdateDownloaderCreditBillingInput,
@@ -871,18 +875,10 @@ export function testEmail(to: string) {
// Profile API (public, no auth)
export interface PublicUser {
username: string
name: string
image: string | null
}
export interface PublicMatter extends StorageObject {
downloadUrl?: string
}
export type { PublicUser }
export function getProfile(username: string) {
return unwrap<{ user: PublicUser; shares: PublicMatter[] }>(users[':username'].$get({ param: { username } }))
return unwrap<PublicProfile>(users[':username'].$get({ param: { username } }))
}
// Teams Activity API
@@ -1000,12 +996,21 @@ export function revokeShare(token: string) {
return unwrap<ShareView>(authedSharesApi[':token'].status.$put({ param: { token }, json: { status: 'revoked' } }))
}
export function listShareOnProfile(token: string) {
return unwrap<{ listedAt: string | null }>(authedSharesApi[':token']['profile-listing'].$put({ param: { token } }))
}
export function unlistShareFromProfile(token: string) {
return discard(authedSharesApi[':token']['profile-listing'].$delete({ param: { token } }))
}
export interface CreateShareResult {
token: string
kind: ShareView['kind']
urls: { landing?: string; direct?: string }
expiresAt: string | null
downloadLimit: number | null
listedAt: string | null
}
export function createShare(data: CreateShareRequest) {
@@ -1016,21 +1021,8 @@ export function verifySharePassword(token: string, password: string) {
return unwrap<{ ok: boolean }>(publicSharesApi[':token'].sessions.$post({ param: { token }, json: { password } }))
}
export interface ShareChildItem {
ref: string
name: string
type: string
size: number
isFolder: boolean
}
export interface ShareChildrenResponse {
items: ShareChildItem[]
total: number
page: number
pageSize: number
breadcrumb: Array<{ name: string; path: string }>
}
export type ShareChildItem = ShareObjectItem
export type ShareChildrenResponse = ShareObjectsResponse
export function listShareObjects(token: string, parent = '', page = 1, pageSize = 50) {
return unwrap<ShareChildrenResponse>(
@@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest'
// ProfilePage components are React rendering components. The project has no
// jsdom or @testing-library/react setup, so we cannot render them here.
// We test the pure logic the components apply:
// - ProfileForm: display name schema validation
// ---------------------------------------------------------------------------
// ProfileForm — display name validation mirrors profileSchema:
// displayName: z.string().min(1).max(100)
// ---------------------------------------------------------------------------
function isValidDisplayName(name: string): boolean {
return name.length >= 1 && name.length <= 100
}
describe('ProfileForm — display name validation', () => {
it('accepts a typical display name', () => {
expect(isValidDisplayName('John Doe')).toBe(true)
})
it('accepts exactly 1 character (minimum)', () => {
expect(isValidDisplayName('J')).toBe(true)
})
it('accepts exactly 100 characters (maximum)', () => {
expect(isValidDisplayName('a'.repeat(100))).toBe(true)
})
it('rejects empty string', () => {
expect(isValidDisplayName('')).toBe(false)
})
it('rejects string longer than 100 characters', () => {
expect(isValidDisplayName('a'.repeat(101))).toBe(false)
})
})
@@ -0,0 +1,74 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { UsernameCard } from './profile'
const session = vi.hoisted(() => ({
value: {
user: {
name: 'Alice',
email: 'alice@example.com',
image: null,
username: 'alice',
},
} as { user: { name: string; email: string; image: string | null; username?: string } } | null,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (options: unknown) => options,
}))
vi.mock('@/lib/auth-client', () => ({
authClient: {
getSession: vi.fn(),
updateUser: vi.fn(),
$store: { notify: vi.fn() },
},
useSession: () => ({ data: session.value }),
}))
vi.mock('@/lib/api', () => ({
deleteAvatar: vi.fn(),
uploadAvatar: vi.fn(),
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
session.value = {
user: {
name: 'Alice',
email: 'alice@example.com',
image: null,
username: 'alice',
},
}
})
describe('profile settings public homepage link', () => {
it('links the signed-in user to their public homepage in a new tab', () => {
render(<UsernameCard />)
const link = screen.getByRole('link', { name: 'settings.profile.publicHomepage' })
expect(link.getAttribute('href')).toBe('/u/alice')
expect(link.getAttribute('target')).toBe('_blank')
expect(link.getAttribute('rel')).toBe('noopener noreferrer')
})
it('does not render a public homepage link before the user has a username', () => {
session.value = {
user: {
name: 'Alice',
email: 'alice@example.com',
image: null,
},
}
render(<UsernameCard />)
expect(screen.queryByRole('link', { name: 'settings.profile.publicHomepage' })).toBeNull()
})
})
+11 -3
View File
@@ -3,7 +3,7 @@ import type { PublicImageMime } from '@shared/schemas'
import { MAX_PUBLIC_IMAGE_SIZE, PUBLIC_IMAGE_MIMES } from '@shared/schemas'
import { useMutation } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Camera, Loader2 } from 'lucide-react'
import { Camera, ExternalLink, Loader2 } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
@@ -182,7 +182,7 @@ function DisplayNameCard() {
)
}
function UsernameCard() {
export function UsernameCard() {
const { t } = useTranslation()
const { data: session } = useSession()
const username = (session?.user as { username?: string })?.username ?? ''
@@ -201,8 +201,16 @@ function UsernameCard() {
<Input value={username} disabled className="rounded-l-none" />
</div>
</CardContent>
<CardFooter className="border-t bg-muted/30">
<CardFooter className="justify-between border-t bg-muted/30">
<p className="text-sm text-muted-foreground">{t('settings.profile.username.hint')}</p>
{username && (
<Button asChild variant="outline" size="sm">
<a href={`/u/${username}`} target="_blank" rel="noopener noreferrer">
{t('settings.profile.publicHomepage')}
<ExternalLink />
</a>
</Button>
)}
</CardFooter>
</Card>
)
@@ -1,99 +0,0 @@
// Tests for shares/index.tsx — covers pure display logic.
// React rendering is not available (no jsdom), so we test helper functions directly.
import { describe, expect, it } from 'vitest'
// Mirrors computeDisplayStatus from shares/index.tsx
function computeDisplayStatus(share: {
status: 'active' | 'revoked'
expiresAt: string | null
}): 'active' | 'revoked' | 'expired' {
if (share.status === 'revoked') return 'revoked'
if (share.expiresAt && new Date(share.expiresAt) < new Date()) return 'expired'
return 'active'
}
// Mirrors getBackendStatus from shares/index.tsx
function getBackendStatus(filter: 'all' | 'active' | 'revoked' | 'expired'): 'active' | 'revoked' | undefined {
if (filter === 'active' || filter === 'expired') return 'active'
if (filter === 'revoked') return 'revoked'
return undefined
}
const PAST = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString()
const FUTURE = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString()
describe('computeDisplayStatus', () => {
it('returns "revoked" when status is revoked regardless of expiresAt', () => {
expect(computeDisplayStatus({ status: 'revoked', expiresAt: null })).toBe('revoked')
expect(computeDisplayStatus({ status: 'revoked', expiresAt: FUTURE })).toBe('revoked')
expect(computeDisplayStatus({ status: 'revoked', expiresAt: PAST })).toBe('revoked')
})
it('returns "expired" when status is active and expiresAt is in the past', () => {
expect(computeDisplayStatus({ status: 'active', expiresAt: PAST })).toBe('expired')
})
it('returns "active" when status is active and expiresAt is in the future', () => {
expect(computeDisplayStatus({ status: 'active', expiresAt: FUTURE })).toBe('active')
})
it('returns "active" when status is active and expiresAt is null (never expires)', () => {
expect(computeDisplayStatus({ status: 'active', expiresAt: null })).toBe('active')
})
})
describe('getBackendStatus', () => {
it('returns undefined for "all" filter (no backend filter applied)', () => {
expect(getBackendStatus('all')).toBeUndefined()
})
it('returns "active" for "active" filter', () => {
expect(getBackendStatus('active')).toBe('active')
})
it('returns "revoked" for "revoked" filter', () => {
expect(getBackendStatus('revoked')).toBe('revoked')
})
it('returns "active" for "expired" filter (expired shares have active status on backend)', () => {
expect(getBackendStatus('expired')).toBe('active')
})
})
// Mirrors the downloads label formatting in ShareTableRow
function formatDownloads(downloads: number, downloadLimit: number | null): string {
return downloadLimit != null ? `${downloads} / ${downloadLimit}` : String(downloads)
}
describe('formatDownloads', () => {
it('shows plain count when no limit is set', () => {
expect(formatDownloads(5, null)).toBe('5')
expect(formatDownloads(0, null)).toBe('0')
})
it('shows "used / limit" when download limit is set', () => {
expect(formatDownloads(23, 100)).toBe('23 / 100')
expect(formatDownloads(0, 50)).toBe('0 / 50')
})
it('shows "used / limit" when limit is zero (edge case)', () => {
expect(formatDownloads(0, 0)).toBe('0 / 0')
})
})
// Mirrors the views label logic in ShareTableRow
function formatViews(kind: 'landing' | 'direct', views: number): string {
return kind === 'direct' ? '—' : String(views)
}
describe('formatViews', () => {
it('returns "—" for direct shares (views not tracked)', () => {
expect(formatViews('direct', 0)).toBe('—')
expect(formatViews('direct', 100)).toBe('—')
})
it('returns the view count as a string for landing shares', () => {
expect(formatViews('landing', 0)).toBe('0')
expect(formatViews('landing', 42)).toBe('42')
})
})
@@ -0,0 +1,165 @@
import type { ShareListItem } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listReceivedShares, listShareOnProfile, listShares, revokeShare, unlistShareFromProfile } from '@/lib/api'
import { SharesPage } from './index'
const router = vi.hoisted(() => ({
navigate: vi.fn(),
search: { status: 'all', page: 1, box: 'sent' },
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, unknown>) => (values ? `${key}:${Object.values(values).join('/')}` : key),
}),
}))
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (options: unknown) => options,
useNavigate: () => router.navigate,
useSearch: () => router.search,
}))
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('@/hooks/use-clipboard', () => ({
useClipboard: () => ({ copy: vi.fn() }),
}))
vi.mock('@/components/layout/page-header', () => ({
PageHeader: () => null,
}))
vi.mock('@/components/shares/share-detail-panel', () => ({
ShareDetailPanel: () => null,
}))
vi.mock('@/components/shares/revoke-confirm-dialog', () => ({
RevokeConfirmDialog: () => null,
}))
vi.mock('@/lib/api', () => ({
listReceivedShares: vi.fn(),
listShareOnProfile: vi.fn(),
listShares: vi.fn(),
revokeShare: vi.fn(),
unlistShareFromProfile: vi.fn(),
}))
function share(overrides: Partial<ShareListItem>): ShareListItem {
return {
id: 'share-1',
token: 'share-token',
kind: 'landing',
matterId: 'matter-1',
orgId: 'org-1',
creatorId: 'user-1',
expiresAt: null,
downloadLimit: null,
views: 2,
downloads: 1,
status: 'active',
listedAt: null,
createdAt: '2026-07-23T00:00:00.000Z',
matter: {
name: 'Public file.pdf',
type: 'application/pdf',
dirtype: 0,
},
recipientCount: 0,
...overrides,
}
}
const shares = [
share({ token: 'unlisted-token', matter: { name: 'Unlisted.pdf', type: 'application/pdf', dirtype: 0 } }),
share({
id: 'share-2',
token: 'listed-token',
listedAt: '2026-07-23T01:00:00.000Z',
matter: { name: 'Listed folder', type: 'folder', dirtype: 1 },
}),
share({
id: 'share-3',
token: 'direct-token',
kind: 'direct',
matter: { name: 'Direct file.pdf', type: 'application/pdf', dirtype: 0 },
}),
]
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={queryClient}>
<SharesPage />
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.mocked(listShares).mockResolvedValue({ items: shares, total: shares.length, page: 1, pageSize: 20 })
vi.mocked(listReceivedShares).mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 20 })
vi.mocked(listShareOnProfile).mockResolvedValue({ listedAt: '2026-07-23T02:00:00.000Z' })
vi.mocked(unlistShareFromProfile).mockResolvedValue(undefined)
vi.mocked(revokeShare).mockResolvedValue({} as never)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('authenticated Shares profile listing actions', () => {
it('lists an eligible landing share and unlists an already listed share', async () => {
renderPage()
expect(await screen.findByText('Unlisted.pdf')).toBeTruthy()
fireEvent.click(screen.getAllByTitle('shares.listOnProfile')[0])
await waitFor(() => expect(listShareOnProfile).toHaveBeenCalledWith('unlisted-token'))
fireEvent.click(screen.getByTitle('shares.unlistFromProfile'))
await waitFor(() => expect(unlistShareFromProfile).toHaveBeenCalledWith('listed-token'))
})
it('disables profile listing for an ineligible direct share', async () => {
renderPage()
expect(await screen.findByText('Direct file.pdf')).toBeTruthy()
const listButtons = screen.getAllByTitle('shares.listOnProfile')
const directButton = listButtons.find((button) => button.closest('tr')?.textContent?.includes('Direct file.pdf'))
expect(directButton?.hasAttribute('disabled')).toBe(true)
fireEvent.click(directButton!)
expect(listShareOnProfile).not.toHaveBeenCalled()
})
it('keeps unlisting available when an already listed landing share has expired', async () => {
const expiredListed = share({
token: 'expired-listed-token',
expiresAt: '2000-01-01T00:00:00.000Z',
listedAt: '1999-12-01T00:00:00.000Z',
matter: { name: 'Expired listed.pdf', type: 'application/pdf', dirtype: 0 },
})
vi.mocked(listShares).mockResolvedValue({
items: [expiredListed],
total: 1,
page: 1,
pageSize: 20,
})
renderPage()
expect(await screen.findByText('Expired listed.pdf')).toBeTruthy()
const unlistButton = screen.getByTitle('shares.unlistFromProfile')
expect(unlistButton.hasAttribute('disabled')).toBe(false)
fireEvent.click(unlistButton)
await waitFor(() => expect(unlistShareFromProfile).toHaveBeenCalledWith('expired-listed-token'))
})
})
+57 -3
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
import { ChevronDown, ClipboardCopy, FileIcon, FolderIcon, Share2, XCircle } from 'lucide-react'
import { ChevronDown, ClipboardCopy, FileIcon, FolderIcon, House, HousePlus, Share2, XCircle } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -18,7 +18,14 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useClipboard } from '@/hooks/use-clipboard'
import { listReceivedShares, listShares, revokeShare, type ShareListItem } from '@/lib/api'
import {
listReceivedShares,
listShareOnProfile,
listShares,
revokeShare,
type ShareListItem,
unlistShareFromProfile,
} from '@/lib/api'
export const Route = createFileRoute('/_authenticated/shares/')({
validateSearch: (search: Record<string, unknown>) => ({
@@ -46,7 +53,23 @@ function computeDisplayStatus(share: ShareListItem): 'active' | 'revoked' | 'exp
return 'active'
}
function SharesPage() {
function canListOnProfile(share: ShareListItem): boolean {
return (
share.kind === 'landing' &&
share.recipientCount === 0 &&
computeDisplayStatus(share) === 'active' &&
(share.downloadLimit == null || share.downloads < share.downloadLimit)
)
}
function canChangeProfileListing(share: ShareListItem): boolean {
if (share.listedAt != null) {
return share.kind === 'landing' && share.recipientCount === 0 && share.status !== 'revoked'
}
return canListOnProfile(share)
}
export function SharesPage() {
const { t } = useTranslation()
const { copy } = useClipboard()
const navigate = useNavigate()
@@ -76,6 +99,18 @@ function SharesPage() {
},
})
const profileListingMutation = useMutation({
mutationFn: async ({ token, listed }: { token: string; listed: boolean }) => {
if (listed) await listShareOnProfile(token)
else await unlistShareFromProfile(token)
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['shares'] })
toast.success(t(variables.listed ? 'shares.profileListSuccess' : 'shares.profileUnlistSuccess'))
},
onError: () => toast.error(t('shares.profileListingError')),
})
const filteredItems = useMemo(() => {
const items = sharesQuery.data?.items ?? []
if (statusFilter === 'active') {
@@ -238,6 +273,12 @@ function SharesPage() {
copy(url, 'shares.urlCopied')
}}
onRevoke={() => setRevokeTarget(share)}
onToggleProfileListing={() =>
profileListingMutation.mutate({ token: share.token, listed: share.listedAt == null })
}
profileListingPending={
profileListingMutation.isPending && profileListingMutation.variables?.token === share.token
}
/>
))}
{filteredItems.length === 0 && (
@@ -314,12 +355,16 @@ function ShareTableRow({
onRowClick,
onCopyUrl,
onRevoke,
onToggleProfileListing,
profileListingPending,
}: {
share: ShareListItem
displayStatus: 'active' | 'revoked' | 'expired'
onRowClick: () => void
onCopyUrl: () => void
onRevoke: () => void
onToggleProfileListing: () => void
profileListingPending: boolean
}) {
const { t } = useTranslation()
@@ -383,6 +428,15 @@ function ShareTableRow({
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation wrapper for action buttons */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation wrapper for action buttons */}
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon-xs"
disabled={!canChangeProfileListing(share) || profileListingPending}
onClick={onToggleProfileListing}
title={t(share.listedAt ? 'shares.unlistFromProfile' : 'shares.listOnProfile')}
>
{share.listedAt ? <House /> : <HousePlus />}
</Button>
<Button variant="ghost" size="icon-xs" onClick={onCopyUrl} title={t('shares.copyUrl')}>
<ClipboardCopy />
</Button>
-30
View File
@@ -1,30 +0,0 @@
import { DirType } from '@shared/constants'
import { describe, expect, it } from 'vitest'
// ProfilePage is a React rendering component. The project has no jsdom or
// @testing-library/react setup, so we cannot render it here.
// We test the pure logic the component applies:
// - folder detection from dirtype
// ---------------------------------------------------------------------------
// Folder detection — mirrors MatterItem:
// const isFolder = matter.dirtype !== DirType.FILE
// ---------------------------------------------------------------------------
function isFolder(dirtype: number): boolean {
return dirtype !== DirType.FILE
}
describe('MatterItem — folder detection', () => {
it('treats FILE dirtype as not a folder', () => {
expect(isFolder(DirType.FILE)).toBe(false)
})
it('treats USER_FOLDER dirtype as a folder', () => {
expect(isFolder(DirType.USER_FOLDER)).toBe(true)
})
it('treats SYSTEM_FOLDER dirtype as a folder', () => {
expect(isFolder(DirType.SYSTEM_FOLDER)).toBe(true)
})
})
+84
View File
@@ -0,0 +1,84 @@
import type { PublicProfile } from '@shared/schemas/profile'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getProfile } from '@/lib/api'
import { ProfilePage } from './$username'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@tanstack/react-router', () => ({
createFileRoute: () => (options: object) => ({
...options,
useParams: () => ({ username: 'alice' }),
}),
}))
vi.mock('@/lib/api', () => ({
getProfile: vi.fn(),
}))
const profile: PublicProfile = {
user: {
username: 'alice',
name: 'Alice',
image: null,
},
shares: [
{
token: 'file-token',
name: 'release-notes.pdf',
type: 'application/pdf',
size: 1024,
isFolder: false,
},
{
token: 'folder-token',
name: 'Public folder',
type: 'folder',
size: null,
isFolder: true,
},
],
}
function renderPage() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={queryClient}>
<ProfilePage />
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.mocked(getProfile).mockResolvedValue(profile)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('public user homepage', () => {
it('loads the requested profile and links curated files and folders to the landing-share flow', async () => {
renderPage()
expect(await screen.findByText('Alice')).toBeTruthy()
expect(getProfile).toHaveBeenCalledWith('alice')
expect(screen.getByRole('link', { name: /release-notes\.pdf/ }).getAttribute('href')).toBe('/s/file-token')
expect(screen.getByRole('link', { name: /Public folder/ }).getAttribute('href')).toBe('/s/folder-token')
expect(screen.queryByText('profile.noShares')).toBeNull()
})
it('shows the empty state when the profile has no curated shares', async () => {
vi.mocked(getProfile).mockResolvedValue({ ...profile, shares: [] })
renderPage()
expect(await screen.findByText('profile.noShares')).toBeTruthy()
expect(screen.queryByRole('link')).toBeNull()
})
})
+37 -8
View File
@@ -1,7 +1,8 @@
import type { PublicProfileShare, PublicUser } from '@shared/schemas/profile'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { FolderIcon } from 'lucide-react'
import type { PublicUser } from '@/lib/api'
import { FileIcon, FolderIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getProfile } from '@/lib/api'
export const Route = createFileRoute('/u/$username')({
@@ -27,15 +28,17 @@ function UserHeader({ profile }: { profile: PublicUser }) {
}
function EmptyState() {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<FolderIcon className="mb-3 h-12 w-12 opacity-30" />
<p className="text-sm">No shared files yet</p>
<p className="text-sm">{t('profile.noShares')}</p>
</div>
)
}
function ProfilePage() {
export function ProfilePage() {
const { t } = useTranslation()
const { username } = Route.useParams()
const profileQuery = useQuery({
@@ -47,7 +50,7 @@ function ProfilePage() {
if (profileQuery.isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">Loading...</p>
<p className="text-muted-foreground">{t('common.loading')}</p>
</div>
)
}
@@ -56,19 +59,45 @@ function ProfilePage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-2">
<h1 className="text-2xl font-bold">404</h1>
<p className="text-muted-foreground">User not found</p>
<p className="text-muted-foreground">{t('profile.notFound')}</p>
</div>
)
}
const { user: profile } = profileQuery.data!
const { user: profile, shares } = profileQuery.data!
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<UserHeader profile={profile} />
<div className="mt-6">
<EmptyState />
{shares.length === 0 ? (
<EmptyState />
) : (
<div className="grid gap-3 sm:grid-cols-2">
{shares.map((share) => (
<ProfileShareCard key={share.token} share={share} />
))}
</div>
)}
</div>
</div>
)
}
function ProfileShareCard({ share }: { share: PublicProfileShare }) {
const Icon = share.isFolder ? FolderIcon : FileIcon
return (
<a
href={`/s/${share.token}`}
className="flex items-center gap-3 rounded-lg border bg-card p-4 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="rounded-md bg-muted p-2">
<Icon className="size-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="truncate font-medium">{share.name}</p>
<p className="truncate text-xs text-muted-foreground">{share.type}</p>
</div>
</a>
)
}