Skip to main content

max / makenotwork

v0.3.17: Dashboard simplification + security hardening Phases 28-32: Collapse secondary dashboard sections behind <details> elements for progressive disclosure. Extract git repos into a Code tab. - Details tab: 10 sections collapsed (2FA/passkeys lazy-load on open) - Creator tab: storage/broadcast/invites collapsed - Payments tab: fees/tax/contacts collapsed - Project dashboard: new Code tab for git repos, removed from settings - Blog/promotions/subscriptions/synckit: creation forms behind details - Security: DOM XSS fix, promo tier verification, N+1 batch queries, dashboard rate limiting, presign quota checks, license key cap, past-date validation, query limits on collections/shared creators - Code quality: discover.rs query helpers, checkout.rs dedup, DbCreatorSubscription.tier String->CreatorTier enum Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 18:03 UTC
Commit: 2958cbe8fd150227b0236041645a4a22ef2d1c54
Parent: 98dbce7
35 files changed, +619 insertions, -428 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.16"
3 + version = "0.3.17"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -96,6 +96,10 @@ pub const SYNCKIT_SYNC_RATE_LIMIT_BURST: u32 = 30;
96 96 pub const TWO_FACTOR_RATE_LIMIT_MS: u64 = 500;
97 97 pub const TWO_FACTOR_RATE_LIMIT_BURST: u32 = 5;
98 98
99 + // Dashboard tab reads: generous but bounded (5/sec, burst 20)
100 + pub const DASHBOARD_READ_RATE_LIMIT_MS: u64 = 200;
101 + pub const DASHBOARD_READ_RATE_LIMIT_BURST: u32 = 20;
102 +
99 103 // -- Pagination --
100 104 pub const DISCOVER_PAGE_SIZE: u32 = 25;
101 105 pub const FEED_PAGE_SIZE: u32 = 25;
@@ -114,6 +114,7 @@ pub async fn get_collections_by_user(
114 114 WHERE c.user_id = $1
115 115 GROUP BY c.id
116 116 ORDER BY c.updated_at DESC
117 + LIMIT 500
117 118 "#,
118 119 )
119 120 .bind(user_id)
@@ -536,3 +536,65 @@ pub async fn check_upload_allowed(
536 536
537 537 Ok(max_storage)
538 538 }
539 +
540 + /// Early presign-time check: reject if the user has no tier or is already at/over
541 + /// their storage cap. This prevents generating presigned URLs that would always
542 + /// fail at confirm time. Does NOT check file size (unknown at presign).
543 + pub async fn check_presign_allowed(
544 + pool: &PgPool,
545 + user_id: UserId,
546 + file_type: FileType,
547 + ) -> Result<()> {
548 + // Covers bypass tier checks
549 + if file_type == FileType::Cover {
550 + return Ok(());
551 + }
552 +
553 + let active_tier = get_active_creator_tier(pool, user_id).await?;
554 + let grandfathered = get_grandfathered_until(pool, user_id).await?;
555 +
556 + let effective_tier = match active_tier {
557 + Some(tier) => Some(tier),
558 + None => {
559 + if let Some(until) = grandfathered {
560 + if Utc::now() < until {
561 + Some(CreatorTier::SmallFiles)
562 + } else {
563 + None
564 + }
565 + } else {
566 + None
567 + }
568 + }
569 + };
570 +
571 + if effective_tier.is_none() {
572 + let in_grace = is_in_grace_period(pool, user_id).await?;
573 + if in_grace {
574 + return Err(AppError::Forbidden);
575 + }
576 + return Err(AppError::BadRequest(
577 + "A creator tier subscription is required to upload files.".to_string(),
578 + ));
579 + }
580 +
581 + let tier = effective_tier.unwrap();
582 + if !tier.allows_file_uploads() {
583 + return Err(AppError::BadRequest(
584 + "Basic tier is text-only. Upgrade to Small Files or higher to upload files.".to_string(),
585 + ));
586 + }
587 +
588 + // Reject if already at/over storage cap
589 + let used = get_storage_used(pool, user_id).await?;
590 + let max_storage = tier.max_storage_bytes();
591 + if used >= max_storage {
592 + return Err(AppError::BadRequest(format!(
593 + "You've used {} of {} storage. Delete files or upgrade your tier.",
594 + format_bytes(used),
595 + format_bytes(max_storage),
596 + )));
597 + }
598 +
599 + Ok(())
600 + }
@@ -23,6 +23,71 @@ pub struct DiscoverFilters<'a> {
23 23 pub sort_by: Option<DiscoverSort>,
24 24 }
25 25
26 + // Shared SQL fragments for fuzzy search (trigram + ILIKE fallback).
27 + // The ILIKE escapes \, %, _ in the search term to prevent injection into LIKE patterns.
28 +
29 + const ITEM_SEARCH_CLAUSE: &str = r#" AND (
30 + i.title % $1
31 + OR COALESCE(i.description, '') % $1
32 + OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
33 + OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
34 + )"#;
35 +
36 + const PROJECT_SEARCH_CLAUSE: &str = r#" AND (
37 + p.title % $1
38 + OR COALESCE(p.description, '') % $1
39 + OR p.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
40 + OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
41 + )"#;
42 +
43 + /// Append discover-item filter clauses to a dynamic query.
44 + /// Parameter positions: $1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag, $6=label.
45 + fn append_item_discover_filters(query: &mut String, filters: &DiscoverFilters<'_>, has_search: bool) {
46 + if has_search {
47 + query.push_str(ITEM_SEARCH_CLAUSE);
48 + }
49 + if filters.item_type.is_some() {
50 + query.push_str(" AND i.item_type = $2");
51 + }
52 + if filters.min_price.is_some() {
53 + query.push_str(" AND i.price_cents >= $3");
54 + }
55 + if filters.max_price.is_some() {
56 + query.push_str(" AND i.price_cents <= $4");
57 + }
58 + if filters.tag.is_some() {
59 + query.push_str(
60 + r#" AND EXISTS (
61 + SELECT 1 FROM item_tags it2
62 + JOIN tags t2 ON t2.id = it2.tag_id
63 + WHERE it2.item_id = i.id
64 + AND (t2.slug = $5 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $5))
65 + )"#,
66 + );
67 + }
68 + if filters.label.is_some() {
69 + query.push_str(
70 + r#" AND EXISTS (
71 + SELECT 1 FROM project_labels pl
72 + JOIN labels l ON l.id = pl.label_id
73 + WHERE pl.project_id = i.project_id AND l.slug = $6
74 + )"#,
75 + );
76 + }
77 + }
78 +
79 + /// Bind the 6 discover-filter parameters ($1-$6) to a sqlx query.
80 + macro_rules! bind_item_discover_filters {
81 + ($q:expr, $filters:expr, $search_term:expr) => {
82 + $q.bind($search_term.unwrap_or(""))
83 + .bind($filters.item_type.map(|t| t.to_string()).unwrap_or_default())
84 + .bind($filters.min_price.unwrap_or(0))
85 + .bind($filters.max_price.unwrap_or(i32::MAX))
86 + .bind($filters.tag.unwrap_or(""))
87 + .bind($filters.label.unwrap_or(""))
88 + };
89 + }
90 +
26 91 /// Search/browse public items with optional text search, item_type, tag, and price filters.
27 92 ///
28 93 /// Uses PostgreSQL `pg_trgm` for fuzzy text matching. The search strategy:
@@ -101,54 +166,7 @@ pub async fn discover_items(
101 166 )
102 167 };
103 168
104 - // Add search condition using trigram fuzzy matching + ILIKE fallback
105 - if has_search {
106 - query.push_str(
107 - r#" AND (
108 - i.title % $1
109 - OR COALESCE(i.description, '') % $1
110 - OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
111 - OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
112 - )"#,
113 - );
114 - }
115 -
116 - // item_type filter (replaces old category filter)
117 - if filters.item_type.is_some() {
118 - query.push_str(" AND i.item_type = $2");
119 - }
120 -
121 - // Add price filters
122 - if filters.min_price.is_some() {
123 - query.push_str(" AND i.price_cents >= $3");
124 - }
125 -
126 - if filters.max_price.is_some() {
127 - query.push_str(" AND i.price_cents <= $4");
128 - }
129 -
130 - // Tag filter: match slug, including children of parent tags
131 - if filters.tag.is_some() {
132 - query.push_str(
133 - r#" AND EXISTS (
134 - SELECT 1 FROM item_tags it2
135 - JOIN tags t2 ON t2.id = it2.tag_id
136 - WHERE it2.item_id = i.id
137 - AND (t2.slug = $5 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $5))
138 - )"#,
139 - );
140 - }
141 -
142 - // Label filter: match by label slug on the parent project
143 - if filters.label.is_some() {
144 - query.push_str(
145 - r#" AND EXISTS (
146 - SELECT 1 FROM project_labels pl
147 - JOIN labels l ON l.id = pl.label_id
148 - WHERE pl.project_id = i.project_id AND l.slug = $6
149 - )"#,
150 - );
151 - }
169 + append_item_discover_filters(&mut query, filters, has_search);
152 170
153 171 // Determine ordering
154 172 let order = if has_search && (filters.sort_by.is_none() || filters.sort_by == Some(DiscoverSort::Newest)) {
@@ -164,17 +182,15 @@ pub async fn discover_items(
164 182
165 183 query.push_str(&format!(" ORDER BY {} LIMIT $7 OFFSET $8", order));
166 184
167 - let items = sqlx::query_as::<_, DbDiscoverItemRow>(&query)
168 - .bind(search_term.unwrap_or(""))
169 - .bind(filters.item_type.map(|t| t.to_string()).unwrap_or_default())
170 - .bind(filters.min_price.unwrap_or(0))
171 - .bind(filters.max_price.unwrap_or(i32::MAX))
172 - .bind(filters.tag.unwrap_or(""))
173 - .bind(filters.label.unwrap_or(""))
174 - .bind(limit)
175 - .bind(offset)
176 - .fetch_all(pool)
177 - .await?;
185 + let items = bind_item_discover_filters!(
186 + sqlx::query_as::<_, DbDiscoverItemRow>(&query),
187 + filters,
188 + search_term
189 + )
190 + .bind(limit)
191 + .bind(offset)
192 + .fetch_all(pool)
193 + .await?;
178 194
179 195 Ok(items)
180 196 }
@@ -196,59 +212,15 @@ pub async fn count_discover_items(
196 212 "#,
197 213 );
198 214
199 - if has_search {
200 - query.push_str(
201 - r#" AND (
202 - i.title % $1
203 - OR COALESCE(i.description, '') % $1
204 - OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
205 - OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
206 - )"#,
207 - );
208 - }
209 -
210 - if filters.item_type.is_some() {
211 - query.push_str(" AND i.item_type = $2");
212 - }
213 -
214 - if filters.min_price.is_some() {
215 - query.push_str(" AND i.price_cents >= $3");
216 - }
215 + append_item_discover_filters(&mut query, filters, has_search);
217 216
218 - if filters.max_price.is_some() {
219 - query.push_str(" AND i.price_cents <= $4");
220 - }
221 -
222 - if filters.tag.is_some() {
223 - query.push_str(
224 - r#" AND EXISTS (
225 - SELECT 1 FROM item_tags it2
226 - JOIN tags t2 ON t2.id = it2.tag_id
227 - WHERE it2.item_id = i.id
228 - AND (t2.slug = $5 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $5))
229 - )"#,
230 - );
231 - }
232 -
233 - if filters.label.is_some() {
234 - query.push_str(
235 - r#" AND EXISTS (
236 - SELECT 1 FROM project_labels pl
237 - JOIN labels l ON l.id = pl.label_id
238 - WHERE pl.project_id = i.project_id AND l.slug = $6
239 - )"#,
240 - );
241 - }
242 -
243 - let count: i64 = sqlx::query_scalar(&query)
244 - .bind(search_term.unwrap_or(""))
245 - .bind(filters.item_type.map(|t| t.to_string()).unwrap_or_default())
246 - .bind(filters.min_price.unwrap_or(0))
247 - .bind(filters.max_price.unwrap_or(i32::MAX))
248 - .bind(filters.tag.unwrap_or(""))
249 - .bind(filters.label.unwrap_or(""))
250 - .fetch_one(pool)
251 - .await?;
217 + let count: i64 = bind_item_discover_filters!(
218 + sqlx::query_scalar(&query),
219 + filters,
220 + search_term
221 + )
222 + .fetch_one(pool)
223 + .await?;
252 224
253 225 Ok(count)
254 226 }
@@ -317,14 +289,7 @@ pub async fn discover_projects(
317 289 };
318 290
319 291 if has_search {
320 - query.push_str(
321 - r#" AND (
322 - p.title % $1
323 - OR COALESCE(p.description, '') % $1
324 - OR p.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
325 - OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
326 - )"#,
327 - );
292 + query.push_str(PROJECT_SEARCH_CLAUSE);
328 293 }
329 294
330 295 if category_slug.is_some() {
@@ -382,14 +347,7 @@ pub async fn count_discover_projects(
382 347 );
383 348
384 349 if has_search {
385 - query.push_str(
386 - r#" AND (
387 - p.title % $1
388 - OR COALESCE(p.description, '') % $1
389 - OR p.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
390 - OR COALESCE(p.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
391 - )"#,
392 - );
350 + query.push_str(PROJECT_SEARCH_CLAUSE);
393 351 }
394 352
395 353 if category_slug.is_some() {
@@ -439,14 +397,7 @@ pub async fn get_item_type_counts(
439 397 );
440 398
441 399 if has_search {
442 - query.push_str(
443 - r#" AND (
444 - i.title % $1
445 - OR COALESCE(i.description, '') % $1
446 - OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
447 - OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
448 - )"#,
449 - );
400 + query.push_str(ITEM_SEARCH_CLAUSE);
450 401 }
451 402
452 403 if tag.is_some() {
@@ -510,14 +461,7 @@ pub async fn get_price_range_counts(
510 461 );
511 462
512 463 if has_search {
513 - query.push_str(
514 - r#" AND (
515 - i.title % $1
516 - OR COALESCE(i.description, '') % $1
517 - OR i.title ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
518 - OR COALESCE(i.description, '') ILIKE '%' || replace(replace(replace($1, '\', '\\'), '%', '\%'), '_', '\_') || '%'
519 - )"#,
520 - );
464 + query.push_str(ITEM_SEARCH_CLAUSE);
521 465 }
522 466
523 467 if item_type.is_some() {
@@ -98,6 +98,21 @@ pub async fn get_item_by_id(pool: &PgPool, id: ItemId) -> Result<Option<DbItem>>
98 98 Ok(item)
99 99 }
100 100
101 + /// Fetch titles for a batch of item IDs. Returns (item_id, title) pairs.
102 + pub async fn get_item_titles_batch(pool: &PgPool, ids: &[ItemId]) -> Result<Vec<(ItemId, String)>> {
103 + if ids.is_empty() {
104 + return Ok(vec![]);
105 + }
106 + let rows: Vec<(ItemId, String)> = sqlx::query_as(
107 + "SELECT id, title FROM items WHERE id = ANY($1)",
108 + )
109 + .bind(ids)
110 + .fetch_all(pool)
111 + .await?;
112 +
113 + Ok(rows)
114 + }
115 +
101 116 /// List all items in a project, ordered by sort_order then newest.
102 117 ///
103 118 /// Capped at 500 as a safety limit.
@@ -58,6 +58,18 @@ pub async fn get_license_key_by_id(pool: &PgPool, id: LicenseKeyId) -> Result<Op
58 58 Ok(key)
59 59 }
60 60
61 + /// Count license keys for an item.
62 + pub async fn count_keys_by_item(pool: &PgPool, item_id: ItemId) -> Result<i64> {
63 + let count: i64 = sqlx::query_scalar(
64 + "SELECT COUNT(*) FROM license_keys WHERE item_id = $1",
65 + )
66 + .bind(item_id)
67 + .fetch_one(pool)
68 + .await?;
69 +
70 + Ok(count)
71 + }
72 +
61 73 /// List all license keys for an item, newest first.
62 74 ///
63 75 /// Hard-caps at 500 rows to bound memory and response size for the creator
@@ -5,6 +5,7 @@ use serde::Serialize;
5 5 use sqlx::FromRow;
6 6 use uuid::Uuid;
7 7
8 + use super::enums::CreatorTier;
8 9 use super::id_types::*;
9 10 use super::validated_types::*;
10 11
@@ -1561,7 +1562,7 @@ pub struct DbCreatorSubscription {
1561 1562 /// Stripe customer ID (e.g. `cus_...`).
1562 1563 pub stripe_customer_id: String,
1563 1564 /// Creator tier (basic, small_files, big_files, streaming).
1564 - pub tier: String,
1565 + pub tier: CreatorTier,
1565 1566 /// Subscription status (active, past_due, canceled).
1566 1567 pub status: String,
1567 1568 /// Start of current billing period.
@@ -349,20 +349,27 @@ pub async fn get_sync_key(
349 349 Ok(row)
350 350 }
351 351
352 - /// Get device count and sync log entry count for a sync app.
353 - pub async fn get_sync_app_stats(pool: &PgPool, app_id: SyncAppId) -> Result<(i64, i64)> {
354 - let row: (i64, i64) = sqlx::query_as(
352 + /// Get device count and sync log entry count for all apps owned by a creator.
353 + /// Returns Vec of (app_id, device_count, log_entry_count). Single query replaces N+1 loop.
354 + pub async fn get_sync_app_stats_batch(
355 + pool: &PgPool,
356 + creator_id: UserId,
357 + ) -> Result<Vec<(SyncAppId, i64, i64)>> {
358 + let rows: Vec<(SyncAppId, i64, i64)> = sqlx::query_as(
355 359 r#"
356 360 SELECT
357 - (SELECT COUNT(*) FROM sync_devices WHERE app_id = $1),
358 - (SELECT COUNT(*) FROM sync_log WHERE app_id = $1)
361 + a.id,
362 + (SELECT COUNT(*) FROM sync_devices d WHERE d.app_id = a.id),
363 + (SELECT COUNT(*) FROM sync_log l WHERE l.app_id = a.id)
364 + FROM sync_apps a
365 + WHERE a.creator_id = $1
359 366 "#,
360 367 )
361 - .bind(app_id)
362 - .fetch_one(pool)
368 + .bind(creator_id)
369 + .fetch_all(pool)
363 370 .await?;
364 371
365 - Ok(row)
372 + Ok(rows)
366 373 }
367 374
368 375 /// Delete sync log entries older than the given number of days.
@@ -346,6 +346,31 @@ pub async fn get_revenue_by_project(pool: &PgPool, project_id: ProjectId) -> Res
346 346 Ok((row.0.unwrap_or(0), row.1.unwrap_or(0)))
347 347 }
348 348
349 + /// Revenue per project for a given seller, returned as (project_id, title, revenue_cents).
350 + /// Single query replaces N+1 loop in dashboard analytics.
351 + pub async fn get_revenue_by_user_projects(
352 + pool: &PgPool,
353 + user_id: UserId,
354 + ) -> Result<Vec<(ProjectId, String, i64)>> {
355 + let rows: Vec<(ProjectId, String, i64)> = sqlx::query_as(
356 + r#"
357 + SELECT p.id, p.title, COALESCE(SUM(t.amount_cents), 0)::BIGINT
358 + FROM projects p
359 + LEFT JOIN items i ON i.project_id = p.id
360 + LEFT JOIN transactions t ON t.item_id = i.id AND t.status = 'completed'
361 + WHERE p.user_id = $1
362 + GROUP BY p.id, p.title
363 + HAVING COALESCE(SUM(t.amount_cents), 0) > 0
364 + ORDER BY COALESCE(SUM(t.amount_cents), 0) DESC
365 + "#,
366 + )
367 + .bind(user_id)
368 + .fetch_all(pool)
369 + .await?;
370 +
371 + Ok(rows)
372 + }
373 +
349 374 /// Remove a free item from library (deletes the claim transaction)
350 375 pub async fn remove_free_item_from_library(
351 376 pool: &PgPool,
@@ -552,6 +577,7 @@ pub async fn get_shared_creators(
552 577 WHERE cr.buyer_id = t.buyer_id AND cr.seller_id = t.seller_id
553 578 )
554 579 ORDER BY u.username
580 + LIMIT 500
555 581 "#,
556 582 )
557 583 .bind(buyer_id)
@@ -215,6 +215,13 @@ pub(super) async fn update_blog_post(
215 215 // Parse publish_at: None = no change, Some("") = clear, Some(datetime) = set schedule
216 216 let publish_at = parse_schedule_datetime(req.publish_at.as_deref());
217 217
218 + // Reject scheduling in the past
219 + if let Some(Some(dt)) = &publish_at {
220 + if *dt < chrono::Utc::now() {
221 + return Err(AppError::BadRequest("Scheduled publish date must be in the future".to_string()));
222 + }
223 + }
224 +
218 225 // If scheduling, don't publish immediately
219 226 let is_published = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() {
220 227 false
@@ -168,6 +168,13 @@ pub(super) async fn update_item(
168 168 // Parse publish_at: None = no change, Some("") = clear, Some(datetime) = set schedule
169 169 let publish_at = parse_schedule_datetime(req.publish_at.as_deref());
170 170
171 + // Reject scheduling in the past
172 + if let Some(Some(dt)) = &publish_at {
173 + if *dt < chrono::Utc::now() {
174 + return Err(AppError::BadRequest("Scheduled publish date must be in the future".to_string()));
175 + }
176 + }
177 +
171 178 // If scheduling, override is_public to false so it doesn't go live immediately
172 179 let is_public = if publish_at.as_ref().and_then(|v| v.as_ref()).is_some() {
173 180 Some(false)
@@ -308,6 +308,14 @@ pub(super) async fn generate_key(
308 308 ));
309 309 }
310 310
311 + // Cap at 1000 manually-generated keys per item
312 + let existing_count = db::license_keys::count_keys_by_item(&state.db, item_id).await?;
313 + if existing_count >= 1000 {
314 + return Err(AppError::BadRequest(
315 + "Maximum of 1000 license keys per item reached".to_string(),
316 + ));
317 + }
318 +
311 319 let key_code = helpers::generate_key_code();
312 320 let max_activations = req.max_activations.or(item.default_max_activations);
313 321
@@ -175,7 +175,7 @@ pub(super) async fn create_promo_code(
175 175 None
176 176 };
177 177
178 - // Parse optional tier_id
178 + // Parse optional tier_id (verify ownership via tier → project → user)
179 179 let tier_id = if let Some(ref id_str) = req.tier_id {
180 180 let id_str = id_str.trim();
181 181 if id_str.is_empty() {
@@ -183,6 +183,17 @@ pub(super) async fn create_promo_code(
183 183 } else {
184 184 let tid: SubscriptionTierId = id_str.parse()
185 185 .map_err(|_| AppError::BadRequest("Invalid tier ID".to_string()))?;
186 + let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tid)
187 + .await?
188 + .ok_or(AppError::NotFound)?;
189 + let tier_project_id = tier.project_id
190 + .ok_or(AppError::BadRequest("Tier has no project".to_string()))?;
191 + let tier_project = db::projects::get_project_by_id(&state.db, tier_project_id)
192 + .await?
193 + .ok_or(AppError::NotFound)?;
194 + if tier_project.user_id != user.id {
195 + return Err(AppError::Forbidden);
196 + }
186 197 Some(tid)
187 198 }
188 199 } else {
@@ -220,6 +220,8 @@ pub(super) async fn dashboard_project(
220 220
221 221 let has_blog = db_project.features.iter().any(|f| f == "blog");
222 222
223 + let git_enabled = state.config.git_repos_path.is_some();
224 +
223 225 Ok(DashboardProjectTemplate {
224 226 csrf_token,
225 227 session_user: Some(session_user.clone()),
@@ -229,6 +231,7 @@ pub(super) async fn dashboard_project(
229 231 items,
230 232 stripe_connected: db_user.stripe_account_id.is_some(),
231 233 has_blog,
234 + git_enabled,
232 235 })
233 236 }
234 237
@@ -8,8 +8,9 @@ pub mod wizards;
8 8
9 9 use axum::{routing::{get, post}, Router};
10 10 use serde::Deserialize;
11 + use tower_governor::GovernorLayer;
11 12
12 - use crate::{db, types::*, AppState};
13 + use crate::{constants, db, types::*, AppState};
13 14
14 15 /// Query parameters for analytics time range selection.
15 16 #[derive(Deserialize)]
@@ -38,11 +39,13 @@ pub(super) fn build_chart_bars(buckets: &[db::analytics::TimeBucket]) -> Vec<Cha
38 39
39 40 /// Register dashboard page routes.
40 41 pub fn dashboard_routes() -> Router<AppState> {
41 - Router::new()
42 - .merge(wizards::wizard_routes())
43 - .route("/dashboard", get(main::dashboard))
44 - .route("/dashboard/project/{slug}", get(main::dashboard_project))
45 - .route("/dashboard/item/{id}", get(main::dashboard_item))
42 + let read_rate_limit = crate::helpers::rate_limiter_ms(
43 + constants::DASHBOARD_READ_RATE_LIMIT_MS,
44 + constants::DASHBOARD_READ_RATE_LIMIT_BURST,
45 + );
46 +
47 + // Tab endpoints — rate limited to prevent rapid polling
48 + let tab_routes = Router::new()
46 49 .route("/dashboard/tabs/details", get(tabs::dashboard_tab_details))
47 50 .route("/dashboard/tabs/payments", get(tabs::dashboard_tab_payments))
48 51 .route("/dashboard/tabs/projects", get(tabs::dashboard_tab_projects))
@@ -54,6 +57,7 @@ pub fn dashboard_routes() -> Router<AppState> {
54 57 .route("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview))
55 58 .route("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content))
56 59 .route("/dashboard/project/{slug}/tabs/analytics", get(project_tabs::project_tab_analytics))
60 + .route("/dashboard/project/{slug}/tabs/code", get(project_tabs::project_tab_code))
57 61 .route("/dashboard/project/{slug}/tabs/settings", get(project_tabs::project_tab_settings))
58 62 .route("/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog))
59 63 .route("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions))
@@ -64,6 +68,14 @@ pub fn dashboard_routes() -> Router<AppState> {
64 68 .route("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files))
65 69 .route("/dashboard/item/{id}/tabs/settings", get(tabs::item_tab_settings))
66 70 .route("/dashboard/item/{id}/analytics", get(main::dashboard_item_analytics))
71 + .route_layer(GovernorLayer { config: read_rate_limit });
72 +
73 + Router::new()
74 + .merge(wizards::wizard_routes())
75 + .route("/dashboard", get(main::dashboard))
76 + .route("/dashboard/project/{slug}", get(main::dashboard_project))
77 + .route("/dashboard/item/{id}", get(main::dashboard_item))
78 + .merge(tab_routes)
67 79 .route("/dashboard/item/{id}/edit-row", get(forms::item_edit_row))
68 80 .route("/dashboard/export", get(forms::export_portal))
69 81 .route("/dashboard/delete-account", get(forms::delete_account_page))
@@ -263,13 +263,6 @@ pub(super) async fn project_tab_settings(
263 263 .await?
264 264 .unwrap_or_default();
265 265
266 - let git_enabled = state.config.git_repos_path.is_some();
267 -
268 - // Fetch linked repos and available (unlinked) repos for this user
269 - let linked_repos = db::git_repos::get_repos_by_project(&state.db, db_project.id).await.unwrap_or_default();
270 - let all_repos = db::git_repos::get_repos_by_user(&state.db, session_user.id).await.unwrap_or_default();
271 - let available_repos: Vec<_> = all_repos.into_iter().filter(|r| r.project_id.is_none()).collect();
272 -
273 266 // Fetch labels for this project and all available labels
274 267 let project_labels = db::labels::get_labels_for_project(&state.db, db_project.id).await?;
275 268 let all_labels = db::labels::get_all_labels(&state.db).await?;
@@ -281,7 +274,7 @@ pub(super) async fn project_tab_settings(
281 274 let features = db_project.features.clone();
282 275 let project_features = db::ProjectFeature::all();
283 276
284 - Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate { project, category_name, git_enabled, linked_repos, available_repos, project_labels, available_labels, project_id, features, project_features }))
277 + Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate { project, category_name, project_labels, available_labels, project_id, features, project_features }))
285 278 }
286 279
287 280 /// Render the HTMX partial for the project subscriptions tab (tier management).
@@ -379,3 +372,33 @@ pub(super) async fn project_tab_promotions(
379 372 items,
380 373 }))
381 374 }
375 +
376 + /// Render the HTMX partial for the project code tab (git repos).
377 + #[tracing::instrument(skip_all, name = "project_tabs::project_tab_code")]
378 + pub(super) async fn project_tab_code(
379 + State(state): State<AppState>,
380 + AuthUser(session_user): AuthUser,
381 + headers: HeaderMap,
382 + Path(slug): Path<String>,
383 + ) -> Result<axum::response::Response> {
384 + let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
385 + Ok(pair) => pair,
386 + Err(not_modified) => return Ok(not_modified),
387 + };
388 +
389 + let git_enabled = state.config.git_repos_path.is_some();
390 + let linked_repos = db::git_repos::get_repos_by_project(&state.db, db_project.id).await.unwrap_or_default();
391 + let all_repos = db::git_repos::get_repos_by_user(&state.db, session_user.id).await.unwrap_or_default();
392 + let available_repos: Vec<_> = all_repos.into_iter().filter(|r| r.project_id.is_none()).collect();
393 +
394 + let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
395 + let project = Project::from_db(&db_project, db_items.len() as u32);
396 +
397 + Ok(helpers::with_etag(generation, ProjectCodeTabTemplate {
398 + project,
399 + git_enabled,
400 + linked_repos,
401 + available_repos,
402 + project_id: db_project.id.to_string(),
403 + }))
404 + }
@@ -219,9 +219,7 @@ pub(super) async fn dashboard_tab_creator(
219 219 let creator_sub = db::creator_tiers::get_creator_sub_by_user(&state.db, session_user.id).await?;
220 220 let (creator_tier_label, creator_period_end, creator_sub_status) = match &creator_sub {
221 221 Some(sub) => {
222 - let label = sub.tier.parse::<crate::db::CreatorTier>()
223 - .map(|t| t.label().to_string())
224 - .ok();
222 + let label = Some(sub.tier.label().to_string());
225 223 let period_end = sub.current_period_end.map(|dt| dt.format("%B %d, %Y").to_string());
226 224 (label, period_end, Some(sub.status.clone()))
227 225 }
@@ -340,19 +338,15 @@ pub(super) async fn dashboard_tab_analytics(
340 338 },
341 339 ];
342 340
343 - // Build per-project revenue breakdown
344 - let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?;
345 - let mut top_projects = Vec::new();
346 - for p in &db_projects {
347 - let (rev_cents, _sales) = db::transactions::get_revenue_by_project(&state.db, p.id).await?;
348 - if rev_cents > 0 {
349 - top_projects.push(ProjectRevenue {
350 - title: p.title.clone(),
351 - revenue: format!("${}.{:02}", rev_cents / 100, rev_cents % 100),
352 - });
353 - }
354 - }
355 - top_projects.sort_by(|a, b| b.revenue.cmp(&a.revenue));
341 + // Build per-project revenue breakdown (single query, no N+1)
342 + let project_revenues = db::transactions::get_revenue_by_user_projects(&state.db, session_user.id).await?;
343 + let top_projects: Vec<ProjectRevenue> = project_revenues
344 + .into_iter()
345 + .map(|(_pid, title, rev_cents)| ProjectRevenue {
346 + title,
347 + revenue: format!("${}.{:02}", rev_cents / 100, rev_cents % 100),
348 + })
349 + .collect();
356 350
357 351 Ok(UserAnalyticsTabTemplate {
358 352 stats,
@@ -469,10 +463,25 @@ pub(super) async fn dashboard_tab_synckit(
469 463 let db_apps = db::synckit::get_sync_apps_by_creator(&state.db, session_user.id).await?;
470 464 let db_projects = db::projects::get_projects_by_user(&state.db, session_user.id).await?;
471 465
466 + // Batch-fetch stats and item titles (no N+1)
467 + let stats_batch = db::synckit::get_sync_app_stats_batch(&state.db, session_user.id).await?;
468 + let stats_map: std::collections::HashMap<_, _> = stats_batch
469 + .into_iter()
470 + .map(|(id, devices, logs)| (id, (devices, logs)))
471 + .collect();
472 +
473 + let item_ids: Vec<db::ItemId> = db_apps.iter().filter_map(|a| a.item_id).collect();
474 + let item_titles_batch = db::items::get_item_titles_batch(&state.db, &item_ids).await?;
475 + let item_title_map: std::collections::HashMap<_, _> = item_titles_batch
476 + .into_iter()
477 + .collect();
478 +
472 479 let mut apps = Vec::with_capacity(db_apps.len());
473 480 for app in &db_apps {
474 - let (device_count, log_entry_count) =
475 - db::synckit::get_sync_app_stats(&state.db, app.id).await?;
481 + let (device_count, log_entry_count) = stats_map
482 + .get(&app.id)
483 + .copied()
484 + .unwrap_or((0, 0));
476 485
477 486 let key = &app.api_key;
478 487 let api_key_masked = if key.len() > 16 {
@@ -488,14 +497,8 @@ pub(super) async fn dashboard_tab_synckit(
488 497 .map(|p| (Some(p.title.clone()), Some(p.slug.to_string())))
489 498 .unwrap_or((None, None));
490 499
491 - // Resolve linked item title
492 - let item_title = if let Some(iid) = app.item_id {
493 - db::items::get_item_by_id(&state.db, iid)
494 - .await?
495 - .map(|i| i.title)
496 - } else {
497 - None
498 - };
500 + // Resolve linked item title from batch
501 + let item_title = app.item_id.and_then(|iid| item_title_map.get(&iid).cloned());
499 502
500 503 apps.push(SyncAppRow {
501 504 id: app.id.to_string(),
@@ -248,6 +248,9 @@ async fn presign_upload(
248 248 return Err(AppError::Forbidden);
249 249 }
250 250
251 + // Early quota check (reject before generating presigned URL)
252 + db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
253 +
251 254 // Generate S3 key
252 255 let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name);
253 256
@@ -476,6 +479,9 @@ async fn version_presign_upload(
476 479 return Err(AppError::Forbidden);
477 480 }
478 481
482 + // Early quota check
483 + db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
484 +
479 485 // Generate S3 key using the version's item_id
480 486 let s3_key = S3Client::generate_key(user.id, version.item_id, file_type, &req.file_name);
481 487
@@ -676,6 +682,9 @@ async fn project_image_presign(
676 682 return Err(AppError::Forbidden);
677 683 }
678 684
685 + // Early quota check
686 + db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
687 +
679 688 let s3_key = S3Client::generate_project_image_key(req.project_id, &req.file_name);
680 689 let expires_in = 3600;
681 690 let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE)).await?;
@@ -802,6 +811,9 @@ async fn item_image_presign(
802 811 return Err(AppError::Forbidden);
803 812 }
804 813
814 + // Early quota check
815 + db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?;
816 +
805 817 let s3_key = S3Client::generate_key(user.id, req.item_id, file_type, &req.file_name);
806 818 let expires_in = 3600;
807 819 let upload_url = s3.presign_upload(&s3_key, &req.content_type, Some(expires_in), Some(CACHE_CONTROL_IMMUTABLE)).await?;
@@ -113,76 +113,44 @@ pub(super) async fn create_checkout(
113 113 let is_platform_wide = pc.is_platform_wide;
114 114
115 115 // Only discount and free_access codes are valid at item checkout
116 - match pc.code_purpose {
117 - CodePurpose::FreeTrial => {
118 - return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string()));
116 + if pc.code_purpose == CodePurpose::FreeTrial {
117 + return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string()));
118 + }
119 +
120 + // Common validation for all item checkout codes
121 + if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() {
122 + return Err(AppError::BadRequest("This promo code has expired".to_string()));
123 + }
124 + if let Some(max) = pc.max_uses && pc.use_count >= max {
125 + return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
126 + }
127 +
128 + // Scope checks only apply to seller codes, not platform-wide credits
129 + if !is_platform_wide {
130 + if let Some(scoped_item) = pc.item_id && scoped_item != item_uuid {
131 + return Err(AppError::BadRequest("This promo code is not valid for this item".to_string()));
132 + }
133 + if let Some(scoped_project) = pc.project_id && item.project_id != scoped_project {
134 + return Err(AppError::BadRequest("This promo code is not valid for this item".to_string()));
119 135 }
136 + }
137 +
138 + // Type-specific logic
139 + match pc.code_purpose {
140 + CodePurpose::FreeTrial => unreachable!(),
120 141 CodePurpose::FreeAccess => {
121 - // free_access = 100% discount
122 142 final_price_cents = 0;
123 - promo_code_id = Some(pc.id);
124 143 }
125 144 CodePurpose::Discount => {
126 - // Check expiry
127 - if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() {
128 - return Err(AppError::BadRequest("This promo code has expired".to_string()));
129 - }
130 -
131 - // Check max uses
132 - if let Some(max) = pc.max_uses && pc.use_count >= max {
133 - return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
145 + if !is_platform_wide && item.price_cents < pc.min_price_cents {
146 + return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string()));
134 147 }
135 -
136 - // Scope checks only apply to seller codes, not platform-wide credits
137 - if !is_platform_wide {
138 - // Check item scope
139 - if let Some(scoped_item) = pc.item_id && scoped_item != item_uuid {
140 - return Err(AppError::BadRequest("This promo code is not valid for this item".to_string()));
141 - }
142 -
143 - // Check project scope
144 - if let Some(scoped_project) = pc.project_id && item.project_id != scoped_project {
145 - return Err(AppError::BadRequest("This promo code is not valid for this item".to_string()));
146 - }
147 -
148 - // Check minimum price
149 - if item.price_cents < pc.min_price_cents {
150 - return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string()));
151 - }
152 - }
153 -
154 148 if let (Some(dt), Some(dv)) = (pc.discount_type, pc.discount_value) {
155 149 final_price_cents = db::promo_codes::apply_discount(item.price_cents, dt, dv);
156 150 }
157 - promo_code_id = Some(pc.id);
158 - }
159 - }
160 -
161 - // Common checks for non-trial codes (free_access)
162 - if pc.code_purpose != CodePurpose::Discount {
163 - // Check expiry
164 - if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() {
165 - return Err(AppError::BadRequest("This code has expired".to_string()));
166 - }
167 -
168 - // Check max uses
169 - if let Some(max) = pc.max_uses && pc.use_count >= max {
170 - return Err(AppError::BadRequest("This code has reached its usage limit".to_string()));
171 - }
172 -
173 - // Scope checks only for seller codes
174 - if !is_platform_wide {
175 - // Check item scope
176 - if let Some(scoped_item) = pc.item_id && scoped_item != item_uuid {
177 - return Err(AppError::BadRequest("This code is not valid for this item".to_string()));
178 - }
179 -
180 - // Check project scope
181 - if let Some(scoped_project) = pc.project_id && item.project_id != scoped_project {
182 - return Err(AppError::BadRequest("This code is not valid for this item".to_string()));
183 - }
184 151 }
185 152 }
153 + promo_code_id = Some(pc.id);
186 154 }
187 155 }
188 156