max / makenotwork
85 files changed,
+2501 insertions,
-372 deletions
| @@ -29,6 +29,18 @@ dependencies = [ | |||
| 29 | 29 | ] | |
| 30 | 30 | ||
| 31 | 31 | [[package]] | |
| 32 | + | name = "ahash" | |
| 33 | + | version = "0.8.12" | |
| 34 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 35 | + | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 36 | + | dependencies = [ | |
| 37 | + | "cfg-if", | |
| 38 | + | "once_cell", | |
| 39 | + | "version_check", | |
| 40 | + | "zerocopy", | |
| 41 | + | ] | |
| 42 | + | ||
| 43 | + | [[package]] | |
| 32 | 44 | name = "aho-corasick" | |
| 33 | 45 | version = "1.1.4" | |
| 34 | 46 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3373,7 +3385,7 @@ dependencies = [ | |||
| 3373 | 3385 | ||
| 3374 | 3386 | [[package]] | |
| 3375 | 3387 | name = "makenotwork" | |
| 3376 | - | version = "0.3.25" | |
| 3388 | + | version = "0.3.26" | |
| 3377 | 3389 | dependencies = [ | |
| 3378 | 3390 | "anyhow", | |
| 3379 | 3391 | "argon2", | |
| @@ -3397,6 +3409,9 @@ dependencies = [ | |||
| 3397 | 3409 | "http-body-util", | |
| 3398 | 3410 | "infer 0.19.0", | |
| 3399 | 3411 | "jsonwebtoken", | |
| 3412 | + | "log", | |
| 3413 | + | "metrics", | |
| 3414 | + | "metrics-exporter-prometheus", | |
| 3400 | 3415 | "openssl", | |
| 3401 | 3416 | "rand 0.8.5", | |
| 3402 | 3417 | "regex", | |
| @@ -3519,6 +3534,46 @@ dependencies = [ | |||
| 3519 | 3534 | ] | |
| 3520 | 3535 | ||
| 3521 | 3536 | [[package]] | |
| 3537 | + | name = "metrics" | |
| 3538 | + | version = "0.24.3" | |
| 3539 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3540 | + | checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8" | |
| 3541 | + | dependencies = [ | |
| 3542 | + | "ahash", | |
| 3543 | + | "portable-atomic", | |
| 3544 | + | ] | |
| 3545 | + | ||
| 3546 | + | [[package]] | |
| 3547 | + | name = "metrics-exporter-prometheus" | |
| 3548 | + | version = "0.18.1" | |
| 3549 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3550 | + | checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda" | |
| 3551 | + | dependencies = [ | |
| 3552 | + | "base64 0.22.1", | |
| 3553 | + | "indexmap", | |
| 3554 | + | "metrics", | |
| 3555 | + | "metrics-util", | |
| 3556 | + | "quanta", | |
| 3557 | + | "thiserror 2.0.18", | |
| 3558 | + | ] | |
| 3559 | + | ||
| 3560 | + | [[package]] | |
| 3561 | + | name = "metrics-util" | |
| 3562 | + | version = "0.20.1" | |
| 3563 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3564 | + | checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4" | |
| 3565 | + | dependencies = [ | |
| 3566 | + | "crossbeam-epoch", | |
| 3567 | + | "crossbeam-utils", | |
| 3568 | + | "hashbrown 0.16.1", | |
| 3569 | + | "metrics", | |
| 3570 | + | "quanta", | |
| 3571 | + | "rand 0.9.2", | |
| 3572 | + | "rand_xoshiro", | |
| 3573 | + | "sketches-ddsketch", | |
| 3574 | + | ] | |
| 3575 | + | ||
| 3576 | + | [[package]] | |
| 3522 | 3577 | name = "mime" | |
| 3523 | 3578 | version = "0.3.17" | |
| 3524 | 3579 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4443,6 +4498,15 @@ dependencies = [ | |||
| 4443 | 4498 | ] | |
| 4444 | 4499 | ||
| 4445 | 4500 | [[package]] | |
| 4501 | + | name = "rand_xoshiro" | |
| 4502 | + | version = "0.7.0" | |
| 4503 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4504 | + | checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" | |
| 4505 | + | dependencies = [ | |
| 4506 | + | "rand_core 0.9.5", | |
| 4507 | + | ] | |
| 4508 | + | ||
| 4509 | + | [[package]] | |
| 4446 | 4510 | name = "raw-cpuid" | |
| 4447 | 4511 | version = "11.6.0" | |
| 4448 | 4512 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5151,6 +5215,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 5151 | 5215 | checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" | |
| 5152 | 5216 | ||
| 5153 | 5217 | [[package]] | |
| 5218 | + | name = "sketches-ddsketch" | |
| 5219 | + | version = "0.3.1" | |
| 5220 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5221 | + | checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" | |
| 5222 | + | ||
| 5223 | + | [[package]] | |
| 5154 | 5224 | name = "slab" | |
| 5155 | 5225 | version = "0.4.12" | |
| 5156 | 5226 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -76,10 +76,17 @@ csv = "1.3" | |||
| 76 | 76 | # CLI | |
| 77 | 77 | clap = { version = "4", features = ["derive"] } | |
| 78 | 78 | ||
| 79 | + | # Logging (used by sqlx slow query config) | |
| 80 | + | log = "0.4" | |
| 81 | + | ||
| 79 | 82 | # Error handling | |
| 80 | 83 | thiserror = "2.0.18" | |
| 81 | 84 | anyhow = "1.0.101" | |
| 82 | 85 | ||
| 86 | + | # Metrics | |
| 87 | + | metrics = "0.24" | |
| 88 | + | metrics-exporter-prometheus = { version = "0.18.1", default-features = false } | |
| 89 | + | ||
| 83 | 90 | # Markdown rendering + documentation engine | |
| 84 | 91 | docengine = { path = "../shared/docengine", features = ["doc-loader", "directives", "frontmatter", "media-urls"] } | |
| 85 | 92 |
| @@ -0,0 +1,73 @@ | |||
| 1 | + | # Documentation Flaws | |
| 2 | + | ||
| 3 | + | Audit of MNW public docs from the perspective of an anti-capitalist, AI-skeptical artist who needs to sell their work. | |
| 4 | + | ||
| 5 | + | Conducted 2026-04-22. Status updated as fixes were applied. | |
| 6 | + | ||
| 7 | + | --- | |
| 8 | + | ||
| 9 | + | ## Critical | |
| 10 | + | ||
| 11 | + | ### 1. AI content policy — RESOLVED | |
| 12 | + | ||
| 13 | + | Three-tier system (Handmade / Assisted / Generated) with mandatory classification at publish time. Assisted tier requires written disclosure. Fan-side filtering. Generative AI defined by training data ethics (unpaid copyright, undisclosed datasets). Full policy at `about/generative-ai.md`. FAQ, acceptable use, getting-started, items guide, and fan guide all updated to reference it. | |
| 14 | + | ||
| 15 | + | ### 2. No statement on AI in the platform itself — RESOLVED | |
| 16 | + | ||
| 17 | + | New section in `about/generative-ai.md`: no generative AI in the product, discovery is explicit (not ML), security/spam reserves the right to use best tools, platform development is LLM-assisted and disclosed in commit logs. Discovery algorithms linked directly to source files. Founder's personal note included as blockquote. | |
| 18 | + | ||
| 19 | + | --- | |
| 20 | + | ||
| 21 | + | ## High | |
| 22 | + | ||
| 23 | + | ### 3. "Creator" language everywhere — RESOLVED | |
| 24 | + | ||
| 25 | + | Landing page: "Creator tiers" → "Pricing tiers", "Every creator gets" → "Everyone gets". how-we-work.md "Who This Is For" now opens with "Artists, musicians, writers, developers, and makers." Targeted changes in audience-facing prose; kept "creator" in technical contexts (dashboard, API) where it's standard. | |
| 26 | + | ||
| 27 | + | ### 4. $10/month floor unaddressed — RESOLVED | |
| 28 | + | ||
| 29 | + | FAQ "What if I earn nothing" now acknowledges the tension honestly and links to earn-back. how-we-work.md unchanged (already had earn-back details). | |
| 30 | + | ||
| 31 | + | ### 5. "Platforms should be infrastructure, not landlords" buried — RESOLVED | |
| 32 | + | ||
| 33 | + | Added as subtagline on landing page hero, in og:description meta tag, and as closing line of story.md "What This Means in Practice" section. CSS added for `.subtagline`. | |
| 34 | + | ||
| 35 | + | --- | |
| 36 | + | ||
| 37 | + | ## Medium | |
| 38 | + | ||
| 39 | + | ### 6. Bus factor handwave — RESOLVED | |
| 40 | + | ||
| 41 | + | FAQ rewritten: acknowledges the risk directly, lists concrete mitigations (public source, export, shutdown protocol, separate funds), states hiring as top financial priority, calls the long-term goal a goal not a guarantee. | |
| 42 | + | ||
| 43 | + | ### 7. Source available vs open source blurring — RESOLVED | |
| 44 | + | ||
| 45 | + | `tech/open-source.md` now explicitly says "source-available, not open source" with explanation of why PolyForm Noncommercial was chosen. `legal/transparency.md` heading changed from "Open Source Transparency" to "Source-Available Transparency." | |
| 46 | + | ||
| 47 | + | ### 8. Platform economics not shown — RESOLVED | |
| 48 | + | ||
| 49 | + | New public page at `about/economics.md`: cost structure by category, per-creator costs and margins by tier, break-even number (36 creators), where surplus goes (wage, hiring via residency program, reserves, development), what surplus does not fund, the margin question addressed directly, "Why These Prices Won't Go Up" section (no hidden subsidy, margins widen with growth, missing cost centers, hosting trends down). Linked from how-we-work, story, and FAQ. | |
| 50 | + | ||
| 51 | + | ### 9. Cooperative ownership unexplored — RESOLVED (partially) | |
| 52 | + | ||
| 53 | + | FAQ and economics page now have honest framing: hard commitment that the company will never be sold to anyone other than its creator community, honest acknowledgment that the legal structure isn't figured out yet, commitment to figure it out with the community when the time comes. Deliberately not over-promising on co-op structure before legal advice. | |
| 54 | + | ||
| 55 | + | --- | |
| 56 | + | ||
| 57 | + | ## Low | |
| 58 | + | ||
| 59 | + | ### 10. Startup-flavored prose — RESOLVED | |
| 60 | + | ||
| 61 | + | Tonal pass across story.md, guarantees.md, how-we-work.md, faq.md, moderation.md. Pattern: replaced self-praise ("this isn't marketing, it's accountability") with action ("you can check"), replaced declarations of intent ("we believe", "our commitment") with descriptions of how things work, replaced sales language ("no catch") with incentive explanations. SLA intro shortened from three lines to one. | |
| 62 | + | ||
| 63 | + | ### 11. Missing artist stories / social proof — DEFERRED | |
| 64 | + | ||
| 65 | + | No testimonials possible during alpha. Will revisit once there are real creators to feature. Goal is honest stories, not marketing fluff. | |
| 66 | + | ||
| 67 | + | ### 12. Enforcement is one person — RESOLVED | |
| 68 | + | ||
| 69 | + | Moderation page now opens with "Current Limitations" section acknowledging one-person enforcement, linking to SLA planned guarantees for independent appeals, and stating hiring priority. "We'd rather be honest about this than pretend we have a trust and safety team." | |
| 70 | + | ||
| 71 | + | ### 13. Community features buried — RESOLVED | |
| 72 | + | ||
| 73 | + | Multithreaded referenced in: how-we-work.md (new "Community" section), roadmap.md (added to "What's Built"), getting-started.md (added to "Your First Week" checklist). Forums page already existed at `support/forums.md` but was orphaned from the main docs flow. |
| @@ -0,0 +1,17 @@ | |||
| 1 | + | -- Idempotency key storage for safe POST retries. | |
| 2 | + | -- Clients send an Idempotency-Key header; the server caches the response | |
| 3 | + | -- body and status code for 24 hours so duplicate requests return the same result. | |
| 4 | + | ||
| 5 | + | CREATE TABLE idempotency_keys ( | |
| 6 | + | key TEXT NOT NULL, | |
| 7 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 8 | + | method TEXT NOT NULL, | |
| 9 | + | path TEXT NOT NULL, | |
| 10 | + | status_code SMALLINT NOT NULL, | |
| 11 | + | response_body TEXT NOT NULL, | |
| 12 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 13 | + | PRIMARY KEY (key, user_id) | |
| 14 | + | ); | |
| 15 | + | ||
| 16 | + | -- Index for cleanup job (expire keys older than 24h) | |
| 17 | + | CREATE INDEX idx_idempotency_keys_created_at ON idempotency_keys (created_at); |
| @@ -0,0 +1,17 @@ | |||
| 1 | + | -- Webhook event retry queue. | |
| 2 | + | -- Failed webhook events are persisted and retried with exponential backoff. | |
| 3 | + | ||
| 4 | + | CREATE TABLE webhook_events ( | |
| 5 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 6 | + | source TEXT NOT NULL, -- 'stripe' or 'postmark' | |
| 7 | + | event_type TEXT NOT NULL, -- e.g. 'checkout.session.completed' | |
| 8 | + | payload TEXT NOT NULL, -- raw JSON body | |
| 9 | + | signature TEXT, -- original signature header (for re-verification) | |
| 10 | + | status TEXT NOT NULL DEFAULT 'failed' CHECK (status IN ('failed', 'retrying', 'dead')), | |
| 11 | + | attempts INT NOT NULL DEFAULT 0, | |
| 12 | + | last_error TEXT, | |
| 13 | + | next_retry_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '60 seconds', | |
| 14 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| 15 | + | ); | |
| 16 | + | ||
| 17 | + | CREATE INDEX idx_webhook_events_retry ON webhook_events (next_retry_at) WHERE status IN ('failed', 'retrying'); |
| @@ -62,7 +62,8 @@ Items are individual pieces of content inside a project. | |||
| 62 | 62 | | **Digital** | Software, plugins, files, images | | |
| 63 | 63 | ||
| 64 | 64 | 4. Set a title and price (free, fixed, or pay-what-you-want) | |
| 65 | - | 5. Upload your content or write your text | |
| 65 | + | 5. Choose your [generative AI tier](../about/generative-ai.md): Handmade, Assisted (with disclosure), or Generated | |
| 66 | + | 6. Upload your content or write your text | |
| 66 | 67 | ||
| 67 | 68 | ## Publish | |
| 68 | 69 | ||
| @@ -76,7 +77,7 @@ Items are individual pieces of content inside a project. | |||
| 76 | 77 | - [ ] Waitlist application submitted (or creator access granted) | |
| 77 | 78 | - [ ] Payment account connected | |
| 78 | 79 | - [ ] First project created with title, slug, and category | |
| 79 | - | - [ ] First item created with content uploaded | |
| 80 | + | - [ ] First item created with content uploaded and AI tier declared | |
| 80 | 81 | - [ ] Item and project published | |
| 81 | 82 | ||
| 82 | 83 | ## What Kind of Creator Are You? | |
| @@ -130,6 +131,7 @@ After your first publish, here's what to focus on: | |||
| 130 | 131 | 3. **Share your link.** Post your profile URL or project URL wherever your audience is. | |
| 131 | 132 | 4. **Set up RSS cross-posting.** Connect your RSS feed to social media or newsletter tools. See [RSS](./rss.md). | |
| 132 | 133 | 5. **Fill in metadata.** Good titles, descriptions, tags, and cover art make your content discoverable and shareable. See [Metadata](./metadata.md). | |
| 134 | + | 6. **Join the forum.** Say hello at [forums.makenot.work](https://forums.makenot.work). It's where platform feedback, feature requests, and creator-to-creator discussion happen. | |
| 133 | 135 | ||
| 134 | 136 | ## See Also | |
| 135 | 137 |
| @@ -8,6 +8,8 @@ When you buy something here, the creator gets almost everything you paid. The pl | |||
| 8 | 8 | ||
| 9 | 9 | There's no algorithm deciding what you see. No ads. No tracking cookies following you around the internet. You find creators through search, links, or recommendations — the same way you find anything worth finding. | |
| 10 | 10 | ||
| 11 | + | Every item on the platform is classified as Handmade, Assisted, or Generated based on generative AI use. You can filter to see only Handmade content, only human-led work, or everything. See the [Generative AI Policy](../about/generative-ai.md) for what these tiers mean. | |
| 12 | + | ||
| 11 | 13 | Your purchases are permanent and downloadable. No DRM, no streaming-only restrictions, no region locks. | |
| 12 | 14 | ||
| 13 | 15 | ## How Payments Work |
| @@ -39,6 +39,7 @@ From the item settings, update: | |||
| 39 | 39 | - **Cover image**: Displayed on the item card and in social previews | |
| 40 | 40 | - **Release date**: When it was or will be released | |
| 41 | 41 | - **Credits**: Collaborators, producers, engineers | |
| 42 | + | - **AI tier**: Handmade, Assisted (with disclosure statement), or Generated — required before publishing. See [Generative AI Policy](../about/generative-ai.md) | |
| 42 | 43 | ||
| 43 | 44 | ## Item URLs | |
| 44 | 45 |
| @@ -44,7 +44,7 @@ Undisclosed paid advertisement is a serious moderation issue. Presenting sponsor | |||
| 44 | 44 | ### Content That Harms the Platform | |
| 45 | 45 | ||
| 46 | 46 | - **Spam** - Automated posting, fake engagement, or promotional flooding | |
| 47 | - | - **Fraud** - Scams, deceptive schemes, or financial manipulation | |
| 47 | + | - **Fraud** - Scams, deceptive schemes, or financial manipulation — including misrepresenting your [generative AI tier](../about/generative-ai.md) (e.g., claiming Handmade when generative AI was used) | |
| 48 | 48 | - **Impersonation** - Pretending to be someone else to deceive | |
| 49 | 49 | - **Malware and harmful software** - Including but not limited to: | |
| 50 | 50 | - Uploading software that contains malware, spyware, or backdoors | |
| @@ -117,6 +117,7 @@ We review all reports. We don't disclose reporter identities to the reported use | |||
| 117 | 117 | ||
| 118 | 118 | ## See Also | |
| 119 | 119 | ||
| 120 | + | - [Generative AI Policy](../about/generative-ai.md) — AI tier definitions and disclosure requirements | |
| 120 | 121 | - [Terms of Service](./terms-of-service.md) — Full legal terms | |
| 121 | 122 | - [Privacy Policy](./privacy-policy.md) — Data collection and handling | |
| 122 | 123 | - [FAQ](../support/faq.md) — Content policy questions |
| @@ -83,7 +83,7 @@ We'll begin publishing transparency reports once we have enough activity to make | |||
| 83 | 83 | ||
| 84 | 84 | --- | |
| 85 | 85 | ||
| 86 | - | ## Open Source Transparency | |
| 86 | + | ## Source-Available Transparency | |
| 87 | 87 | ||
| 88 | 88 | Beyond reports, our moderation approach is transparent because: | |
| 89 | 89 |
| @@ -24,7 +24,9 @@ Available for review: | |||
| 24 | 24 | ||
| 25 | 25 | ## License | |
| 26 | 26 | ||
| 27 | - | PolyForm Noncommercial 1.0.0. You can read, review, and use the code for any noncommercial purpose. Commercial use requires a separate license from Make Creative, LLC. | |
| 27 | + | PolyForm Noncommercial 1.0.0. This is **source-available, not open source** — an important distinction. You can read, review, learn from, and use the code for any noncommercial purpose. You cannot fork it and run a competing commercial service. Commercial use requires a separate license from Make Creative, LLC. | |
| 28 | + | ||
| 29 | + | We chose this license deliberately. Full open source (MIT, GPL, etc.) would let a well-funded company clone the platform, add a percentage cut, and outspend us on marketing. Source-available gives you the transparency and auditability that matter — verifying our claims, reading our code, holding us accountable — without giving venture capital a free blueprint. | |
| 28 | 30 | ||
| 29 | 31 | ## See Also | |
| 30 | 32 |
| @@ -147,6 +147,10 @@ impl FromRequestParts<crate::AppState> for AuthUser { | |||
| 147 | 147 | } | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | + | // Record user_id in the current span so all downstream logs | |
| 151 | + | // (DB queries, error handlers, etc.) include it automatically. | |
| 152 | + | tracing::Span::current().record("user_id", tracing::field::display(&user.id)); | |
| 153 | + | ||
| 150 | 154 | Ok(AuthUser(user)) | |
| 151 | 155 | } | |
| 152 | 156 | } |
| @@ -767,7 +767,7 @@ async fn exec_git_operation( | |||
| 767 | 767 | } | |
| 768 | 768 | } | |
| 769 | 769 | GitOperation::UploadPack | GitOperation::Archive => { | |
| 770 | - | if repo.visibility == "private" && user_id != owner_user.id { | |
| 770 | + | if repo.visibility == db::Visibility::Private && user_id != owner_user.id { | |
| 771 | 771 | anyhow::bail!("repository not found"); | |
| 772 | 772 | } | |
| 773 | 773 | } | |
| @@ -835,7 +835,7 @@ enum ManagementCommand { | |||
| 835 | 835 | RepoList, | |
| 836 | 836 | RepoInfo { name: String }, | |
| 837 | 837 | RepoDelete { name: String }, | |
| 838 | - | RepoSetVisibility { name: String, visibility: String }, | |
| 838 | + | RepoSetVisibility { name: String, visibility: db::Visibility }, | |
| 839 | 839 | RepoSetDescription { name: String, description: String }, | |
| 840 | 840 | KeyList, | |
| 841 | 841 | KeyRemove { fingerprint: String }, | |
| @@ -882,13 +882,11 @@ fn parse_management_command(tokens: &[String]) -> anyhow::Result<ManagementComma | |||
| 882 | 882 | ["repo", "delete", name, "--confirm"] => Ok(ManagementCommand::RepoDelete { name: name.to_string() }), | |
| 883 | 883 | ["repo", "delete", _, ..] => anyhow::bail!("repo delete requires --confirm flag"), | |
| 884 | 884 | ["repo", "set-visibility", name, vis] => { | |
| 885 | - | match *vis { | |
| 886 | - | "public" | "private" | "unlisted" => {} | |
| 887 | - | _ => anyhow::bail!("visibility must be public, private, or unlisted"), | |
| 888 | - | } | |
| 885 | + | let visibility: db::Visibility = vis.parse() | |
| 886 | + | .map_err(|_| anyhow::anyhow!("visibility must be public, private, or unlisted"))?; | |
| 889 | 887 | Ok(ManagementCommand::RepoSetVisibility { | |
| 890 | 888 | name: name.to_string(), | |
| 891 | - | visibility: vis.to_string(), | |
| 889 | + | visibility, | |
| 892 | 890 | }) | |
| 893 | 891 | } | |
| 894 | 892 | ["repo", "set-description", name, desc] => Ok(ManagementCommand::RepoSetDescription { | |
| @@ -915,7 +913,7 @@ async fn exec_management_command( | |||
| 915 | 913 | ManagementCommand::RepoInfo { name } => cmd_ssh_repo_info(pool, user_id, &name).await, | |
| 916 | 914 | ManagementCommand::RepoDelete { name } => cmd_ssh_repo_delete(pool, user_id, username, &name).await, | |
| 917 | 915 | ManagementCommand::RepoSetVisibility { name, visibility } => { | |
| 918 | - | cmd_ssh_repo_set_visibility(pool, user_id, &name, &visibility).await | |
| 916 | + | cmd_ssh_repo_set_visibility(pool, user_id, &name, visibility).await | |
| 919 | 917 | } | |
| 920 | 918 | ManagementCommand::RepoSetDescription { name, description } => { | |
| 921 | 919 | cmd_ssh_repo_set_description(pool, user_id, &name, &description).await | |
| @@ -999,7 +997,7 @@ async fn cmd_ssh_repo_set_visibility( | |||
| 999 | 997 | pool: &PgPool, | |
| 1000 | 998 | user_id: db::UserId, | |
| 1001 | 999 | name: &str, | |
| 1002 | - | visibility: &str, | |
| 1000 | + | visibility: db::Visibility, | |
| 1003 | 1001 | ) -> anyhow::Result<()> { | |
| 1004 | 1002 | let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name) | |
| 1005 | 1003 | .await? | |
| @@ -1021,7 +1019,7 @@ async fn cmd_ssh_repo_set_description( | |||
| 1021 | 1019 | .await? | |
| 1022 | 1020 | .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?; | |
| 1023 | 1021 | ||
| 1024 | - | db::git_repos::update_repo_settings(pool, repo.id, description, &repo.visibility).await?; | |
| 1022 | + | db::git_repos::update_repo_settings(pool, repo.id, description, repo.visibility).await?; | |
| 1025 | 1023 | ||
| 1026 | 1024 | println!("Updated description of '{}'.", name); | |
| 1027 | 1025 | Ok(()) | |
| @@ -1192,7 +1190,7 @@ mod tests { | |||
| 1192 | 1190 | let tokens: Vec<String> = vec!["repo".into(), "set-visibility".into(), "myrepo".into(), "private".into()]; | |
| 1193 | 1191 | assert_eq!( | |
| 1194 | 1192 | parse_management_command(&tokens).unwrap(), | |
| 1195 | - | ManagementCommand::RepoSetVisibility { name: "myrepo".into(), visibility: "private".into() }, | |
| 1193 | + | ManagementCommand::RepoSetVisibility { name: "myrepo".into(), visibility: db::Visibility::Private }, | |
| 1196 | 1194 | ); | |
| 1197 | 1195 | } | |
| 1198 | 1196 |
| @@ -6,7 +6,12 @@ | |||
| 6 | 6 | ||
| 7 | 7 | // -- Database -- | |
| 8 | 8 | pub const DB_POOL_MAX_CONNECTIONS: u32 = 25; | |
| 9 | + | pub const DB_POOL_MIN_CONNECTIONS: u32 = 2; | |
| 9 | 10 | pub const DB_ACQUIRE_TIMEOUT_SECS: u64 = 3; | |
| 11 | + | /// Rotate connections after 30 minutes to prevent stale sessions. | |
| 12 | + | pub const DB_MAX_LIFETIME_SECS: u64 = 1800; | |
| 13 | + | /// Prune idle connections after 10 minutes. | |
| 14 | + | pub const DB_IDLE_TIMEOUT_SECS: u64 = 600; | |
| 10 | 15 | ||
| 11 | 16 | // -- Sessions -- | |
| 12 | 17 | pub const SESSION_EXPIRY_DAYS: i64 = 7; |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | use chrono::{DateTime, Utc}; | |
| 4 | 4 | use sqlx::PgPool; | |
| 5 | 5 | ||
| 6 | - | use super::enums::CreatorTier; | |
| 6 | + | use super::enums::{CreatorTier, SubscriptionStatus}; | |
| 7 | 7 | use super::id_types::*; | |
| 8 | 8 | use super::models::{DbCreatorSubscription, StorageBreakdown}; | |
| 9 | 9 | use crate::error::{AppError, Result}; | |
| @@ -93,7 +93,7 @@ pub async fn get_active_creator_tier( | |||
| 93 | 93 | pub async fn update_creator_sub_status<'e>( | |
| 94 | 94 | executor: impl sqlx::PgExecutor<'e>, | |
| 95 | 95 | stripe_subscription_id: &str, | |
| 96 | - | status: &str, | |
| 96 | + | status: SubscriptionStatus, | |
| 97 | 97 | ) -> Result<Option<DbCreatorSubscription>> { | |
| 98 | 98 | let sub = sqlx::query_as::<_, DbCreatorSubscription>( | |
| 99 | 99 | r#" |
| @@ -61,6 +61,25 @@ macro_rules! impl_str_enum { | |||
| 61 | 61 | Ok(s.parse::<Self>()?) | |
| 62 | 62 | } | |
| 63 | 63 | } | |
| 64 | + | ||
| 65 | + | // Allow comparison with string slices (useful in Askama templates). | |
| 66 | + | impl PartialEq<&str> for $enum_name { | |
| 67 | + | fn eq(&self, other: &&str) -> bool { | |
| 68 | + | let s: &str = match self { | |
| 69 | + | $( Self::$variant => $str, )+ | |
| 70 | + | }; | |
| 71 | + | s == *other | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | impl PartialEq<str> for $enum_name { | |
| 76 | + | fn eq(&self, other: &str) -> bool { | |
| 77 | + | let s: &str = match self { | |
| 78 | + | $( Self::$variant => $str, )+ | |
| 79 | + | }; | |
| 80 | + | s == other | |
| 81 | + | } | |
| 82 | + | } | |
| 64 | 83 | }; | |
| 65 | 84 | } | |
| 66 | 85 | ||
| @@ -179,6 +198,36 @@ impl_str_enum!(SubscriptionStatus { | |||
| 179 | 198 | Unpaid => "unpaid", | |
| 180 | 199 | }); | |
| 181 | 200 | ||
| 201 | + | // ── Git repository visibility ── | |
| 202 | + | ||
| 203 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 204 | + | #[serde(rename_all = "lowercase")] | |
| 205 | + | pub enum Visibility { | |
| 206 | + | Public, | |
| 207 | + | Unlisted, | |
| 208 | + | Private, | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | impl_str_enum!(Visibility { | |
| 212 | + | Public => "public", | |
| 213 | + | Unlisted => "unlisted", | |
| 214 | + | Private => "private", | |
| 215 | + | }); | |
| 216 | + | ||
| 217 | + | // ── Project member roles ── | |
| 218 | + | ||
| 219 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 220 | + | #[serde(rename_all = "lowercase")] | |
| 221 | + | pub enum ProjectRole { | |
| 222 | + | Owner, | |
| 223 | + | Member, | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | impl_str_enum!(ProjectRole { | |
| 227 | + | Owner => "owner", | |
| 228 | + | Member => "member", | |
| 229 | + | }); | |
| 230 | + | ||
| 182 | 231 | // ── SyncKit ── | |
| 183 | 232 | ||
| 184 | 233 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | use chrono::{DateTime, Utc}; | |
| 4 | 4 | use sqlx::PgPool; | |
| 5 | 5 | ||
| 6 | + | use super::enums::SubscriptionStatus; | |
| 6 | 7 | use super::id_types::*; | |
| 7 | 8 | use super::models::DbFanPlusSubscription; | |
| 8 | 9 | use crate::error::Result; | |
| @@ -56,7 +57,7 @@ pub async fn get_fan_plus_by_stripe_id( | |||
| 56 | 57 | pub async fn update_fan_plus_status<'e>( | |
| 57 | 58 | executor: impl sqlx::PgExecutor<'e>, | |
| 58 | 59 | stripe_subscription_id: &str, | |
| 59 | - | status: &str, | |
| 60 | + | status: SubscriptionStatus, | |
| 60 | 61 | ) -> Result<Option<DbFanPlusSubscription>> { | |
| 61 | 62 | let sub = sqlx::query_as::<_, DbFanPlusSubscription>( | |
| 62 | 63 | r#" |
| @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; | |||
| 4 | 4 | use sqlx::{FromRow, PgPool}; | |
| 5 | 5 | ||
| 6 | 6 | use super::models::DbGitRepo; | |
| 7 | - | use super::{GitRepoId, ProjectId, UserId}; | |
| 7 | + | use super::{GitRepoId, ProjectId, UserId, Visibility}; | |
| 8 | 8 | use crate::error::Result; | |
| 9 | 9 | ||
| 10 | 10 | /// A public repo joined with its owner's username, for the explore page. | |
| @@ -40,7 +40,7 @@ pub async fn create_repo_with_visibility( | |||
| 40 | 40 | pool: &PgPool, | |
| 41 | 41 | user_id: UserId, | |
| 42 | 42 | name: &str, | |
| 43 | - | visibility: &str, | |
| 43 | + | visibility: Visibility, | |
| 44 | 44 | ) -> Result<DbGitRepo> { | |
| 45 | 45 | let repo = sqlx::query_as::<_, DbGitRepo>( | |
| 46 | 46 | r#" | |
| @@ -163,7 +163,7 @@ pub async fn unlink_repo_from_project(pool: &PgPool, repo_id: GitRepoId) -> Resu | |||
| 163 | 163 | pub async fn update_visibility( | |
| 164 | 164 | pool: &PgPool, | |
| 165 | 165 | repo_id: GitRepoId, | |
| 166 | - | visibility: &str, | |
| 166 | + | visibility: Visibility, | |
| 167 | 167 | ) -> Result<()> { | |
| 168 | 168 | sqlx::query("UPDATE git_repos SET visibility = $2 WHERE id = $1") | |
| 169 | 169 | .bind(repo_id) | |
| @@ -180,7 +180,7 @@ pub async fn update_repo_settings( | |||
| 180 | 180 | pool: &PgPool, | |
| 181 | 181 | repo_id: GitRepoId, | |
| 182 | 182 | description: &str, | |
| 183 | - | visibility: &str, | |
| 183 | + | visibility: Visibility, | |
| 184 | 184 | ) -> Result<()> { | |
| 185 | 185 | sqlx::query("UPDATE git_repos SET description = $2, visibility = $3 WHERE id = $1") | |
| 186 | 186 | .bind(repo_id) |
| @@ -0,0 +1,71 @@ | |||
| 1 | + | //! Idempotency key storage for safe POST retries. | |
| 2 | + | ||
| 3 | + | use sqlx::PgPool; | |
| 4 | + | use crate::db::UserId; | |
| 5 | + | use crate::error::Result; | |
| 6 | + | ||
| 7 | + | /// A cached idempotency response. | |
| 8 | + | #[derive(sqlx::FromRow)] | |
| 9 | + | pub struct CachedResponse { | |
| 10 | + | pub status_code: i16, | |
| 11 | + | pub response_body: String, | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | /// Look up a cached response for an idempotency key. | |
| 15 | + | #[tracing::instrument(skip_all)] | |
| 16 | + | pub async fn get_cached_response( | |
| 17 | + | pool: &PgPool, | |
| 18 | + | key: &str, | |
| 19 | + | user_id: UserId, | |
| 20 | + | ) -> Result<Option<CachedResponse>> { | |
| 21 | + | let row = sqlx::query_as::<_, CachedResponse>( | |
| 22 | + | "SELECT status_code, response_body FROM idempotency_keys WHERE key = $1 AND user_id = $2", | |
| 23 | + | ) | |
| 24 | + | .bind(key) | |
| 25 | + | .bind(user_id) | |
| 26 | + | .fetch_optional(pool) | |
| 27 | + | .await?; | |
| 28 | + | ||
| 29 | + | Ok(row) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Store a response for an idempotency key. Uses ON CONFLICT to handle | |
| 33 | + | /// race conditions (first writer wins). | |
| 34 | + | #[tracing::instrument(skip_all)] | |
| 35 | + | pub async fn store_response( | |
| 36 | + | pool: &PgPool, | |
| 37 | + | key: &str, | |
| 38 | + | user_id: UserId, | |
| 39 | + | method: &str, | |
| 40 | + | path: &str, | |
| 41 | + | status_code: u16, | |
| 42 | + | response_body: &str, | |
| 43 | + | ) -> Result<()> { | |
| 44 | + | sqlx::query( | |
| 45 | + | r#"INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body) | |
| 46 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 47 | + | ON CONFLICT (key, user_id) DO NOTHING"#, | |
| 48 | + | ) | |
| 49 | + | .bind(key) | |
| 50 | + | .bind(user_id) | |
| 51 | + | .bind(method) | |
| 52 | + | .bind(path) | |
| 53 | + | .bind(status_code as i16) | |
| 54 | + | .bind(response_body) | |
| 55 | + | .execute(pool) | |
| 56 | + | .await?; | |
| 57 | + | ||
| 58 | + | Ok(()) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Delete expired idempotency keys (older than 24 hours). | |
| 62 | + | #[tracing::instrument(skip_all)] | |
| 63 | + | pub async fn cleanup_expired(pool: &PgPool) -> Result<u64> { | |
| 64 | + | let result = sqlx::query( | |
| 65 | + | "DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'", | |
| 66 | + | ) | |
| 67 | + | .execute(pool) | |
| 68 | + | .await?; | |
| 69 | + | ||
| 70 | + | Ok(result.rows_affected()) | |
| 71 | + | } |
| @@ -58,6 +58,8 @@ pub(crate) mod imports; | |||
| 58 | 58 | pub(crate) mod media_files; | |
| 59 | 59 | pub(crate) mod tips; | |
| 60 | 60 | pub(crate) mod project_members; | |
| 61 | + | pub(crate) mod idempotency; | |
| 62 | + | pub(crate) mod webhook_events; | |
| 61 | 63 | ||
| 62 | 64 | pub use id_types::*; | |
| 63 | 65 | pub use validated_types::*; |