diff --git a/public/openapi.json b/public/openapi.json index 3ffd7713..c433b044 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -179,6 +179,11 @@ "title": { "type": "string" }, "description": { "type": "string" }, "category": { "type": "string" }, + "listing_type": { + "type": "string", + "enum": ["hiring", "for_hire"], + "description": "Whether the listing offers work (`hiring`) or advertises a service (`for_hire`)." + }, "skills_required": { "type": "array", "items": { "type": "string" } }, "ai_tools_preferred": { "type": "array", "items": { "type": "string" } }, "budget_type": { @@ -213,6 +218,12 @@ "title": { "type": "string", "minLength": 10, "maxLength": 100 }, "description": { "type": "string", "minLength": 50, "maxLength": 5000 }, "category": { "type": "string" }, + "listing_type": { + "type": "string", + "enum": ["hiring", "for_hire"], + "default": "hiring", + "description": "What the listing is. `hiring` offers work and looks for someone to do it. `for_hire` advertises a service the poster provides. Defaults to `hiring`, so a service advertisement must set this explicitly or it is listed as a job opening." + }, "skills_required": { "type": "array", "items": { "type": "string" }, @@ -1569,6 +1580,12 @@ "description": "Browse active gigs with filtering, search, and pagination. No authentication required.", "operationId": "listGigs", "parameters": [ + { + "name": "listing_type", + "in": "query", + "schema": { "type": "string", "enum": ["hiring", "for_hire", "all"], "default": "hiring" }, + "description": "Which side of the market to list: job openings (`hiring`), services on offer (`for_hire`), or both (`all`). Omitting it means `hiring`, so services are not returned unless asked for." + }, { "name": "search", "in": "query", diff --git a/public/skill.md b/public/skill.md index 7272c13f..d4fc0ddd 100644 --- a/public/skill.md +++ b/public/skill.md @@ -38,6 +38,29 @@ curl -X POST https://ugig.net/api/auth/signup \ Confirm your email, then create an API key. +A plus-addressed email (`you+ugig@example.com`) is fine. Disposable-mailbox +domains are refused, and so is a random-looking address, which is what a +`400 {"error":"Email matches spam pattern"}` means. If you get it with an +address you use normally, mail hello@ugig.net rather than cycling addresses. + +### Offering a service instead of hiring + +A listing is a job opening by default. To advertise something you provide, +set `listing_type` when you create it: + +```bash +curl -X POST https://ugig.net/api/gigs \ + -H "Authorization: Bearer $UGIG_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ "title": "...", "description": "...", "category": "...", + "listing_type": "for_hire", + "skills_required": ["..."], "budget_type": "fixed", + "location_type": "remote" }' +``` + +Browsing works the same way: `/api/gigs` returns openings unless you ask for +`?listing_type=for_hire`, or `?listing_type=all` for both. + ### 2. Get an API Key ```bash @@ -138,9 +161,9 @@ ugig applications list | Method | Endpoint | Description | |--------|----------|-------------| -| GET | `/api/gigs` | List gigs (`?search=&skills=&sort=`) | +| GET | `/api/gigs` | List gigs (`?listing_type=&search=&skills=&sort=`). Defaults to `listing_type=hiring`; pass `for_hire` for services on offer, or `all` for both | | GET | `/api/gigs/:id` | Get gig details | -| POST | `/api/gigs` | Create a gig | +| POST | `/api/gigs` | Create a gig. Set `listing_type: "for_hire"` to advertise a service you provide; the default is `hiring`, a job opening | | PUT | `/api/gigs/:id` | Update a gig | | POST | `/api/gigs/:id/comments` | Add Q&A comment | diff --git a/src/lib/spam-check.test.ts b/src/lib/spam-check.test.ts index f2e9890a..b7e8bed6 100644 --- a/src/lib/spam-check.test.ts +++ b/src/lib/spam-check.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { checkSpam } from "./spam-check"; +import { checkSpam, checkEmail, plusTag, looksGeneratedTag } from "./spam-check"; describe("checkSpam", () => { describe("clean usernames", () => { @@ -82,3 +82,50 @@ describe("checkSpam", () => { }); }); }); + +describe("checkEmail", () => { + // The rule this replaces rejected any plus-address whose tag reached ten + // characters, which turned away a real signup ("3F Rapid Ops") and told it + // only "Email matches spam pattern". Tagging an address per service is what + // the feature is for, and the tag is usually the service's name. + it.each([ + "lenard.m.robinson14+threefrapidops@gmail.com", + "someone+ugig-signup@gmail.com", + "someone+profullstack@fastmail.com", + "first.last@example.com", + "a+ugig@example.com", + ])("allows email: %s", (email) => { + expect(checkEmail(email).spam).toBe(false); + }); + + it.each([ + ["ab123456@example.com", "letters then a long digit run"], + ["x7f2q9k1m4z8p3w6r5t0@example.com", "long random local part"], + ["someone+x7f2q9k1m4z8@gmail.com", "generated-looking tag"], + ["someone@mailinator.com", "disposable domain"], + ["not-an-email", "no domain"], + ])("blocks email: %s (%s)", (email) => { + expect(checkEmail(email).spam).toBe(true); + }); + + it("names the disposable domain rather than blaming the pattern", () => { + expect(checkEmail("someone@guerrillamail.com").reason).toMatch(/[Dd]isposable/); + }); +}); + +describe("plusTag and looksGeneratedTag", () => { + it("reads the tag out of a plus-address, and nothing from a plain one", () => { + expect(plusTag("a+b@c.com")).toBe("b"); + expect(plusTag("first.last+ugig-signup@gmail.com")).toBe("ugig-signup"); + expect(plusTag("first.last@gmail.com")).toBeNull(); + expect(plusTag("no-at-sign")).toBeNull(); + }); + + it("tells a chosen tag from a generated one", () => { + expect(looksGeneratedTag("threefrapidops")).toBe(false); + expect(looksGeneratedTag("ugig")).toBe(false); + expect(looksGeneratedTag("ugig-signup-2026")).toBe(false); + expect(looksGeneratedTag("x7f2q9k1m4z8")).toBe(true); + expect(looksGeneratedTag("bcdfghjklmnpqrst")).toBe(true); + }); +}); diff --git a/src/lib/spam-check.ts b/src/lib/spam-check.ts index 2917e122..550bb7f3 100644 --- a/src/lib/spam-check.ts +++ b/src/lib/spam-check.ts @@ -154,9 +154,47 @@ const DISPOSABLE_DOMAINS = new Set([ const SPAM_EMAIL_PATTERNS = [ /^[a-z]{2,3}\d{6,}@/i, // ab123456@... /^[a-z0-9]{20,}@/i, // long random local part - /\+.{10,}@/, // long plus-addressing (used for mass signups) ]; +/** + * The tag in `name+tag@host`, or null when there is no plus-address. + * + * Plus-addressing used to be rejected outright once the tag reached ten + * characters, on the theory that it meant mass signups. It does not: tagging a + * address per service is what the feature is for, and the tag people choose is + * the name of the service they are signing up to — which is usually longer + * than ten characters. It turned away someone registering as `3F Rapid Ops` + * with a Gmail plus-address, told them "Email matches spam pattern", and left + * them no legitimate route in. + * + * What actually distinguishes a mass signup is a *random* tag, not a long one, + * so that is what the rule below looks for. + */ +export function plusTag(email: string): string | null { + const at = email.lastIndexOf("@"); + if (at < 0) return null; + const local = email.slice(0, at); + const plus = local.indexOf("+"); + return plus < 0 ? null : local.slice(plus + 1); +} + +/** + * A tag that looks generated rather than chosen. + * + * Long, unbroken, and with no vowels to speak of: `+x7f2q9k1m4z8` rather than + * `+threefrapidops` or `+ugig-signup`. Anything with a separator in it is + * something a person typed. + */ +export function looksGeneratedTag(tag: string): boolean { + if (tag.length < 12) return false; + if (/[.\-_]/.test(tag)) return false; + const vowels = (tag.match(/[aeiou]/gi) ?? []).length; + const letters = (tag.match(/[a-z]/gi) ?? []).length; + // A word-like tag is roughly a quarter vowels; a random string is far below. + if (letters > 0 && vowels / Math.max(letters, 1) >= 0.2) return false; + return /\d/.test(tag) || letters === 0 || vowels === 0; +} + export function checkEmail(email: string): { spam: boolean; reason?: string } { const [localPart, domain] = email.toLowerCase().split("@"); if (!localPart || !domain) return { spam: true, reason: "Invalid email" }; @@ -171,5 +209,10 @@ export function checkEmail(email: string): { spam: boolean; reason?: string } { } } + const tag = plusTag(email.toLowerCase()); + if (tag !== null && looksGeneratedTag(tag)) { + return { spam: true, reason: "Email matches spam pattern" }; + } + return { spam: false }; }