From 526d237a4ebfbdb9c30ec46efce482b81e51c290 Mon Sep 17 00:00:00 2001 From: "agent-kanban[bot]" <295243365+agent-kanban[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:39:25 -0400 Subject: [PATCH] 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 --- cmd/internal/openapi/client.gen.go | 470 +- e2e/profile-shares.spec.ts | 73 + migrations/0070_profile-share-listing.sql | 2 + migrations/meta/0070_snapshot.json | 4518 +++++++++++++++++ migrations/meta/_journal.json | 7 + server/adapters/repos/share.ts | 55 + server/db/schema.ts | 2 + server/http/shares.integration.test.ts | 125 + server/http/shares.ts | 86 +- server/http/users.cf-test.ts | 272 + server/http/users.integration.test.ts | 182 +- server/http/users.ts | 36 +- server/openapi.test.ts | 65 + server/test/setup.ts | 2 + server/usecases/ports/profile.ts | 8 +- server/usecases/ports/share.ts | 14 +- server/usecases/share.ts | 80 +- shared/schemas/index.ts | 13 +- shared/schemas/profile.ts | 24 + shared/schemas/share.ts | 29 + shared/types/index.ts | 1 + spec/profile.feature | 40 +- spec/shares.feature | 30 + specs/curated-profile-shares_design.md | 47 + .../dialogs/share-dialog.render.test.tsx | 114 + .../files/dialogs/share-dialog.test.ts | 1 + src/components/files/dialogs/share-dialog.tsx | 23 +- src/components/share/share-landing.tsx | 11 +- src/i18n/locales/en.json | 10 + src/i18n/locales/zh.json | 10 + src/lib/api.test.ts | 76 +- src/lib/api.ts | 42 +- .../_authenticated/settings/profile.test.ts | 37 - .../_authenticated/settings/profile.test.tsx | 74 + .../_authenticated/settings/profile.tsx | 14 +- .../_authenticated/shares/index.test.ts | 99 - .../_authenticated/shares/index.test.tsx | 165 + src/routes/_authenticated/shares/index.tsx | 60 +- src/routes/u/$username.test.ts | 30 - src/routes/u/$username.test.tsx | 84 + src/routes/u/$username.tsx | 45 +- 41 files changed, 6579 insertions(+), 497 deletions(-) create mode 100644 e2e/profile-shares.spec.ts create mode 100644 migrations/0070_profile-share-listing.sql create mode 100644 migrations/meta/0070_snapshot.json create mode 100644 server/http/users.cf-test.ts create mode 100644 shared/schemas/profile.ts create mode 100644 specs/curated-profile-shares_design.md create mode 100644 src/components/files/dialogs/share-dialog.render.test.tsx delete mode 100644 src/routes/_authenticated/settings/profile.test.ts create mode 100644 src/routes/_authenticated/settings/profile.test.tsx delete mode 100644 src/routes/_authenticated/shares/index.test.ts create mode 100644 src/routes/_authenticated/shares/index.test.tsx delete mode 100644 src/routes/u/$username.test.ts create mode 100644 src/routes/u/$username.test.tsx diff --git a/cmd/internal/openapi/client.gen.go b/cmd/internal/openapi/client.gen.go index ea1e18dc..16026ef5 100644 --- a/cmd/internal/openapi/client.gen.go +++ b/cmd/internal/openapi/client.gen.go @@ -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 -} diff --git a/e2e/profile-shares.spec.ts b/e2e/profile-shares.spec.ts new file mode 100644 index 00000000..75084642 --- /dev/null +++ b/e2e/profile-shares.spec.ts @@ -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() +}) diff --git a/migrations/0070_profile-share-listing.sql b/migrations/0070_profile-share-listing.sql new file mode 100644 index 00000000..dc6841f7 --- /dev/null +++ b/migrations/0070_profile-share-listing.sql @@ -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`); \ No newline at end of file diff --git a/migrations/meta/0070_snapshot.json b/migrations/meta/0070_snapshot.json new file mode 100644 index 00000000..659e70bf --- /dev/null +++ b/migrations/meta/0070_snapshot.json @@ -0,0 +1,4518 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ed39be92-e745-405c-8fec-41c201cde65e", + "prevId": "a4497c04-7c98-4577-9992-82151f2893be", + "tables": { + "announcements": { + "name": "announcements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "announcements_status_priority_idx": { + "name": "announcements_status_priority_idx", + "columns": [ + "status", + "priority" + ], + "isUnique": false + }, + "announcements_published_idx": { + "name": "announcements_published_idx", + "columns": [ + "published_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_events": { + "name": "audit_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_name": { + "name": "target_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_ref": { + "name": "actor_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_events_org_created_idx": { + "name": "audit_events_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "audit_events_user_created_idx": { + "name": "audit_events_user_created_idx", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "audit_events_action_created_idx": { + "name": "audit_events_action_created_idx", + "columns": [ + "action", + "created_at" + ], + "isUnique": false + }, + "audit_events_target_created_idx": { + "name": "audit_events_target_created_idx", + "columns": [ + "target_type", + "target_id", + "created_at" + ], + "isUnique": false + }, + "audit_events_created_idx": { + "name": "audit_events_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "background_jobs": { + "name": "background_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_folder": { + "name": "target_folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_path": { + "name": "target_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_bytes": { + "name": "input_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_bytes": { + "name": "output_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processed_bytes": { + "name": "processed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_filename": { + "name": "current_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_metadata": { + "name": "result_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retryable": { + "name": "retryable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cancelable": { + "name": "cancelable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "retried_from_job_id": { + "name": "retried_from_job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "background_jobs_org_created_idx": { + "name": "background_jobs_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "background_jobs_org_status_idx": { + "name": "background_jobs_org_status_idx", + "columns": [ + "org_id", + "status" + ], + "isUnique": false + }, + "background_jobs_org_type_idx": { + "name": "background_jobs_org_type_idx", + "columns": [ + "org_id", + "type" + ], + "isUnique": false + }, + "background_jobs_created_idx": { + "name": "background_jobs_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cloud_traffic_reports": { + "name": "cloud_traffic_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_bytes": { + "name": "unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credits_per_unit": { + "name": "credits_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "cloud_traffic_reports_event_uniq": { + "name": "cloud_traffic_reports_event_uniq", + "columns": [ + "event_id" + ], + "isUnique": true + }, + "cloud_traffic_reports_org_period_idx": { + "name": "cloud_traffic_reports_org_period_idx", + "columns": [ + "org_id", + "period" + ], + "isUnique": false + }, + "cloud_traffic_reports_status_idx": { + "name": "cloud_traffic_reports_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "cloud_traffic_reports_retry_idx": { + "name": "cloud_traffic_reports_retry_idx", + "columns": [ + "status", + "next_retry_at", + "created_at" + ], + "isUnique": false + }, + "cloud_traffic_reports_issued_idx": { + "name": "cloud_traffic_reports_issued_idx", + "columns": [ + "issued_at" + ], + "isUnique": false + }, + "cloud_traffic_reports_updated_idx": { + "name": "cloud_traffic_reports_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "download_tasks": { + "name": "download_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_uri": { + "name": "source_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_folder": { + "name": "target_folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "assigned_downloader_id": { + "name": "assigned_downloader_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "billing_authorized_bytes": { + "name": "billing_authorized_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billing_charged_bytes": { + "name": "billing_charged_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billing_charged_credits": { + "name": "billing_charged_credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_object_id": { + "name": "result_object_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runtime": { + "name": "runtime", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "resolve_started_at": { + "name": "resolve_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolve_completed_at": { + "name": "resolve_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_completed_at": { + "name": "download_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ingest_started_at": { + "name": "ingest_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ingest_completed_at": { + "name": "ingest_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seeding_started_at": { + "name": "seeding_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seeding_stopped_at": { + "name": "seeding_stopped_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "download_tasks_org_created_idx": { + "name": "download_tasks_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "download_tasks_org_status_idx": { + "name": "download_tasks_org_status_idx", + "columns": [ + "org_id", + "status" + ], + "isUnique": false + }, + "download_tasks_org_category_idx": { + "name": "download_tasks_org_category_idx", + "columns": [ + "org_id", + "category" + ], + "isUnique": false + }, + "download_tasks_org_tags_idx": { + "name": "download_tasks_org_tags_idx", + "columns": [ + "org_id", + "tags" + ], + "isUnique": false + }, + "download_tasks_downloader_idx": { + "name": "download_tasks_downloader_idx", + "columns": [ + "assigned_downloader_id", + "status" + ], + "isUnique": false + }, + "download_tasks_created_idx": { + "name": "download_tasks_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "download_tasks_finished_idx": { + "name": "download_tasks_finished_idx", + "columns": [ + "finished_at" + ], + "isUnique": false + }, + "download_tasks_org_deleted_created_idx": { + "name": "download_tasks_org_deleted_created_idx", + "columns": [ + "org_id", + "deleted_at", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloaders": { + "name": "downloaders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_jti": { + "name": "token_jti", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'offline'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "arch": { + "name": "arch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "engine": { + "name": "engine", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "max_concurrent_tasks": { + "name": "max_concurrent_tasks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "current_tasks": { + "name": "current_tasks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "download_bps": { + "name": "download_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upload_bps": { + "name": "upload_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "free_disk_bytes": { + "name": "free_disk_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remote_download_credit_billing_enabled": { + "name": "remote_download_credit_billing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_download_credit_unit_bytes": { + "name": "remote_download_credit_unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 104857600 + }, + "remote_download_credit_per_unit": { + "name": "remote_download_credit_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "downloaders_token_jti_unique": { + "name": "downloaders_token_jti_unique", + "columns": [ + "token_jti" + ], + "isUnique": true + }, + "downloaders_status_idx": { + "name": "downloaders_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "downloaders_enabled_idx": { + "name": "downloaders_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + }, + "downloaders_created_idx": { + "name": "downloaders_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_hosting_configs": { + "name": "image_hosting_configs", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain_verified_at": { + "name": "domain_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referer_allowlist": { + "name": "referer_allowlist", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_hosting_configs_custom_domain_unique": { + "name": "image_hosting_configs_custom_domain_unique", + "columns": [ + "custom_domain" + ], + "isUnique": true + } + }, + "foreignKeys": { + "image_hosting_configs_org_id_organization_id_fk": { + "name": "image_hosting_configs_org_id_organization_id_fk", + "tableFrom": "image_hosting_configs", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_hostings": { + "name": "image_hostings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "purged_at": { + "name": "purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_hostings_token_unique": { + "name": "image_hostings_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "image_hostings_org_path_uniq": { + "name": "image_hostings_org_path_uniq", + "columns": [ + "org_id", + "path" + ], + "isUnique": true, + "where": "\"image_hostings\".\"purged_at\" IS NULL" + }, + "image_hostings_org_created_idx": { + "name": "image_hostings_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "image_hostings_token_idx": { + "name": "image_hostings_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "image_hostings_org_id_organization_id_fk": { + "name": "image_hostings_org_id_organization_id_fk", + "tableFrom": "image_hostings", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "image_hostings_storage_id_storages_id_fk": { + "name": "image_hostings_storage_id_storages_id_fk", + "tableFrom": "image_hostings", + "tableTo": "storages", + "columnsFrom": [ + "storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite_codes": { + "name": "invite_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_by": { + "name": "used_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_codes_code_unique": { + "name": "invite_codes_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "license_bindings": { + "name": "license_bindings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cloud_binding_id": { + "name": "cloud_binding_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_store_id": { + "name": "cloud_store_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_account_id": { + "name": "cloud_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_account_email": { + "name": "cloud_account_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cached_certificate": { + "name": "cached_certificate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cached_certificate_expires_at": { + "name": "cached_certificate_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bound_at": { + "name": "bound_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refresh_at": { + "name": "last_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refresh_error": { + "name": "last_refresh_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "license_bindings_active_uniq": { + "name": "license_bindings_active_uniq", + "columns": [ + "status" + ], + "isUnique": true, + "where": "status = 'active'" + }, + "license_bindings_cloud_binding_idx": { + "name": "license_bindings_cloud_binding_idx", + "columns": [ + "cloud_binding_id" + ], + "isUnique": false + }, + "license_bindings_instance_idx": { + "name": "license_bindings_instance_idx", + "columns": [ + "instance_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "matters": { + "name": "matters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "dirtype": { + "name": "dirtype", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent": { + "name": "parent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "object": { + "name": "object", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "trashed_at": { + "name": "trashed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "matters_alias_unique": { + "name": "matters_alias_unique", + "columns": [ + "alias" + ], + "isUnique": true + }, + "matters_status_dir_created_idx": { + "name": "matters_status_dir_created_idx", + "columns": [ + "status", + "dirtype", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "notifications_user_read_idx": { + "name": "notifications_user_read_idx", + "columns": [ + "user_id", + "read_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "object_upload_sessions": { + "name": "object_upload_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_id": { + "name": "object_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upload_id": { + "name": "upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_conflict": { + "name": "on_conflict", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fail'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "object_upload_sessions_object_idx": { + "name": "object_upload_sessions_object_idx", + "columns": [ + "org_id", + "object_id" + ], + "isUnique": false + }, + "object_upload_sessions_expires_idx": { + "name": "object_upload_sessions_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_quota_entitlements": { + "name": "org_quota_entitlements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entitlement_type": { + "name": "entitlement_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'grant'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starts_at": { + "name": "starts_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "org_quota_entitlements_org_resource_idx": { + "name": "org_quota_entitlements_org_resource_idx", + "columns": [ + "org_id", + "resource_type", + "status" + ], + "isUnique": false + }, + "org_quota_entitlements_org_type_idx": { + "name": "org_quota_entitlements_org_type_idx", + "columns": [ + "org_id", + "resource_type", + "entitlement_type", + "status" + ], + "isUnique": false + }, + "org_quota_entitlements_active_plan_uniq": { + "name": "org_quota_entitlements_active_plan_uniq", + "columns": [ + "org_id", + "resource_type", + "entitlement_type" + ], + "isUnique": true, + "where": "status = 'active' AND entitlement_type = 'plan' AND source <> 'free_plan'" + }, + "org_quota_entitlements_source_resource_uniq": { + "name": "org_quota_entitlements_source_resource_uniq", + "columns": [ + "source", + "source_id", + "resource_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_quotas": { + "name": "org_quotas", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quota": { + "name": "quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_quota": { + "name": "traffic_quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_used": { + "name": "traffic_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_period": { + "name": "traffic_period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01'" + } + }, + "indexes": { + "org_quotas_org_uniq": { + "name": "org_quotas_org_uniq", + "columns": [ + "org_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "remote_download_usage_reports": { + "name": "remote_download_usage_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloader_id": { + "name": "downloader_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit_index": { + "name": "unit_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit_bytes": { + "name": "unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits_per_unit": { + "name": "credits_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "remote_download_usage_reports_event_id_unique": { + "name": "remote_download_usage_reports_event_id_unique", + "columns": [ + "event_id" + ], + "isUnique": true + }, + "remote_download_usage_task_unit_uniq": { + "name": "remote_download_usage_task_unit_uniq", + "columns": [ + "task_id", + "unit_index" + ], + "isUnique": true + }, + "remote_download_usage_org_idx": { + "name": "remote_download_usage_org_idx", + "columns": [ + "org_id" + ], + "isUnique": false + }, + "remote_download_usage_status_idx": { + "name": "remote_download_usage_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "remote_download_usage_created_idx": { + "name": "remote_download_usage_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "share_recipients": { + "name": "share_recipients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "share_recipients_share_id_idx": { + "name": "share_recipients_share_id_idx", + "columns": [ + "share_id" + ], + "isUnique": false + }, + "share_recipients_user_id_idx": { + "name": "share_recipients_user_id_idx", + "columns": [ + "recipient_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shares": { + "name": "shares", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "matter_id": { + "name": "matter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_limit": { + "name": "download_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "downloads": { + "name": "downloads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "listed_at": { + "name": "listed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "shares_token_unique": { + "name": "shares_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "shares_creator_status_created_idx": { + "name": "shares_creator_status_created_idx", + "columns": [ + "creator_id", + "status", + "created_at" + ], + "isUnique": false + }, + "shares_creator_listed_idx": { + "name": "shares_creator_listed_idx", + "columns": [ + "creator_id", + "listed_at" + ], + "isUnique": false + }, + "shares_created_idx": { + "name": "shares_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_invitations": { + "name": "site_invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "site_invitations_token_unique": { + "name": "site_invitations_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "site_invitations_email_idx": { + "name": "site_invitations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "site_invitations_created_idx": { + "name": "site_invitations_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "site_invitations_expires_idx": { + "name": "site_invitations_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stats_rollups_hourly": { + "name": "stats_rollups_hourly", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bucket_start": { + "name": "bucket_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "metric_key": { + "name": "metric_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dimension_key": { + "name": "dimension_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "dimension_value": { + "name": "dimension_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unique_count": { + "name": "unique_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "stats_rollups_hourly_bucket_metric_dim_uniq": { + "name": "stats_rollups_hourly_bucket_metric_dim_uniq", + "columns": [ + "bucket_start", + "org_id", + "metric_key", + "dimension_key", + "dimension_value" + ], + "isUnique": true + }, + "stats_rollups_hourly_metric_bucket_idx": { + "name": "stats_rollups_hourly_metric_bucket_idx", + "columns": [ + "metric_key", + "bucket_start" + ], + "isUnique": false + }, + "stats_rollups_hourly_dimension_bucket_idx": { + "name": "stats_rollups_hourly_dimension_bucket_idx", + "columns": [ + "metric_key", + "dimension_key", + "bucket_start" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_usage_breakdowns": { + "name": "storage_usage_breakdowns", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "storage_usage_breakdowns_org_category_uniq": { + "name": "storage_usage_breakdowns_org_category_uniq", + "columns": [ + "org_id", + "category" + ], + "isUnique": true + }, + "storage_usage_breakdowns_org_idx": { + "name": "storage_usage_breakdowns_org_idx", + "columns": [ + "org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_usage_ledger": { + "name": "storage_usage_ledger", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delta_bytes": { + "name": "delta_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "storage_usage_ledger_event_key_unique": { + "name": "storage_usage_ledger_event_key_unique", + "columns": [ + "event_key" + ], + "isUnique": true + }, + "storage_usage_ledger_occurred_idx": { + "name": "storage_usage_ledger_occurred_idx", + "columns": [ + "occurred_at" + ], + "isUnique": false + }, + "storage_usage_ledger_org_occurred_idx": { + "name": "storage_usage_ledger_org_occurred_idx", + "columns": [ + "org_id", + "occurred_at" + ], + "isUnique": false + }, + "storage_usage_ledger_storage_occurred_idx": { + "name": "storage_usage_ledger_storage_occurred_idx", + "columns": [ + "storage_id", + "occurred_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storages": { + "name": "storages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "access_key": { + "name": "access_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_key": { + "name": "secret_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "custom_host": { + "name": "custom_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "egress_credit_billing_enabled": { + "name": "egress_credit_billing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "egress_credit_unit_bytes": { + "name": "egress_credit_unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 104857600 + }, + "egress_credit_per_unit": { + "name": "egress_credit_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "force_path_style": { + "name": "force_path_style", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_checked_at": { + "name": "status_checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_options": { + "name": "system_options", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_invite_links": { + "name": "team_invite_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "team_invite_links_token_unique": { + "name": "team_invite_links_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webdav_dead_properties": { + "name": "webdav_dead_properties", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_path": { + "name": "resource_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webdav_dead_properties_resource_prop_uniq": { + "name": "webdav_dead_properties_resource_prop_uniq", + "columns": [ + "org_id", + "resource_path", + "namespace", + "name" + ], + "isUnique": true + }, + "webdav_dead_properties_resource_idx": { + "name": "webdav_dead_properties_resource_idx", + "columns": [ + "org_id", + "resource_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webdav_locks": { + "name": "webdav_locks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_path": { + "name": "resource_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "depth": { + "name": "depth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'infinity'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webdav_locks_token_unique": { + "name": "webdav_locks_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "webdav_locks_resource_idx": { + "name": "webdav_locks_resource_idx", + "columns": [ + "org_id", + "resource_path" + ], + "isUnique": false + }, + "webdav_locks_expires_idx": { + "name": "webdav_locks_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_events": { + "name": "webhook_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cloud'" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'order.quota_changed'" + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_events_source_event_uniq": { + "name": "webhook_events_source_event_uniq", + "columns": [ + "source", + "event_id" + ], + "isUnique": true + }, + "webhook_events_source_created_idx": { + "name": "webhook_events_source_created_idx", + "columns": [ + "source", + "created_at" + ], + "isUnique": false + }, + "webhook_events_status_idx": { + "name": "webhook_events_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "webhook_events_processed_idx": { + "name": "webhook_events_processed_idx", + "columns": [ + "processed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "reference_id" + ], + "isUnique": false + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + "key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deviceCode": { + "name": "deviceCode", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "deviceCode_device_code_idx": { + "name": "deviceCode_device_code_idx", + "columns": [ + "device_code" + ], + "isUnique": false + }, + "deviceCode_user_code_idx": { + "name": "deviceCode_user_code_idx", + "columns": [ + "user_code" + ], + "isUnique": false + }, + "deviceCode_status_idx": { + "name": "deviceCode_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "session_created_idx": { + "name": "session_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned": { + "name": "banned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "user_created_idx": { + "name": "user_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "user_lastActiveAt_idx": { + "name": "user_lastActiveAt_idx", + "columns": [ + "last_active_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 941798c3..a49991fb 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/server/adapters/repos/share.ts b/server/adapters/repos/share.ts index dc0994ce..e8ae9513 100644 --- a/server/adapters/repos/share.ts +++ b/server/adapters/repos/share.ts @@ -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 { + 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, diff --git a/server/db/schema.ts b/server/db/schema.ts index 4ad7db3f..72cded05 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -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), ], ) diff --git a/server/http/shares.integration.test.ts b/server/http/shares.integration.test.ts index 6c82b873..2a4bcbcb 100644 --- a/server/http/shares.integration.test.ts +++ b/server/http/shares.integration.test.ts @@ -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, +) { + 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)', () => { diff --git a/server/http/shares.ts b/server/http/shares.ts index 2e84a51b..6670dc77 100644 --- a/server/http/shares.ts +++ b/server/http/shares.ts @@ -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 ({ + ...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 { - 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, diff --git a/server/http/users.cf-test.ts b/server/http/users.cf-test.ts new file mode 100644 index 00000000..4328ddf6 --- /dev/null +++ b/server/http/users.cf-test.ts @@ -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 +type TestDb = ReturnType['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, body: Record) { + 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, + ]) + }) +}) diff --git a/server/http/users.integration.test.ts b/server/http/users.integration.test.ts index a220aed5..992314bc 100644 --- a/server/http/users.integration.test.ts +++ b/server/http/users.integration.test.ts @@ -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) { @@ -574,6 +573,69 @@ async function insertUser( return { orgId: `org-${opts.id}` } } +type ProfileTestDb = Awaited>['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', + ]) }) }) diff --git a/server/http/users.ts b/server/http/users.ts index d6cfd8eb..ab6ce2b3 100644 --- a/server/http/users.ts +++ b/server/http/users.ts @@ -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() 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 }) diff --git a/server/openapi.test.ts b/server/openapi.test.ts index a0f9b84a..8d513601 100644 --- a/server/openapi.test.ts +++ b/server/openapi.test.ts @@ -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 + } + } + > + components?: { + schemas?: Record< + string, + { + properties?: Record< + string, + { + type?: string + properties?: Record + items?: { + type?: string + properties?: Record + 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') diff --git a/server/test/setup.ts b/server/test/setup.ts index c1ccae8a..1aba9171 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -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, diff --git a/server/usecases/ports/profile.ts b/server/usecases/ports/profile.ts index 153cd685..7eee86b4 100644 --- a/server/usecases/ports/profile.ts +++ b/server/usecases/ports/profile.ts @@ -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 diff --git a/server/usecases/ports/share.ts b/server/usecases/ports/share.ts index aff6bbc3..c933caab 100644 --- a/server/usecases/ports/share.ts +++ b/server/usecases/ports/share.ts @@ -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 revokeByMatter(matterId: string): Promise revokeByToken(token: string, creatorId: string): Promise + setProfileListing(token: string, creatorId: string, listedAt: Date | null): Promise + listPublicProfileShares(username: string, now: Date): Promise listForApi( creatorId: string, opts: { page: number; pageSize: number; status?: string }, diff --git a/server/usecases/share.ts b/server/usecases/share.ts index 6f893ae4..5bfc1686 100644 --- a/server/usecases/share.ts +++ b/server/usecases/share.ts @@ -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 = { 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 { + 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 } diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index dd33c608..27b33a5d 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -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, diff --git a/shared/schemas/profile.ts b/shared/schemas/profile.ts new file mode 100644 index 00000000..1521407b --- /dev/null +++ b/shared/schemas/profile.ts @@ -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 +export type PublicProfileShare = z.infer +export type PublicProfile = z.infer diff --git a/shared/schemas/share.ts b/shared/schemas/share.ts index 7b41a670..4d753c78 100644 --- a/shared/schemas/share.ts +++ b/shared/schemas/share.ts @@ -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 @@ -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 +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 +export type ShareObjectsResponse = z.infer + export const saveShareRequestSchema = z.object({ targetOrgId: z.string().min(1), targetParent: z.string().default(''), diff --git a/shared/types/index.ts b/shared/types/index.ts index d91a3eb1..f86d6dea 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -476,6 +476,7 @@ export interface Share { views: number downloads: number status: 'active' | 'revoked' + listedAt: string | null createdAt: string } diff --git a/spec/profile.feature b/spec/profile.feature index 4e11eea2..c504d436 100644 --- a/spec/profile.feature +++ b/spec/profile.feature @@ -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 diff --git a/spec/shares.feature b/spec/shares.feature index f1a5beba..f7377840 100644 --- a/spec/shares.feature +++ b/spec/shares.feature @@ -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 diff --git a/specs/curated-profile-shares_design.md b/specs/curated-profile-shares_design.md new file mode 100644 index 00000000..487e6700 --- /dev/null +++ b/specs/curated-profile-shares_design.md @@ -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 owner’s 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. diff --git a/src/components/files/dialogs/share-dialog.render.test.tsx b/src/components/files/dialogs/share-dialog.render.test.tsx new file mode 100644 index 00000000..a6e95ec7 --- /dev/null +++ b/src/components/files/dialogs/share-dialog.render.test.tsx @@ -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) => (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( + + + , + ) +} + +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') + }) +}) diff --git a/src/components/files/dialogs/share-dialog.test.ts b/src/components/files/dialogs/share-dialog.test.ts index 7fdd4382..1eeda1a4 100644 --- a/src/components/files/dialogs/share-dialog.test.ts +++ b/src/components/files/dialogs/share-dialog.test.ts @@ -118,6 +118,7 @@ const makeResult = ( urls: { landing: '/s/tok' }, expiresAt: null, downloadLimit: null, + listedAt: null, ...overrides, }) diff --git a/src/components/files/dialogs/share-dialog.tsx b/src/components/files/dialogs/share-dialog.tsx index a1ce3d9d..a9d82113 100644 --- a/src/components/files/dialogs/share-dialog.tsx +++ b/src/components/files/dialogs/share-dialog.tsx @@ -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(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' && } + {mode === 'page' && ( +
+
+
+ + +
+

{t('share.showOnProfileHint')}

+
+ +
+ )} 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, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f5335acf..66872cb3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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.", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 57777bea..75deb75a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "此分享链接不存在或已被删除。", diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index fe970b07..0ce3ff77 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -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) expect(headers.get('Content-Type')).toContain('application/json') diff --git a/src/lib/api.ts b/src/lib/api.ts index bbcf6ce8..374d0d2e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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(users[':username'].$get({ param: { username } })) } // Teams Activity API @@ -1000,12 +996,21 @@ export function revokeShare(token: string) { return unwrap(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( diff --git a/src/routes/_authenticated/settings/profile.test.ts b/src/routes/_authenticated/settings/profile.test.ts deleted file mode 100644 index b0c10dcb..00000000 --- a/src/routes/_authenticated/settings/profile.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/routes/_authenticated/settings/profile.test.tsx b/src/routes/_authenticated/settings/profile.test.tsx new file mode 100644 index 00000000..1f88a372 --- /dev/null +++ b/src/routes/_authenticated/settings/profile.test.tsx @@ -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() + + 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() + + expect(screen.queryByRole('link', { name: 'settings.profile.publicHomepage' })).toBeNull() + }) +}) diff --git a/src/routes/_authenticated/settings/profile.tsx b/src/routes/_authenticated/settings/profile.tsx index af54d035..5d1b060d 100644 --- a/src/routes/_authenticated/settings/profile.tsx +++ b/src/routes/_authenticated/settings/profile.tsx @@ -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() { - +

{t('settings.profile.username.hint')}

+ {username && ( + + )}
) diff --git a/src/routes/_authenticated/shares/index.test.ts b/src/routes/_authenticated/shares/index.test.ts deleted file mode 100644 index 56f6f056..00000000 --- a/src/routes/_authenticated/shares/index.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/routes/_authenticated/shares/index.test.tsx b/src/routes/_authenticated/shares/index.test.tsx new file mode 100644 index 00000000..a53d84f9 --- /dev/null +++ b/src/routes/_authenticated/shares/index.test.tsx @@ -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) => (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 { + 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( + + + , + ) +} + +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')) + }) +}) diff --git a/src/routes/_authenticated/shares/index.tsx b/src/routes/_authenticated/shares/index.tsx index 0d522021..b064ffde 100644 --- a/src/routes/_authenticated/shares/index.tsx +++ b/src/routes/_authenticated/shares/index.tsx @@ -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) => ({ @@ -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 */}
e.stopPropagation()}> + diff --git a/src/routes/u/$username.test.ts b/src/routes/u/$username.test.ts deleted file mode 100644 index 2201a8f6..00000000 --- a/src/routes/u/$username.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/routes/u/$username.test.tsx b/src/routes/u/$username.test.tsx new file mode 100644 index 00000000..3573a0c7 --- /dev/null +++ b/src/routes/u/$username.test.tsx @@ -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( + + + , + ) +} + +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() + }) +}) diff --git a/src/routes/u/$username.tsx b/src/routes/u/$username.tsx index 80ae094b..e8ef6bd3 100644 --- a/src/routes/u/$username.tsx +++ b/src/routes/u/$username.tsx @@ -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 (
-

No shared files yet

+

{t('profile.noShares')}

) } -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 (
-

Loading...

+

{t('common.loading')}

) } @@ -56,19 +59,45 @@ function ProfilePage() { return (

404

-

User not found

+

{t('profile.notFound')}

) } - const { user: profile } = profileQuery.data! + const { user: profile, shares } = profileQuery.data! return (
- + {shares.length === 0 ? ( + + ) : ( +
+ {shares.map((share) => ( + + ))} +
+ )}
) } + +function ProfileShareCard({ share }: { share: PublicProfileShare }) { + const Icon = share.isFolder ? FolderIcon : FileIcon + return ( + +
+ +
+
+

{share.name}

+

{share.type}

+
+
+ ) +}