feat(shares)!: make landing shares public by default

Replace opt-in profile listings with an opt-out private flag and a unified privacy endpoint.

BREAKING CHANGE: showOnProfile, listedAt, and the profile-listing endpoints are replaced by private and PUT /api/shares/:token/privacy.
This commit is contained in:
saltbo
2026-07-24 01:07:01 -04:00
parent 10602e5a50
commit 0550e41868
33 changed files with 4884 additions and 556 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ The product boundary is intentional: ZPan is a purpose-built S3-backed web drive
- **S3 web drive** — Manage files, folders, previews, trash, quotas, and team workspaces on top of your own object storage
- **Image hosting** — Upload via PicGo, PicList, uPic, ShareX, Flameshot, or API and get a stable URL instantly
- **File sharing** — Publish share links with password, expiration, download limits, direct links, and save-to-drive flows
- **Personal homepage** — Give each user a public `/u/username` page for curated shared files and folder-style browsing
- **Personal homepage** — Give each user a public `/u/username` page for public shared files and folder-style browsing
- **External access** — Mount files through WebDAV and run downloader workers for remote-download workflows
## Why ZPan?
+47 -140
View File
@@ -1891,7 +1891,7 @@ type CreatedShare struct {
DownloadLimit *int `json:"downloadLimit"`
ExpiresAt *string `json:"expiresAt"`
Kind string `json:"kind"`
ListedAt *string `json:"listedAt"`
Private bool `json:"private"`
Token string `json:"token"`
Urls struct {
Direct *string `json:"direct,omitempty"`
@@ -2577,7 +2577,6 @@ 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"`
@@ -2585,6 +2584,7 @@ type ShareListItem struct {
} `json:"matter"`
MatterId string `json:"matterId"`
OrgId string `json:"orgId"`
Private bool `json:"private"`
RecipientCount int `json:"recipientCount"`
Status string `json:"status"`
Token string `json:"token"`
@@ -2609,9 +2609,9 @@ type ShareObjects struct {
Total int `json:"total"`
}
// ShareProfileListing defines model for ShareProfileListing.
type ShareProfileListing struct {
ListedAt *string `json:"listedAt"`
// SharePrivacy defines model for SharePrivacy.
type SharePrivacy struct {
Private bool `json:"private"`
}
// ShareView defines model for ShareView.
@@ -4058,11 +4058,11 @@ type CreateShareJSONBody struct {
Kind CreateShareJSONBodyKind `json:"kind"`
MatterId string `json:"matterId"`
Password *string `json:"password,omitempty"`
Private *bool `json:"private,omitempty"`
Recipients *[]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.
@@ -4672,6 +4672,9 @@ type CreateShareJSONRequestBody CreateShareJSONBody
// SaveShareJSONRequestBody defines body for SaveShare for application/json ContentType.
type SaveShareJSONRequestBody SaveShareJSONBody
// PutSharePrivacyJSONRequestBody defines body for PutSharePrivacy for application/json ContentType.
type PutSharePrivacyJSONRequestBody = SharePrivacy
// VerifySharePasswordJSONRequestBody defines body for VerifySharePassword for application/json ContentType.
type VerifySharePasswordJSONRequestBody VerifySharePasswordJSONBody
@@ -5602,11 +5605,10 @@ 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)
// PutSharePrivacyWithBody request with any body
PutSharePrivacyWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
// PutShareProfileListing request
PutShareProfileListing(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error)
PutSharePrivacy(ctx context.Context, token string, body PutSharePrivacyJSONRequestBody, 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)
@@ -8456,8 +8458,8 @@ 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)
func (c *Client) PutSharePrivacyWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPutSharePrivacyRequestWithBody(c.Server, token, contentType, body)
if err != nil {
return nil, err
}
@@ -8468,8 +8470,8 @@ func (c *Client) DeleteShareProfileListing(ctx context.Context, token string, re
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)
func (c *Client) PutSharePrivacy(ctx context.Context, token string, body PutSharePrivacyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPutSharePrivacyRequest(c.Server, token, body)
if err != nil {
return nil, err
}
@@ -15733,42 +15735,19 @@ 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: ""})
// NewPutSharePrivacyRequest calls the generic PutSharePrivacy builder with application/json body
func NewPutSharePrivacyRequest(server string, token string, body PutSharePrivacyJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
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
bodyReader = bytes.NewReader(buf)
return NewPutSharePrivacyRequestWithBody(server, token, "application/json", bodyReader)
}
// NewPutShareProfileListingRequest generates requests for PutShareProfileListing
func NewPutShareProfileListingRequest(server string, token string) (*http.Request, error) {
// NewPutSharePrivacyRequestWithBody generates requests for PutSharePrivacy with any type of body
func NewPutSharePrivacyRequestWithBody(server string, token string, contentType string, body io.Reader) (*http.Request, error) {
var err error
var pathParam0 string
@@ -15783,7 +15762,7 @@ func NewPutShareProfileListingRequest(server string, token string) (*http.Reques
return nil, err
}
operationPath := fmt.Sprintf("/api/shares/%s/profile-listing", pathParam0)
operationPath := fmt.Sprintf("/api/shares/%s/privacy", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
@@ -15793,11 +15772,13 @@ func NewPutShareProfileListingRequest(server string, token string) (*http.Reques
return nil, err
}
req, err := http.NewRequest(http.MethodPut, queryURL.String(), nil)
req, err := http.NewRequest(http.MethodPut, queryURL.String(), body)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", contentType)
return req, nil
}
@@ -19866,11 +19847,10 @@ 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)
// PutSharePrivacyWithBodyWithResponse request with any body
PutSharePrivacyWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutSharePrivacyResponse, error)
// PutShareProfileListingWithResponse request
PutShareProfileListingWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*PutShareProfileListingResponse, error)
PutSharePrivacyWithResponse(ctx context.Context, token string, body PutSharePrivacyJSONRequestBody, reqEditors ...RequestEditorFn) (*PutSharePrivacyResponse, error)
// VerifySharePasswordWithBodyWithResponse request with any body
VerifySharePasswordWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*VerifySharePasswordResponse, error)
@@ -26391,16 +26371,17 @@ func (r SaveShareResponse) ContentType() string {
return ""
}
type DeleteShareProfileListingResponse struct {
type PutSharePrivacyResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *SharePrivacy
JSON400 *Error
JSON403 *Error
JSON404 *Error
}
// Status returns HTTPResponse.Status
func (r DeleteShareProfileListingResponse) Status() string {
func (r PutSharePrivacyResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
@@ -26408,7 +26389,7 @@ func (r DeleteShareProfileListingResponse) Status() string {
}
// StatusCode returns HTTPResponse.StatusCode
func (r DeleteShareProfileListingResponse) StatusCode() int {
func (r PutSharePrivacyResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
@@ -26416,40 +26397,7 @@ func (r DeleteShareProfileListingResponse) StatusCode() int {
}
// 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 {
func (r PutSharePrivacyResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
@@ -30974,22 +30922,21 @@ 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...)
// PutSharePrivacyWithBodyWithResponse request with arbitrary body returning *PutSharePrivacyResponse
func (c *ClientWithResponses) PutSharePrivacyWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutSharePrivacyResponse, error) {
rsp, err := c.PutSharePrivacyWithBody(ctx, token, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParseDeleteShareProfileListingResponse(rsp)
return ParsePutSharePrivacyResponse(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...)
func (c *ClientWithResponses) PutSharePrivacyWithResponse(ctx context.Context, token string, body PutSharePrivacyJSONRequestBody, reqEditors ...RequestEditorFn) (*PutSharePrivacyResponse, error) {
rsp, err := c.PutSharePrivacy(ctx, token, body, reqEditors...)
if err != nil {
return nil, err
}
return ParsePutShareProfileListingResponse(rsp)
return ParsePutSharePrivacyResponse(rsp)
}
// VerifySharePasswordWithBodyWithResponse request with arbitrary body returning *VerifySharePasswordResponse
@@ -41256,62 +41203,22 @@ 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) {
// ParsePutSharePrivacyResponse parses an HTTP response from a PutSharePrivacyWithResponse call
func ParsePutSharePrivacyResponse(rsp *http.Response) (*PutSharePrivacyResponse, 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{
response := &PutSharePrivacyResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest ShareProfileListing
var dest SharePrivacy
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
+1 -1
View File
@@ -42,7 +42,7 @@ ZPan 是一个构建在 S3-compatible 存储之上的轻量级文件托管平台
- **S3 网盘** — 在你自己的对象存储之上管理文件、文件夹、预览、回收站、配额和团队工作区
- **图床** — 通过 PicGo、PicList、uPic、ShareX、Flameshot 或 API 上传,即刻获得稳定的 URL
- **文件分享** — 发布带密码、过期时间、下载次数限制、直链以及转存到网盘的分享链接
- **个人主页** — 为每个用户提供一个公开的 `/u/username` 页面,用于精选分享文件和文件夹式浏览
- **个人主页** — 为每个用户提供一个公开的 `/u/username` 页面,用于展示公开分享和文件夹式浏览
- **外部访问** — 通过 WebDAV 挂载文件,并运行下载节点以支持远程下载工作流
## 为什么选择 ZPan
+1 -1
View File
@@ -13,7 +13,7 @@ Shared file spaces for small teams with role-based permissions.
### User Share Homepage
- Public profile URL — `zpan.app/u/username` or custom slug
- Curated share list — user chooses which shares appear
- Public share list — untargeted landing shares appear by default unless marked private
- Directory browsing for shared folders
## User Scenarios
+1 -1
View File
@@ -17,7 +17,7 @@ const folderShare = {
rootRef: 'profile-folder-root',
}
test('anonymous profile opens curated files and folders through the landing-share flow @desktop [spec: profile/share-flow]', async ({
test('anonymous profile opens public files and folders through the landing-share flow @desktop [spec: profile/share-flow]', async ({
page,
}) => {
await page.route('**/api/users/alice', (route) =>
+4
View File
@@ -0,0 +1,4 @@
DROP INDEX `shares_creator_listed_idx`;--> statement-breakpoint
ALTER TABLE `shares` ADD `private` integer DEFAULT false NOT NULL;--> statement-breakpoint
CREATE INDEX `shares_creator_private_created_idx` ON `shares` (`creator_id`,`private`,`created_at`);--> statement-breakpoint
ALTER TABLE `shares` DROP COLUMN `listed_at`;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -491,6 +491,13 @@
"when": 1784850571453,
"tag": "0070_profile-share-listing",
"breakpoints": true
},
{
"idx": 71,
"version": "6",
"when": 1784869319265,
"tag": "0071_share-privacy",
"breakpoints": true
}
]
}
+7 -14
View File
@@ -34,13 +34,6 @@ 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()
.from(matters)
@@ -65,7 +58,7 @@ export function createShareRepo(db: Database): ShareRepo {
views: 0,
downloads: 0,
status: 'active',
listedAt: input.showOnProfile ? now : null,
private: input.private ?? false,
createdAt: now,
}
@@ -182,10 +175,10 @@ export function createShareRepo(db: Database): ShareRepo {
return result.length > 0
},
async setProfileListing(token: string, creatorId: string, listedAt: Date | null): Promise<boolean> {
async setPrivacy(token: string, creatorId: string, isPrivate: boolean): Promise<boolean> {
const result = await db
.update(shares)
.set({ listedAt })
.set({ private: isPrivate })
.where(and(eq(shares.token, token), eq(shares.creatorId, creatorId)))
.returning({ id: shares.id })
@@ -208,7 +201,7 @@ export function createShareRepo(db: Database): ShareRepo {
.where(
and(
eq(user.username, username),
isNotNull(shares.listedAt),
eq(shares.private, false),
eq(shares.kind, 'landing'),
eq(shares.status, 'active'),
or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`),
@@ -223,7 +216,7 @@ export function createShareRepo(db: Database): ShareRepo {
)`,
),
)
.orderBy(desc(shares.listedAt), desc(shares.id))
.orderBy(desc(shares.createdAt), desc(shares.id))
return rows.map(({ dirtype, ...row }) => ({ ...row, isFolder: dirtype !== DirType.FILE }))
},
@@ -253,7 +246,7 @@ export function createShareRepo(db: Database): ShareRepo {
views: shares.views,
downloads: shares.downloads,
status: shares.status,
listedAt: shares.listedAt,
private: shares.private,
createdAt: shares.createdAt,
matterName: matters.name,
matterType: matters.type,
@@ -311,7 +304,7 @@ export function createShareRepo(db: Database): ShareRepo {
views: shares.views,
downloads: shares.downloads,
status: shares.status,
listedAt: shares.listedAt,
private: shares.private,
createdAt: shares.createdAt,
matterName: matters.name,
matterType: matters.type,
+2 -2
View File
@@ -565,12 +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' }),
private: integer('private', { mode: 'boolean' }).notNull().default(false),
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_creator_private_created_idx').on(t.creatorId, t.private, t.createdAt),
index('shares_created_idx').on(t.createdAt),
],
)
+32 -41
View File
@@ -257,7 +257,7 @@ 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 () => {
it('creates an eligible landing share as public by default [spec: shares/create-public]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -267,21 +267,19 @@ describe('POST /api/shares', () => {
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 body = (await res.json()) as { token: string; private: boolean }
expect(body.private).toBe(false)
const rows = await db
.select({ listedAt: shares.listedAt, status: shares.status })
.select({ private: shares.private, status: shares.status })
.from(shares)
.where(eq(shares.token, body.token))
expect(rows[0]?.listedAt).toBeInstanceOf(Date)
expect(rows[0]?.status).toBe('active')
expect(rows[0]).toEqual({ private: false, status: 'active' })
})
it('rejects forged profile listing on direct and recipient-targeted shares', async () => {
it('accepts private creation without changing direct or targeted eligibility', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -291,23 +289,17 @@ describe('POST /api/shares', () => {
const direct = await createShare(app, headers, {
matterId: 'profile-ineligible',
kind: 'direct',
showOnProfile: true,
private: true,
})
expect(direct.status).toBe(400)
expect(((await direct.json()) as { error: { details: Array<{ reason: string }> } }).error.details[0]?.reason).toBe(
'PROFILE_LISTING_INELIGIBLE',
)
expect(direct.status).toBe(201)
const targeted = await createShare(app, headers, {
matterId: 'profile-ineligible',
kind: 'landing',
recipients: [{ recipientEmail: 'private@example.com' }],
showOnProfile: true,
private: true,
})
expect(targeted.status).toBe(400)
expect(
((await targeted.json()) as { error: { details: Array<{ reason: string }> } }).error.details[0]?.reason,
).toBe('PROFILE_LISTING_INELIGIBLE')
expect(targeted.status).toBe(201)
})
it('returns 400 with DIRECT_NO_RECIPIENTS when creating direct share with recipients [spec: shares/direct-no-recipients]', async () => {
@@ -359,25 +351,23 @@ describe('POST /api/shares', () => {
})
})
// ─── PUT/DELETE /api/shares/:token/profile-listing ───────────────────────────
// ─── PUT /api/shares/:token/privacy ──────────────────────────────────────────
function profileListingRequest(
app: TestApp,
token: string,
method: 'PUT' | 'DELETE',
headers?: Record<string, string>,
) {
return app.request(`/api/shares/${token}/profile-listing`, { method, headers })
function privacyRequest(app: TestApp, token: string, isPrivate: boolean, headers?: Record<string, string>) {
return app.request(`/api/shares/${token}/privacy`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ private: isPrivate }),
})
}
describe('share profile listing mutation', () => {
describe('share privacy 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)
expect((await privacyRequest(app, 'unknown', true)).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 () => {
it('lets only the owner make a landing share private or public without revoking it [spec: shares/privacy-owner] [spec: shares/privacy-authorization] [spec: shares/privacy-preserves-access]', async () => {
const { app, db } = await createTestApp()
const ownerHeaders = await authedHeaders(app, `profile-owner-${nanoid()}@example.com`)
await insertStorage(db)
@@ -387,25 +377,26 @@ describe('share profile listing mutation', () => {
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)
expect((await privacyRequest(app, token, true, 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 madePrivate = await privacyRequest(app, token, true, ownerHeaders)
expect(madePrivate.status).toBe(200)
expect(await madePrivate.json()).toEqual({ private: true })
const unlisted = await profileListingRequest(app, token, 'DELETE', ownerHeaders)
expect(unlisted.status).toBe(204)
const madePublic = await privacyRequest(app, token, false, ownerHeaders)
expect(madePublic.status).toBe(200)
expect(await madePublic.json()).toEqual({ private: false })
const rows = await db
.select({ listedAt: shares.listedAt, status: shares.status })
.select({ private: shares.private, status: shares.status })
.from(shares)
.where(eq(shares.token, token))
expect(rows[0]).toEqual({ listedAt: null, status: 'active' })
expect(rows[0]).toEqual({ private: false, 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 () => {
it('rejects privacy mutations for direct and recipient-targeted shares [spec: shares/privacy-ineligible]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app, `profile-ineligible-owner-${nanoid()}@example.com`)
await insertStorage(db)
@@ -423,10 +414,10 @@ describe('share profile listing mutation', () => {
const targetedToken = ((await targetedCreated.json()) as { token: string }).token
for (const token of [directToken, targetedToken]) {
const res = await profileListingRequest(app, token, 'PUT', headers)
const res = await privacyRequest(app, token, true, 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')
expect(body.error.details[0]?.reason).toBe('SHARE_PRIVACY_INELIGIBLE')
}
})
})
+18 -40
View File
@@ -23,7 +23,7 @@ import {
type ShareCreatorDto,
type ShareViewerDto,
saveShare,
setProfileListing,
setSharePrivacy,
verifySharePassword,
viewShare,
} from '../usecases/share'
@@ -117,7 +117,7 @@ const shareListItemSchema = z
views: z.number().int(),
downloads: z.number().int(),
status: z.string(),
listedAt: z.string().nullable(),
private: z.boolean(),
createdAt: z.string(),
matter: z.object({ name: z.string(), type: z.string(), dirtype: z.number().int() }),
recipientCount: z.number().int(),
@@ -129,7 +129,6 @@ function toShareListItemDTO(s: ShareListItem): z.infer<typeof shareListItemSchem
return {
...s,
expiresAt: s.expiresAt ? s.expiresAt.toISOString() : null,
listedAt: s.listedAt ? s.listedAt.toISOString() : null,
createdAt: s.createdAt.toISOString(),
}
}
@@ -145,7 +144,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(),
private: z.boolean(),
})
.openapi('CreatedShare')
@@ -387,33 +386,21 @@ const revokeShareRoute = createRoute({
},
})
const profileListingSchema = z.object({ listedAt: z.string().nullable() }).openapi('ShareProfileListing')
const sharePrivacySchema = z.object({ private: z.boolean() }).openapi('SharePrivacy')
const putProfileListingRoute = createRoute({
operationId: 'putShareProfileListing',
summary: 'Show a share on the owner public profile',
const putSharePrivacyRoute = createRoute({
operationId: 'putSharePrivacy',
summary: 'Set whether a share is hidden from 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'),
path: '/{token}/privacy',
request: {
params: z.object({ token: z.string() }),
...jsonBody(sharePrivacySchema),
},
})
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'),
200: jsonContent(sharePrivacySchema, 'Share privacy'),
400: errorResponse('Share does not have configurable privacy'),
403: errorResponse('Forbidden'),
404: errorResponse('Not found'),
},
@@ -461,29 +448,20 @@ 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,
private: out.share.private,
},
201,
)
}
throw out.error
})
.openapi(putProfileListingRoute, async (c) => {
const out = await setProfileListing(c.get('deps'), {
.openapi(putSharePrivacyRoute, async (c) => {
const out = await setSharePrivacy(c.get('deps'), {
token: c.req.valid('param').token,
userId: c.get('userId')!,
listed: true,
private: c.req.valid('json').private,
})
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)
if (out.ok) return c.json({ private: out.private }, 200)
throw out.error
})
.openapi(revokeShareRoute, async (c) => {
+31 -30
View File
@@ -91,7 +91,7 @@ function createShare(app: TestApp, headers: Record<string, string>, body: Record
}
describe('[CF] public profile shares', () => {
it('lists a landing share selected at creation and unlisting leaves the share usable', async () => {
it('lists a landing share by default and making it private 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')
@@ -99,11 +99,10 @@ describe('[CF] public profile shares', () => {
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 created = (await creation.json()) as { token: string; private: boolean }
expect(created.private).toBe(false)
const profile = await app.request(`/api/users/${owner.username}`)
expect(profile.status).toBe(200)
@@ -120,11 +119,12 @@ describe('[CF] public profile shares', () => {
],
})
const unlisted = await app.request(`/api/shares/${created.token}/profile-listing`, {
method: 'DELETE',
headers: owner.headers,
const madePrivate = await app.request(`/api/shares/${created.token}/privacy`, {
method: 'PUT',
headers: { ...owner.headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ private: true }),
})
expect(unlisted.status).toBe(204)
expect(madePrivate.status).toBe(200)
const afterUnlisting = await app.request(`/api/users/${owner.username}`)
expect(((await afterUnlisting.json()) as { shares: unknown[] }).shares).toEqual([])
@@ -133,7 +133,7 @@ describe('[CF] public profile shares', () => {
expect(underlyingShare.status).toBe(200)
})
it('requires authentication and ownership to change a profile listing', async () => {
it('requires authentication and ownership to change share privacy', 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)}`)
@@ -145,18 +145,24 @@ describe('[CF] public profile shares', () => {
kind: 'landing',
})
const anonymous = await app.request(`/api/shares/${share.token}/profile-listing`, { method: 'PUT' })
const anonymous = await app.request(`/api/shares/${share.token}/privacy`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ private: true }),
})
expect(anonymous.status).toBe(401)
const nonOwner = await app.request(`/api/shares/${share.token}/profile-listing`, {
const nonOwner = await app.request(`/api/shares/${share.token}/privacy`, {
method: 'PUT',
headers: other.headers,
headers: { ...other.headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ private: true }),
})
expect(nonOwner.status).toBe(403)
const ownerMutation = await app.request(`/api/shares/${share.token}/profile-listing`, {
const ownerMutation = await app.request(`/api/shares/${share.token}/privacy`, {
method: 'PUT',
headers: owner.headers,
headers: { ...owner.headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ private: false }),
})
expect(ownerMutation.status).toBe(200)
@@ -166,7 +172,7 @@ describe('[CF] public profile shares', () => {
])
})
it('rejects forged listing requests and never exposes direct or recipient-targeted shares', async () => {
it('rejects ineligible privacy 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')
@@ -176,7 +182,6 @@ describe('[CF] public profile shares', () => {
orgId: owner.orgId,
creatorId: owner.userId,
kind: 'landing',
showOnProfile: true,
})
const direct = await repo.create({
matterId,
@@ -192,30 +197,26 @@ describe('[CF] public profile shares', () => {
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`, {
const mutation = await app.request(`/api/shares/${token}/privacy`, {
method: 'PUT',
headers: owner.headers,
headers: { ...owner.headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ private: true }),
})
expect(mutation.status).toBe(400)
}
for (const body of [
{ matterId, kind: 'direct', showOnProfile: true },
{ matterId, kind: 'direct', private: true },
{
matterId,
kind: 'landing',
recipients: [{ recipientEmail: 'recipient@example.com' }],
showOnProfile: true,
private: true,
},
]) {
const creation = await createShare(app, owner.headers, body)
expect(creation.status).toBe(400)
expect(creation.status).toBe(201)
}
const profile = await app.request(`/api/users/${owner.username}`)
@@ -224,7 +225,7 @@ describe('[CF] public profile shares', () => {
])
})
it('filters revoked, expired, exhausted, trashed, inactive, missing, and unlisted targets at read time', async () => {
it('filters private, revoked, expired, exhausted, trashed, inactive, and missing targets at read time', async () => {
const { app, db } = await buildApp()
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
const repo = createShareRepo(db)
@@ -236,7 +237,6 @@ describe('[CF] public profile shares', () => {
orgId: owner.orgId,
creatorId: owner.userId,
kind: 'landing',
showOnProfile: true,
...options,
})
return { matterId, share }
@@ -249,12 +249,13 @@ describe('[CF] public profile shares', () => {
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')
const privateMatterId = await insertMatter(db, owner.orgId, 'Private.txt')
await repo.create({
matterId: unlistedMatterId,
matterId: privateMatterId,
orgId: owner.orgId,
creatorId: owner.userId,
kind: 'landing',
private: true,
})
await db.run(sql`UPDATE shares SET status = 'revoked' WHERE id = ${revoked.share.id}`)
+7 -7
View File
@@ -613,7 +613,7 @@ async function insertProfileShare(
token?: string
kind?: 'landing' | 'direct'
status?: 'active' | 'revoked'
listed?: boolean
private?: boolean
expiresAt?: number | null
downloadLimit?: number | null
downloads?: number
@@ -625,12 +625,12 @@ async function insertProfileShare(
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
download_limit, views, downloads, status, private, 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}
${opts.status ?? 'active'}, ${opts.private ? 1 : 0}, ${now}
)
`)
return { id, token }
@@ -679,7 +679,7 @@ describe('GET /api/users/:username', () => {
expect(body.shares).toEqual([])
})
it('returns exactly the selected public landing shares without authentication [spec: profile/curated-shares]', async () => {
it('returns public landing shares and hides private ones without authentication [spec: profile/public-shares]', async () => {
const { app, db } = await createTestApp()
const { orgId } = await insertUser(db, {
id: 'curated-user',
@@ -688,12 +688,12 @@ describe('GET /api/users/:username', () => {
})
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 insertProfileMatter(db, orgId, 'private-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', {
await insertProfileShare(db, 'curated-user', orgId, 'private-file', {
token: 'hidden-file',
listed: false,
private: true,
})
const res = await app.request('/api/users/curated')
+2 -2
View File
@@ -368,11 +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,
private INTEGER NOT NULL DEFAULT 0,
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 INDEX IF NOT EXISTS shares_creator_private_created_idx ON shares(creator_id, private, created_at);
CREATE TABLE IF NOT EXISTS share_recipients (
id TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
+4 -11
View File
@@ -19,7 +19,7 @@ export interface ShareRecord {
views: number
downloads: number
status: string
listedAt: Date | null
private: boolean
createdAt: Date
}
@@ -43,7 +43,7 @@ export interface ShareListItem {
views: number
downloads: number
status: string
listedAt: Date | null
private: boolean
createdAt: Date
matter: { name: string; type: string; dirtype: number }
recipientCount: number
@@ -61,14 +61,7 @@ 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'
| 'PROFILE_LISTING_INELIGIBLE',
) {
constructor(public code: 'MATTER_NOT_FOUND' | 'DIRECT_NO_FOLDER' | 'DIRECT_NO_PASSWORD' | 'DIRECT_NO_RECIPIENTS') {
super(code)
}
}
@@ -83,7 +76,7 @@ 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>
setPrivacy(token: string, creatorId: string, isPrivate: boolean): Promise<boolean>
listPublicProfileShares(username: string, now: Date): Promise<PublicProfileShare[]>
listForApi(
creatorId: string,
+13 -36
View File
@@ -443,7 +443,7 @@ export type CreatedShare = {
kind: string
expiresAt: Date | null
downloadLimit: number | null
listedAt: Date | null
private: boolean
}
export type CreateShareOutcome = { ok: true; share: CreatedShare } | { ok: false; error: AppError }
@@ -455,10 +455,6 @@ 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(
@@ -487,7 +483,7 @@ export async function createShare(
expiresAt,
downloadLimit: input.downloadLimit,
recipients: input.recipients,
showOnProfile: input.showOnProfile,
private: input.private,
})
} catch (err) {
if (err instanceof CreateShareError) return { ok: false, error: CREATE_SHARE_ERRORS[err.code] }
@@ -515,59 +511,40 @@ export async function createShare(
kind: share.kind,
expiresAt: share.expiresAt,
downloadLimit: share.downloadLimit,
listedAt: share.listedAt,
private: share.private,
},
}
}
// ─── PUT/DELETE /:token/profile-listing — owner-curated profile state ───────
// ─── PUT /:token/privacy — owner-controlled profile visibility ──────────────
export type SetProfileListingParams = {
export type SetSharePrivacyParams = {
token: string
userId: string
listed: boolean
now?: Date
private: boolean
}
export type SetProfileListingOutcome = { ok: true; listedAt: Date | null } | { ok: false; error: AppError }
export type SetSharePrivacyOutcome = { ok: true; private: boolean } | { ok: false; error: AppError }
export async function setProfileListing(
deps: ShareDeps,
params: SetProfileListingParams,
): Promise<SetProfileListingOutcome> {
const { token, userId, listed, now = new Date() } = params
export async function setSharePrivacy(deps: ShareDeps, params: SetSharePrivacyParams): Promise<SetSharePrivacyOutcome> {
const { token, userId, private: isPrivate } = 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
const { share, 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'),
error: badRequest('Only untargeted landing shares have configurable privacy', 'SHARE_PRIVACY_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)
const updated = await deps.share.setPrivacy(token, userId, isPrivate)
if (!updated) return { ok: false, error: notFound() }
return { ok: true, listedAt }
return { ok: true, private: isPrivate }
}
export function listPublicProfileShares(deps: ShareDeps, username: string, now = new Date()) {
+4 -4
View File
@@ -26,10 +26,10 @@ export const createShareSchema = z.object({
expiresAt: z.date().optional(),
downloadLimit: z.number().int().positive().optional(),
recipients: z.array(shareRecipientSchema).optional(),
showOnProfile: z.boolean().optional(),
private: z.boolean().default(false),
})
export type CreateShareInput = z.infer<typeof createShareSchema>
export type CreateShareInput = z.input<typeof createShareSchema>
export const listSharesQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
@@ -45,10 +45,10 @@ 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(),
private: z.boolean().default(false),
})
export type CreateShareRequest = z.infer<typeof createShareRequestSchema>
export type CreateShareRequest = z.input<typeof createShareRequestSchema>
export const shareObjectItemSchema = z.object({
ref: z.string(),
+1 -1
View File
@@ -476,7 +476,7 @@ export interface Share {
views: number
downloads: number
status: 'active' | 'revoked'
listedAt: string | null
private: boolean
createdAt: string
}
+10 -10
View File
@@ -1,6 +1,6 @@
Feature: Public profiles
Each user has a public profile page listing owner-curated public landing
shares, reachable without authentication.
Each user has a public profile page listing public landing shares that have
not been marked private, reachable without authentication.
@profile/user-not-found @api
Scenario: An unknown user id has no profile
@@ -26,26 +26,26 @@ Feature: Public profiles
When their profile is requested
Then their user info is returned
@profile/curated-shares @api
Scenario: A profile returns only selected public landing shares
Given selected and unselected public landing shares owned by a user
@profile/public-shares @api
Scenario: A profile returns public landing shares and hides private ones
Given public and private landing shares owned by a user
When their profile is requested without authentication
Then exactly the selected shares are returned
Then exactly the public shares are returned
@profile/privacy-boundaries @api
Scenario: Private share modes never appear on a profile
Given forged selected direct and recipient-targeted shares
Given direct and recipient-targeted shares
When the owner's profile is requested
Then neither private share is returned
@profile/availability-filtering @api
Scenario: Unavailable selected shares disappear at read time
Given selected revoked, expired, exhausted, trashed, purged, draft, and missing-target shares
Scenario: Unavailable public shares disappear at read time
Given public 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
Given 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
+19 -19
View File
@@ -69,34 +69,34 @@ 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
@shares/create-public @api
Scenario: An eligible landing share is public by default
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
When they do not enable private sharing
Then the share appears on their public profile
@shares/profile-listing-owner @api
Scenario: An owner lists and unlists an eligible share
@shares/privacy-owner @api
Scenario: An owner changes an eligible share between public and private
Given an owner's untargeted landing share
When they add and remove its profile listing
Then only the listing state changes
When they enable and disable private sharing
Then only the privacy state changes
@shares/profile-listing-authorization @api
Scenario: Another user cannot change a profile listing
@shares/privacy-authorization @api
Scenario: Another user cannot change share privacy
Given a landing share owned by another user
When a non-owner tries to change its profile listing
When a non-owner tries to change its privacy
Then the API responds 403
@shares/profile-listing-ineligible @api
Scenario: Direct and recipient-targeted shares cannot be listed
@shares/privacy-ineligible @api
Scenario: Direct and recipient-targeted shares do not have configurable privacy
Given direct and recipient-targeted shares
When forged profile-listing requests are submitted
Then the API responds 400 PROFILE_LISTING_INELIGIBLE
When privacy requests are submitted
Then the API responds 400 SHARE_PRIVACY_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
@shares/privacy-preserves-access @api
Scenario: Making a share private does not revoke it
Given a public landing share
When its owner enables private sharing
Then its original landing URL remains usable
@shares/create-notify-best-effort @api
-47
View File
@@ -1,47 +0,0 @@
# 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.
+47
View File
@@ -0,0 +1,47 @@
# Feature: Public profile shares
## Requirements
- While a user creates an untargeted landing share, the system shall make it public by default unless they enable “Private share”.
- While an authenticated owner views sent shares, when they change an eligible landing share between public and private, the system shall update only its privacy state.
- While an anonymous visitor requests a public profile, the system shall return only that owners accessible, non-private, untargeted landing shares.
- While a visitor opens a public item, the system shall use the existing `/s/:token` landing and folder-navigation flow.
## Architecture
### Frontend
- Add a “Private share” switch to the existing share-creation dialog. It is off by default, available only for untargeted landing shares, and resets when another mode is selected.
- Add public/private 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 non-null `shares.private` with a default of `false`; existing shares become public.
- Generate the migration with `pnpm db:generate`.
- Extend share creation with `private?: boolean`, persisted atomically with the share.
- Model the owner-controlled state as `PUT /api/shares/:token/privacy` with `{ private: boolean }`.
- Return the updated privacy state from `PUT`.
- Remove the unused `GET /api/users/:username/objects` placeholder.
- Define the public profile response with a shared Zod schema and inferred wire types.
- Query public shares using an inner join to the target and read-time predicates for `private = false`, owner, landing kind, no recipients, active status, unexpired limit, remaining downloads, active target, and non-trashed/non-purged target.
### Security
- Require authentication on privacy mutations and scope writes by both token and creator ID.
- Revalidate landing kind and the absence of recipients on the server; direct or targeted privacy 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 shares deterministically by `createdAt` then share ID.
- Changing privacy never revokes the share; anyone with its URL can still use the existing access flow.
- 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 privacy use cases plus REST routes.
- [x] Replace profile placeholders with the public-share 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.
@@ -75,7 +75,7 @@ beforeEach(() => {
urls: { landing: '/s/share-token' },
expiresAt: null,
downloadLimit: null,
listedAt: '2026-07-23T00:00:00.000Z',
private: false,
})
})
@@ -84,11 +84,11 @@ afterEach(() => {
vi.clearAllMocks()
})
describe('ShareDialog profile listing selection', () => {
it('sends showOnProfile when the owner enables it for a landing share', async () => {
describe('ShareDialog privacy selection', () => {
it('sends private when the owner enables it for a landing share', async () => {
renderDialog()
fireEvent.click(screen.getByRole('switch', { name: 'share.showOnProfile' }))
fireEvent.click(screen.getByRole('switch', { name: 'share.private' }))
fireEvent.click(screen.getByRole('button', { name: 'share.createButton' }))
await waitFor(() =>
@@ -96,19 +96,19 @@ describe('ShareDialog profile listing selection', () => {
expect.objectContaining({
matterId: 'matter-1',
kind: 'landing',
showOnProfile: true,
private: true,
}),
expect.anything(),
),
)
})
it('omits showOnProfile unless the owner enables the control', async () => {
it('omits private 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')
expect(vi.mocked(createShare).mock.calls[0]?.[0]).not.toHaveProperty('private')
})
})
@@ -118,7 +118,7 @@ const makeResult = (
urls: { landing: '/s/tok' },
expiresAt: null,
downloadLimit: null,
listedAt: null,
private: false,
...overrides,
})
+11 -11
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, House, KeyRound, Share2, TriangleAlert, X } from 'lucide-react'
import { CheckCircle2, Copy, File, Folder, KeyRound, Lock, Share2, TriangleAlert, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -79,7 +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 [privateShare, setPrivateShare] = useState(false)
const [result, setResult] = useState<CreateShareResult | null>(null)
useEffect(() => {
@@ -93,7 +93,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
setCustomExpires('')
setLimitOption('unlimited')
setCustomLimit('')
setShowOnProfile(false)
setPrivateShare(false)
setResult(null)
}, [open])
@@ -147,7 +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
if (mode === 'page' && privateShare) body.private = true
mutation.mutate(body)
}
@@ -186,7 +186,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
isFolder={isFolder}
onChange={(next) => {
setMode(next)
setShowOnProfile(false)
setPrivateShare(false)
setPasswordEnabled(false)
setPassword('')
if (!isTargetedMode(next)) {
@@ -220,16 +220,16 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
<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>
<Lock className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="private-share">{t('share.private')}</Label>
</div>
<p className="text-xs leading-5 text-muted-foreground">{t('share.showOnProfileHint')}</p>
<p className="text-xs leading-5 text-muted-foreground">{t('share.privateHint')}</p>
</div>
<Switch
id="share-profile-listing"
id="private-share"
className="mt-0.5"
checked={showOnProfile}
onCheckedChange={setShowOnProfile}
checked={privateShare}
onCheckedChange={setPrivateShare}
/>
</div>
)}
+7 -7
View File
@@ -1475,11 +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.makePublic": "Make public",
"shares.makePrivate": "Make private",
"shares.makePublicSuccess": "Share is now visible on your public profile",
"shares.makePrivateSuccess": "Share is now hidden from your public profile",
"shares.privacyError": "Failed to update share privacy",
"shares.emptyState": "No shares yet",
"shares.emptyStateHint": "Right-click a file to create your first share",
"shares.boxSent": "My shares",
@@ -1515,8 +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.private": "Private share",
"share.privateHint": "Hide this share from your public profile. Anyone with the URL can still access it.",
"share.generatePassword": "Generate",
"share.expires": "Expires",
"share.expires1d": "1 day",
+7 -7
View File
@@ -1475,11 +1475,11 @@
"shares.revokeConfirm": "撤销 {{name}} 的分享?任何人将立即失去访问权限,此操作不可撤回。",
"shares.revokeSuccess": "分享已撤销",
"shares.revokeError": "撤销分享失败",
"shares.listOnProfile": "显示在公开主页",
"shares.unlistFromProfile": "从公开主页移除",
"shares.profileListSuccess": "分享已添加到公开主页",
"shares.profileUnlistSuccess": "分享已从公开主页移除",
"shares.profileListingError": "更新公开主页展示失败",
"shares.makePublic": "设为公开",
"shares.makePrivate": "设为私密",
"shares.makePublicSuccess": "分享现已显示在公开主页",
"shares.makePrivateSuccess": "分享已从公开主页隐藏",
"shares.privacyError": "更新分享隐私设置失败",
"shares.emptyState": "暂无分享",
"shares.emptyStateHint": "右键点击文件即可创建第一个分享",
"shares.boxSent": "我的分享",
@@ -1515,8 +1515,8 @@
"share.recipientsHint": "系统会为这些邮箱创建定向访问入口,仍受有效期和下载限额约束",
"share.password": "密码",
"share.passwordHint": "仅访问页支持密码保护。拿到链接的人需要输入密码后才能访问。",
"share.showOnProfile": "显示在个人主页",
"share.showOnProfileHint": "将这个公开访问页添加到你的精选主页。",
"share.private": "私密分享",
"share.privateHint": "不在公开主页显示;拥有链接的人仍然可以访问。",
"share.generatePassword": "生成",
"share.expires": "过期时间",
"share.expires1d": "1天",
+14 -30
View File
@@ -94,7 +94,6 @@ import {
listQuotas,
listReceivedShares,
listShareObjects,
listShareOnProfile,
listShares,
listSiteInvitations,
listStorages,
@@ -129,9 +128,9 @@ import {
saveShareToDrive,
sendDownloaderHeartbeat,
serverEventsUrl,
setSharePrivacy,
testEmail,
transferObject,
unlistShareFromProfile,
updateAnnouncement,
updateDownloader,
updateDownloaderCreditBilling,
@@ -2708,61 +2707,46 @@ 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' }
describe('share privacy', () => {
it('updates privacy with PUT on the exact resource path', async () => {
const payload = { private: true }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
await expect(listShareOnProfile('tok123')).resolves.toEqual(payload)
await expect(setSharePrivacy('tok123', true)).resolves.toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/shares/tok123/profile-listing')
expect(url).toBe('/api/shares/tok123/privacy')
expect(init.method).toBe('PUT')
expect(init.body).toBeUndefined()
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toEqual({ private: true })
})
it('surfaces listing errors', async () => {
it('surfaces privacy 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)
await expect(setSharePrivacy('tok123', true)).rejects.toBeInstanceOf(ApiError)
})
})
describe('createShare', () => {
it('posts the profile selection flag to the exact share collection path', async () => {
it('posts the privacy 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',
private: true,
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await createShare({ matterId: 'obj-1', kind: 'landing', showOnProfile: true })
const result = await createShare({ matterId: 'obj-1', kind: 'landing', private: true })
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/shares')
expect(init.method).toBe('POST')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toEqual({ matterId: 'obj-1', kind: 'landing', showOnProfile: true })
expect(body).toEqual({ matterId: 'obj-1', kind: 'landing', private: 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')
+5 -7
View File
@@ -996,12 +996,10 @@ 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 function setSharePrivacy(token: string, isPrivate: boolean) {
return unwrap<{ private: boolean }>(
authedSharesApi[':token'].privacy.$put({ param: { token }, json: { private: isPrivate } }),
)
}
export interface CreateShareResult {
@@ -1010,7 +1008,7 @@ export interface CreateShareResult {
urls: { landing?: string; direct?: string }
expiresAt: string | null
downloadLimit: number | null
listedAt: string | null
private: boolean
}
export function createShare(data: CreateShareRequest) {
+32 -32
View File
@@ -2,7 +2,7 @@ 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 { listReceivedShares, listShares, revokeShare, setSharePrivacy } from '@/lib/api'
import { SharesPage } from './index'
const router = vi.hoisted(() => ({
@@ -47,10 +47,9 @@ vi.mock('@/components/shares/revoke-confirm-dialog', () => ({
vi.mock('@/lib/api', () => ({
listReceivedShares: vi.fn(),
listShareOnProfile: vi.fn(),
listShares: vi.fn(),
revokeShare: vi.fn(),
unlistShareFromProfile: vi.fn(),
setSharePrivacy: vi.fn(),
}))
function share(overrides: Partial<ShareListItem>): ShareListItem {
@@ -66,7 +65,7 @@ function share(overrides: Partial<ShareListItem>): ShareListItem {
views: 2,
downloads: 1,
status: 'active',
listedAt: null,
private: false,
createdAt: '2026-07-23T00:00:00.000Z',
matter: {
name: 'Public file.pdf',
@@ -79,12 +78,12 @@ function share(overrides: Partial<ShareListItem>): ShareListItem {
}
const shares = [
share({ token: 'unlisted-token', matter: { name: 'Unlisted.pdf', type: 'application/pdf', dirtype: 0 } }),
share({ token: 'public-token', matter: { name: 'Public.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 },
token: 'private-token',
private: true,
matter: { name: 'Private folder', type: 'folder', dirtype: 1 },
}),
share({
id: 'share-3',
@@ -106,8 +105,7 @@ function renderPage() {
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(setSharePrivacy).mockResolvedValue({ private: true })
vi.mocked(revokeShare).mockResolvedValue({} as never)
})
@@ -116,39 +114,41 @@ afterEach(() => {
vi.clearAllMocks()
})
describe('authenticated Shares profile listing actions', () => {
it('lists an eligible landing share and unlists an already listed share', async () => {
describe('authenticated Shares privacy actions', () => {
it('makes a public share private and a private share public', async () => {
renderPage()
expect(await screen.findByText('Unlisted.pdf')).toBeTruthy()
fireEvent.click(screen.getAllByTitle('shares.listOnProfile')[0])
await waitFor(() => expect(listShareOnProfile).toHaveBeenCalledWith('unlisted-token'))
expect(await screen.findByText('Public.pdf')).toBeTruthy()
const publicButton = screen
.getAllByTitle('shares.makePrivate')
.find((button) => button.closest('tr')?.textContent?.includes('Public.pdf'))
fireEvent.click(publicButton!)
await waitFor(() => expect(setSharePrivacy).toHaveBeenCalledWith('public-token', true))
fireEvent.click(screen.getByTitle('shares.unlistFromProfile'))
await waitFor(() => expect(unlistShareFromProfile).toHaveBeenCalledWith('listed-token'))
fireEvent.click(screen.getByTitle('shares.makePublic'))
await waitFor(() => expect(setSharePrivacy).toHaveBeenCalledWith('private-token', false))
})
it('disables profile listing for an ineligible direct share', async () => {
it('disables privacy changes 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'))
const privacyButtons = screen.getAllByTitle('shares.makePrivate')
const directButton = privacyButtons.find((button) => button.closest('tr')?.textContent?.includes('Direct file.pdf'))
expect(directButton?.hasAttribute('disabled')).toBe(true)
fireEvent.click(directButton!)
expect(listShareOnProfile).not.toHaveBeenCalled()
expect(setSharePrivacy).not.toHaveBeenCalled()
})
it('keeps unlisting available when an already listed landing share has expired', async () => {
const expiredListed = share({
token: 'expired-listed-token',
it('keeps privacy changes available when a landing share has expired', async () => {
const expiredPublic = share({
token: 'expired-public-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 },
matter: { name: 'Expired public.pdf', type: 'application/pdf', dirtype: 0 },
})
vi.mocked(listShares).mockResolvedValue({
items: [expiredListed],
items: [expiredPublic],
total: 1,
page: 1,
pageSize: 20,
@@ -156,10 +156,10 @@ describe('authenticated Shares profile listing actions', () => {
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'))
expect(await screen.findByText('Expired public.pdf')).toBeTruthy()
const privateButton = screen.getByTitle('shares.makePrivate')
expect(privateButton.hasAttribute('disabled')).toBe(false)
fireEvent.click(privateButton)
await waitFor(() => expect(setSharePrivacy).toHaveBeenCalledWith('expired-public-token', true))
})
})
+19 -44
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, House, HousePlus, Share2, XCircle } from 'lucide-react'
import { ChevronDown, ClipboardCopy, FileIcon, FolderIcon, Globe2, Lock, Share2, XCircle } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -18,14 +18,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useClipboard } from '@/hooks/use-clipboard'
import {
listReceivedShares,
listShareOnProfile,
listShares,
revokeShare,
type ShareListItem,
unlistShareFromProfile,
} from '@/lib/api'
import { listReceivedShares, listShares, revokeShare, type ShareListItem, setSharePrivacy } from '@/lib/api'
export const Route = createFileRoute('/_authenticated/shares/')({
validateSearch: (search: Record<string, unknown>) => ({
@@ -53,20 +46,8 @@ function computeDisplayStatus(share: ShareListItem): 'active' | 'revoked' | 'exp
return 'active'
}
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)
function canChangePrivacy(share: ShareListItem): boolean {
return share.kind === 'landing' && share.recipientCount === 0 && share.status !== 'revoked'
}
export function SharesPage() {
@@ -99,16 +80,14 @@ export function SharesPage() {
},
})
const profileListingMutation = useMutation({
mutationFn: async ({ token, listed }: { token: string; listed: boolean }) => {
if (listed) await listShareOnProfile(token)
else await unlistShareFromProfile(token)
},
const privacyMutation = useMutation({
mutationFn: ({ token, private: isPrivate }: { token: string; private: boolean }) =>
setSharePrivacy(token, isPrivate),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['shares'] })
toast.success(t(variables.listed ? 'shares.profileListSuccess' : 'shares.profileUnlistSuccess'))
toast.success(t(variables.private ? 'shares.makePrivateSuccess' : 'shares.makePublicSuccess'))
},
onError: () => toast.error(t('shares.profileListingError')),
onError: () => toast.error(t('shares.privacyError')),
})
const filteredItems = useMemo(() => {
@@ -273,12 +252,8 @@ export 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
}
onTogglePrivacy={() => privacyMutation.mutate({ token: share.token, private: !share.private })}
privacyPending={privacyMutation.isPending && privacyMutation.variables?.token === share.token}
/>
))}
{filteredItems.length === 0 && (
@@ -355,16 +330,16 @@ function ShareTableRow({
onRowClick,
onCopyUrl,
onRevoke,
onToggleProfileListing,
profileListingPending,
onTogglePrivacy,
privacyPending,
}: {
share: ShareListItem
displayStatus: 'active' | 'revoked' | 'expired'
onRowClick: () => void
onCopyUrl: () => void
onRevoke: () => void
onToggleProfileListing: () => void
profileListingPending: boolean
onTogglePrivacy: () => void
privacyPending: boolean
}) {
const { t } = useTranslation()
@@ -431,11 +406,11 @@ function ShareTableRow({
<Button
variant="ghost"
size="icon-xs"
disabled={!canChangeProfileListing(share) || profileListingPending}
onClick={onToggleProfileListing}
title={t(share.listedAt ? 'shares.unlistFromProfile' : 'shares.listOnProfile')}
disabled={!canChangePrivacy(share) || privacyPending}
onClick={onTogglePrivacy}
title={t(share.private ? 'shares.makePublic' : 'shares.makePrivate')}
>
{share.listedAt ? <House /> : <HousePlus />}
{share.private ? <Globe2 /> : <Lock />}
</Button>
<Button variant="ghost" size="icon-xs" onClick={onCopyUrl} title={t('shares.copyUrl')}>
<ClipboardCopy />
+2 -2
View File
@@ -63,7 +63,7 @@ afterEach(() => {
})
describe('public user homepage', () => {
it('loads the requested profile and links curated files and folders to the landing-share flow', async () => {
it('loads the requested profile and links public files and folders to the landing-share flow', async () => {
renderPage()
expect(await screen.findByText('Alice')).toBeTruthy()
@@ -73,7 +73,7 @@ describe('public user homepage', () => {
expect(screen.queryByText('profile.noShares')).toBeNull()
})
it('shows the empty state when the profile has no curated shares', async () => {
it('shows the empty state when the profile has no public shares', async () => {
vi.mocked(getProfile).mockResolvedValue({ ...profile, shares: [] })
renderPage()