mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38: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,
|
password: config.database.password,
|
||||||
database: config.database.database,
|
database: config.database.database,
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
entities: ["./entities/*.ts"],
|
entities: [process.cwd() + "/database/entities/*.ts"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export { AppDataSource };
|
export { AppDataSource };
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ export class Application extends BaseEntity {
|
||||||
})
|
})
|
||||||
vapid_key!: string | null;
|
vapid_key!: string | null;
|
||||||
|
|
||||||
|
@Column("varchar")
|
||||||
|
client_id!: string;
|
||||||
|
|
||||||
@Column("varchar")
|
@Column("varchar")
|
||||||
secret!: string;
|
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 { User } from "./User";
|
||||||
import { Application } from "./Application";
|
import { Application } from "./Application";
|
||||||
import { Emoji } from "./Emoji";
|
import { Emoji } from "./Emoji";
|
||||||
import { Favourite } from "./Favourite";
|
|
||||||
import { RawActivity } from "./RawActivity";
|
import { RawActivity } from "./RawActivity";
|
||||||
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
@ -70,20 +69,10 @@ export class Status extends BaseEntity {
|
||||||
emojis!: Emoji[];
|
emojis!: Emoji[];
|
||||||
|
|
||||||
@ManyToMany(() => RawActivity, activity => activity.id, {})
|
@ManyToMany(() => RawActivity, activity => activity.id, {})
|
||||||
likes: RawActivity[] = [];
|
likes!: RawActivity[];
|
||||||
|
|
||||||
@ManyToMany(() => RawActivity, activity => activity.id, {})
|
@ManyToMany(() => RawActivity, activity => activity.id, {})
|
||||||
announces: RawActivity[] = [];
|
announces!: RawActivity[];
|
||||||
|
|
||||||
async getFavourites(): Promise<Favourite[]> {
|
|
||||||
return Favourite.find({
|
|
||||||
where: {
|
|
||||||
object: {
|
|
||||||
id: this.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async toAPI(): Promise<APIStatus> {
|
async toAPI(): Promise<APIStatus> {
|
||||||
return {
|
return {
|
||||||
|
|
@ -95,7 +84,7 @@ export class Status extends BaseEntity {
|
||||||
this.emojis.map(async emoji => await emoji.toAPI())
|
this.emojis.map(async emoji => await emoji.toAPI())
|
||||||
),
|
),
|
||||||
favourited: false,
|
favourited: false,
|
||||||
favourites_count: (await this.getFavourites()).length,
|
favourites_count: 0,
|
||||||
id: this.id,
|
id: this.id,
|
||||||
in_reply_to_account_id: null,
|
in_reply_to_account_id: null,
|
||||||
in_reply_to_id: null,
|
in_reply_to_id: null,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { User } from "./User";
|
||||||
import { Application } from "./Application";
|
import { Application } from "./Application";
|
||||||
|
|
||||||
export enum TokenType {
|
export enum TokenType {
|
||||||
BEARER = "bearer",
|
BEARER = "Bearer",
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({
|
@Entity({
|
||||||
|
|
@ -21,7 +21,7 @@ export class Token extends BaseEntity {
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@Column("varchar")
|
@Column("varchar")
|
||||||
token_type!: TokenType;
|
token_type: TokenType = TokenType.BEARER;
|
||||||
|
|
||||||
@Column("varchar")
|
@Column("varchar")
|
||||||
scope!: string;
|
scope!: string;
|
||||||
|
|
@ -29,6 +29,9 @@ export class Token extends BaseEntity {
|
||||||
@Column("varchar")
|
@Column("varchar")
|
||||||
access_token!: string;
|
access_token!: string;
|
||||||
|
|
||||||
|
@Column("varchar")
|
||||||
|
code!: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
created_at!: Date;
|
created_at!: Date;
|
||||||
|
|
||||||
|
|
|
||||||
5
index.ts
5
index.ts
|
|
@ -1,5 +1,6 @@
|
||||||
import { getConfig } from "@config";
|
import { getConfig } from "@config";
|
||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
|
import { AppDataSource } from "~database/datasource";
|
||||||
|
|
||||||
const router = new Bun.FileSystemRouter({
|
const router = new Bun.FileSystemRouter({
|
||||||
style: "nextjs",
|
style: "nextjs",
|
||||||
|
|
@ -10,9 +11,11 @@ console.log("[+] Starting FediProject...");
|
||||||
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
|
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
|
||||||
|
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
port: config.http.port,
|
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) {
|
async fetch(req) {
|
||||||
const matchedRoute = router.match(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