diff --git a/cmd/server/main.go b/cmd/server/main.go index cfbe3f4..51b0f7e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -83,6 +83,7 @@ func main() { r.Get("/download", nodeH.Download) r.Get("/access", nodeH.ListAccess) r.Put("/access", nodeH.ReplaceAccess) + r.Get("/public-links", nodeH.ListPublicLinks) r.Post("/public-links", nodeH.CreatePublicLink) }) } diff --git a/internal/handler/node.go b/internal/handler/node.go index 9f5413a..e0c1d1b 100644 --- a/internal/handler/node.go +++ b/internal/handler/node.go @@ -315,6 +315,22 @@ func (h *NodeHandler) ReplaceAccess(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *NodeHandler) ListPublicLinks(w http.ResponseWriter, r *http.Request) { + userID := commonmw.GetUserID(r.Context()) + id := chi.URLParam(r, "id") + links, err := h.repo.ListPublicLinks(r.Context(), id, userID) + if err != nil { + writeInternalError(w, r, err, "failed to list public links") + return + } + for i := range links { + if links[i].URL != "" { + links[i].URL = h.publicURL(links[i].URL) + } + } + writeJSON(w, http.StatusOK, links) +} + func (h *NodeHandler) CreatePublicLink(w http.ResponseWriter, r *http.Request) { var req model.PublicLinkRequest if err := decodeJSON(r, &req); err != nil { @@ -332,7 +348,7 @@ func (h *NodeHandler) CreatePublicLink(w http.ResponseWriter, r *http.Request) { } userID := commonmw.GetUserID(r.Context()) id := chi.URLParam(r, "id") - linkID, err := h.repo.CreatePublicLink(r.Context(), id, userID, token, req.ExpiresAt) + link, err := h.repo.CreatePublicLink(r.Context(), id, userID, token, req.ExpiresAt) if errors.Is(err, repository.ErrNotFound) { writeError(w, http.StatusNotFound, "file not found") return @@ -342,11 +358,8 @@ func (h *NodeHandler) CreatePublicLink(w http.ResponseWriter, r *http.Request) { return } h.repo.Audit(r.Context(), userID, "files.public_link_create", "files_node", id, "{}") - writeJSON(w, http.StatusCreated, model.PublicLinkResponse{ - ID: linkID, - URL: h.publicURL(token), - ExpiresAt: req.ExpiresAt, - }) + link.URL = h.publicURL(token) + writeJSON(w, http.StatusCreated, link) } func (h *NodeHandler) PublicMeta(w http.ResponseWriter, r *http.Request) { diff --git a/internal/model/model.go b/internal/model/model.go index 59f1b0f..58e60af 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -86,6 +86,7 @@ type PublicLinkRequest struct { type PublicLinkResponse struct { ID string `json:"id"` - URL string `json:"url"` + URL string `json:"url,omitempty"` ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at,omitempty"` } diff --git a/internal/repository/node.go b/internal/repository/node.go index e988d24..1def47c 100644 --- a/internal/repository/node.go +++ b/internal/repository/node.go @@ -465,19 +465,49 @@ func normalizeAccess(access []model.Access) []model.Access { return out } -func (r *NodeRepository) CreatePublicLink(ctx context.Context, nodeID, actorID, token string, expiresAt time.Time) (string, error) { +func (r *NodeRepository) CreatePublicLink(ctx context.Context, nodeID, actorID, token string, expiresAt time.Time) (*model.PublicLinkResponse, error) { hash := TokenHash(token) - var id string + var link model.PublicLinkResponse err := r.pool.QueryRow(ctx, ` - INSERT INTO files_public_links (node_id, token_hash, expires_at, created_by) - SELECT $1, $2, $3, $4 - WHERE effective_node_access($1, $4, '{}'::text[]) = 'edit' - RETURNING id - `, nodeID, hash, expiresAt, actorID).Scan(&id) + INSERT INTO files_public_links (node_id, token_hash, public_token, expires_at, created_by) + SELECT $1, $2, $3, $4, $5 + WHERE effective_node_access($1, $5, '{}'::text[]) = 'edit' + RETURNING id, expires_at, created_at + `, nodeID, hash, token, expiresAt, actorID).Scan(&link.ID, &link.ExpiresAt, &link.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { - return "", ErrNotFound + return nil, ErrNotFound } - return id, err + return &link, err +} + +func (r *NodeRepository) ListPublicLinks(ctx context.Context, nodeID, actorID string) ([]model.PublicLinkResponse, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, COALESCE(public_token, ''), expires_at, created_at + FROM files_public_links + WHERE node_id = $1 + AND revoked_at IS NULL + AND expires_at > now() + AND effective_node_access($1, $2, '{}'::text[]) = 'edit' + ORDER BY expires_at DESC, created_at DESC + `, nodeID, actorID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]model.PublicLinkResponse, 0) + for rows.Next() { + var link model.PublicLinkResponse + var token string + if err := rows.Scan(&link.ID, &token, &link.ExpiresAt, &link.CreatedAt); err != nil { + return nil, err + } + if token != "" { + link.URL = token + } + out = append(out, link) + } + return out, rows.Err() } func (r *NodeRepository) GetByPublicToken(ctx context.Context, token string) (*model.Node, error) { diff --git a/migrations/005_public_link_tokens.down.sql b/migrations/005_public_link_tokens.down.sql new file mode 100644 index 0000000..8460162 --- /dev/null +++ b/migrations/005_public_link_tokens.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE files_public_links + DROP COLUMN IF EXISTS public_token; + diff --git a/migrations/005_public_link_tokens.up.sql b/migrations/005_public_link_tokens.up.sql new file mode 100644 index 0000000..329fb1f --- /dev/null +++ b/migrations/005_public_link_tokens.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE files_public_links + ADD COLUMN IF NOT EXISTS public_token TEXT; +