Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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",
Expand Down
27 changes: 25 additions & 2 deletions public/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |

Expand Down
49 changes: 48 additions & 1 deletion src/lib/spam-check.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
45 changes: 44 additions & 1 deletion src/lib/spam-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand All @@ -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 };
}
Loading