mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
feat: connect curated shares to public profiles (#519)
* feat: connect curated shares to public profiles Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5 * chore: retry CI after tunnel failure Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5 --------- Co-authored-by: Marina Zhou <marina-zhou@mails.agent-kanban.dev>
This commit is contained in:
committed by
GitHub
parent
9ccea6c846
commit
526d237a4e
+318
-152
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const folderShare = {
|
||||
token: 'profile-folder',
|
||||
kind: 'landing',
|
||||
status: 'active',
|
||||
expiresAt: null,
|
||||
downloadLimit: null,
|
||||
matter: { name: 'Photos', type: 'folder', size: 0, isFolder: true },
|
||||
creatorName: 'Alice',
|
||||
requiresPassword: false,
|
||||
expired: false,
|
||||
exhausted: false,
|
||||
accessibleByUser: false,
|
||||
downloads: 0,
|
||||
views: 0,
|
||||
rootRef: 'profile-folder-root',
|
||||
}
|
||||
|
||||
test('anonymous profile opens curated files and folders through the landing-share flow @desktop [spec: profile/share-flow]', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('**/api/users/alice', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
user: { username: 'alice', name: 'Alice', image: null },
|
||||
shares: [
|
||||
{ token: 'profile-file', name: 'Brief.txt', type: 'text/plain', size: 50, isFolder: false },
|
||||
{ token: folderShare.token, name: 'Photos', type: 'folder', size: 0, isFolder: true },
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(`**/api/shares/${folderShare.token}`, (route) => route.fulfill({ json: folderShare }))
|
||||
await page.route(`**/api/shares/${folderShare.token}/objects?*`, (route) => {
|
||||
const parent = new URL(route.request().url()).searchParams.get('parent') ?? ''
|
||||
return route.fulfill({
|
||||
json:
|
||||
parent === 'Albums'
|
||||
? {
|
||||
items: [{ ref: 'summer-ref', name: 'summer.jpg', type: 'image/jpeg', size: 128, isFolder: false }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
breadcrumb: [
|
||||
{ name: 'Photos', path: '' },
|
||||
{ name: 'Albums', path: 'Albums' },
|
||||
],
|
||||
}
|
||||
: {
|
||||
items: [{ ref: 'albums-ref', name: 'Albums', type: 'folder', size: 0, isFolder: true }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
breadcrumb: [{ name: 'Photos', path: '' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/u/alice')
|
||||
|
||||
await expect(page.getByText('@alice')).toBeVisible()
|
||||
await expect(page.locator('a[href="/s/profile-file"]')).toContainText('Brief.txt')
|
||||
const folderLink = page.locator(`a[href="/s/${folderShare.token}"]`)
|
||||
await expect(folderLink).toContainText('Photos')
|
||||
await folderLink.click()
|
||||
|
||||
await expect(page).toHaveURL(`/s/${folderShare.token}`)
|
||||
await expect(page.getByTestId('page-header')).toContainText('Photos')
|
||||
await page.getByRole('button', { name: 'Albums' }).dblclick()
|
||||
await expect(page.getByTestId('page-header')).toContainText('Albums')
|
||||
await expect(page.getByText('summer.jpg')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `shares` ADD `listed_at` integer;--> statement-breakpoint
|
||||
CREATE INDEX `shares_creator_listed_idx` ON `shares` (`creator_id`,`listed_at`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,6 +34,12 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
if (input.kind === 'direct' && input.password) throw new CreateShareError('DIRECT_NO_PASSWORD')
|
||||
if (input.kind === 'direct' && input.recipients && input.recipients.length > 0)
|
||||
throw new CreateShareError('DIRECT_NO_RECIPIENTS')
|
||||
if (
|
||||
input.showOnProfile &&
|
||||
(input.kind !== 'landing' || (input.recipients != null && input.recipients.length > 0))
|
||||
) {
|
||||
throw new CreateShareError('PROFILE_LISTING_INELIGIBLE')
|
||||
}
|
||||
|
||||
const matter = await db
|
||||
.select()
|
||||
@@ -59,6 +65,7 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
views: 0,
|
||||
downloads: 0,
|
||||
status: 'active',
|
||||
listedAt: input.showOnProfile ? now : null,
|
||||
createdAt: now,
|
||||
}
|
||||
|
||||
@@ -175,6 +182,52 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
return result.length > 0
|
||||
},
|
||||
|
||||
async setProfileListing(token: string, creatorId: string, listedAt: Date | null): Promise<boolean> {
|
||||
const result = await db
|
||||
.update(shares)
|
||||
.set({ listedAt })
|
||||
.where(and(eq(shares.token, token), eq(shares.creatorId, creatorId)))
|
||||
.returning({ id: shares.id })
|
||||
|
||||
return result.length > 0
|
||||
},
|
||||
|
||||
async listPublicProfileShares(username: string, now: Date) {
|
||||
const nowSecs = Math.floor(now.getTime() / 1000)
|
||||
const rows = await db
|
||||
.select({
|
||||
token: shares.token,
|
||||
name: matters.name,
|
||||
type: matters.type,
|
||||
size: matters.size,
|
||||
dirtype: matters.dirtype,
|
||||
})
|
||||
.from(shares)
|
||||
.innerJoin(user, eq(shares.creatorId, user.id))
|
||||
.innerJoin(matters, eq(shares.matterId, matters.id))
|
||||
.where(
|
||||
and(
|
||||
eq(user.username, username),
|
||||
isNotNull(shares.listedAt),
|
||||
eq(shares.kind, 'landing'),
|
||||
eq(shares.status, 'active'),
|
||||
or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`),
|
||||
or(isNull(shares.downloadLimit), sql`${shares.downloads} < ${shares.downloadLimit}`),
|
||||
eq(matters.status, 'active'),
|
||||
isNull(matters.trashedAt),
|
||||
isNull(matters.purgedAt),
|
||||
sql`NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ${shareRecipients}
|
||||
WHERE ${shareRecipients.shareId} = ${shares.id}
|
||||
)`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(shares.listedAt), desc(shares.id))
|
||||
|
||||
return rows.map(({ dirtype, ...row }) => ({ ...row, isFolder: dirtype !== DirType.FILE }))
|
||||
},
|
||||
|
||||
async listForApi(
|
||||
creatorId: string,
|
||||
opts: { page: number; pageSize: number; status?: string },
|
||||
@@ -200,6 +253,7 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
views: shares.views,
|
||||
downloads: shares.downloads,
|
||||
status: shares.status,
|
||||
listedAt: shares.listedAt,
|
||||
createdAt: shares.createdAt,
|
||||
matterName: matters.name,
|
||||
matterType: matters.type,
|
||||
@@ -257,6 +311,7 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
views: shares.views,
|
||||
downloads: shares.downloads,
|
||||
status: shares.status,
|
||||
listedAt: shares.listedAt,
|
||||
createdAt: shares.createdAt,
|
||||
matterName: matters.name,
|
||||
matterType: matters.type,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -257,6 +257,59 @@ describe('POST /api/shares', () => {
|
||||
expect(body.downloadLimit).toBe(5)
|
||||
})
|
||||
|
||||
it('atomically lists an eligible landing share at creation time [spec: shares/create-profile-listing]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertFile(db, orgId, { id: 'profile-create', name: 'homepage.txt' })
|
||||
|
||||
const res = await createShare(app, headers, {
|
||||
matterId: 'profile-create',
|
||||
kind: 'landing',
|
||||
showOnProfile: true,
|
||||
})
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as { token: string; listedAt: string | null }
|
||||
expect(body.listedAt).not.toBeNull()
|
||||
const rows = await db
|
||||
.select({ listedAt: shares.listedAt, status: shares.status })
|
||||
.from(shares)
|
||||
.where(eq(shares.token, body.token))
|
||||
expect(rows[0]?.listedAt).toBeInstanceOf(Date)
|
||||
expect(rows[0]?.status).toBe('active')
|
||||
})
|
||||
|
||||
it('rejects forged profile listing on direct and recipient-targeted shares', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertFile(db, orgId, { id: 'profile-ineligible', name: 'private.txt' })
|
||||
|
||||
const direct = await createShare(app, headers, {
|
||||
matterId: 'profile-ineligible',
|
||||
kind: 'direct',
|
||||
showOnProfile: true,
|
||||
})
|
||||
expect(direct.status).toBe(400)
|
||||
expect(((await direct.json()) as { error: { details: Array<{ reason: string }> } }).error.details[0]?.reason).toBe(
|
||||
'PROFILE_LISTING_INELIGIBLE',
|
||||
)
|
||||
|
||||
const targeted = await createShare(app, headers, {
|
||||
matterId: 'profile-ineligible',
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientEmail: 'private@example.com' }],
|
||||
showOnProfile: true,
|
||||
})
|
||||
expect(targeted.status).toBe(400)
|
||||
expect(
|
||||
((await targeted.json()) as { error: { details: Array<{ reason: string }> } }).error.details[0]?.reason,
|
||||
).toBe('PROFILE_LISTING_INELIGIBLE')
|
||||
})
|
||||
|
||||
it('returns 400 with DIRECT_NO_RECIPIENTS when creating direct share with recipients [spec: shares/direct-no-recipients]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
@@ -306,6 +359,78 @@ describe('POST /api/shares', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── PUT/DELETE /api/shares/:token/profile-listing ───────────────────────────
|
||||
|
||||
function profileListingRequest(
|
||||
app: TestApp,
|
||||
token: string,
|
||||
method: 'PUT' | 'DELETE',
|
||||
headers?: Record<string, string>,
|
||||
) {
|
||||
return app.request(`/api/shares/${token}/profile-listing`, { method, headers })
|
||||
}
|
||||
|
||||
describe('share profile listing mutation', () => {
|
||||
it('requires authentication', async () => {
|
||||
const { app } = await createTestApp()
|
||||
expect((await profileListingRequest(app, 'unknown', 'PUT')).status).toBe(401)
|
||||
expect((await profileListingRequest(app, 'unknown', 'DELETE')).status).toBe(401)
|
||||
})
|
||||
|
||||
it('lets only the owner list and unlist, without revoking the landing share [spec: shares/profile-listing-owner] [spec: shares/profile-listing-authorization] [spec: shares/profile-unlist-preserves-access]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const ownerHeaders = await authedHeaders(app, `profile-owner-${nanoid()}@example.com`)
|
||||
await insertStorage(db)
|
||||
const ownerOrgId = await getOrgId(db)
|
||||
await insertFile(db, ownerOrgId, { id: 'profile-toggle', name: 'toggle.txt' })
|
||||
const created = await createShare(app, ownerHeaders, { matterId: 'profile-toggle', kind: 'landing' })
|
||||
const token = ((await created.json()) as { token: string }).token
|
||||
const otherHeaders = await authedHeaders(app, `profile-other-${nanoid()}@example.com`)
|
||||
|
||||
expect((await profileListingRequest(app, token, 'PUT', otherHeaders)).status).toBe(403)
|
||||
|
||||
const listed = await profileListingRequest(app, token, 'PUT', ownerHeaders)
|
||||
expect(listed.status).toBe(200)
|
||||
expect(((await listed.json()) as { listedAt: string | null }).listedAt).not.toBeNull()
|
||||
|
||||
const unlisted = await profileListingRequest(app, token, 'DELETE', ownerHeaders)
|
||||
expect(unlisted.status).toBe(204)
|
||||
const rows = await db
|
||||
.select({ listedAt: shares.listedAt, status: shares.status })
|
||||
.from(shares)
|
||||
.where(eq(shares.token, token))
|
||||
expect(rows[0]).toEqual({ listedAt: null, status: 'active' })
|
||||
|
||||
const landing = await app.request(`/api/shares/${token}`)
|
||||
expect(landing.status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects forged listing mutations for direct and recipient-targeted shares [spec: shares/profile-listing-ineligible]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app, `profile-ineligible-owner-${nanoid()}@example.com`)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertFile(db, orgId, { id: 'profile-direct-toggle', name: 'direct.txt' })
|
||||
await insertFile(db, orgId, { id: 'profile-targeted-toggle', name: 'targeted.txt' })
|
||||
|
||||
const directCreated = await createShare(app, headers, { matterId: 'profile-direct-toggle', kind: 'direct' })
|
||||
const directToken = ((await directCreated.json()) as { token: string }).token
|
||||
const targetedCreated = await createShare(app, headers, {
|
||||
matterId: 'profile-targeted-toggle',
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientEmail: 'recipient@example.com' }],
|
||||
})
|
||||
const targetedToken = ((await targetedCreated.json()) as { token: string }).token
|
||||
|
||||
for (const token of [directToken, targetedToken]) {
|
||||
const res = await profileListingRequest(app, token, 'PUT', headers)
|
||||
expect(res.status).toBe(400)
|
||||
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
|
||||
expect(body.error.details[0]?.reason).toBe('PROFILE_LISTING_INELIGIBLE')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── GET /api/shares auth guard ───────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/shares (auth guard)', () => {
|
||||
|
||||
+73
-13
@@ -3,7 +3,13 @@ import type { Context } from 'hono'
|
||||
import { getCookie, setCookie } from 'hono/cookie'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import { pageSchema } from '../../shared/schemas'
|
||||
import { createShareRequestSchema, listSharesQuerySchema, saveShareRequestSchema } from '../../shared/schemas/share'
|
||||
import {
|
||||
createShareRequestSchema,
|
||||
listSharesQuerySchema,
|
||||
saveShareRequestSchema,
|
||||
shareObjectsResponseSchema,
|
||||
shareRecipientViewSchema,
|
||||
} from '../../shared/schemas/share'
|
||||
import { transferAuditActor } from '../middleware/audit-transfers'
|
||||
import { requireAuth, requireTeamRole } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
@@ -17,6 +23,7 @@ import {
|
||||
type ShareCreatorDto,
|
||||
type ShareViewerDto,
|
||||
saveShare,
|
||||
setProfileListing,
|
||||
verifySharePassword,
|
||||
viewShare,
|
||||
} from '../usecases/share'
|
||||
@@ -59,7 +66,7 @@ const shareViewSchema = z
|
||||
orgId: z.string().optional(),
|
||||
creatorId: z.string().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
recipients: z.array(z.unknown()).optional(),
|
||||
recipients: z.array(shareRecipientViewSchema).optional(),
|
||||
})
|
||||
.openapi('ShareView')
|
||||
|
||||
@@ -88,7 +95,10 @@ function toShareViewDTO(dto: ShareViewerDto | ShareCreatorDto): z.infer<typeof s
|
||||
orgId: dto.orgId,
|
||||
creatorId: dto.creatorId,
|
||||
createdAt: dto.createdAt.toISOString(),
|
||||
recipients: dto.recipients,
|
||||
recipients: dto.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
createdAt: recipient.createdAt.toISOString(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
return base
|
||||
@@ -107,6 +117,7 @@ const shareListItemSchema = z
|
||||
views: z.number().int(),
|
||||
downloads: z.number().int(),
|
||||
status: z.string(),
|
||||
listedAt: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
matter: z.object({ name: z.string(), type: z.string(), dirtype: z.number().int() }),
|
||||
recipientCount: z.number().int(),
|
||||
@@ -115,20 +126,17 @@ const shareListItemSchema = z
|
||||
.openapi('ShareListItem')
|
||||
|
||||
function toShareListItemDTO(s: ShareListItem): z.infer<typeof shareListItemSchema> {
|
||||
return { ...s, expiresAt: s.expiresAt ? s.expiresAt.toISOString() : null, createdAt: s.createdAt.toISOString() }
|
||||
return {
|
||||
...s,
|
||||
expiresAt: s.expiresAt ? s.expiresAt.toISOString() : null,
|
||||
listedAt: s.listedAt ? s.listedAt.toISOString() : null,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const shareListSchema = pageSchema(shareListItemSchema, 'ShareList')
|
||||
|
||||
const shareObjectsSchema = z
|
||||
.object({
|
||||
items: z.array(z.unknown()),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
breadcrumb: z.array(z.object({ name: z.string(), path: z.string() })),
|
||||
})
|
||||
.openapi('ShareObjects')
|
||||
const shareObjectsSchema = shareObjectsResponseSchema.openapi('ShareObjects')
|
||||
|
||||
const createdShareSchema = z
|
||||
.object({
|
||||
@@ -137,6 +145,7 @@ const createdShareSchema = z
|
||||
urls: z.object({ landing: z.string().optional(), direct: z.string().optional() }),
|
||||
expiresAt: z.string().nullable(),
|
||||
downloadLimit: z.number().int().nullable(),
|
||||
listedAt: z.string().nullable(),
|
||||
})
|
||||
.openapi('CreatedShare')
|
||||
|
||||
@@ -378,6 +387,38 @@ const revokeShareRoute = createRoute({
|
||||
},
|
||||
})
|
||||
|
||||
const profileListingSchema = z.object({ listedAt: z.string().nullable() }).openapi('ShareProfileListing')
|
||||
|
||||
const putProfileListingRoute = createRoute({
|
||||
operationId: 'putShareProfileListing',
|
||||
summary: 'Show a share on the owner public profile',
|
||||
tags: ['Shares'],
|
||||
method: 'put',
|
||||
path: '/{token}/profile-listing',
|
||||
request: { params: z.object({ token: z.string() }) },
|
||||
responses: {
|
||||
200: jsonContent(profileListingSchema, 'Profile listing'),
|
||||
400: errorResponse('Share is not eligible for profile listing'),
|
||||
403: errorResponse('Forbidden'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
})
|
||||
|
||||
const deleteProfileListingRoute = createRoute({
|
||||
operationId: 'deleteShareProfileListing',
|
||||
summary: 'Remove a share from the owner public profile',
|
||||
tags: ['Shares'],
|
||||
method: 'delete',
|
||||
path: '/{token}/profile-listing',
|
||||
request: { params: z.object({ token: z.string() }) },
|
||||
responses: {
|
||||
204: { description: 'Profile listing removed' },
|
||||
400: errorResponse('Share is not eligible for profile listing'),
|
||||
403: errorResponse('Forbidden'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
})
|
||||
|
||||
const saveShareRoute = createRoute({
|
||||
operationId: 'saveShare',
|
||||
summary: 'Save a share to my drive',
|
||||
@@ -420,12 +461,31 @@ export const authedShares = authedApp
|
||||
urls: shareUrls(out.share.kind, out.share.token),
|
||||
expiresAt: out.share.expiresAt ? out.share.expiresAt.toISOString() : null,
|
||||
downloadLimit: out.share.downloadLimit,
|
||||
listedAt: out.share.listedAt ? out.share.listedAt.toISOString() : null,
|
||||
},
|
||||
201,
|
||||
)
|
||||
}
|
||||
throw out.error
|
||||
})
|
||||
.openapi(putProfileListingRoute, async (c) => {
|
||||
const out = await setProfileListing(c.get('deps'), {
|
||||
token: c.req.valid('param').token,
|
||||
userId: c.get('userId')!,
|
||||
listed: true,
|
||||
})
|
||||
if (out.ok) return c.json({ listedAt: out.listedAt?.toISOString() ?? null }, 200)
|
||||
throw out.error
|
||||
})
|
||||
.openapi(deleteProfileListingRoute, async (c) => {
|
||||
const out = await setProfileListing(c.get('deps'), {
|
||||
token: c.req.valid('param').token,
|
||||
userId: c.get('userId')!,
|
||||
listed: false,
|
||||
})
|
||||
if (out.ok) return c.body(null, 204)
|
||||
throw out.error
|
||||
})
|
||||
.openapi(revokeShareRoute, async (c) => {
|
||||
const out = await revokeShare(c.get('deps'), {
|
||||
token: c.req.valid('param').token,
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { env } from 'cloudflare:workers'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createShareRepo } from '../adapters/repos/share'
|
||||
import { createApp } from '../app'
|
||||
import { createAuth } from '../auth'
|
||||
import { createCloudflarePlatform } from '../platform/cloudflare'
|
||||
|
||||
type TestApp = ReturnType<typeof createApp>
|
||||
type TestDb = ReturnType<typeof createCloudflarePlatform>['db']
|
||||
|
||||
async function buildApp() {
|
||||
const platform = createCloudflarePlatform(env)
|
||||
const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET)
|
||||
return { app: createApp(platform, auth), db: platform.db }
|
||||
}
|
||||
|
||||
async function signUp(app: TestApp, db: TestDb, username: string) {
|
||||
const emailLocalPart = `${username}-${nanoid(6)}`.toLowerCase()
|
||||
const email = `${emailLocalPart}@example.com`
|
||||
const response = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: username, email, password: 'password123456' }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email} LIMIT 1`)
|
||||
const userId = users[0]?.id
|
||||
if (!userId) throw new Error(`Missing signed-up user for ${email}`)
|
||||
|
||||
await db.run(sql`UPDATE user SET username = ${username}, display_username = ${username} WHERE id = ${userId}`)
|
||||
const organizations = await db.all<{ id: string }>(sql`
|
||||
SELECT organization.id
|
||||
FROM organization
|
||||
INNER JOIN member ON member.organization_id = organization.id
|
||||
WHERE member.user_id = ${userId}
|
||||
AND COALESCE(organization.metadata, '') LIKE '%"type":"personal"%'
|
||||
LIMIT 1
|
||||
`)
|
||||
const orgId = organizations[0]?.id
|
||||
if (!orgId) throw new Error(`Missing personal organization for ${email}`)
|
||||
|
||||
return {
|
||||
headers: { Cookie: response.headers.getSetCookie().join('; ') },
|
||||
orgId,
|
||||
userId,
|
||||
username,
|
||||
}
|
||||
}
|
||||
|
||||
async function insertMatter(
|
||||
db: TestDb,
|
||||
orgId: string,
|
||||
name: string,
|
||||
options: { dirtype?: number; status?: string } = {},
|
||||
) {
|
||||
const id = `matter-${nanoid()}`
|
||||
const now = Date.now()
|
||||
const dirtype = options.dirtype ?? 0
|
||||
await db.run(sql`
|
||||
INSERT INTO matters
|
||||
(id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
|
||||
VALUES
|
||||
(
|
||||
${id},
|
||||
${orgId},
|
||||
${`alias-${nanoid()}`},
|
||||
${name},
|
||||
${dirtype === 0 ? 'text/plain' : 'folder'},
|
||||
${dirtype === 0 ? 128 : 0},
|
||||
${dirtype},
|
||||
'',
|
||||
${dirtype === 0 ? `objects/${id}` : ''},
|
||||
'profile-cf-storage',
|
||||
${options.status ?? 'active'},
|
||||
${now},
|
||||
${now}
|
||||
)
|
||||
`)
|
||||
return id
|
||||
}
|
||||
|
||||
function createShare(app: TestApp, headers: Record<string, string>, body: Record<string, unknown>) {
|
||||
return app.request('/api/shares', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('[CF] public profile shares', () => {
|
||||
it('lists a landing share selected at creation and unlisting leaves the share usable', async () => {
|
||||
const { app, db } = await buildApp()
|
||||
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
|
||||
const matterId = await insertMatter(db, owner.orgId, 'Public guide.txt')
|
||||
|
||||
const creation = await createShare(app, owner.headers, {
|
||||
matterId,
|
||||
kind: 'landing',
|
||||
showOnProfile: true,
|
||||
})
|
||||
expect(creation.status).toBe(201)
|
||||
const created = (await creation.json()) as { token: string; listedAt: string | null }
|
||||
expect(created.listedAt).toEqual(expect.any(String))
|
||||
|
||||
const profile = await app.request(`/api/users/${owner.username}`)
|
||||
expect(profile.status).toBe(200)
|
||||
expect(await profile.json()).toMatchObject({
|
||||
user: { username: owner.username },
|
||||
shares: [
|
||||
{
|
||||
token: created.token,
|
||||
name: 'Public guide.txt',
|
||||
type: 'text/plain',
|
||||
size: 128,
|
||||
isFolder: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const unlisted = await app.request(`/api/shares/${created.token}/profile-listing`, {
|
||||
method: 'DELETE',
|
||||
headers: owner.headers,
|
||||
})
|
||||
expect(unlisted.status).toBe(204)
|
||||
|
||||
const afterUnlisting = await app.request(`/api/users/${owner.username}`)
|
||||
expect(((await afterUnlisting.json()) as { shares: unknown[] }).shares).toEqual([])
|
||||
|
||||
const underlyingShare = await app.request(`/api/shares/${created.token}`)
|
||||
expect(underlyingShare.status).toBe(200)
|
||||
})
|
||||
|
||||
it('requires authentication and ownership to change a profile listing', async () => {
|
||||
const { app, db } = await buildApp()
|
||||
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
|
||||
const other = await signUp(app, db, `profile-other-${nanoid(5)}`)
|
||||
const matterId = await insertMatter(db, owner.orgId, 'Owner only.txt')
|
||||
const share = await createShareRepo(db).create({
|
||||
matterId,
|
||||
orgId: owner.orgId,
|
||||
creatorId: owner.userId,
|
||||
kind: 'landing',
|
||||
})
|
||||
|
||||
const anonymous = await app.request(`/api/shares/${share.token}/profile-listing`, { method: 'PUT' })
|
||||
expect(anonymous.status).toBe(401)
|
||||
|
||||
const nonOwner = await app.request(`/api/shares/${share.token}/profile-listing`, {
|
||||
method: 'PUT',
|
||||
headers: other.headers,
|
||||
})
|
||||
expect(nonOwner.status).toBe(403)
|
||||
|
||||
const ownerMutation = await app.request(`/api/shares/${share.token}/profile-listing`, {
|
||||
method: 'PUT',
|
||||
headers: owner.headers,
|
||||
})
|
||||
expect(ownerMutation.status).toBe(200)
|
||||
|
||||
const profile = await app.request(`/api/users/${owner.username}`)
|
||||
expect(((await profile.json()) as { shares: Array<{ token: string }> }).shares.map((item) => item.token)).toEqual([
|
||||
share.token,
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects forged listing requests and never exposes direct or recipient-targeted shares', async () => {
|
||||
const { app, db } = await buildApp()
|
||||
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
|
||||
const matterId = await insertMatter(db, owner.orgId, 'Privacy boundary.txt')
|
||||
const repo = createShareRepo(db)
|
||||
const visible = await repo.create({
|
||||
matterId,
|
||||
orgId: owner.orgId,
|
||||
creatorId: owner.userId,
|
||||
kind: 'landing',
|
||||
showOnProfile: true,
|
||||
})
|
||||
const direct = await repo.create({
|
||||
matterId,
|
||||
orgId: owner.orgId,
|
||||
creatorId: owner.userId,
|
||||
kind: 'direct',
|
||||
})
|
||||
const targeted = await repo.create({
|
||||
matterId,
|
||||
orgId: owner.orgId,
|
||||
creatorId: owner.userId,
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientEmail: 'recipient@example.com' }],
|
||||
})
|
||||
|
||||
const forgedListedAt = Date.now()
|
||||
await db.run(
|
||||
sql`UPDATE shares SET listed_at = ${forgedListedAt} WHERE token IN (${direct.token}, ${targeted.token})`,
|
||||
)
|
||||
|
||||
for (const token of [direct.token, targeted.token]) {
|
||||
const mutation = await app.request(`/api/shares/${token}/profile-listing`, {
|
||||
method: 'PUT',
|
||||
headers: owner.headers,
|
||||
})
|
||||
expect(mutation.status).toBe(400)
|
||||
}
|
||||
|
||||
for (const body of [
|
||||
{ matterId, kind: 'direct', showOnProfile: true },
|
||||
{
|
||||
matterId,
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientEmail: 'recipient@example.com' }],
|
||||
showOnProfile: true,
|
||||
},
|
||||
]) {
|
||||
const creation = await createShare(app, owner.headers, body)
|
||||
expect(creation.status).toBe(400)
|
||||
}
|
||||
|
||||
const profile = await app.request(`/api/users/${owner.username}`)
|
||||
expect(((await profile.json()) as { shares: Array<{ token: string }> }).shares.map((item) => item.token)).toEqual([
|
||||
visible.token,
|
||||
])
|
||||
})
|
||||
|
||||
it('filters revoked, expired, exhausted, trashed, inactive, missing, and unlisted targets at read time', async () => {
|
||||
const { app, db } = await buildApp()
|
||||
const owner = await signUp(app, db, `profile-owner-${nanoid(5)}`)
|
||||
const repo = createShareRepo(db)
|
||||
|
||||
async function listedShare(name: string, options: { expiresAt?: Date; downloadLimit?: number } = {}) {
|
||||
const matterId = await insertMatter(db, owner.orgId, name)
|
||||
const share = await repo.create({
|
||||
matterId,
|
||||
orgId: owner.orgId,
|
||||
creatorId: owner.userId,
|
||||
kind: 'landing',
|
||||
showOnProfile: true,
|
||||
...options,
|
||||
})
|
||||
return { matterId, share }
|
||||
}
|
||||
|
||||
const available = await listedShare('Available.txt')
|
||||
const revoked = await listedShare('Revoked.txt')
|
||||
await listedShare('Expired.txt', { expiresAt: new Date(Date.now() - 60_000) })
|
||||
const exhausted = await listedShare('Exhausted.txt', { downloadLimit: 1 })
|
||||
const trashed = await listedShare('Trashed.txt')
|
||||
const inactive = await listedShare('Inactive.txt')
|
||||
const missing = await listedShare('Missing.txt')
|
||||
const unlistedMatterId = await insertMatter(db, owner.orgId, 'Unlisted.txt')
|
||||
await repo.create({
|
||||
matterId: unlistedMatterId,
|
||||
orgId: owner.orgId,
|
||||
creatorId: owner.userId,
|
||||
kind: 'landing',
|
||||
})
|
||||
|
||||
await db.run(sql`UPDATE shares SET status = 'revoked' WHERE id = ${revoked.share.id}`)
|
||||
await db.run(sql`UPDATE shares SET downloads = 1 WHERE id = ${exhausted.share.id}`)
|
||||
await db.run(sql`UPDATE matters SET trashed_at = ${Date.now()} WHERE id = ${trashed.matterId}`)
|
||||
await db.run(sql`UPDATE matters SET status = 'processing' WHERE id = ${inactive.matterId}`)
|
||||
await db.run(sql`DELETE FROM matters WHERE id = ${missing.matterId}`)
|
||||
|
||||
const profile = await app.request(`/api/users/${owner.username}`)
|
||||
expect(profile.status).toBe(200)
|
||||
expect(((await profile.json()) as { shares: Array<{ token: string }> }).shares.map((item) => item.token)).toEqual([
|
||||
available.share.token,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildBreadcrumb } from '../domain/breadcrumb.js'
|
||||
import { authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup.js'
|
||||
|
||||
async function adminHeaders(app: ReturnType<typeof import('../app')['createApp']>) {
|
||||
@@ -574,6 +573,69 @@ async function insertUser(
|
||||
return { orgId: `org-${opts.id}` }
|
||||
}
|
||||
|
||||
type ProfileTestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
async function insertProfileMatter(
|
||||
db: ProfileTestDb,
|
||||
orgId: string,
|
||||
id: string,
|
||||
opts: {
|
||||
name?: string
|
||||
status?: string
|
||||
dirtype?: number
|
||||
trashedAt?: number | null
|
||||
purgedAt?: number | null
|
||||
} = {},
|
||||
) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const dirtype = opts.dirtype ?? 0
|
||||
await db.run(sql`
|
||||
INSERT INTO matters (
|
||||
id, org_id, alias, name, type, size, dirtype, parent, object, storage_id,
|
||||
status, trashed_at, purged_at, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
${id}, ${orgId}, ${`${id}-alias`}, ${opts.name ?? `${id}.txt`},
|
||||
${dirtype === 0 ? 'text/plain' : 'folder'}, ${dirtype === 0 ? 100 : 0}, ${dirtype},
|
||||
'', ${dirtype === 0 ? `objects/${id}` : ''}, 'profile-storage',
|
||||
${opts.status ?? 'active'}, ${opts.trashedAt ?? null}, ${opts.purgedAt ?? null}, ${now}, ${now}
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
async function insertProfileShare(
|
||||
db: ProfileTestDb,
|
||||
creatorId: string,
|
||||
orgId: string,
|
||||
matterId: string,
|
||||
opts: {
|
||||
id?: string
|
||||
token?: string
|
||||
kind?: 'landing' | 'direct'
|
||||
status?: 'active' | 'revoked'
|
||||
listed?: boolean
|
||||
expiresAt?: number | null
|
||||
downloadLimit?: number | null
|
||||
downloads?: number
|
||||
} = {},
|
||||
) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const id = opts.id ?? `share-${matterId}`
|
||||
const token = opts.token ?? `token-${matterId}`
|
||||
await db.run(sql`
|
||||
INSERT INTO shares (
|
||||
id, token, kind, matter_id, org_id, creator_id, password_hash, expires_at,
|
||||
download_limit, views, downloads, status, listed_at, created_at
|
||||
)
|
||||
VALUES (
|
||||
${id}, ${token}, ${opts.kind ?? 'landing'}, ${matterId}, ${orgId}, ${creatorId},
|
||||
NULL, ${opts.expiresAt ?? null}, ${opts.downloadLimit ?? null}, 0, ${opts.downloads ?? 0},
|
||||
${opts.status ?? 'active'}, ${opts.listed === false ? null : now}, ${now}
|
||||
)
|
||||
`)
|
||||
return { id, token }
|
||||
}
|
||||
|
||||
describe('GET /api/users/:username', () => {
|
||||
it('returns 404 when user does not exist [spec: profile/user-not-found]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
@@ -616,43 +678,93 @@ describe('GET /api/users/:username', () => {
|
||||
expect(body.user.username).toBe('orphanuser')
|
||||
expect(body.shares).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/users/:username/objects', () => {
|
||||
it('returns 404 for unknown username [spec: profile/unknown-username]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const res = await app.request('/api/users/nonexistent/objects')
|
||||
expect(res.status).toBe(404)
|
||||
const body = (await res.json()) as { error: { message: string } }
|
||||
expect(body.error.message).toBe('User not found')
|
||||
})
|
||||
|
||||
it('returns empty items and breadcrumb for known user [spec: profile/empty-listing]', async () => {
|
||||
it('returns exactly the selected public landing shares without authentication [spec: profile/curated-shares]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertUser(db, { id: 'user-1', username: 'testuser', email: 'test@example.com' })
|
||||
const { orgId } = await insertUser(db, {
|
||||
id: 'curated-user',
|
||||
username: 'curated',
|
||||
email: 'curated@example.com',
|
||||
})
|
||||
await insertProfileMatter(db, orgId, 'curated-file', { name: 'Public.txt' })
|
||||
await insertProfileMatter(db, orgId, 'curated-folder', { name: 'Photos', dirtype: 1 })
|
||||
await insertProfileMatter(db, orgId, 'unlisted-file', { name: 'Hidden.txt' })
|
||||
await insertProfileShare(db, 'curated-user', orgId, 'curated-file', { token: 'public-file' })
|
||||
await insertProfileShare(db, 'curated-user', orgId, 'curated-folder', { token: 'public-folder' })
|
||||
await insertProfileShare(db, 'curated-user', orgId, 'unlisted-file', {
|
||||
token: 'hidden-file',
|
||||
listed: false,
|
||||
})
|
||||
|
||||
const res = await app.request('/api/users/curated')
|
||||
|
||||
const res = await app.request('/api/users/testuser/objects')
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; breadcrumb: string[] }
|
||||
expect(body.items).toEqual([])
|
||||
expect(body.breadcrumb).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildBreadcrumb', () => {
|
||||
it('returns empty array for empty string', () => {
|
||||
expect(buildBreadcrumb('')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns single segment for a simple name', () => {
|
||||
expect(buildBreadcrumb('photos')).toEqual(['photos'])
|
||||
})
|
||||
|
||||
it('splits nested path into segments [spec: profile/breadcrumb-segments]', () => {
|
||||
expect(buildBreadcrumb('a/b/c')).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('returns two segments for one-level-deep path', () => {
|
||||
expect(buildBreadcrumb('Parent/Child')).toEqual(['Parent', 'Child'])
|
||||
const body = (await res.json()) as {
|
||||
shares: Array<{ token: string; name: string; type: string; size: number | null; isFolder: boolean }>
|
||||
}
|
||||
expect(body.shares).toEqual([
|
||||
{ token: 'public-folder', name: 'Photos', type: 'folder', size: 0, isFolder: true },
|
||||
{ token: 'public-file', name: 'Public.txt', type: 'text/plain', size: 100, isFolder: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('never leaks forged listed direct or recipient-targeted shares [spec: profile/privacy-boundaries]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { orgId } = await insertUser(db, {
|
||||
id: 'privacy-user',
|
||||
username: 'privacy',
|
||||
email: 'privacy@example.com',
|
||||
})
|
||||
await insertProfileMatter(db, orgId, 'private-direct')
|
||||
await insertProfileMatter(db, orgId, 'private-targeted')
|
||||
const direct = await insertProfileShare(db, 'privacy-user', orgId, 'private-direct', { kind: 'direct' })
|
||||
const targeted = await insertProfileShare(db, 'privacy-user', orgId, 'private-targeted')
|
||||
await db.run(sql`
|
||||
INSERT INTO share_recipients (id, share_id, recipient_email, created_at)
|
||||
VALUES ('private-recipient', ${targeted.id}, 'recipient@example.com', ${Math.floor(Date.now() / 1000)})
|
||||
`)
|
||||
|
||||
const res = await app.request('/api/users/privacy')
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as { shares: unknown[] }).shares).toEqual([])
|
||||
expect(direct.token).toBeTruthy()
|
||||
})
|
||||
|
||||
it('filters every unavailable selected share at read time [spec: profile/availability-filtering]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { orgId } = await insertUser(db, {
|
||||
id: 'availability-user',
|
||||
username: 'availability',
|
||||
email: 'availability@example.com',
|
||||
})
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const matters = [
|
||||
['available', {}],
|
||||
['revoked', {}],
|
||||
['expired', {}],
|
||||
['exhausted', {}],
|
||||
['trashed', { trashedAt: now }],
|
||||
['purged', { purgedAt: now }],
|
||||
['draft', { status: 'draft' }],
|
||||
] as const
|
||||
for (const [id, options] of matters) await insertProfileMatter(db, orgId, id, options)
|
||||
|
||||
await insertProfileShare(db, 'availability-user', orgId, 'available', { token: 'only-available' })
|
||||
await insertProfileShare(db, 'availability-user', orgId, 'revoked', { status: 'revoked' })
|
||||
await insertProfileShare(db, 'availability-user', orgId, 'expired', { expiresAt: now - 1 })
|
||||
await insertProfileShare(db, 'availability-user', orgId, 'exhausted', {
|
||||
downloadLimit: 2,
|
||||
downloads: 2,
|
||||
})
|
||||
await insertProfileShare(db, 'availability-user', orgId, 'trashed')
|
||||
await insertProfileShare(db, 'availability-user', orgId, 'purged')
|
||||
await insertProfileShare(db, 'availability-user', orgId, 'draft')
|
||||
await insertProfileShare(db, 'availability-user', orgId, 'missing-target', { token: 'missing-target' })
|
||||
|
||||
const res = await app.request('/api/users/availability')
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as { shares: Array<{ token: string }> }).shares.map((share) => share.token)).toEqual([
|
||||
'only-available',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
+10
-26
@@ -1,4 +1,5 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { publicProfileSchema } from '@shared/schemas/profile'
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
unsupportedMediaType,
|
||||
} from '../usecases/ports'
|
||||
import { getUserQuota } from '../usecases/quota'
|
||||
import { listPublicProfileShares } from '../usecases/share'
|
||||
import {
|
||||
getPublicProfile,
|
||||
grantUserEntitlement,
|
||||
@@ -34,11 +36,7 @@ import { errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
// lookup, the authenticated user's own avatar — plus the admin storage
|
||||
// entitlement grants, which live in our own quota domain rather than better-auth.
|
||||
|
||||
const publicUserSchema = z
|
||||
.object({ username: z.string(), name: z.string(), image: z.string().nullable() })
|
||||
.openapi('PublicUser')
|
||||
|
||||
const publicProfileSchema = z.object({ user: publicUserSchema, shares: z.array(z.unknown()) }).openapi('PublicProfile')
|
||||
const publicProfileResponseSchema = publicProfileSchema.openapi('PublicProfile')
|
||||
|
||||
const grantEntitlementSchema = z.object({
|
||||
resourceType: z.literal('storage'),
|
||||
@@ -104,20 +102,7 @@ const getUserRoute = createRoute({
|
||||
path: '/{username}',
|
||||
request: { params: z.object({ username: z.string() }) },
|
||||
responses: {
|
||||
200: jsonContent(publicProfileSchema, 'User'),
|
||||
404: errorResponse('User not found'),
|
||||
},
|
||||
})
|
||||
|
||||
const userObjectsRoute = createRoute({
|
||||
operationId: 'listUserObjects',
|
||||
summary: "List a user's public objects",
|
||||
tags: ['Users'],
|
||||
method: 'get',
|
||||
path: '/{username}/objects',
|
||||
request: { params: z.object({ username: z.string() }) },
|
||||
responses: {
|
||||
200: jsonContent(z.object({ items: z.array(z.unknown()), breadcrumb: z.array(z.unknown()) }), 'Objects'),
|
||||
200: jsonContent(publicProfileResponseSchema, 'User'),
|
||||
404: errorResponse('User not found'),
|
||||
},
|
||||
})
|
||||
@@ -220,14 +205,13 @@ export const users = new OpenAPIHono<Env>()
|
||||
return c.body(null, 204)
|
||||
})
|
||||
.openapi(getUserRoute, async (c) => {
|
||||
const user = await getPublicProfile(c.get('deps'), c.req.valid('param').username)
|
||||
const username = c.req.valid('param').username
|
||||
const [user, shares] = await Promise.all([
|
||||
getPublicProfile(c.get('deps'), username),
|
||||
listPublicProfileShares(c.get('deps'), username),
|
||||
])
|
||||
if (!user) throw notFound('User not found')
|
||||
return c.json({ user, shares: [] }, 200)
|
||||
})
|
||||
.openapi(userObjectsRoute, async (c) => {
|
||||
const user = await getPublicProfile(c.get('deps'), c.req.valid('param').username)
|
||||
if (!user) throw notFound('User not found')
|
||||
return c.json({ items: [], breadcrumb: [] }, 200)
|
||||
return c.json({ user, shares }, 200)
|
||||
})
|
||||
.openapi(getUserQuotaRoute, async (c) => {
|
||||
const quota = await getUserQuota(c.get('deps'), { userId: c.req.valid('param').userId })
|
||||
|
||||
@@ -65,6 +65,71 @@ describe('global OpenAPI document', () => {
|
||||
expect(events?.description).toContain('?downloadTasks=1')
|
||||
})
|
||||
|
||||
it('documents the concrete public profile contract without the removed objects placeholder', async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const res = await app.request('/api/openapi.json')
|
||||
const doc = (await res.json()) as {
|
||||
paths: Record<
|
||||
string,
|
||||
{
|
||||
get?: {
|
||||
responses?: Record<string, { content?: { 'application/json'?: { schema?: { $ref?: string } } } }>
|
||||
}
|
||||
}
|
||||
>
|
||||
components?: {
|
||||
schemas?: Record<
|
||||
string,
|
||||
{
|
||||
properties?: Record<
|
||||
string,
|
||||
{
|
||||
type?: string
|
||||
properties?: Record<string, { type?: string; nullable?: boolean }>
|
||||
items?: {
|
||||
type?: string
|
||||
properties?: Record<string, { type?: string; nullable?: boolean }>
|
||||
required?: string[]
|
||||
}
|
||||
}
|
||||
>
|
||||
required?: string[]
|
||||
}
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
expect(doc.paths['/api/users/{username}']?.get?.responses?.['200']?.content?.['application/json']?.schema).toEqual({
|
||||
$ref: '#/components/schemas/PublicProfile',
|
||||
})
|
||||
expect(doc.paths['/api/users/{username}/objects']).toBeUndefined()
|
||||
|
||||
const profile = doc.components?.schemas?.PublicProfile
|
||||
expect(profile?.required).toEqual(['user', 'shares'])
|
||||
expect(profile?.properties?.user).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
image: { type: 'string', nullable: true },
|
||||
},
|
||||
})
|
||||
expect(profile?.properties?.shares).toMatchObject({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
size: { type: 'integer', nullable: true },
|
||||
isFolder: { type: 'boolean' },
|
||||
},
|
||||
required: ['token', 'name', 'type', 'size', 'isFolder'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("merges better-auth's auto-generated schema (incl. the device flow) into the same doc", async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const res = await app.request('/api/openapi.json')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export interface PublicUser {
|
||||
username: string
|
||||
name: string
|
||||
image: string | null
|
||||
}
|
||||
import type { PublicUser } from '@shared/schemas/profile'
|
||||
|
||||
export type { PublicUser } from '@shared/schemas/profile'
|
||||
|
||||
export interface ProfileRepo {
|
||||
getUserByUsername(username: string): Promise<PublicUser | null>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PublicProfileShare } from '@shared/schemas/profile'
|
||||
import type { CreateShareInput } from '@shared/schemas/share'
|
||||
import type { Matter } from './matter'
|
||||
|
||||
@@ -18,6 +19,7 @@ export interface ShareRecord {
|
||||
views: number
|
||||
downloads: number
|
||||
status: string
|
||||
listedAt: Date | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
@@ -41,6 +43,7 @@ export interface ShareListItem {
|
||||
views: number
|
||||
downloads: number
|
||||
status: string
|
||||
listedAt: Date | null
|
||||
createdAt: Date
|
||||
matter: { name: string; type: string; dirtype: number }
|
||||
recipientCount: number
|
||||
@@ -58,7 +61,14 @@ export type ShareResolution =
|
||||
// Thrown by createShare on invalid share-shape combinations. Carries a stable
|
||||
// code the http layer maps to a 400/404.
|
||||
export class CreateShareError extends Error {
|
||||
constructor(public code: 'MATTER_NOT_FOUND' | 'DIRECT_NO_FOLDER' | 'DIRECT_NO_PASSWORD' | 'DIRECT_NO_RECIPIENTS') {
|
||||
constructor(
|
||||
public code:
|
||||
| 'MATTER_NOT_FOUND'
|
||||
| 'DIRECT_NO_FOLDER'
|
||||
| 'DIRECT_NO_PASSWORD'
|
||||
| 'DIRECT_NO_RECIPIENTS'
|
||||
| 'PROFILE_LISTING_INELIGIBLE',
|
||||
) {
|
||||
super(code)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +83,8 @@ export interface ShareRepo {
|
||||
listRecipientUserIds(shareId: string): Promise<string[]>
|
||||
revokeByMatter(matterId: string): Promise<void>
|
||||
revokeByToken(token: string, creatorId: string): Promise<boolean>
|
||||
setProfileListing(token: string, creatorId: string, listedAt: Date | null): Promise<boolean>
|
||||
listPublicProfileShares(username: string, now: Date): Promise<PublicProfileShare[]>
|
||||
listForApi(
|
||||
creatorId: string,
|
||||
opts: { page: number; pageSize: number; status?: string },
|
||||
|
||||
+69
-11
@@ -11,7 +11,7 @@
|
||||
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { DirType } from '@shared/constants'
|
||||
import type { CreateShareRequest } from '@shared/schemas/share'
|
||||
import type { CreateShareRequest, ShareObjectsResponse } from '@shared/schemas/share'
|
||||
import { isAccessibleByUser } from '../domain/share'
|
||||
import { verifyPassword as verifyPasswordHash } from '../lib/password'
|
||||
import type { Platform } from '../platform/interface'
|
||||
@@ -222,15 +222,7 @@ export type ListShareObjectsParams = {
|
||||
now?: Date
|
||||
}
|
||||
|
||||
export type ShareObjectItem = { ref: string; name: string; type: string; size: number | null; isFolder: boolean }
|
||||
|
||||
export type ListShareObjectsResult = {
|
||||
items: ShareObjectItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
breadcrumb: Array<{ name: string; path: string }>
|
||||
}
|
||||
export type ListShareObjectsResult = ShareObjectsResponse
|
||||
|
||||
export type ListShareObjectsOutcome = { ok: true; result: ListShareObjectsResult } | { ok: false; error: AppError }
|
||||
|
||||
@@ -451,6 +443,7 @@ export type CreatedShare = {
|
||||
kind: string
|
||||
expiresAt: Date | null
|
||||
downloadLimit: number | null
|
||||
listedAt: Date | null
|
||||
}
|
||||
|
||||
export type CreateShareOutcome = { ok: true; share: CreatedShare } | { ok: false; error: AppError }
|
||||
@@ -462,6 +455,10 @@ const CREATE_SHARE_ERRORS: Record<CreateShareError['code'], AppError> = {
|
||||
DIRECT_NO_FOLDER: badRequest('Direct shares cannot be folders', 'DIRECT_NO_FOLDER'),
|
||||
DIRECT_NO_PASSWORD: badRequest('Direct shares cannot have a password', 'DIRECT_NO_PASSWORD'),
|
||||
DIRECT_NO_RECIPIENTS: badRequest('Direct shares cannot have recipients', 'DIRECT_NO_RECIPIENTS'),
|
||||
PROFILE_LISTING_INELIGIBLE: badRequest(
|
||||
'Only untargeted landing shares can be shown on a profile',
|
||||
'PROFILE_LISTING_INELIGIBLE',
|
||||
),
|
||||
}
|
||||
|
||||
export async function createShare(
|
||||
@@ -490,6 +487,7 @@ export async function createShare(
|
||||
expiresAt,
|
||||
downloadLimit: input.downloadLimit,
|
||||
recipients: input.recipients,
|
||||
showOnProfile: input.showOnProfile,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof CreateShareError) return { ok: false, error: CREATE_SHARE_ERRORS[err.code] }
|
||||
@@ -512,10 +510,70 @@ export async function createShare(
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
share: { token: share.token, kind: share.kind, expiresAt: share.expiresAt, downloadLimit: share.downloadLimit },
|
||||
share: {
|
||||
token: share.token,
|
||||
kind: share.kind,
|
||||
expiresAt: share.expiresAt,
|
||||
downloadLimit: share.downloadLimit,
|
||||
listedAt: share.listedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PUT/DELETE /:token/profile-listing — owner-curated profile state ───────
|
||||
|
||||
export type SetProfileListingParams = {
|
||||
token: string
|
||||
userId: string
|
||||
listed: boolean
|
||||
now?: Date
|
||||
}
|
||||
|
||||
export type SetProfileListingOutcome = { ok: true; listedAt: Date | null } | { ok: false; error: AppError }
|
||||
|
||||
export async function setProfileListing(
|
||||
deps: ShareDeps,
|
||||
params: SetProfileListingParams,
|
||||
): Promise<SetProfileListingOutcome> {
|
||||
const { token, userId, listed, now = new Date() } = params
|
||||
const resolved = await deps.share.resolveByToken(token)
|
||||
if (resolved.status === 'not_found' || resolved.status === 'revoked') {
|
||||
return { ok: false, error: notFound() }
|
||||
}
|
||||
|
||||
const { share, matter, recipients } = resolved
|
||||
if (share.creatorId !== userId) return { ok: false, error: forbidden() }
|
||||
if (share.kind !== 'landing' || recipients.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: badRequest('Only untargeted landing shares can be shown on a profile', 'PROFILE_LISTING_INELIGIBLE'),
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
listed &&
|
||||
(resolved.status === 'matter_trashed' ||
|
||||
matter.status !== 'active' ||
|
||||
matter.purgedAt != null ||
|
||||
(share.expiresAt != null && share.expiresAt <= now) ||
|
||||
(share.downloadLimit != null && share.downloads >= share.downloadLimit))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: badRequest('Unavailable shares cannot be shown on a profile', 'PROFILE_LISTING_UNAVAILABLE'),
|
||||
}
|
||||
}
|
||||
|
||||
const listedAt = listed ? now : null
|
||||
const updated = await deps.share.setProfileListing(token, userId, listedAt)
|
||||
if (!updated) return { ok: false, error: notFound() }
|
||||
return { ok: true, listedAt }
|
||||
}
|
||||
|
||||
export function listPublicProfileShares(deps: ShareDeps, username: string, now = new Date()) {
|
||||
return deps.share.listPublicProfileShares(username, now)
|
||||
}
|
||||
|
||||
// ─── PUT /:token/status — revoke (ownership-scoped) ──────────────────────────
|
||||
|
||||
export type RevokeShareParams = { token: string; userId: string; now?: Date }
|
||||
|
||||
+12
-1
@@ -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,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const publicUserSchema = z.object({
|
||||
username: z.string(),
|
||||
name: z.string(),
|
||||
image: z.string().nullable(),
|
||||
})
|
||||
|
||||
export const publicProfileShareSchema = z.object({
|
||||
token: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
size: z.number().int().nullable(),
|
||||
isFolder: z.boolean(),
|
||||
})
|
||||
|
||||
export const publicProfileSchema = z.object({
|
||||
user: publicUserSchema,
|
||||
shares: z.array(publicProfileShareSchema),
|
||||
})
|
||||
|
||||
export type PublicUser = z.infer<typeof publicUserSchema>
|
||||
export type PublicProfileShare = z.infer<typeof publicProfileShareSchema>
|
||||
export type PublicProfile = z.infer<typeof publicProfileSchema>
|
||||
@@ -9,6 +9,14 @@ export const shareRecipientSchema = z.object({
|
||||
recipientEmail: z.string().email().optional(),
|
||||
})
|
||||
|
||||
export const shareRecipientViewSchema = z.object({
|
||||
id: z.string(),
|
||||
shareId: z.string(),
|
||||
recipientUserId: z.string().nullable(),
|
||||
recipientEmail: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
export const createShareSchema = z.object({
|
||||
matterId: z.string().min(1),
|
||||
orgId: z.string().min(1),
|
||||
@@ -18,6 +26,7 @@ export const createShareSchema = z.object({
|
||||
expiresAt: z.date().optional(),
|
||||
downloadLimit: z.number().int().positive().optional(),
|
||||
recipients: z.array(shareRecipientSchema).optional(),
|
||||
showOnProfile: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type CreateShareInput = z.infer<typeof createShareSchema>
|
||||
@@ -36,10 +45,30 @@ export const createShareRequestSchema = z.object({
|
||||
expiresAt: z.string().datetime({ offset: true }).optional(),
|
||||
downloadLimit: z.number().int().positive().optional(),
|
||||
recipients: z.array(shareRecipientSchema).optional(),
|
||||
showOnProfile: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type CreateShareRequest = z.infer<typeof createShareRequestSchema>
|
||||
|
||||
export const shareObjectItemSchema = z.object({
|
||||
ref: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
size: z.number().int().nullable(),
|
||||
isFolder: z.boolean(),
|
||||
})
|
||||
|
||||
export const shareObjectsResponseSchema = z.object({
|
||||
items: z.array(shareObjectItemSchema),
|
||||
total: z.number().int(),
|
||||
page: z.number().int(),
|
||||
pageSize: z.number().int(),
|
||||
breadcrumb: z.array(z.object({ name: z.string(), path: z.string() })),
|
||||
})
|
||||
|
||||
export type ShareObjectItem = z.infer<typeof shareObjectItemSchema>
|
||||
export type ShareObjectsResponse = z.infer<typeof shareObjectsResponseSchema>
|
||||
|
||||
export const saveShareRequestSchema = z.object({
|
||||
targetOrgId: z.string().min(1),
|
||||
targetParent: z.string().default(''),
|
||||
|
||||
@@ -476,6 +476,7 @@ export interface Share {
|
||||
views: number
|
||||
downloads: number
|
||||
status: 'active' | 'revoked'
|
||||
listedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
|
||||
+23
-17
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,114 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import type { StorageObject } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createShare } from '@/lib/api'
|
||||
import { ShareDialog } from './share-dialog'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => (values ? `${key}:${Object.values(values).join('/')}` : key),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-clipboard', () => ({
|
||||
useClipboard: () => ({ copy: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
createShare: vi.fn(),
|
||||
}))
|
||||
|
||||
const item: StorageObject = {
|
||||
id: 'matter-1',
|
||||
orgId: 'org-1',
|
||||
alias: '',
|
||||
name: 'release-notes.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 1024,
|
||||
dirtype: DirType.FILE,
|
||||
parent: '',
|
||||
object: 'release-notes.pdf',
|
||||
storageId: 'storage-1',
|
||||
status: 'active',
|
||||
trashedAt: null,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
updatedAt: '2026-07-23T00:00:00.000Z',
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderDialog() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ShareDialog open item={item} onOpenChange={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(createShare).mockResolvedValue({
|
||||
token: 'share-token',
|
||||
kind: 'landing',
|
||||
urls: { landing: '/s/share-token' },
|
||||
expiresAt: null,
|
||||
downloadLimit: null,
|
||||
listedAt: '2026-07-23T00:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ShareDialog profile listing selection', () => {
|
||||
it('sends showOnProfile when the owner enables it for a landing share', async () => {
|
||||
renderDialog()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'share.showOnProfile' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'share.createButton' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createShare).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
matterId: 'matter-1',
|
||||
kind: 'landing',
|
||||
showOnProfile: true,
|
||||
}),
|
||||
expect.anything(),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('omits showOnProfile unless the owner enables the control', async () => {
|
||||
renderDialog()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'share.createButton' }))
|
||||
|
||||
await waitFor(() => expect(createShare).toHaveBeenCalled())
|
||||
expect(vi.mocked(createShare).mock.calls[0]?.[0]).not.toHaveProperty('showOnProfile')
|
||||
})
|
||||
})
|
||||
@@ -118,6 +118,7 @@ const makeResult = (
|
||||
urls: { landing: '/s/tok' },
|
||||
expiresAt: null,
|
||||
downloadLimit: null,
|
||||
listedAt: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DirType } from '@shared/constants'
|
||||
import type { CreateShareRequest } from '@shared/schemas'
|
||||
import type { StorageObject } from '@shared/types'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { CheckCircle2, Copy, File, Folder, KeyRound, Share2, TriangleAlert, X } from 'lucide-react'
|
||||
import { CheckCircle2, Copy, File, Folder, House, KeyRound, Share2, TriangleAlert, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -79,6 +79,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
|
||||
const [customExpires, setCustomExpires] = useState('')
|
||||
const [limitOption, setLimitOption] = useState('unlimited')
|
||||
const [customLimit, setCustomLimit] = useState('')
|
||||
const [showOnProfile, setShowOnProfile] = useState(false)
|
||||
const [result, setResult] = useState<CreateShareResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,6 +93,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
|
||||
setCustomExpires('')
|
||||
setLimitOption('unlimited')
|
||||
setCustomLimit('')
|
||||
setShowOnProfile(false)
|
||||
setResult(null)
|
||||
}, [open])
|
||||
|
||||
@@ -145,6 +147,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
|
||||
if (isTargetedMode(mode) && chips.length > 0) {
|
||||
body.recipients = chips.filter((c) => c.valid).map((c) => ({ recipientEmail: c.value }))
|
||||
}
|
||||
if (mode === 'page' && showOnProfile) body.showOnProfile = true
|
||||
mutation.mutate(body)
|
||||
}
|
||||
|
||||
@@ -183,6 +186,7 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
|
||||
isFolder={isFolder}
|
||||
onChange={(next) => {
|
||||
setMode(next)
|
||||
setShowOnProfile(false)
|
||||
setPasswordEnabled(false)
|
||||
setPassword('')
|
||||
if (!isTargetedMode(next)) {
|
||||
@@ -212,6 +216,23 @@ export function ShareDialog({ open, item, onOpenChange, onViewShares }: ShareDia
|
||||
)}
|
||||
|
||||
{mode === 'page' && <PasswordField enabled={passwordEnabled} onToggle={handlePasswordToggle} />}
|
||||
{mode === 'page' && (
|
||||
<div className="flex min-h-11 items-start justify-between gap-4 rounded-md border bg-background p-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<House className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="share-profile-listing">{t('share.showOnProfile')}</Label>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t('share.showOnProfileHint')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-profile-listing"
|
||||
className="mt-0.5"
|
||||
checked={showOnProfile}
|
||||
onCheckedChange={setShowOnProfile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExpiresField
|
||||
option={expiresOption}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import type { ShareObjectItem } from '@shared/schemas'
|
||||
import type { ShareView, StorageObject } from '@shared/types'
|
||||
import { UserRound } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
@@ -16,13 +17,7 @@ interface ShareLandingProps {
|
||||
onPasswordRequired?: () => void
|
||||
}
|
||||
|
||||
function toStorageObject(item: {
|
||||
ref: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
isFolder: boolean
|
||||
}): StorageObject {
|
||||
function toStorageObject(item: ShareObjectItem): StorageObject {
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
id: item.ref,
|
||||
@@ -30,7 +25,7 @@ function toStorageObject(item: {
|
||||
alias: item.ref,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
size: item.size,
|
||||
size: item.size ?? 0,
|
||||
dirtype: item.isFolder ? DirType.USER_FOLDER : DirType.FILE,
|
||||
parent: '',
|
||||
object: item.ref,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1115,6 +1115,7 @@
|
||||
"settings.profile.displayName.hint": "最多 100 个字符。",
|
||||
"settings.profile.username.description": "你的账户用户名。",
|
||||
"settings.profile.username.hint": "用户名无法修改。",
|
||||
"settings.profile.publicHomepage": "查看公开主页",
|
||||
"settings.profile.email.description": "用于登录和接收通知。",
|
||||
"settings.profile.password.description": "使用一个你不会在其他地方重复使用的强密码。",
|
||||
"settings.profile.password.hint": "至少 8 个字符。",
|
||||
@@ -1474,6 +1475,11 @@
|
||||
"shares.revokeConfirm": "撤销 {{name}} 的分享?任何人将立即失去访问权限,此操作不可撤回。",
|
||||
"shares.revokeSuccess": "分享已撤销",
|
||||
"shares.revokeError": "撤销分享失败",
|
||||
"shares.listOnProfile": "显示在公开主页",
|
||||
"shares.unlistFromProfile": "从公开主页移除",
|
||||
"shares.profileListSuccess": "分享已添加到公开主页",
|
||||
"shares.profileUnlistSuccess": "分享已从公开主页移除",
|
||||
"shares.profileListingError": "更新公开主页展示失败",
|
||||
"shares.emptyState": "暂无分享",
|
||||
"shares.emptyStateHint": "右键点击文件即可创建第一个分享",
|
||||
"shares.boxSent": "我的分享",
|
||||
@@ -1509,6 +1515,8 @@
|
||||
"share.recipientsHint": "系统会为这些邮箱创建定向访问入口,仍受有效期和下载限额约束",
|
||||
"share.password": "密码",
|
||||
"share.passwordHint": "仅访问页支持密码保护。拿到链接的人需要输入密码后才能访问。",
|
||||
"share.showOnProfile": "显示在个人主页",
|
||||
"share.showOnProfileHint": "将这个公开访问页添加到你的精选主页。",
|
||||
"share.generatePassword": "生成",
|
||||
"share.expires": "过期时间",
|
||||
"share.expires1d": "1天",
|
||||
@@ -1540,6 +1548,8 @@
|
||||
"share.done": "完成",
|
||||
"share.invalidExpiry": "自定义过期时间必须是未来日期",
|
||||
"share.invalidLimit": "下载限额必须为正整数",
|
||||
"profile.noShares": "暂无公开分享",
|
||||
"profile.notFound": "用户不存在",
|
||||
"files.share": "分享",
|
||||
"share.notFound": "分享不存在",
|
||||
"share.notFoundDesc": "此分享链接不存在或已被删除。",
|
||||
|
||||
+54
-22
@@ -94,6 +94,7 @@ import {
|
||||
listQuotas,
|
||||
listReceivedShares,
|
||||
listShareObjects,
|
||||
listShareOnProfile,
|
||||
listShares,
|
||||
listSiteInvitations,
|
||||
listStorages,
|
||||
@@ -130,6 +131,7 @@ import {
|
||||
serverEventsUrl,
|
||||
testEmail,
|
||||
transferObject,
|
||||
unlistShareFromProfile,
|
||||
updateAnnouncement,
|
||||
updateDownloader,
|
||||
updateDownloaderCreditBilling,
|
||||
@@ -2330,32 +2332,26 @@ describe('api', () => {
|
||||
})
|
||||
|
||||
describe('getProfile', () => {
|
||||
it('fetches public profile by username', async () => {
|
||||
it('gets the exact public profile path and returns its concrete share items', async () => {
|
||||
const payload = {
|
||||
user: { username: 'alice', name: 'Alice', image: null },
|
||||
shares: [],
|
||||
shares: [{ token: 'share-1', name: 'photo.jpg', type: 'image/jpeg', size: 42, isFolder: false }],
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await getProfile('alice')
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
||||
expect(url).toContain('/api/users/alice')
|
||||
})
|
||||
|
||||
it('returns shares with download URLs', async () => {
|
||||
const matter = { id: 'm1', name: 'photo.jpg', dirtype: 0, downloadUrl: 'https://s3/photo.jpg' }
|
||||
const payload = {
|
||||
user: { username: 'bob', name: 'Bob', image: null },
|
||||
shares: [matter],
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await getProfile('bob')
|
||||
|
||||
expect(result.shares).toHaveLength(1)
|
||||
expect(result.shares[0].downloadUrl).toBe('https://s3/photo.jpg')
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/users/alice')
|
||||
expect(init.method).toBe('GET')
|
||||
expect(result.shares[0]).toEqual({
|
||||
token: 'share-1',
|
||||
name: 'photo.jpg',
|
||||
type: 'image/jpeg',
|
||||
size: 42,
|
||||
isFolder: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on 404 response', async () => {
|
||||
@@ -2712,25 +2708,61 @@ describe('api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('profile share listing', () => {
|
||||
it('lists a share with PUT on the exact resource path', async () => {
|
||||
const payload = { listedAt: '2026-07-23T12:00:00.000Z' }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
await expect(listShareOnProfile('tok123')).resolves.toEqual(payload)
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/shares/tok123/profile-listing')
|
||||
expect(init.method).toBe('PUT')
|
||||
expect(init.body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces listing errors', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
||||
await expect(listShareOnProfile('tok123')).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
|
||||
it('unlists a share with DELETE on the exact resource path', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
|
||||
|
||||
await expect(unlistShareFromProfile('tok123')).resolves.toBeUndefined()
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/shares/tok123/profile-listing')
|
||||
expect(init.method).toBe('DELETE')
|
||||
expect(init.body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces unlisting errors', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Not found' }, false, 404))
|
||||
await expect(unlistShareFromProfile('missing')).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createShare', () => {
|
||||
it('posts share data to /api/shares and returns created share result', async () => {
|
||||
it('posts the profile selection flag to the exact share collection path', async () => {
|
||||
const payload = {
|
||||
token: 'tok123',
|
||||
kind: 'landing' as const,
|
||||
urls: { landing: 'https://zpan.io/s/tok123' },
|
||||
expiresAt: null,
|
||||
downloadLimit: null,
|
||||
listedAt: '2026-07-23T12:00:00.000Z',
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await createShare({ matterId: 'obj-1', kind: 'landing' })
|
||||
const result = await createShare({ matterId: 'obj-1', kind: 'landing', showOnProfile: true })
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/shares')
|
||||
expect(url).toBe('/api/shares')
|
||||
expect(init.method).toBe('POST')
|
||||
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
||||
expect(body).toMatchObject({ matterId: 'obj-1', kind: 'landing' })
|
||||
expect(body).toEqual({ matterId: 'obj-1', kind: 'landing', showOnProfile: true })
|
||||
const headers =
|
||||
init.headers instanceof Headers ? init.headers : new Headers(init.headers as Record<string, string>)
|
||||
expect(headers.get('Content-Type')).toContain('application/json')
|
||||
|
||||
+17
-25
@@ -16,8 +16,12 @@ import type {
|
||||
DownloadTaskActionInput,
|
||||
PatchStorageInput,
|
||||
PresignObjectUploadPartsInput,
|
||||
PublicProfile,
|
||||
PublicUser,
|
||||
RedeemGiftCardResponse,
|
||||
ReplaceStorageInput,
|
||||
ShareObjectItem,
|
||||
ShareObjectsResponse,
|
||||
SiteConfig,
|
||||
SiteSettings,
|
||||
UpdateDownloaderCreditBillingInput,
|
||||
@@ -871,18 +875,10 @@ export function testEmail(to: string) {
|
||||
|
||||
// Profile API (public, no auth)
|
||||
|
||||
export interface PublicUser {
|
||||
username: string
|
||||
name: string
|
||||
image: string | null
|
||||
}
|
||||
|
||||
export interface PublicMatter extends StorageObject {
|
||||
downloadUrl?: string
|
||||
}
|
||||
export type { PublicUser }
|
||||
|
||||
export function getProfile(username: string) {
|
||||
return unwrap<{ user: PublicUser; shares: PublicMatter[] }>(users[':username'].$get({ param: { username } }))
|
||||
return unwrap<PublicProfile>(users[':username'].$get({ param: { username } }))
|
||||
}
|
||||
|
||||
// Teams Activity API
|
||||
@@ -1000,12 +996,21 @@ export function revokeShare(token: string) {
|
||||
return unwrap<ShareView>(authedSharesApi[':token'].status.$put({ param: { token }, json: { status: 'revoked' } }))
|
||||
}
|
||||
|
||||
export function listShareOnProfile(token: string) {
|
||||
return unwrap<{ listedAt: string | null }>(authedSharesApi[':token']['profile-listing'].$put({ param: { token } }))
|
||||
}
|
||||
|
||||
export function unlistShareFromProfile(token: string) {
|
||||
return discard(authedSharesApi[':token']['profile-listing'].$delete({ param: { token } }))
|
||||
}
|
||||
|
||||
export interface CreateShareResult {
|
||||
token: string
|
||||
kind: ShareView['kind']
|
||||
urls: { landing?: string; direct?: string }
|
||||
expiresAt: string | null
|
||||
downloadLimit: number | null
|
||||
listedAt: string | null
|
||||
}
|
||||
|
||||
export function createShare(data: CreateShareRequest) {
|
||||
@@ -1016,21 +1021,8 @@ export function verifySharePassword(token: string, password: string) {
|
||||
return unwrap<{ ok: boolean }>(publicSharesApi[':token'].sessions.$post({ param: { token }, json: { password } }))
|
||||
}
|
||||
|
||||
export interface ShareChildItem {
|
||||
ref: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
isFolder: boolean
|
||||
}
|
||||
|
||||
export interface ShareChildrenResponse {
|
||||
items: ShareChildItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
breadcrumb: Array<{ name: string; path: string }>
|
||||
}
|
||||
export type ShareChildItem = ShareObjectItem
|
||||
export type ShareChildrenResponse = ShareObjectsResponse
|
||||
|
||||
export function listShareObjects(token: string, parent = '', page = 1, pageSize = 50) {
|
||||
return unwrap<ShareChildrenResponse>(
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// ProfilePage components are React rendering components. The project has no
|
||||
// jsdom or @testing-library/react setup, so we cannot render them here.
|
||||
// We test the pure logic the components apply:
|
||||
// - ProfileForm: display name schema validation
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProfileForm — display name validation mirrors profileSchema:
|
||||
// displayName: z.string().min(1).max(100)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isValidDisplayName(name: string): boolean {
|
||||
return name.length >= 1 && name.length <= 100
|
||||
}
|
||||
|
||||
describe('ProfileForm — display name validation', () => {
|
||||
it('accepts a typical display name', () => {
|
||||
expect(isValidDisplayName('John Doe')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts exactly 1 character (minimum)', () => {
|
||||
expect(isValidDisplayName('J')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts exactly 100 characters (maximum)', () => {
|
||||
expect(isValidDisplayName('a'.repeat(100))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(isValidDisplayName('')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects string longer than 100 characters', () => {
|
||||
expect(isValidDisplayName('a'.repeat(101))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { UsernameCard } from './profile'
|
||||
|
||||
const session = vi.hoisted(() => ({
|
||||
value: {
|
||||
user: {
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
image: null,
|
||||
username: 'alice',
|
||||
},
|
||||
} as { user: { name: string; email: string; image: string | null; username?: string } } | null,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute: () => (options: unknown) => options,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
authClient: {
|
||||
getSession: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
$store: { notify: vi.fn() },
|
||||
},
|
||||
useSession: () => ({ data: session.value }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
deleteAvatar: vi.fn(),
|
||||
uploadAvatar: vi.fn(),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
session.value = {
|
||||
user: {
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
image: null,
|
||||
username: 'alice',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('profile settings public homepage link', () => {
|
||||
it('links the signed-in user to their public homepage in a new tab', () => {
|
||||
render(<UsernameCard />)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'settings.profile.publicHomepage' })
|
||||
expect(link.getAttribute('href')).toBe('/u/alice')
|
||||
expect(link.getAttribute('target')).toBe('_blank')
|
||||
expect(link.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
it('does not render a public homepage link before the user has a username', () => {
|
||||
session.value = {
|
||||
user: {
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
image: null,
|
||||
},
|
||||
}
|
||||
|
||||
render(<UsernameCard />)
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'settings.profile.publicHomepage' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { PublicImageMime } from '@shared/schemas'
|
||||
import { MAX_PUBLIC_IMAGE_SIZE, PUBLIC_IMAGE_MIMES } from '@shared/schemas'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Camera, Loader2 } from 'lucide-react'
|
||||
import { Camera, ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -182,7 +182,7 @@ function DisplayNameCard() {
|
||||
)
|
||||
}
|
||||
|
||||
function UsernameCard() {
|
||||
export function UsernameCard() {
|
||||
const { t } = useTranslation()
|
||||
const { data: session } = useSession()
|
||||
const username = (session?.user as { username?: string })?.username ?? ''
|
||||
@@ -201,8 +201,16 @@ function UsernameCard() {
|
||||
<Input value={username} disabled className="rounded-l-none" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/30">
|
||||
<CardFooter className="justify-between border-t bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground">{t('settings.profile.username.hint')}</p>
|
||||
{username && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={`/u/${username}`} target="_blank" rel="noopener noreferrer">
|
||||
{t('settings.profile.publicHomepage')}
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// Tests for shares/index.tsx — covers pure display logic.
|
||||
// React rendering is not available (no jsdom), so we test helper functions directly.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Mirrors computeDisplayStatus from shares/index.tsx
|
||||
function computeDisplayStatus(share: {
|
||||
status: 'active' | 'revoked'
|
||||
expiresAt: string | null
|
||||
}): 'active' | 'revoked' | 'expired' {
|
||||
if (share.status === 'revoked') return 'revoked'
|
||||
if (share.expiresAt && new Date(share.expiresAt) < new Date()) return 'expired'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
// Mirrors getBackendStatus from shares/index.tsx
|
||||
function getBackendStatus(filter: 'all' | 'active' | 'revoked' | 'expired'): 'active' | 'revoked' | undefined {
|
||||
if (filter === 'active' || filter === 'expired') return 'active'
|
||||
if (filter === 'revoked') return 'revoked'
|
||||
return undefined
|
||||
}
|
||||
|
||||
const PAST = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString()
|
||||
const FUTURE = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString()
|
||||
|
||||
describe('computeDisplayStatus', () => {
|
||||
it('returns "revoked" when status is revoked regardless of expiresAt', () => {
|
||||
expect(computeDisplayStatus({ status: 'revoked', expiresAt: null })).toBe('revoked')
|
||||
expect(computeDisplayStatus({ status: 'revoked', expiresAt: FUTURE })).toBe('revoked')
|
||||
expect(computeDisplayStatus({ status: 'revoked', expiresAt: PAST })).toBe('revoked')
|
||||
})
|
||||
|
||||
it('returns "expired" when status is active and expiresAt is in the past', () => {
|
||||
expect(computeDisplayStatus({ status: 'active', expiresAt: PAST })).toBe('expired')
|
||||
})
|
||||
|
||||
it('returns "active" when status is active and expiresAt is in the future', () => {
|
||||
expect(computeDisplayStatus({ status: 'active', expiresAt: FUTURE })).toBe('active')
|
||||
})
|
||||
|
||||
it('returns "active" when status is active and expiresAt is null (never expires)', () => {
|
||||
expect(computeDisplayStatus({ status: 'active', expiresAt: null })).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBackendStatus', () => {
|
||||
it('returns undefined for "all" filter (no backend filter applied)', () => {
|
||||
expect(getBackendStatus('all')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns "active" for "active" filter', () => {
|
||||
expect(getBackendStatus('active')).toBe('active')
|
||||
})
|
||||
|
||||
it('returns "revoked" for "revoked" filter', () => {
|
||||
expect(getBackendStatus('revoked')).toBe('revoked')
|
||||
})
|
||||
|
||||
it('returns "active" for "expired" filter (expired shares have active status on backend)', () => {
|
||||
expect(getBackendStatus('expired')).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
// Mirrors the downloads label formatting in ShareTableRow
|
||||
function formatDownloads(downloads: number, downloadLimit: number | null): string {
|
||||
return downloadLimit != null ? `${downloads} / ${downloadLimit}` : String(downloads)
|
||||
}
|
||||
|
||||
describe('formatDownloads', () => {
|
||||
it('shows plain count when no limit is set', () => {
|
||||
expect(formatDownloads(5, null)).toBe('5')
|
||||
expect(formatDownloads(0, null)).toBe('0')
|
||||
})
|
||||
|
||||
it('shows "used / limit" when download limit is set', () => {
|
||||
expect(formatDownloads(23, 100)).toBe('23 / 100')
|
||||
expect(formatDownloads(0, 50)).toBe('0 / 50')
|
||||
})
|
||||
|
||||
it('shows "used / limit" when limit is zero (edge case)', () => {
|
||||
expect(formatDownloads(0, 0)).toBe('0 / 0')
|
||||
})
|
||||
})
|
||||
|
||||
// Mirrors the views label logic in ShareTableRow
|
||||
function formatViews(kind: 'landing' | 'direct', views: number): string {
|
||||
return kind === 'direct' ? '—' : String(views)
|
||||
}
|
||||
|
||||
describe('formatViews', () => {
|
||||
it('returns "—" for direct shares (views not tracked)', () => {
|
||||
expect(formatViews('direct', 0)).toBe('—')
|
||||
expect(formatViews('direct', 100)).toBe('—')
|
||||
})
|
||||
|
||||
it('returns the view count as a string for landing shares', () => {
|
||||
expect(formatViews('landing', 0)).toBe('0')
|
||||
expect(formatViews('landing', 42)).toBe('42')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { ShareListItem } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listReceivedShares, listShareOnProfile, listShares, revokeShare, unlistShareFromProfile } from '@/lib/api'
|
||||
import { SharesPage } from './index'
|
||||
|
||||
const router = vi.hoisted(() => ({
|
||||
navigate: vi.fn(),
|
||||
search: { status: 'all', page: 1, box: 'sent' },
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => (values ? `${key}:${Object.values(values).join('/')}` : key),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute: () => (options: unknown) => options,
|
||||
useNavigate: () => router.navigate,
|
||||
useSearch: () => router.search,
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-clipboard', () => ({
|
||||
useClipboard: () => ({ copy: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/page-header', () => ({
|
||||
PageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/shares/share-detail-panel', () => ({
|
||||
ShareDetailPanel: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/shares/revoke-confirm-dialog', () => ({
|
||||
RevokeConfirmDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
listReceivedShares: vi.fn(),
|
||||
listShareOnProfile: vi.fn(),
|
||||
listShares: vi.fn(),
|
||||
revokeShare: vi.fn(),
|
||||
unlistShareFromProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
function share(overrides: Partial<ShareListItem>): ShareListItem {
|
||||
return {
|
||||
id: 'share-1',
|
||||
token: 'share-token',
|
||||
kind: 'landing',
|
||||
matterId: 'matter-1',
|
||||
orgId: 'org-1',
|
||||
creatorId: 'user-1',
|
||||
expiresAt: null,
|
||||
downloadLimit: null,
|
||||
views: 2,
|
||||
downloads: 1,
|
||||
status: 'active',
|
||||
listedAt: null,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
matter: {
|
||||
name: 'Public file.pdf',
|
||||
type: 'application/pdf',
|
||||
dirtype: 0,
|
||||
},
|
||||
recipientCount: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const shares = [
|
||||
share({ token: 'unlisted-token', matter: { name: 'Unlisted.pdf', type: 'application/pdf', dirtype: 0 } }),
|
||||
share({
|
||||
id: 'share-2',
|
||||
token: 'listed-token',
|
||||
listedAt: '2026-07-23T01:00:00.000Z',
|
||||
matter: { name: 'Listed folder', type: 'folder', dirtype: 1 },
|
||||
}),
|
||||
share({
|
||||
id: 'share-3',
|
||||
token: 'direct-token',
|
||||
kind: 'direct',
|
||||
matter: { name: 'Direct file.pdf', type: 'application/pdf', dirtype: 0 },
|
||||
}),
|
||||
]
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SharesPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listShares).mockResolvedValue({ items: shares, total: shares.length, page: 1, pageSize: 20 })
|
||||
vi.mocked(listReceivedShares).mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 20 })
|
||||
vi.mocked(listShareOnProfile).mockResolvedValue({ listedAt: '2026-07-23T02:00:00.000Z' })
|
||||
vi.mocked(unlistShareFromProfile).mockResolvedValue(undefined)
|
||||
vi.mocked(revokeShare).mockResolvedValue({} as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('authenticated Shares profile listing actions', () => {
|
||||
it('lists an eligible landing share and unlists an already listed share', async () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Unlisted.pdf')).toBeTruthy()
|
||||
fireEvent.click(screen.getAllByTitle('shares.listOnProfile')[0])
|
||||
await waitFor(() => expect(listShareOnProfile).toHaveBeenCalledWith('unlisted-token'))
|
||||
|
||||
fireEvent.click(screen.getByTitle('shares.unlistFromProfile'))
|
||||
await waitFor(() => expect(unlistShareFromProfile).toHaveBeenCalledWith('listed-token'))
|
||||
})
|
||||
|
||||
it('disables profile listing for an ineligible direct share', async () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Direct file.pdf')).toBeTruthy()
|
||||
const listButtons = screen.getAllByTitle('shares.listOnProfile')
|
||||
const directButton = listButtons.find((button) => button.closest('tr')?.textContent?.includes('Direct file.pdf'))
|
||||
|
||||
expect(directButton?.hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(directButton!)
|
||||
expect(listShareOnProfile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps unlisting available when an already listed landing share has expired', async () => {
|
||||
const expiredListed = share({
|
||||
token: 'expired-listed-token',
|
||||
expiresAt: '2000-01-01T00:00:00.000Z',
|
||||
listedAt: '1999-12-01T00:00:00.000Z',
|
||||
matter: { name: 'Expired listed.pdf', type: 'application/pdf', dirtype: 0 },
|
||||
})
|
||||
vi.mocked(listShares).mockResolvedValue({
|
||||
items: [expiredListed],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Expired listed.pdf')).toBeTruthy()
|
||||
const unlistButton = screen.getByTitle('shares.unlistFromProfile')
|
||||
expect(unlistButton.hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(unlistButton)
|
||||
await waitFor(() => expect(unlistShareFromProfile).toHaveBeenCalledWith('expired-listed-token'))
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { ChevronDown, ClipboardCopy, FileIcon, FolderIcon, Share2, XCircle } from 'lucide-react'
|
||||
import { ChevronDown, ClipboardCopy, FileIcon, FolderIcon, House, HousePlus, Share2, XCircle } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -18,7 +18,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useClipboard } from '@/hooks/use-clipboard'
|
||||
import { listReceivedShares, listShares, revokeShare, type ShareListItem } from '@/lib/api'
|
||||
import {
|
||||
listReceivedShares,
|
||||
listShareOnProfile,
|
||||
listShares,
|
||||
revokeShare,
|
||||
type ShareListItem,
|
||||
unlistShareFromProfile,
|
||||
} from '@/lib/api'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/shares/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -46,7 +53,23 @@ function computeDisplayStatus(share: ShareListItem): 'active' | 'revoked' | 'exp
|
||||
return 'active'
|
||||
}
|
||||
|
||||
function SharesPage() {
|
||||
function canListOnProfile(share: ShareListItem): boolean {
|
||||
return (
|
||||
share.kind === 'landing' &&
|
||||
share.recipientCount === 0 &&
|
||||
computeDisplayStatus(share) === 'active' &&
|
||||
(share.downloadLimit == null || share.downloads < share.downloadLimit)
|
||||
)
|
||||
}
|
||||
|
||||
function canChangeProfileListing(share: ShareListItem): boolean {
|
||||
if (share.listedAt != null) {
|
||||
return share.kind === 'landing' && share.recipientCount === 0 && share.status !== 'revoked'
|
||||
}
|
||||
return canListOnProfile(share)
|
||||
}
|
||||
|
||||
export function SharesPage() {
|
||||
const { t } = useTranslation()
|
||||
const { copy } = useClipboard()
|
||||
const navigate = useNavigate()
|
||||
@@ -76,6 +99,18 @@ function SharesPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const profileListingMutation = useMutation({
|
||||
mutationFn: async ({ token, listed }: { token: string; listed: boolean }) => {
|
||||
if (listed) await listShareOnProfile(token)
|
||||
else await unlistShareFromProfile(token)
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shares'] })
|
||||
toast.success(t(variables.listed ? 'shares.profileListSuccess' : 'shares.profileUnlistSuccess'))
|
||||
},
|
||||
onError: () => toast.error(t('shares.profileListingError')),
|
||||
})
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const items = sharesQuery.data?.items ?? []
|
||||
if (statusFilter === 'active') {
|
||||
@@ -238,6 +273,12 @@ function SharesPage() {
|
||||
copy(url, 'shares.urlCopied')
|
||||
}}
|
||||
onRevoke={() => setRevokeTarget(share)}
|
||||
onToggleProfileListing={() =>
|
||||
profileListingMutation.mutate({ token: share.token, listed: share.listedAt == null })
|
||||
}
|
||||
profileListingPending={
|
||||
profileListingMutation.isPending && profileListingMutation.variables?.token === share.token
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
@@ -314,12 +355,16 @@ function ShareTableRow({
|
||||
onRowClick,
|
||||
onCopyUrl,
|
||||
onRevoke,
|
||||
onToggleProfileListing,
|
||||
profileListingPending,
|
||||
}: {
|
||||
share: ShareListItem
|
||||
displayStatus: 'active' | 'revoked' | 'expired'
|
||||
onRowClick: () => void
|
||||
onCopyUrl: () => void
|
||||
onRevoke: () => void
|
||||
onToggleProfileListing: () => void
|
||||
profileListingPending: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -383,6 +428,15 @@ function ShareTableRow({
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop-propagation wrapper for action buttons */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation wrapper for action buttons */}
|
||||
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={!canChangeProfileListing(share) || profileListingPending}
|
||||
onClick={onToggleProfileListing}
|
||||
title={t(share.listedAt ? 'shares.unlistFromProfile' : 'shares.listOnProfile')}
|
||||
>
|
||||
{share.listedAt ? <House /> : <HousePlus />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={onCopyUrl} title={t('shares.copyUrl')}>
|
||||
<ClipboardCopy />
|
||||
</Button>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { PublicProfile } from '@shared/schemas/profile'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getProfile } from '@/lib/api'
|
||||
import { ProfilePage } from './$username'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute: () => (options: object) => ({
|
||||
...options,
|
||||
useParams: () => ({ username: 'alice' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
const profile: PublicProfile = {
|
||||
user: {
|
||||
username: 'alice',
|
||||
name: 'Alice',
|
||||
image: null,
|
||||
},
|
||||
shares: [
|
||||
{
|
||||
token: 'file-token',
|
||||
name: 'release-notes.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 1024,
|
||||
isFolder: false,
|
||||
},
|
||||
{
|
||||
token: 'folder-token',
|
||||
name: 'Public folder',
|
||||
type: 'folder',
|
||||
size: null,
|
||||
isFolder: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProfilePage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getProfile).mockResolvedValue(profile)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('public user homepage', () => {
|
||||
it('loads the requested profile and links curated files and folders to the landing-share flow', async () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('Alice')).toBeTruthy()
|
||||
expect(getProfile).toHaveBeenCalledWith('alice')
|
||||
expect(screen.getByRole('link', { name: /release-notes\.pdf/ }).getAttribute('href')).toBe('/s/file-token')
|
||||
expect(screen.getByRole('link', { name: /Public folder/ }).getAttribute('href')).toBe('/s/folder-token')
|
||||
expect(screen.queryByText('profile.noShares')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the empty state when the profile has no curated shares', async () => {
|
||||
vi.mocked(getProfile).mockResolvedValue({ ...profile, shares: [] })
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText('profile.noShares')).toBeTruthy()
|
||||
expect(screen.queryByRole('link')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PublicProfileShare, PublicUser } from '@shared/schemas/profile'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { FolderIcon } from 'lucide-react'
|
||||
import type { PublicUser } from '@/lib/api'
|
||||
import { FileIcon, FolderIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getProfile } from '@/lib/api'
|
||||
|
||||
export const Route = createFileRoute('/u/$username')({
|
||||
@@ -27,15 +28,17 @@ function UserHeader({ profile }: { profile: PublicUser }) {
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<FolderIcon className="mb-3 h-12 w-12 opacity-30" />
|
||||
<p className="text-sm">No shared files yet</p>
|
||||
<p className="text-sm">{t('profile.noShares')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfilePage() {
|
||||
export function ProfilePage() {
|
||||
const { t } = useTranslation()
|
||||
const { username } = Route.useParams()
|
||||
|
||||
const profileQuery = useQuery({
|
||||
@@ -47,7 +50,7 @@ function ProfilePage() {
|
||||
if (profileQuery.isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -56,19 +59,45 @@ function ProfilePage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-2">
|
||||
<h1 className="text-2xl font-bold">404</h1>
|
||||
<p className="text-muted-foreground">User not found</p>
|
||||
<p className="text-muted-foreground">{t('profile.notFound')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { user: profile } = profileQuery.data!
|
||||
const { user: profile, shares } = profileQuery.data!
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||
<UserHeader profile={profile} />
|
||||
<div className="mt-6">
|
||||
<EmptyState />
|
||||
{shares.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{shares.map((share) => (
|
||||
<ProfileShareCard key={share.token} share={share} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileShareCard({ share }: { share: PublicProfileShare }) {
|
||||
const Icon = share.isFolder ? FolderIcon : FileIcon
|
||||
return (
|
||||
<a
|
||||
href={`/s/${share.token}`}
|
||||
className="flex items-center gap-3 rounded-lg border bg-card p-4 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="rounded-md bg-muted p-2">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{share.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{share.type}</p>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user