Skip to main content

max / makenotwork

Add guest checkout, embeddable widgets, and trust audit fixes Guest checkout: fans can purchase without an MNW account. Stripe collects email, download link sent immediately, purchases auto-attach when they later create an account. Free item guest claiming included. Direct purchase page at /buy/{item_id} for link-in-bio sharing. Embeddable widgets: five iframe-based embed types (buy button, product card, audio player, tip button, project card) served from /embed/ with permissive frame headers. Dashboard "Embed" tab with live previews and copy-to-clipboard code snippets. Trust audit fixes: IP scrubbing bug (wrong column name), encryption docs clarified as infrastructure-provided, streaming tier blocked from purchase, 90-day buyer access grace period on creator account deletion, video docs updated to reflect implemented state, chargeback fee documented, sandbox linked from creators page, fan guide updated with discovery and guest checkout sections.
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-27 03:17 UTC
Commit: bd0e8e721a63a48b26654c40e23c1717c153f92f
Parent: 528e7a7
60 files changed, +3602 insertions, -48 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.4.2"
3 + version = "0.4.3"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -129,6 +129,7 @@
129 129 .merge(git_issue_routes())
130 130 .merge(ota_routes())
131 131 .merge(build_routes())
132 + .merge(routes::embed::embed_routes())
132 133 .nest_service(
133 134 "/static",
134 135 tower::ServiceBuilder::new()
@@ -191,16 +192,37 @@
191 192 }
192 193
193 194 /// Middleware that sets security headers on all responses.
195 + /// Embed routes (`/embed/`) get permissive frame headers for iframe embedding.
194 196 async fn security_headers_middleware(
195 197 request: axum::http::Request<axum::body::Body>,
196 198 next: middleware::Next,
197 199 ) -> axum::response::Response {
200 + let is_embed = request.uri().path().starts_with("/embed/");
198 201 let mut response = next.run(request).await;
199 202 let headers = response.headers_mut();
200 - headers.insert(
201 - axum::http::header::X_FRAME_OPTIONS,
202 - HeaderValue::from_static("DENY"),
203 - );
203 +
204 + if is_embed {
205 + // Embed routes: allow framing from any origin
206 + headers.insert(
207 + axum::http::header::X_FRAME_OPTIONS,
208 + HeaderValue::from_static("ALLOWALL"),
209 + );
210 + headers.insert(
211 + axum::http::header::HeaderName::from_static("content-security-policy"),
212 + HeaderValue::from_static("frame-ancestors *"),
213 + );
214 + } else {
215 + // Normal routes: deny framing
216 + headers.insert(
217 + axum::http::header::X_FRAME_OPTIONS,
218 + HeaderValue::from_static("DENY"),
219 + );
220 + headers.insert(
221 + axum::http::header::HeaderName::from_static("content-security-policy"),
222 + HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self' https://cdn.makenot.work; frame-ancestors 'none'"),
223 + );
224 + }
225 +
204 226 headers.insert(
205 227 axum::http::header::X_CONTENT_TYPE_OPTIONS,
206 228 HeaderValue::from_static("nosniff"),
@@ -213,9 +235,5 @@
213 235 axum::http::header::HeaderName::from_static("permissions-policy"),
214 236 HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
215 237 );
216 - headers.insert(
217 - axum::http::header::HeaderName::from_static("content-security-policy"),
218 - HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self' https://cdn.makenot.work; frame-ancestors 'none'"),
219 - );
220 238 response
221 239 }
@@ -427,6 +427,9 @@
427 427
428 428 // Delete terminated accounts whose 30-day export window has expired
429 429 delete_expired_terminated_accounts(&state).await;
430 +
431 + // Delete self-deleted creator accounts whose 90-day content grace period has expired
432 + delete_expired_content_removal_accounts(&state).await;
430 433 }
431 434 }
432 435 })
@@ -843,6 +846,48 @@
843 846 }
844 847 }
845 848
849 + // ============================================================================
850 + // Content removal grace period (90-day buyer download window)
851 + // ============================================================================
852 +
853 + /// Delete creator accounts whose 90-day content removal grace period has expired.
854 + /// These are creators who self-deleted but had completed sales — content was kept
855 + /// accessible for buyers to download. Same cleanup as terminated accounts.
856 + async fn delete_expired_content_removal_accounts(state: &AppState) {
857 + let expired_ids = match db::users::get_expired_content_removal_ids(&state.db).await {
858 + Ok(ids) if ids.is_empty() => return,
859 + Ok(ids) => ids,
860 + Err(e) => {
861 + tracing::error!(error = ?e, "failed to query expired content removal accounts");
862 + return;
863 + }
864 + };
865 +
866 + for user_id in &expired_ids {
867 + if let Some(ref s3) = state.s3 {
868 + let user_prefix = format!("{user_id}/");
869 + if let Err(e) = s3.delete_prefix(&user_prefix).await {
870 + tracing::warn!(error = ?e, %user_id, "failed to delete content-removal user S3 objects");
871 + }
872 +
873 + if let Ok(project_ids) = db::projects::get_project_ids_for_user(&state.db, *user_id).await {
874 + for pid in project_ids {
875 + let proj_prefix = format!("projects/{pid}/");
876 + if let Err(e) = s3.delete_prefix(&proj_prefix).await {
877 + tracing::warn!(error = ?e, %user_id, %pid, "failed to delete content-removal project S3 objects");
878 + }
879 + }
880 + }
881 + }
882 +
883 + if let Err(e) = db::users::delete_user(&state.db, *user_id).await {
884 + tracing::error!(error = ?e, %user_id, "failed to delete content-removal account");
885 + } else {
886 + tracing::info!(%user_id, event = "content_removal_expired", "creator account deleted after 90-day content grace period");
887 + }
888 + }
889 + }
890 +
846 891 // ============================================================================
847 892 // IP address scrubbing (privacy policy: 30-day retention)
848 893 // ============================================================================
@@ -867,9 +912,9 @@
867 912 Err(e) => tracing::error!(error = ?e, "failed to scrub IPs from user_sessions"),
868 913 }
869 914
870 - // download_fingerprints: ip_address INET, keyed on downloaded_at
915 + // download_fingerprints: ip_address INET, keyed on created_at
871 916 match sqlx::query(
872 - "UPDATE download_fingerprints SET ip_address = NULL WHERE ip_address IS NOT NULL AND downloaded_at < $1",
917 + "UPDATE download_fingerprints SET ip_address = NULL WHERE ip_address IS NOT NULL AND created_at < $1",
873 918 )
874 919 .bind(cutoff)
875 920 .execute(&state.db)