diff --git a/README.md b/README.md index e8ecc79b..4925c6f3 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,2 @@ # fedi-project -To install dependencies: - -```bash -bun install -``` - -To run: - -```bash -bun run index.ts -``` - -This project was created using `bun init` in bun v0.8.1. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/bun.lockb b/bun.lockb index b2a94eb1..e2ea5ecd 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.ts b/index.ts index 0d3cf121..058a7193 100644 --- a/index.ts +++ b/index.ts @@ -19,8 +19,6 @@ Bun.serve({ async fetch(req) { const matchedRoute = router.match(req); - console.log(req.url); - if (matchedRoute) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return (await import(matchedRoute.filePath)).default( diff --git a/package.json b/package.json index a5702f7c..8ff8b58d 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,11 @@ "module": "index.ts", "type": "module", "devDependencies": { + "@julr/unocss-preset-forms": "^0.0.5", "@types/jsonld": "^1.5.9", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", + "@unocss/cli": "^0.55.7", "activitypub-types": "^1.0.3", "bun-types": "latest", "eslint": "^8.49.0", @@ -14,7 +16,8 @@ "eslint-formatter-summary": "^1.1.0", "eslint-plugin-prettier": "^5.0.0", "prettier": "^3.0.3", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "unocss": "^0.55.7" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/pages/login.html b/pages/login.html index 0a90d7c1..03ac18e4 100644 --- a/pages/login.html +++ b/pages/login.html @@ -1,13 +1,445 @@ + -Login with FediProject - + Login with FediProject + {{STYLES}} + + -
- - - -
+
+
+
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
\ No newline at end of file diff --git a/pages/uno.css b/pages/uno.css new file mode 100644 index 00000000..4e3b1a12 --- /dev/null +++ b/pages/uno.css @@ -0,0 +1,155 @@ +/* layer: preflights */ +*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgba(0,0,0,0);--un-ring-shadow:0 0 rgba(0,0,0,0);--un-shadow-inset: ;--un-shadow:0 0 rgba(0,0,0,0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgba(0,0,0,0);--un-ring-shadow:0 0 rgba(0,0,0,0);--un-shadow-inset: ;--un-shadow:0 0 rgba(0,0,0,0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;} +[type='text'], [type='email'], [type='url'], [type='password'], [type='number'], [type='date'], [type='datetime-local'], [type='month'], [type='search'], [type='tel'], [type='time'], [type='week'], [multiple], textarea, select { appearance: none; +background-color: #fff; +border-color: #6b7280; +border-width: 1px; +border-radius: 0; +padding-top: 0.5rem; +padding-right: 0.75rem; +padding-bottom: 0.5rem; +padding-left: 0.75rem; +font-size: 1rem; +line-height: 1.5rem; +--un-shadow: 0 0 #0000; } +[type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { outline: 2px solid transparent; +outline-offset: 2px; +--un-ring-inset: var(--un-empty,/*!*/ /*!*/); +--un-ring-offset-width: 0px; +--un-ring-offset-color: #fff; +--un-ring-color: #2563eb; +--un-ring-offset-shadow: var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color); +--un-ring-shadow: var(--un-ring-inset) 0 0 0 calc(1px + var(--un-ring-offset-width)) var(--un-ring-color); +box-shadow: var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow); +border-color: #2563eb; } +input::placeholder, textarea::placeholder { color: #6b7280; +opacity: 1; } +::-webkit-datetime-edit-fields-wrapper { padding: 0; } +::-webkit-date-and-time-value { min-height: 1.5em; } +::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-top: 0; +padding-bottom: 0; } +select { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); +background-position: right 0.5rem center; +background-repeat: no-repeat; +background-size: 1.5em 1.5em; +padding-right: 2.5rem; +print-color-adjust: exact; } +[multiple] { background-image: initial; +background-position: initial; +background-repeat: unset; +background-size: initial; +padding-right: 0.75rem; +print-color-adjust: unset; } +[type='checkbox'], [type='radio'] { appearance: none; +padding: 0; +print-color-adjust: exact; +display: inline-block; +vertical-align: middle; +background-origin: border-box; +user-select: none; +flex-shrink: 0; +height: 1rem; +width: 1rem; +color: #2563eb; +background-color: #fff; +border-color: #6b7280; +border-width: 1px; +--un-shadow: 0 0 #0000; } +[type='checkbox'] { border-radius: 0; } +[type='radio'] { border-radius: 100%; } +[type='checkbox']:focus, [type='radio']:focus { outline: 2px solid transparent; +outline-offset: 2px; +--un-ring-inset: var(--un-empty,/*!*/ /*!*/); +--un-ring-offset-width: 2px; +--un-ring-offset-color: #fff; +--un-ring-color: #2563eb; +--un-ring-offset-shadow: var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color); +--un-ring-shadow: var(--un-ring-inset) 0 0 0 calc(2px + var(--un-ring-offset-width)) var(--un-ring-color); +box-shadow: var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow); } +[type='checkbox']:checked, [type='radio']:checked { border-color: transparent; +background-color: currentColor; +background-size: 100% 100%; +background-position: center; +background-repeat: no-repeat; } +[type='checkbox']:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); } +[type='radio']:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); } +[type='checkbox']:checked:hover, [type='checkbox']:checked:focus, [type='radio']:checked:hover, [type='radio']:checked:focus { border-color: transparent; +background-color: currentColor; } +[type='checkbox']:indeterminate { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); +border-color: transparent; +background-color: currentColor; +background-size: 100% 100%; +background-position: center; +background-repeat: no-repeat; } +[type='checkbox']:indeterminate:hover, [type='checkbox']:indeterminate:focus { border-color: transparent; +background-color: currentColor; } +[type='file'] { background: unset; +border-color: inherit; +border-width: 0; +border-radius: 0; +padding: 0; +font-size: unset; +line-height: inherit; } +[type='file']:focus { outline: 1px solid ButtonText , 1px auto -webkit-focus-ring-color; } +/* layer: default */ +.visible{visibility:visible;} +.relative{position:relative;} +.mt-10{margin-top:2.5rem;} +.mt-2{margin-top:0.5rem;} +.block{display:block;} +.contents{display:contents;} +.list-item{display:list-item;} +.hidden{display:none;} +.h6{height:1.5rem;} +.min-h-screen{min-height:100vh;} +.w-full{width:100%;} +.flex{display:flex;} +.flex-col{flex-direction:column;} +.table{display:table;} +.border-collapse{border-collapse:collapse;} +.transform{transform:translateX(var(--un-translate-x)) translateY(var(--un-translate-y)) translateZ(var(--un-translate-z)) rotate(var(--un-rotate)) rotateX(var(--un-rotate-x)) rotateY(var(--un-rotate-y)) rotateZ(var(--un-rotate-z)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) scaleZ(var(--un-scale-z));} +.resize{resize:both;} +.items-center{align-items:center;} +.justify-center{justify-content:center;} +.justify-between{justify-content:space-between;} +.space-y-6>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(1.5rem * var(--un-space-y-reverse));} +.border{border-width:1px;} +.border-0{border-width:0;} +.rounded-md{border-radius:0.375rem;} +.\!bg-indigo-600{--un-bg-opacity:1 !important;background-color:rgba(79,70,229,var(--un-bg-opacity)) !important;} +.hover\:\!bg-indigo-500:hover{--un-bg-opacity:1 !important;background-color:rgba(99,102,241,var(--un-bg-opacity)) !important;} +.px-3{padding-left:0.75rem;padding-right:0.75rem;} +.px-6{padding-left:1.5rem;padding-right:1.5rem;} +.py-1\.5{padding-top:0.375rem;padding-bottom:0.375rem;} +.py-12{padding-top:3rem;padding-bottom:3rem;} +.text-sm{font-size:0.875rem;line-height:1.25rem;} +.font-medium{font-weight:500;} +.font-semibold{font-weight:600;} +.leading-6{line-height:1.5rem;} +.text-gray-900{--un-text-opacity:1;color:rgba(17,24,39,var(--un-text-opacity));} +.text-white{--un-text-opacity:1;color:rgba(255,255,255,var(--un-text-opacity));} +.placeholder\:text-gray-400::placeholder{--un-text-opacity:1;color:rgba(156,163,175,var(--un-text-opacity));} +.underline{text-decoration-line:underline;} +.tab{-moz-tab-size:4;-o-tab-size:4;tab-size:4;} +.shadow-sm{--un-shadow:var(--un-shadow-inset) 0 1px 2px 0 var(--un-shadow-color, rgba(0,0,0,0.05));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);} +.focus-visible\:outline-2:focus-visible{outline-width:2px;} +.focus-visible\:outline-indigo-600:focus-visible{--un-outline-color-opacity:1;outline-color:rgba(79,70,229,var(--un-outline-color-opacity));} +.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px;} +.outline{outline-style:solid;} +.focus-visible\:outline:focus-visible{outline-style:solid;} +.ring-1{--un-ring-width:1px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);} +.focus\:ring-2:focus{--un-ring-width:2px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);} +.ring-gray-300{--un-ring-opacity:1;--un-ring-color:rgba(209,213,219,var(--un-ring-opacity));} +.focus\:ring-indigo-600:focus{--un-ring-opacity:1;--un-ring-color:rgba(79,70,229,var(--un-ring-opacity));} +.ring-inset{--un-ring-inset:inset;} +.focus\:ring-inset:focus{--un-ring-inset:inset;} +@media (min-width: 640px){ +.sm\:mx-auto{margin-left:auto;margin-right:auto;} +.sm\:max-w-sm{max-width:24rem;} +.sm\:w-full{width:100%;} +.sm\:text-sm{font-size:0.875rem;line-height:1.25rem;} +.sm\:leading-6{line-height:1.5rem;} +} +@media (min-width: 1024px){ +.lg\:px-8{padding-left:2rem;padding-right:2rem;} +} \ No newline at end of file diff --git a/server/api/[username]/inbox/index.ts b/server/api/[username]/inbox/index.ts index ff373a95..c714be9b 100644 --- a/server/api/[username]/inbox/index.ts +++ b/server/api/[username]/inbox/index.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { getConfig } from "@config"; import { errorResponse, jsonResponse } from "@response"; import { APAccept, @@ -8,6 +9,7 @@ import { APFollow, APObject, APReject, + APTombstone, APUpdate, } from "activitypub-types"; import { MatchedRoute } from "bun"; @@ -26,6 +28,8 @@ export default async ( return errorResponse("Method not allowed", 405); } + const config = getConfig(); + // Process request body const body: APActivity = await req.json(); @@ -113,7 +117,35 @@ export default async ( if (!object) return errorResponse("Object not found", 404); - await object.remove(); + const activities = await RawActivity.findBy({ + objects: { + id: (body.object as RawObject).id, + }, + }); + + if (config.activitypub.use_tombstones) { + object.data = { + ...object.data, + type: "Tombstone", + deleted: new Date(), + formerType: object.data.type, + } as APTombstone; + + await object.save(); + } else { + activities.forEach( + activity => + (activity.objects = activity.objects.filter( + o => o.id !== object.id + )) + ); + + await Promise.all( + activities.map(async activity => await activity.save()) + ); + + await object.remove(); + } break; } case "Accept" as APAccept: { diff --git a/server/api/oauth/authorize/index.ts b/server/api/oauth/authorize/index.ts new file mode 100644 index 00000000..3f395c41 --- /dev/null +++ b/server/api/oauth/authorize/index.ts @@ -0,0 +1,25 @@ +import { MatchedRoute } from "bun"; + +/** + * Returns an HTML login form + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const html = Bun.file("./pages/login.html"); + const css = Bun.file("./pages/uno.css"); + return new Response( + (await html.text()) + .replace( + "{{URL}}", + `/auth/login?redirect_uri=${matchedRoute.query.redirect_uri}&response_type=${matchedRoute.query.response_type}&client_id=${matchedRoute.query.client_id}&scopes=${matchedRoute.query.scopes}` + ) + .replace("{{STYLES}}", ``), + { + headers: { + "Content-Type": "text/html", + }, + } + ); +}; diff --git a/server/api/v1/oauth/token/index.ts b/server/api/oauth/token/index.ts similarity index 66% rename from server/api/v1/oauth/token/index.ts rename to server/api/oauth/token/index.ts index 6d00f772..dc1284cf 100644 --- a/server/api/v1/oauth/token/index.ts +++ b/server/api/oauth/token/index.ts @@ -1,3 +1,4 @@ +import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { Token } from "~database/entities/Token"; @@ -5,14 +6,15 @@ import { Token } from "~database/entities/Token"; * Allows getting token from OAuth code */ export default async (req: Request): Promise => { - const body = await req.formData(); - - const grant_type = body.get("grant_type")?.toString() || null; - const code = body.get("code")?.toString() || ""; - const redirect_uri = body.get("redirect_uri")?.toString() || ""; - const client_id = body.get("client_id")?.toString() || ""; - const client_secret = body.get("client_secret")?.toString() || ""; - const scope = body.get("scope")?.toString() || null; + const { grant_type, code, redirect_uri, client_id, client_secret, scope } = + await parseRequest<{ + grant_type: string; + code: string; + redirect_uri: string; + client_id: string; + client_secret: string; + scope: string; + }>(req); if (grant_type !== "authorization_code") return errorResponse( diff --git a/server/api/v1/accounts/index.ts b/server/api/v1/accounts/index.ts index c9f74a00..b7622ae2 100644 --- a/server/api/v1/accounts/index.ts +++ b/server/api/v1/accounts/index.ts @@ -1,4 +1,5 @@ import { getConfig } from "@config"; +import { parseRequest } from "@request"; import { jsonResponse } from "@response"; import { tempmailDomains } from "@tempmail"; import { User } from "~database/entities/User"; @@ -9,14 +10,14 @@ import { User } from "~database/entities/User"; export default async (req: Request): Promise => { // TODO: Add Authorization check - const body: { + const body = await parseRequest<{ username: string; email: string; password: string; agreement: boolean; locale: string; reason: string; - } = await req.json(); + }>(req); const config = getConfig(); @@ -63,28 +64,28 @@ export default async (req: Request): Promise => { config.validation.max_username_size; // Check if username is valid - if (!body.username.match(/^[a-zA-Z0-9_]+$/)) + if (!body.username?.match(/^[a-zA-Z0-9_]+$/)) errors.details.username.push({ error: "ERR_INVALID", description: `must only contain letters, numbers, and underscores`, }); // Check if username is too long - if (body.username.length > config.validation.max_username_size) + if ((body.username?.length ?? 0) > config.validation.max_username_size) errors.details.username.push({ error: "ERR_TOO_LONG", description: `is too long (maximum is ${config.validation.max_username_size} characters)`, }); // Check if username is too short - if (body.username.length < 3) + if ((body.username?.length ?? 0) < 3) errors.details.username.push({ error: "ERR_TOO_SHORT", description: `is too short (minimum is 3 characters)`, }); // Check if username is reserved - if (config.validation.username_blacklist.includes(body.username)) + if (config.validation.username_blacklist.includes(body.username ?? "")) errors.details.username.push({ error: "ERR_RESERVED", description: `is reserved`, @@ -99,7 +100,7 @@ export default async (req: Request): Promise => { // Check if email is valid if ( - !body.email.match( + !body.email?.match( /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ ) ) @@ -110,9 +111,9 @@ export default async (req: Request): Promise => { // Check if email is blocked if ( - config.validation.email_blacklist.includes(body.email) || + config.validation.email_blacklist.includes(body.email ?? "") || (config.validation.blacklist_tempmail && - tempmailDomains.domains.includes(body.email.split("@")[1])) + tempmailDomains.domains.includes((body.email ?? "").split("@")[1])) ) errors.details.email.push({ error: "ERR_BLOCKED", @@ -148,9 +149,9 @@ export default async (req: Request): Promise => { const newUser = new User(); - newUser.username = body.username; - newUser.email = body.email; - newUser.password = await Bun.password.hash(body.password); + newUser.username = body.username ?? ""; + newUser.email = body.email ?? ""; + newUser.password = await Bun.password.hash(body.password ?? ""); // TODO: Return access token return new Response(); diff --git a/server/api/v1/accounts/update_credentials/index.ts b/server/api/v1/accounts/update_credentials/index.ts index a303df9d..b10c5a8d 100644 --- a/server/api/v1/accounts/update_credentials/index.ts +++ b/server/api/v1/accounts/update_credentials/index.ts @@ -1,5 +1,6 @@ import { getUserByToken } from "@auth"; import { getConfig } from "@config"; +import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; /** @@ -21,16 +22,18 @@ export default async (req: Request): Promise => { if (!user) return errorResponse("Unauthorized", 401); const config = getConfig(); - const body = await req.formData(); - const display_name = body.get("display_name")?.toString() || null; - const note = body.get("note")?.toString() || null; - // Avatar is a file element - const avatar = (body.get("avatar") as File | null) || null; - const header = (body.get("header") as File | null) || null; - const locked = body.get("locked")?.toString() || null; - const bot = body.get("bot")?.toString() || null; - const discoverable = body.get("discoverable")?.toString() || null; + const { display_name, note, avatar, header, locked, bot, discoverable } = + await parseRequest<{ + display_name: string; + note: string; + avatar: File; + header: File; + locked: string; + bot: string; + discoverable: string; + }>(req); + // TODO: Implement other options like field or source // const source_privacy = body.get("source[privacy]")?.toString() || null; // const source_sensitive = body.get("source[sensitive]")?.toString() || null; diff --git a/server/api/v1/apps/index.ts b/server/api/v1/apps/index.ts index 32e282a6..8191b7f4 100644 --- a/server/api/v1/apps/index.ts +++ b/server/api/v1/apps/index.ts @@ -1,3 +1,4 @@ +import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { randomBytes } from "crypto"; import { Application } from "~database/entities/Application"; @@ -6,12 +7,12 @@ import { Application } from "~database/entities/Application"; * Creates a new application to obtain OAuth 2 credentials */ export default async (req: Request): Promise => { - const body = await req.formData(); - - const client_name = body.get("client_name")?.toString() || null; - const redirect_uris = body.get("redirect_uris")?.toString() || null; - const scopes = body.get("scopes")?.toString() || null; - const website = body.get("website")?.toString() || null; + const { client_name, redirect_uris, scopes, website } = await parseRequest<{ + client_name: string; + redirect_uris: string; + scopes: string; + website: string; + }>(req); const application = new Application(); diff --git a/server/api/v1/oauth/authorize/index.ts b/server/api/v1/oauth/authorize/index.ts deleted file mode 100644 index c2b72f31..00000000 --- a/server/api/v1/oauth/authorize/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { MatchedRoute } from "bun"; - -/** - * Returns an HTML login form - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - const html = Bun.file("./pages/login.html"); - return new Response( - (await html.text()).replace( - "{{URL}}", - `/auth/login?redirect_uri=${matchedRoute.query.redirect_uri}&response_type=${matchedRoute.query.response_type}&client_id=${matchedRoute.query.client_id}&scopes=${matchedRoute.query.scopes}` - ), - { - headers: { - "Content-Type": "text/html", - }, - } - ); -}; diff --git a/tests/inbox.test.ts b/tests/inbox.test.ts index 882dad6b..b211574e 100644 --- a/tests/inbox.test.ts +++ b/tests/inbox.test.ts @@ -150,6 +150,62 @@ describe("POST /@test/inbox", () => { published: "2021-01-01T00:00:00.000Z", }); }); + + test("should delete the Note object", async () => { + const response = await fetch( + `${config.http.base_url}:${config.http.port}/@test/inbox/`, + { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Delete", + id: "https://example.com/notes/1/activity", + actor: `${config.http.base_url}:${config.http.port}/@test`, + to: ["https://www.w3.org/ns/activitystreams#Public"], + cc: [], + published: "2021-01-03T00:00:00.000Z", + object: { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://example.com/notes/1", + type: "Note", + content: "This note has been deleted!", + summary: null, + inReplyTo: null, + published: "2021-01-01T00:00:00.000Z", + }, + }), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const activity = await RawActivity.createQueryBuilder("activity") + // Where id is part of the jsonb column 'data' + .where("activity.data->>'id' = :id", { + id: "https://example.com/notes/1/activity", + }) + .leftJoinAndSelect("activity.objects", "objects") + // Sort by most recent + .orderBy("activity.data->>'published'", "DESC") + .getOne(); + + expect(activity).not.toBeUndefined(); + expect(activity?.data).toEqual({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Delete", + id: "https://example.com/notes/1/activity", + actor: `${config.http.base_url}:${config.http.port}/@test`, + to: ["https://www.w3.org/ns/activitystreams#Public"], + cc: [], + published: "2021-01-03T00:00:00.000Z", + }); + + expect(activity?.objects).toHaveLength(0); + }); }); afterAll(async () => { diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index baf4e202..971fc2c6 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -89,7 +89,7 @@ describe("POST /auth/login/", () => { }); }); -describe("POST /v1/oauth/token/", () => { +describe("POST /oauth/token/", () => { test("should get an access token", async () => { const formData = new FormData(); @@ -101,7 +101,7 @@ describe("POST /v1/oauth/token/", () => { formData.append("scope", "read write"); const response = await fetch( - `${config.http.base_url}:${config.http.port}/v1/oauth/token/`, + `${config.http.base_url}:${config.http.port}/oauth/token/`, { method: "POST", body: formData, diff --git a/tests/test-utils.ts b/tests/test-utils.ts new file mode 100644 index 00000000..e69de29b diff --git a/uno.config.ts b/uno.config.ts new file mode 100644 index 00000000..ff472c3a --- /dev/null +++ b/uno.config.ts @@ -0,0 +1,22 @@ +import { + defineConfig, + presetUno, + presetTypography, + presetWebFonts, +} from "unocss"; +import { presetForms } from "@julr/unocss-preset-forms"; + +export default defineConfig({ + presets: [ + presetUno(), + presetTypography({ + cssExtend: { + "h1,h2,h3,h4,h5.h6": { + "font-family": "'Poppins'", + }, + }, + }), + presetWebFonts(), + presetForms(), + ], +}); diff --git a/utils/config.ts b/utils/config.ts index 903385fd..393291b6 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -8,10 +8,12 @@ export interface ConfigType { password: string; database: string; }; + http: { port: number; base_url: string; }; + validation: { max_displayname_size: number; max_bio_size: number; @@ -28,11 +30,98 @@ export interface ConfigType { email_blacklist: string[]; url_scheme_whitelist: string[]; }; + + activitypub: { + use_tombstones: boolean; + }; [key: string]: unknown; } +export const configDefaults: ConfigType = { + http: { + port: 3000, + base_url: "http://0.0.0.0", + }, + database: { + host: "localhost", + port: 5432, + username: "postgres", + password: "postgres", + database: "fediproject", + }, + validation: { + max_displayname_size: 50, + max_bio_size: 6000, + max_note_size: 5000, + max_avatar_size: 5_000_000, + max_header_size: 5_000_000, + max_media_size: 40_000_000, + max_media_attachments: 4, + max_media_description_size: 1000, + max_username_size: 30, + + username_blacklist: [ + ".well-known", + "~", + "about", + "activities", + "api", + "auth", + "dev", + "inbox", + "internal", + "main", + "media", + "nodeinfo", + "notice", + "oauth", + "objects", + "proxy", + "push", + "registration", + "relay", + "settings", + "status", + "tag", + "users", + "web", + "search", + "mfa", + ], + + blacklist_tempmail: false, + + email_blacklist: [], + + url_scheme_whitelist: [ + "http", + "https", + "ftp", + "dat", + "dweb", + "gopher", + "hyper", + "ipfs", + "ipns", + "irc", + "xmpp", + "ircs", + "magnet", + "mailto", + "mumble", + "ssb", + ], + }, + activitypub: { + use_tombstones: true, + }, +}; + export const getConfig = () => { - return data as ConfigType; + return { + ...configDefaults, + ...(data as ConfigType), + }; }; export const getHost = () => { diff --git a/utils/request.ts b/utils/request.ts new file mode 100644 index 00000000..39692a84 --- /dev/null +++ b/utils/request.ts @@ -0,0 +1,54 @@ +/** + * Takes a request, and turns FormData or query parameters + * into a JSON object as would be returned by req.json() + * This is a translation layer that allows clients to use + * either FormData, query parameters, or JSON in the request + * @param request The request to parse + */ +export async function parseRequest(request: Request): Promise> { + const formData = await request.formData(); + const query = new URL(request.url).searchParams; + + // if request contains a JSON body + if (request.headers.get("Content-Type")?.includes("application/json")) { + return (await request.json()) as T; + } + + // If request contains FormData + if (request.headers.get("Content-Type")?.includes("multipart/form-data")) { + if ([...formData.entries()].length > 0) { + const data: Record = {}; + + for (const [key, value] of formData.entries()) { + // If object, parse as JSON + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-base-to-string + data[key] = JSON.parse(value.toString()); + } catch { + // If a file, set as a file + if (value instanceof File) { + data[key] = value; + } + + // Otherwise, set as a string + // eslint-disable-next-line @typescript-eslint/no-base-to-string + data[key] = value.toString(); + } + } + + return data as T; + } + } + + if ([...query.entries()].length > 0) { + const data: Record = {}; + + for (const [key, value] of query.entries()) { + data[key] = value.toString(); + } + + return data as T; + } + + return {}; +}