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 {};
+}