Skip to main content

max / makenotwork

7.6 KB · 211 lines History Blame Raw
1 //! post reads. `get_post_for_edit` returns [`super::Unscoped`], so a by-id
2 //! caller cannot skip the C1 community check
3
4 use super::{DateTime, PgPool, Utc, Uuid};
5
6 #[derive(sqlx::FromRow)]
7 pub struct PostWithAuthor {
8 pub id: Uuid,
9 pub author_id: Uuid,
10 pub author_name: String,
11 pub author_username: String,
12 pub body_html: String,
13 pub created_at: DateTime<Utc>,
14 pub edited_at: Option<DateTime<Utc>>,
15 pub deleted_at: Option<DateTime<Utc>>,
16 pub removed_at: Option<DateTime<Utc>>,
17 /// Author's current Fan+ status (denormalised on users; refreshed at
18 /// OAuth callback / `POST /auth/refresh`). Used for the + badge and
19 /// for signature visibility, signatures only render for current
20 /// Fan+ subscribers.
21 pub author_is_fan_plus: bool,
22 /// Author's saved signature HTML (rendered at save time). Render only
23 /// when `author_is_fan_plus` is true.
24 pub author_signature_html: Option<String>,
25 }
26
27 #[derive(sqlx::FromRow)]
28 pub struct PostForEdit {
29 pub id: Uuid,
30 pub author_id: Uuid,
31 pub body_markdown: String,
32 pub created_at: DateTime<Utc>,
33 pub deleted_at: Option<DateTime<Utc>>,
34 pub thread_id: Uuid,
35 pub thread_title: String,
36 pub community_name: String,
37 pub community_slug: String,
38 pub community_id: Uuid,
39 pub category_name: String,
40 pub category_slug: String,
41 }
42
43 #[tracing::instrument(skip_all)]
44 pub async fn list_posts_in_thread(
45 pool: &PgPool,
46 thread_id: Uuid,
47 ) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
48 sqlx::query_as!(
49 PostWithAuthor,
50 r#"SELECT p.id, p.author_id,
51 COALESCE(u.display_name, u.username) AS "author_name!",
52 u.username AS author_username,
53 p.body_html,
54 p.created_at AS "created_at: chrono::DateTime<chrono::Utc>",
55 p.edited_at AS "edited_at: chrono::DateTime<chrono::Utc>",
56 p.deleted_at AS "deleted_at: chrono::DateTime<chrono::Utc>",
57 p.removed_at AS "removed_at: chrono::DateTime<chrono::Utc>",
58 u.is_fan_plus AS author_is_fan_plus,
59 u.signature_html AS author_signature_html
60 FROM posts p
61 JOIN users u ON u.mnw_account_id = p.author_id
62 WHERE p.thread_id = $1
63 ORDER BY p.created_at"#,
64 thread_id,
65 )
66 .fetch_all(pool)
67 .await
68 }
69
70 #[tracing::instrument(skip_all)]
71 pub async fn list_posts_in_thread_paginated(
72 pool: &PgPool,
73 thread_id: Uuid,
74 limit: i64,
75 offset: i64,
76 ) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
77 sqlx::query_as!(
78 PostWithAuthor,
79 r#"SELECT p.id, p.author_id,
80 COALESCE(u.display_name, u.username) AS "author_name!",
81 u.username AS author_username,
82 p.body_html,
83 p.created_at AS "created_at: chrono::DateTime<chrono::Utc>",
84 p.edited_at AS "edited_at: chrono::DateTime<chrono::Utc>",
85 p.deleted_at AS "deleted_at: chrono::DateTime<chrono::Utc>",
86 p.removed_at AS "removed_at: chrono::DateTime<chrono::Utc>",
87 u.is_fan_plus AS author_is_fan_plus,
88 u.signature_html AS author_signature_html
89 FROM posts p
90 JOIN users u ON u.mnw_account_id = p.author_id
91 WHERE p.thread_id = $1
92 ORDER BY p.created_at
93 LIMIT $2 OFFSET $3"#,
94 thread_id,
95 limit,
96 offset,
97 )
98 .fetch_all(pool)
99 .await
100 }
101
102 #[tracing::instrument(skip_all)]
103 pub async fn count_posts_in_thread(pool: &PgPool, thread_id: Uuid) -> Result<i64, sqlx::Error> {
104 // Intentionally counts ALL posts including mod-removed/soft-deleted: this
105 // drives the thread-view pagination, and `list_posts_in_thread_paginated`
106 // returns those rows so they render as tombstones. The count must match the
107 // list it paginates, or the last page of tombstones would be cut off. For an
108 // active-only count (e.g. the server-facing stats), use `get_thread_stats`.
109 sqlx::query_scalar!(
110 r#"SELECT COUNT(*) AS "count!" FROM posts WHERE thread_id = $1"#,
111 thread_id,
112 )
113 .fetch_one(pool)
114 .await
115 }
116
117 /// Count posts + footnotes by a user in the last N seconds (for per-user rate limiting).
118 #[tracing::instrument(skip_all)]
119 pub async fn count_recent_posts_by_user(
120 pool: &PgPool,
121 user_id: Uuid,
122 seconds: i64,
123 ) -> Result<i64, sqlx::Error> {
124 sqlx::query_scalar!(
125 r#"SELECT (SELECT COUNT(*) FROM posts WHERE author_id = $1 AND created_at > NOW() - make_interval(secs => $2))
126 + (SELECT COUNT(*) FROM post_footnotes WHERE author_id = $1 AND created_at > NOW() - make_interval(secs => $2)) AS "count!""#,
127 user_id,
128 seconds as f64,
129 )
130 .fetch_one(pool)
131 .await
132 }
133
134 #[tracing::instrument(skip_all)]
135 pub async fn get_post_for_edit(
136 pool: &PgPool,
137 post_id: Uuid,
138 ) -> Result<Option<super::Unscoped<PostForEdit>>, sqlx::Error> {
139 sqlx::query_as!(
140 PostForEdit,
141 r#"SELECT p.id, p.author_id, p.body_markdown,
142 p.created_at AS "created_at: chrono::DateTime<chrono::Utc>",
143 p.deleted_at AS "deleted_at: chrono::DateTime<chrono::Utc>",
144 p.thread_id, t.title AS thread_title,
145 co.name AS community_name, co.slug AS community_slug,
146 co.id AS community_id,
147 c.name AS category_name, c.slug AS category_slug
148 FROM posts p
149 JOIN threads t ON t.id = p.thread_id
150 JOIN categories c ON c.id = t.category_id
151 JOIN communities co ON co.id = c.community_id
152 WHERE p.id = $1"#,
153 post_id,
154 )
155 .fetch_optional(pool)
156 .await
157 .map(|opt| {
158 opt.map(|row| {
159 let community_id = row.community_id;
160 super::Unscoped::new(row, community_id)
161 })
162 })
163 }
164
165 /// Fetch a post's author_id and body_markdown for quote verification, scoped to
166 /// the quoting post's community.
167 ///
168 /// The `community_id` filter is not optional: a `[quote:UUID:HASH]` marker only
169 /// verifies against a post *in the same community*, so a member of community A
170 /// can never use quote verification as an oracle against a post in community B
171 /// (C1 scope class, N3). There is deliberately no unscoped `by-id` variant of
172 /// this loader, the scoping is structural, which is why it needs no
173 /// `clippy.toml` disallowed-methods entry (cf. `get_category_in_community`).
174 #[tracing::instrument(skip_all)]
175 pub async fn get_post_body_markdown_in_community(
176 pool: &PgPool,
177 post_id: Uuid,
178 community_id: Uuid,
179 ) -> Result<Option<(Uuid, String)>, sqlx::Error> {
180 // Only live posts can be quoted: a mod-removed post, an author-deleted one,
181 // or one in a soft-deleted thread must fail quote verification so its text
182 // cannot be re-surfaced into a live post by quoting it back. `p.is_active`
183 // (migration 034) covers the first two; the thread's own `deleted_at` is a
184 // separate row and still needs saying.
185 sqlx::query!(
186 "SELECT p.author_id, p.body_markdown
187 FROM posts p
188 JOIN threads t ON t.id = p.thread_id
189 JOIN categories c ON c.id = t.category_id
190 WHERE p.id = $1 AND c.community_id = $2
191 AND p.is_active
192 AND t.deleted_at IS NULL",
193 post_id,
194 community_id,
195 )
196 .fetch_optional(pool)
197 .await
198 .map(|opt| opt.map(|r| (r.author_id, r.body_markdown)))
199 }
200
201 /// Whether a post is mod-removed (`removed_at IS NOT NULL`).
202 #[tracing::instrument(skip_all)]
203 pub async fn is_post_removed(pool: &PgPool, post_id: Uuid) -> Result<bool, sqlx::Error> {
204 sqlx::query_scalar!(
205 r#"SELECT (removed_at IS NOT NULL) AS "removed!" FROM posts WHERE id = $1"#,
206 post_id,
207 )
208 .fetch_one(pool)
209 .await
210 }
211