mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
RAHHHHHHH
This commit is contained in:
parent
298c5bceae
commit
91242b73bf
|
|
@ -11,7 +11,7 @@ const AppDataSource = new DataSource({
|
|||
password: config.database.password,
|
||||
database: config.database.database,
|
||||
synchronize: true,
|
||||
entities: ["./entities/*.ts"],
|
||||
entities: [process.cwd() + "/database/entities/*.ts"],
|
||||
});
|
||||
|
||||
export { AppDataSource };
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ export class Application extends BaseEntity {
|
|||
})
|
||||
vapid_key!: string | null;
|
||||
|
||||
@Column("varchar")
|
||||
client_id!: string;
|
||||
|
||||
@Column("varchar")
|
||||
secret!: string;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from "typeorm";
|
||||
import { User } from "./User";
|
||||
import { Status } from "./Status";
|
||||
|
||||
/**
|
||||
* Stores an ActivityPub Like event
|
||||
*/
|
||||
@Entity({
|
||||
name: "favourites",
|
||||
})
|
||||
export class Favourite extends BaseEntity {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => User, user => user.id)
|
||||
actor!: User;
|
||||
|
||||
@ManyToOne(() => Status, status => status.id)
|
||||
object!: Status;
|
||||
|
||||
@Column("datetime")
|
||||
published!: Date;
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from "typeorm";
|
||||
import { User } from "./User";
|
||||
import { Status } from "./Status";
|
||||
|
||||
/**
|
||||
* Stores an ActivityPub Renote event
|
||||
*/
|
||||
@Entity({
|
||||
name: "renotes",
|
||||
})
|
||||
export class Renote extends BaseEntity {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => User, user => user.id)
|
||||
actor!: User;
|
||||
|
||||
@ManyToOne(() => Status, status => status.id)
|
||||
object!: Status;
|
||||
|
||||
@Column("datetime")
|
||||
published!: Date;
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ import { APIStatus } from "~types/entities/status";
|
|||
import { User } from "./User";
|
||||
import { Application } from "./Application";
|
||||
import { Emoji } from "./Emoji";
|
||||
import { Favourite } from "./Favourite";
|
||||
import { RawActivity } from "./RawActivity";
|
||||
|
||||
const config = getConfig();
|
||||
|
|
@ -70,20 +69,10 @@ export class Status extends BaseEntity {
|
|||
emojis!: Emoji[];
|
||||
|
||||
@ManyToMany(() => RawActivity, activity => activity.id, {})
|
||||
likes: RawActivity[] = [];
|
||||
likes!: RawActivity[];
|
||||
|
||||
@ManyToMany(() => RawActivity, activity => activity.id, {})
|
||||
announces: RawActivity[] = [];
|
||||
|
||||
async getFavourites(): Promise<Favourite[]> {
|
||||
return Favourite.find({
|
||||
where: {
|
||||
object: {
|
||||
id: this.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
announces!: RawActivity[];
|
||||
|
||||
async toAPI(): Promise<APIStatus> {
|
||||
return {
|
||||
|
|
@ -95,7 +84,7 @@ export class Status extends BaseEntity {
|
|||
this.emojis.map(async emoji => await emoji.toAPI())
|
||||
),
|
||||
favourited: false,
|
||||
favourites_count: (await this.getFavourites()).length,
|
||||
favourites_count: 0,
|
||||
id: this.id,
|
||||
in_reply_to_account_id: null,
|
||||
in_reply_to_id: null,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { User } from "./User";
|
|||
import { Application } from "./Application";
|
||||
|
||||
export enum TokenType {
|
||||
BEARER = "bearer",
|
||||
BEARER = "Bearer",
|
||||
}
|
||||
|
||||
@Entity({
|
||||
|
|
@ -21,7 +21,7 @@ export class Token extends BaseEntity {
|
|||
id!: string;
|
||||
|
||||
@Column("varchar")
|
||||
token_type!: TokenType;
|
||||
token_type: TokenType = TokenType.BEARER;
|
||||
|
||||
@Column("varchar")
|
||||
scope!: string;
|
||||
|
|
@ -29,6 +29,9 @@ export class Token extends BaseEntity {
|
|||
@Column("varchar")
|
||||
access_token!: string;
|
||||
|
||||
@Column("varchar")
|
||||
code!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at!: Date;
|
||||
|
||||
|
|
|
|||
5
index.ts
5
index.ts
|
|
@ -1,5 +1,6 @@
|
|||
import { getConfig } from "@config";
|
||||
import "reflect-metadata";
|
||||
import { AppDataSource } from "~database/datasource";
|
||||
|
||||
const router = new Bun.FileSystemRouter({
|
||||
style: "nextjs",
|
||||
|
|
@ -10,9 +11,11 @@ console.log("[+] Starting FediProject...");
|
|||
|
||||
const config = getConfig();
|
||||
|
||||
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
|
||||
|
||||
Bun.serve({
|
||||
port: config.http.port,
|
||||
hostname: "0.0.0.0", // defaults to "0.0.0.0"
|
||||
hostname: config.http.base_url || "0.0.0.0", // defaults to "0.0.0.0"
|
||||
async fetch(req) {
|
||||
const matchedRoute = router.match(req);
|
||||
|
||||
|
|
|
|||
13
pages/login.html
Normal file
13
pages/login.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>Login with FediProject</title>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<form method="post" action="{{URL}}">
|
||||
<input type="text" name="username" placeholder="Username" required />
|
||||
<input type="password" name="password" placeholder="Password" required />
|
||||
<input type="submit" value="Login" />
|
||||
</form>
|
||||
</body>
|
||||
63
server/api/auth/login/index.ts
Normal file
63
server/api/auth/login/index.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { errorResponse } from "@response";
|
||||
import { MatchedRoute } from "bun";
|
||||
import { randomBytes } from "crypto";
|
||||
import { Application } from "~database/entities/Application";
|
||||
import { Token } from "~database/entities/Token";
|
||||
import { User } from "~database/entities/User";
|
||||
|
||||
/**
|
||||
* OAuth Code flow
|
||||
*/
|
||||
export default async (
|
||||
req: Request,
|
||||
matchedRoute: MatchedRoute
|
||||
): Promise<Response> => {
|
||||
const scopes = matchedRoute.query.scopes.replaceAll("+", " ").split(" ");
|
||||
const redirect_uri = matchedRoute.query.redirect_uri;
|
||||
const response_type = matchedRoute.query.response_type;
|
||||
const client_id = matchedRoute.query.client_id;
|
||||
|
||||
const formData = await req.formData();
|
||||
|
||||
const username = formData.get("username")?.toString() || null;
|
||||
const password = formData.get("password")?.toString() || null;
|
||||
|
||||
if (response_type !== "code")
|
||||
return errorResponse("Invalid response type (try 'code')", 400);
|
||||
|
||||
if (!username || !password)
|
||||
return errorResponse("Missing username or password", 400);
|
||||
|
||||
// Get user
|
||||
const user = await User.findOneBy({
|
||||
username,
|
||||
});
|
||||
|
||||
if (!user || !(await Bun.password.verify(password, user.password)))
|
||||
return errorResponse("Invalid username or password", 401);
|
||||
|
||||
// Get application
|
||||
const application = await Application.findOneBy({
|
||||
client_id,
|
||||
});
|
||||
|
||||
if (!application) return errorResponse("Invalid client_id", 404);
|
||||
|
||||
const token = new Token();
|
||||
|
||||
token.access_token = randomBytes(64).toString("base64url");
|
||||
token.code = randomBytes(32).toString("hex");
|
||||
token.application = application;
|
||||
token.scope = scopes.join(" ");
|
||||
token.user = user;
|
||||
|
||||
await token.save();
|
||||
|
||||
// Redirect back to application
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `${redirect_uri}?code=${token.code}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
55
server/api/v1/apps/index.ts
Normal file
55
server/api/v1/apps/index.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { errorResponse, jsonResponse } from "@response";
|
||||
import { randomBytes } from "crypto";
|
||||
import { Application } from "~database/entities/Application";
|
||||
|
||||
/**
|
||||
* Creates a new application to obtain OAuth 2 credentials
|
||||
*/
|
||||
export default async (req: Request): Promise<Response> => {
|
||||
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 application = new Application();
|
||||
|
||||
application.name = client_name || "";
|
||||
|
||||
// Check if redirect URI is a valid URI, and also an absolute URI
|
||||
if (redirect_uris) {
|
||||
try {
|
||||
const redirect_uri = new URL(redirect_uris);
|
||||
|
||||
if (!redirect_uri.protocol.startsWith("http")) {
|
||||
return errorResponse(
|
||||
"Redirect URI must be an absolute URI",
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
application.redirect_uris = redirect_uris;
|
||||
} catch {
|
||||
return errorResponse("Redirect URI must be a valid URI", 422);
|
||||
}
|
||||
}
|
||||
|
||||
application.scopes = scopes || "read";
|
||||
application.website = website || null;
|
||||
|
||||
application.client_id = randomBytes(32).toString("base64url");
|
||||
application.secret = randomBytes(64).toString("base64url");
|
||||
|
||||
await application.save();
|
||||
|
||||
return jsonResponse({
|
||||
id: application.id,
|
||||
name: application.name,
|
||||
website: application.website,
|
||||
client_id: application.client_id,
|
||||
client_secret: application.secret,
|
||||
redirect_uri: application.redirect_uris,
|
||||
vapid_link: application.vapid_key,
|
||||
});
|
||||
};
|
||||
22
server/api/v1/oauth/authorize/index.ts
Normal file
22
server/api/v1/oauth/authorize/index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { MatchedRoute } from "bun";
|
||||
|
||||
/**
|
||||
* Returns an HTML login form
|
||||
*/
|
||||
export default async (
|
||||
req: Request,
|
||||
matchedRoute: MatchedRoute
|
||||
): Promise<Response> => {
|
||||
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",
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
43
server/api/v1/oauth/token/index.ts
Normal file
43
server/api/v1/oauth/token/index.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { errorResponse, jsonResponse } from "@response";
|
||||
import { Token } from "~database/entities/Token";
|
||||
|
||||
/**
|
||||
* Allows getting token from OAuth code
|
||||
*/
|
||||
export default async (req: Request): Promise<Response> => {
|
||||
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;
|
||||
|
||||
if (grant_type !== "authorization_code")
|
||||
return errorResponse(
|
||||
"Invalid grant type (try 'authorization_code')",
|
||||
400
|
||||
);
|
||||
|
||||
// Get associated token
|
||||
const token = await Token.findOneBy({
|
||||
code,
|
||||
application: {
|
||||
client_id,
|
||||
secret: client_secret,
|
||||
redirect_uris: redirect_uri,
|
||||
},
|
||||
scope: scope?.replaceAll("+", " "),
|
||||
});
|
||||
|
||||
if (!token)
|
||||
return errorResponse("Invalid access token or client credentials", 401);
|
||||
|
||||
return jsonResponse({
|
||||
access_token: token.access_token,
|
||||
token_type: token.token_type,
|
||||
scope: token.scope,
|
||||
created_at: token.created_at,
|
||||
});
|
||||
};
|
||||
146
tests/oauth.test.ts
Normal file
146
tests/oauth.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { getConfig } from "@config";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { AppDataSource } from "~database/datasource";
|
||||
import { Application } from "~database/entities/Application";
|
||||
import { Token } from "~database/entities/Token";
|
||||
import { User } from "~database/entities/User";
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
let client_id: string;
|
||||
let client_secret: string;
|
||||
let code: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
|
||||
|
||||
// Initialize test user
|
||||
const user = new User();
|
||||
|
||||
user.email = "test@test.com";
|
||||
user.username = "test";
|
||||
user.password = await Bun.password.hash("test");
|
||||
user.display_name = "";
|
||||
user.bio = "";
|
||||
|
||||
await user.save();
|
||||
});
|
||||
|
||||
describe("POST /v1/apps/", () => {
|
||||
test("should create an application", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("client_name", "Test Application");
|
||||
formData.append("website", "https://example.com");
|
||||
formData.append("redirect_uris", "https://example.com");
|
||||
formData.append("scopes", "read write");
|
||||
const response = await fetch(
|
||||
`${config.http.base_url}:${config.http.port}/v1/apps/`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const json = await response.json();
|
||||
|
||||
expect(json).toEqual({
|
||||
id: expect.any(String),
|
||||
name: "Test Application",
|
||||
website: "https://example.com",
|
||||
client_id: expect.any(String),
|
||||
client_secret: expect.any(String),
|
||||
redirect_uri: "https://example.com",
|
||||
vapid_link: null,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
client_id = json.client_id;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
client_secret = json.client_secret;
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /auth/login/", () => {
|
||||
test("should get a code", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("username", "test");
|
||||
formData.append("password", "test");
|
||||
const response = await fetch(
|
||||
`${config.http.base_url}:${config.http.port}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scopes=read+write`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toMatch(
|
||||
/https:\/\/example.com\?code=/
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
code = response.headers.get("location")?.split("=")[1] || "";
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/oauth/token/", () => {
|
||||
test("should get an access token", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("grant_type", "authorization_code");
|
||||
formData.append("code", code);
|
||||
formData.append("redirect_uri", "https://example.com");
|
||||
formData.append("client_id", client_id);
|
||||
formData.append("client_secret", client_secret);
|
||||
formData.append("scope", "read write");
|
||||
|
||||
const response = await fetch(
|
||||
`${config.http.base_url}:${config.http.port}/v1/oauth/token/`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(await response.json()).toEqual({
|
||||
access_token: expect.any(String),
|
||||
token_type: "bearer",
|
||||
scope: "read write",
|
||||
created_at: expect.any(Number),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up user
|
||||
const user = await User.findOneBy({
|
||||
username: "test",
|
||||
});
|
||||
|
||||
// Clean up tokens
|
||||
const tokens = await Token.findBy({
|
||||
user: {
|
||||
username: "test",
|
||||
},
|
||||
});
|
||||
|
||||
const applications = await Application.findBy({
|
||||
client_id,
|
||||
secret: client_secret,
|
||||
});
|
||||
|
||||
await Promise.all(tokens.map(async token => await token.remove()));
|
||||
await Promise.all(
|
||||
applications.map(async application => await application.remove())
|
||||
);
|
||||
|
||||
if (user) await user.remove();
|
||||
});
|
||||
Loading…
Reference in a new issue