Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions internal/transport/cli/posts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/transport/cli/testdata/help-digests.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 31 additions & 2 deletions internal/transport/cli/visibility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand All @@ -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"))
Expand Down
3 changes: 3 additions & 0 deletions internal/transport/http/gin/posts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 5 additions & 1 deletion internal/transport/http/gin/session_renewal_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
10 changes: 8 additions & 2 deletions internal/transport/http/gin/visibility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions web/src/features/timeline/TimelinePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -104,6 +105,7 @@ function renderTimeline(
api: PublicApi,
initialSearch = "",
canPublish = false,
emptyContext: "timeline" | "archive" = "timeline",
) {
return render(
<FeedbackProvider>
Expand All @@ -112,6 +114,7 @@ function renderTimeline(
api={api}
initialSearch={initialSearch}
canPublish={canPublish}
emptyContext={emptyContext}
site={site}
/>
</NavigationProvider>
Expand Down Expand Up @@ -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: "<p>Private record</p>",
};
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");
Expand Down
48 changes: 31 additions & 17 deletions web/src/features/timeline/TimelinePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
7 changes: 6 additions & 1 deletion web/src/features/timeline/timelineItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading