diff --git a/docs/client-api.md b/docs/client-api.md index f343fa82..7f672f1e 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -184,7 +184,7 @@ An available comment target uses the existing `quoted_post` projection with `id` ## Timeline paging -`GET /api/v1/posts` reads one page at a time, newest first. `author_user_id` filters by an immutable account ID without widening visibility; the CLI exposes the same filter as `post list --author-id`. The page's `meta` +`GET /api/v1/posts` reads one public timeline page at a time, newest first, even for authenticated callers. With `author_user_id`, the page contains that account's publications and includes private posts only when the authenticated caller is their author. The CLI uses the same scopes in direct and API modes: `post list` reads the public timeline, and `post list --author-id` selects an author. The page's `meta` carries two opaque cursors: `next_cursor` continues *older* from the page's last item and `prev_cursor` continues *newer* from its first. Either goes back as `cursor=`; a cursor knows its own direction. Each is `null` once the read diff --git a/internal/transport/cli/posts.go b/internal/transport/cli/posts.go index d268878c..13d7f960 100644 --- a/internal/transport/cli/posts.go +++ b/internal/transport/cli/posts.go @@ -41,7 +41,7 @@ func registerPosts(application *Application) { application.MustRegisterLeaf(LeafSpec{ Path: []string{"post", "list"}, Short: "List the public Moment timeline", - Long: "Read one stable page, newest first, through the selected local instance or HTTP API: the head of the timeline, a cursor's continuation in the direction it names, or the end of an anchored day read back from.", + Long: "Read one stable page, newest first, through the selected local instance or HTTP API: the head of the public timeline, a cursor's continuation in the direction it names, or the end of an anchored day read back from. With --author-id, read that account's publications, including private posts when reading as their author.", Inputs: "Use --cursor for continuation, --limit from 1 to 100, and optional YYYY-MM-DD boundaries: inclusive --from and exclusive --to in the configured site timezone. --anchor names a site day to read back from instead; it cannot be combined with --cursor, --from, or --to.", Output: "A canonical post array in data.items with nullable meta.next_cursor (continues older) and meta.prev_cursor (continues newer); meta.refresh_cursor is a reusable newer watermark, including an empty head. With --author-id, data.author_post_count counts that author's visible published originals across all pages and filters.", Errors: "Invalid cursors, limits, dates, or site authority return stable machine codes.", @@ -269,7 +269,10 @@ func runPostList(ctx context.Context, invocation Invocation, input CommandInput) return Result{}, postCLIError(err) } listInput.Timezone = settings.Timezone - listInput.Viewer = app.ViewerFromContext(ctx) + listInput.Viewer = app.PublicViewer() + if listInput.AuthorUserID != "" { + listInput.Viewer = app.ViewerFromContext(ctx) + } result, err := service.List(ctx, listInput) if err != nil { return Result{}, postCLIError(err) diff --git a/internal/transport/cli/testdata/help-digests.json b/internal/transport/cli/testdata/help-digests.json index 5c419cfb..247a305e 100644 --- a/internal/transport/cli/testdata/help-digests.json +++ b/internal/transport/cli/testdata/help-digests.json @@ -76,7 +76,7 @@ "post get": "f6b4bc71ca5e52275c27d3d2a10f1340e0759d78d17001539400674ecd459951", "post like": "8dc8064fe79a043eb67351fe1ae8972b4a1825c6c66fe64093deceefb60c6413", "post like-status": "e3a0987a8b420549ef3711ad88e8f6831f5e943b6dbd3f6e0ddb929f118db06a", - "post list": "deade5652cbf567b34f8b1fbd66e9ceb13501115c0222ed52c0e5e94767f1d19", + "post list": "327aa9f748b318634926881d7520ab4dfadce7e4516b8db1e5d2fef2b45558e0", "post mentions": "b13d17a5ba70840b216d7262349da0471138ea2661162a32e5bbc0a9104bf6f3", "post pin": "3fad21dfa00e2949b7c97458d73e13d6bc96f2d95db59b66d94357590d2b875b", "post reply-context": "5a53d9f974de6b099fc75a9ba3b6d27aa4951be86877f422959fc86a0e9ab082", diff --git a/internal/transport/cli/visibility_test.go b/internal/transport/cli/visibility_test.go index 72b82322..19e3da30 100644 --- a/internal/transport/cli/visibility_test.go +++ b/internal/transport/cli/visibility_test.go @@ -29,8 +29,33 @@ func TestPostVisibilityFlagDirectAPIParityAndVisitorReads(t *testing.T) { t.Fatalf("direct create = %#v, API get with token = %#v", created, apiGet) } apiList := commandData(t, execute(application, "", "--url", fixture.server.URL, "post", "list", "--json")) - if items := apiList["items"].([]any); len(items) != 2 { - t.Fatalf("owner API list = %#v", apiList) + directList := commandData(t, execute(application, "", "post", "list", "--data-dir", fixture.dataDir, "--as", "writer", "--json")) + if !reflect.DeepEqual(directList, apiList) { + t.Fatalf("public timeline direct = %#v, API = %#v", directList, apiList) + } + if items := apiList["items"].([]any); len(items) != 1 || items[0].(map[string]any)["id"] != public["id"] { + t.Fatalf("public timeline = %#v", apiList) + } + + authorID := created["author"].(map[string]any)["user_id"].(string) + apiProfile := commandData(t, execute(application, "", "--url", fixture.server.URL, "post", "list", "--author-id", authorID, "--json")) + directProfile := commandData(t, execute(application, "", "post", "list", "--data-dir", fixture.dataDir, "--as", "writer", "--author-id", authorID, "--json")) + if !reflect.DeepEqual(directProfile, apiProfile) { + t.Fatalf("author timeline direct = %#v, API = %#v", directProfile, apiProfile) + } + items := apiProfile["items"].([]any) + if len(items) != 2 || apiProfile["author_post_count"] != float64(2) { + t.Fatalf("author timeline = %#v", apiProfile) + } + foundPrivate := false + for _, item := range items { + post := item.(map[string]any) + if post["id"] == id && post["visibility"] == "only_me" { + foundPrivate = true + } + } + if !foundPrivate { + t.Fatalf("author timeline omitted private post: %#v", apiProfile) } t.Setenv("MOMENT_TOKEN", "") @@ -42,6 +67,10 @@ func TestPostVisibilityFlagDirectAPIParityAndVisitorReads(t *testing.T) { if items := visitorList["items"].([]any); len(items) != 1 || items[0].(map[string]any)["id"] != public["id"] { t.Fatalf("visitor list = %#v", visitorList) } + visitorProfile := commandData(t, execute(application, "", "--url", fixture.server.URL, "post", "list", "--author-id", authorID, "--json")) + if items := visitorProfile["items"].([]any); len(items) != 1 || items[0].(map[string]any)["id"] != public["id"] || visitorProfile["author_post_count"] != float64(1) { + t.Fatalf("visitor author timeline = %#v", visitorProfile) + } t.Setenv("MOMENT_TOKEN", fixture.token) revealed := commandData(t, execute(application, "", "--url", fixture.server.URL, "post", "update", id, "--visibility", "everyone", "--json")) diff --git a/internal/transport/http/gin/posts.go b/internal/transport/http/gin/posts.go index 8e97efb3..f1fc9e2e 100644 --- a/internal/transport/http/gin/posts.go +++ b/internal/transport/http/gin/posts.go @@ -112,6 +112,9 @@ func listPosts(posts *app.PostService, site *app.SiteService, gate *Gate, ownArc } input.Timezone = settings.Timezone input.Viewer = gate.Viewer(ctx) + if input.AuthorUserID == "" { + input.Viewer = app.PublicViewer() + } if len(ownArchive) > 0 && ownArchive[0] { input.Viewer = app.AccountViewer(input.AuthorUserID) } diff --git a/internal/transport/http/gin/session_renewal_security_test.go b/internal/transport/http/gin/session_renewal_security_test.go index 51ccce84..3c07a1a3 100644 --- a/internal/transport/http/gin/session_renewal_security_test.go +++ b/internal/transport/http/gin/session_renewal_security_test.go @@ -85,7 +85,11 @@ func TestRenewedSessionsKeepPostAndMediaAuthority(t *testing.T) { for _, path := range []string{"/api/v1/posts?limit=10", "/api/v1/search?q=auditsentinel"} { r := call("GET", path, "", viewer.cookie, "") raw := r.Body.String() - if r.Code != 200 || len(decodeDataMap(t, r)["items"].([]any)) != viewer.count || strings.Contains(raw, viewer.hiddenID) { + wantCount := viewer.count + if path == "/api/v1/posts?limit=10" { + wantCount = 1 + } + if r.Code != 200 || len(decodeDataMap(t, r)["items"].([]any)) != wantCount || strings.Contains(raw, viewer.hiddenID) { t.Fatalf("%s leaked private content: %d %s", path, r.Code, r.Body) } if r.Header().Get("Cache-Control") != "no-store" { diff --git a/internal/transport/http/gin/visibility_test.go b/internal/transport/http/gin/visibility_test.go index ac18e8ab..415cfabc 100644 --- a/internal/transport/http/gin/visibility_test.go +++ b/internal/transport/http/gin/visibility_test.go @@ -66,8 +66,14 @@ func TestHiddenPostsAndTheirMediaAnswerOnlyToTheAuthorOverHTTP(t *testing.T) { if detail.Code != http.StatusOK || decodeDataMap(t, detail)["visibility"] != "only_me" { t.Fatalf("owner detail = %d %s", detail.Code, detail.Body.String()) } - if items := decodeDataMap(t, performRequest(fixture.engine, asAuthor(jsonRequest(http.MethodGet, "/api/v1/posts?limit=10", ""))))["items"].([]any); len(items) != 2 { - t.Fatalf("owner timeline = %#v", items) + if items := decodeDataMap(t, performRequest(fixture.engine, asAuthor(jsonRequest(http.MethodGet, "/api/v1/posts?limit=10", ""))))["items"].([]any); len(items) != 1 || items[0].(map[string]any)["visibility"] != "everyone" { + t.Fatalf("owner home timeline = %#v", items) + } + for _, path := range []string{"/api/v1/archive/posts?limit=10", "/api/v1/posts?author_user_id=" + createdData["author"].(map[string]any)["user_id"].(string)} { + response := performRequest(fixture.engine, asAuthor(jsonRequest(http.MethodGet, path, ""))) + if response.Code != http.StatusOK || len(decodeDataMap(t, response)["items"].([]any)) != 2 { + t.Fatalf("author private list %s = %d %s", path, response.Code, response.Body.String()) + } } authorSearch := decodeDataMap(t, performRequest(fixture.engine, asAuthor(jsonRequest(http.MethodGet, "/api/v1/search?q=shoreline", "")))) if items := authorSearch["items"].([]any); len(items) != 2 || authorSearch["year_counts"].([]any)[0].(map[string]any)["count"].(float64) != 2 { diff --git a/web/src/features/timeline/TimelinePage.test.tsx b/web/src/features/timeline/TimelinePage.test.tsx index c2ebde17..6ea745ef 100644 --- a/web/src/features/timeline/TimelinePage.test.tsx +++ b/web/src/features/timeline/TimelinePage.test.tsx @@ -17,6 +17,7 @@ import { NavigationProvider } from "../../app/navigation"; import { FeedbackProvider } from "../../components/Feedback"; import { SessionProvider } from "../auth/SessionProvider"; import { TimelinePage } from "./TimelinePage"; +import { announcePostChanged } from "../posts/postEvents"; const site: SiteIdentity = { profileUserId: "00000000000000000000000000000000", @@ -104,6 +105,7 @@ function renderTimeline( api: PublicApi, initialSearch = "", canPublish = false, + emptyContext: "timeline" | "archive" = "timeline", ) { return render( @@ -112,6 +114,7 @@ function renderTimeline( api={api} initialSearch={initialSearch} canPublish={canPublish} + emptyContext={emptyContext} site={site} /> @@ -154,6 +157,35 @@ describe("public timeline", () => { vi.unstubAllGlobals(); }); + it.each(["timeline", "archive"] as const)( + "keeps private post events scoped to the %s surface", + async (context) => { + const api = new PublicApi(async () => + page([post("01K2ABCDEF0123456789ABCDE0", "Visible record")], null), + ); + const visible = (await api.timeline()).items[0]; + renderTimeline(api, "", false, context); + await screen.findByText("Visible record"); + const privatePost = { + ...visible, + visibility: "only_me" as const, + contentMd: "Private record", + contentHtml: "

Private record

", + }; + act(() => announcePostChanged({ kind: "updated", post: privatePost })); + expect(screen.queryByText("Private record") !== null).toBe( + context === "archive", + ); + expect(screen.queryByText("Visible record")).toBeNull(); + act(() => announcePostChanged({ kind: "created", post: privatePost })); + expect(screen.queryByText("Private record") !== null).toBe( + context === "archive", + ); + act(() => announcePostChanged({ kind: "created", post: visible })); + expect(await screen.findByText("Visible record")).toBeInTheDocument(); + }, + ); + it("adds a linked repost at the top, keeps the original, and removes the projection on undo", async () => { const id = "01K2ABCDEF0123456789ABCDE0"; const original = post(id, "一条会被转发的 Moment"); diff --git a/web/src/features/timeline/TimelinePage.tsx b/web/src/features/timeline/TimelinePage.tsx index d5dd2fd0..d86c696a 100644 --- a/web/src/features/timeline/TimelinePage.tsx +++ b/web/src/features/timeline/TimelinePage.tsx @@ -176,24 +176,38 @@ export function TimelinePage({ /** The top row and where it was, measured just before a page is prepended. */ const compensation = useRef<{ key: string; top: number } | null>(null); - const reconcilePostChanges = useCallback((items: PublicPost[]) => { - let reconciled = items; - for (const event of postChanges.current.values()) { - reconciled = reconcileTimelineEvent(reconciled, event); - } - return reconciled; - }, []); - - const handlePostChange = useCallback((event: PostChangeEvent) => { - postChanges.current.set(event.post.id, event); - setPosts((current) => { - if (event.kind === "created") { - setFreshId(event.post.id); - window.setTimeout(() => setFreshId(null), 700); + const reconcilePostChanges = useCallback( + (items: PublicPost[]) => { + let reconciled = items; + for (const event of postChanges.current.values()) { + reconciled = reconcileTimelineEvent( + reconciled, + event, + emptyContext === "timeline", + ); } - return reconcileTimelineEvent(current, event); - }); - }, []); + return reconciled; + }, + [emptyContext], + ); + + const handlePostChange = useCallback( + (event: PostChangeEvent) => { + postChanges.current.set(event.post.id, event); + setPosts((current) => { + if (event.kind === "created") { + setFreshId(event.post.id); + window.setTimeout(() => setFreshId(null), 700); + } + return reconcileTimelineEvent( + current, + event, + emptyContext === "timeline", + ); + }); + }, + [emptyContext], + ); const handleReposted = useCallback( (post: PublicPost) => handlePostChange({ kind: "reordered", post }), diff --git a/web/src/features/timeline/timelineItems.ts b/web/src/features/timeline/timelineItems.ts index dee27127..b044ac21 100644 --- a/web/src/features/timeline/timelineItems.ts +++ b/web/src/features/timeline/timelineItems.ts @@ -44,11 +44,16 @@ function asTimelineItem( export function reconcileTimelineEvent( current: PublicPost[], event: PostChangeEvent, + publicOnly = false, ): PublicPost[] { const matching = current.filter((post) => post.id === event.post.id); const others = current.filter((post) => post.id !== event.post.id); - if (event.kind === "deleted") return others; + if ( + event.kind === "deleted" || + (publicOnly && event.post.visibility === "only_me") + ) + return others; if (event.kind === "created") { return [asTimelineItem(event.post, "original"), ...others].sort( compareTimelineItems,