feat: initial commit

This commit is contained in:
2024-08-08 06:07:30 +07:00
commit 8f286de718
56 changed files with 6343 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import * as schema from "../models";
const DATABASE_PATH = import.meta.env.DATABASE_PATH || "./data.db";
const sqlite = new Database(DATABASE_PATH);
const db = drizzle(sqlite, { schema });
export default db;
+38
View File
@@ -0,0 +1,38 @@
CREATE TABLE `repositories` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`uri` text NOT NULL,
`language` text NOT NULL,
`stars` integer NOT NULL,
`last_update` text NOT NULL,
`languages` text,
`contributors` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`name` text NOT NULL,
`avatar` text,
`location` text,
`followers` integer DEFAULT 0 NOT NULL,
`following` integer DEFAULT 0 NOT NULL,
`achievements` text DEFAULT '[]',
`points` integer DEFAULT 0 NOT NULL,
`commits` integer DEFAULT 0 NOT NULL,
`line_of_codes` integer DEFAULT 0 NOT NULL,
`github_id` integer,
`access_token` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX `repositories_name_idx` ON `repositories` (`name`);--> statement-breakpoint
CREATE INDEX `repositories_uri_idx` ON `repositories` (`uri`);--> statement-breakpoint
CREATE INDEX `repositories_language_idx` ON `repositories` (`language`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_github_id_unique` ON `users` (`github_id`);
@@ -0,0 +1,276 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ccf7929b-8198-4452-ad60-e02285ac8149",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"repositories": {
"name": "repositories",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"uri": {
"name": "uri",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stars": {
"name": "stars",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_update": {
"name": "last_update",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"languages": {
"name": "languages",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contributors": {
"name": "contributors",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"repositories_name_idx": {
"name": "repositories_name_idx",
"columns": [
"name"
],
"isUnique": false
},
"repositories_uri_idx": {
"name": "repositories_uri_idx",
"columns": [
"uri"
],
"isUnique": false
},
"repositories_language_idx": {
"name": "repositories_language_idx",
"columns": [
"language"
],
"isUnique": false
}
},
"foreignKeys": {
"repositories_user_id_users_id_fk": {
"name": "repositories_user_id_users_id_fk",
"tableFrom": "repositories",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"followers": {
"name": "followers",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"following": {
"name": "following",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"achievements": {
"name": "achievements",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"points": {
"name": "points",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"commits": {
"name": "commits",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"line_of_codes": {
"name": "line_of_codes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"github_id": {
"name": "github_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_github_id_unique": {
"name": "users_github_id_unique",
"columns": [
"github_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1723071912146,
"tag": "0000_low_catseye",
"breakpoints": true
}
]
}
+39
View File
@@ -0,0 +1,39 @@
import { users } from "../models";
import db from ".";
import logger from "../lib/logger";
import { sql } from "drizzle-orm";
import { faker } from "@faker-js/faker";
import queue from "@server/lib/queue";
const seed = async () => {
logger.info("🌿 Seeding database...");
await db.transaction(async (tx) => {
tx.run(sql`DELETE FROM users`);
await tx
.insert(users)
.values({ username: "khairul169", name: "Khairul Hidayat" });
await tx.insert(users).values(
[...Array(50)].map(() => ({
username: faker.internet.userName(),
name: faker.person.fullName(),
location: faker.location.city(),
followers: faker.number.int({ min: 0, max: 1000 }),
following: faker.number.int({ min: 0, max: 1000 }),
points: faker.number.int({ min: 20, max: 3000 }),
commits: faker.number.int({ min: 20, max: 420 }),
lineOfCodes: faker.number.int({ min: 1000, max: 300000 }),
}))
);
});
await queue.add("fetchUserProfile", { userId: 1 });
logger.info("🌱 Database seeded");
process.exit();
};
seed();
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "drizzle-kit";
const DATABASE_PATH = process.env.DATABASE_PATH || "./data.db";
export default defineConfig({
schema: "./server/models/index.ts",
out: "./server/db/migrations",
dialect: "sqlite",
dbCredentials: { url: DATABASE_PATH },
verbose: true,
});
+84
View File
@@ -0,0 +1,84 @@
import db from "@server/db";
import { repositories, users } from "@server/models";
import { eq } from "drizzle-orm";
type CalculateUserPointsType = {
userId: number;
};
const weights = {
followers: 20,
following: 10,
achievements: 100,
repositories: 1,
contributorsAmount: 25,
stars: 10,
forks: 10,
languagesKnown: 50,
commits: 1,
lineOfCodes: 0.01,
};
export const calculateUserPoints = async (data: CalculateUserPointsType) => {
const [user] = await db.select().from(users).where(eq(users.id, data.userId));
if (!user) {
throw new Error("User not found!");
}
let points = 0;
let totalCommits = 0;
let totalLineOfCodes = 0;
// User statistics
points += user.followers * weights.followers;
points += user.following * weights.following;
points += user.achievements
? user.achievements?.length * weights.achievements
: 0;
// User repositories
const repos = await db
.select()
.from(repositories)
.where(eq(repositories.userId, user.id));
points += repos.length * weights.repositories;
// Languages known
const languages = new Set(
repos.flatMap((i) => i.languages?.map((j) => j.lang))
);
points += languages.size * weights.languagesKnown;
// Activities
repos.forEach((repo) => {
const contributors = repo.contributors?.filter(
(i) => i.author?.login !== user.username
);
points += contributors
? contributors.length * weights.contributorsAmount
: 0;
points += repo.stars * weights.stars;
points += repo.forks * weights.forks;
const contrib = repo.contributors?.find(
(i) => i.author?.login === user.username
);
const commits = contrib?.commits || 0;
const lineOfCodes = contrib?.additions || 0;
points += commits * weights.commits;
points += lineOfCodes * weights.lineOfCodes;
totalCommits += commits;
totalLineOfCodes += lineOfCodes;
});
await db
.update(users)
.set({
points: Math.round(points),
commits: totalCommits,
lineOfCodes: totalLineOfCodes,
})
.where(eq(users.id, user.id));
};
+46
View File
@@ -0,0 +1,46 @@
import db from "@server/db";
import github from "@server/lib/github";
import queue from "@server/lib/queue";
import { repositories, users } from "@server/models";
import { eq } from "drizzle-orm";
export type FetchRepoContributorsType = {
id: number;
uri: string;
};
export const fetchRepoContributors = async (
data: FetchRepoContributorsType
) => {
const [repo] = await db
.select({ id: repositories.id, userAccessToken: users.accessToken })
.from(repositories)
.innerJoin(users, eq(users.id, repositories.userId))
.where(eq(repositories.id, data.id));
if (!repo) {
throw new Error("Repository not found!");
}
if (!repo.userAccessToken) {
throw new Error("User access token not found!");
}
const contributors = await github.getRepoContributors(data.uri, {
headers: {
Authorization: `Bearer ${repo.userAccessToken}`,
},
});
const [result] = await db
.update(repositories)
.set({ contributors })
.where(eq(repositories.id, data.id))
.returning();
if (!result) {
throw new Error("Cannot update repository!");
}
await queue.add("calculateUserPoints", { userId: result.userId });
};
+26
View File
@@ -0,0 +1,26 @@
import db from "@server/db";
import github from "@server/lib/github";
import queue from "@server/lib/queue";
import { repositories } from "@server/models";
import { eq } from "drizzle-orm";
export type FetchRepoDataJobType = {
id: number;
uri: string;
};
export const fetchRepoData = async (data: FetchRepoDataJobType) => {
const details = await github.getRepoDetails(data.uri);
const [result] = await db
.update(repositories)
.set({ languages: details.languages })
.where(eq(repositories.id, data.id))
.returning();
if (!result) {
throw new Error("Repository not found!");
}
await queue.add("calculateUserPoints", { userId: result.userId });
};
+30
View File
@@ -0,0 +1,30 @@
import db from "@server/db";
import github from "@server/lib/github";
import queue from "@server/lib/queue";
import { users } from "@server/models";
import { eq } from "drizzle-orm";
export type FetchUserProfileType = {
userId: number;
};
export const fetchUserProfile = async (data: FetchUserProfileType) => {
const [user] = await db.select().from(users).where(eq(users.id, data.userId));
if (!user) {
throw new Error("User not found!");
}
const details = await github.getUser(user.username);
await db
.update(users)
.set({
name: details.name,
followers: details.followers,
following: details.following,
location: details.location,
achievements: details.achievements || user.achievements,
})
.where(eq(users.id, user.id));
await queue.add("calculateUserPoints", { userId: user.id });
};
+69
View File
@@ -0,0 +1,69 @@
import db from "@server/db";
import github from "@server/lib/github";
import queue from "@server/lib/queue";
import { repositories, users } from "@server/models";
import { and, eq } from "drizzle-orm";
import { FetchRepoDataJobType } from "./fetch-repo-data";
export type FetchUserRepos = {
userId: number;
};
export const fetchUserRepos = async (data: FetchUserRepos) => {
const [user] = await db.select().from(users).where(eq(users.id, data.userId));
if (!user) {
throw new Error("User not found!");
}
const res = await github.getRepositories(user.username, {
sort: "stargazers",
fetchAll: true,
});
const jobList = [] as FetchRepoDataJobType[];
await db.transaction(async (tx) => {
for (const repo of res.repositories) {
const data = {
...repo,
userId: user.id,
lastUpdate: repo.lastUpdate.toISOString(),
};
const [existing] = await tx
.select({ id: repositories.id })
.from(repositories)
.where(
and(
eq(repositories.userId, data.userId),
eq(repositories.name, data.name)
)
);
if (existing) {
await tx
.update(repositories)
.set(data)
.where(eq(repositories.id, existing.id));
jobList.push({ id: existing.id, uri: data.uri });
} else {
const [result] = await tx.insert(repositories).values(data).returning();
jobList.push({ id: result.id, uri: data.uri });
}
}
});
// Queue fetch repo details
queue.addBulk(jobList.map((data) => ({ name: "fetchRepoData", data })));
queue.addBulk(
jobList.map((data) => ({
name: "fetchRepoContributors",
data,
opts: {
attempts: 5,
backoff: { type: "exponential", delay: 30000 },
jobId: `contributors:${data.uri}`,
},
}))
);
};
+15
View File
@@ -0,0 +1,15 @@
import { calculateUserPoints } from "./calculate-user-points";
import { fetchRepoContributors } from "./fetch-repo-contributors";
import { fetchRepoData } from "./fetch-repo-data";
import { fetchUserProfile } from "./fetch-user-profile";
import { fetchUserRepos } from "./fetch-user-repos";
export const jobs = {
fetchUserRepos,
fetchRepoData,
fetchRepoContributors,
calculateUserPoints,
fetchUserProfile,
};
export type JobNames = keyof typeof jobs;
+12
View File
@@ -0,0 +1,12 @@
//
export const __PROD = import.meta.env.NODE_ENV === "production";
export const __DEV = !__PROD;
export const BULLMQ_CONNECTION = {
host: import.meta.env.REDIS_HOST || "127.0.0.1",
port: Number(import.meta.env.REDIS_PORT) || 6379,
};
export const BULLMQ_JOB_NAME = "ghcontribjob";
export const JWT_SECRET = import.meta.env.JWT_SECRET || "secret";
+283
View File
@@ -0,0 +1,283 @@
import * as cheerio from "cheerio";
import { intval } from "./utils";
import dayjs from "dayjs";
const GITHUB_URL = "https://github.com";
const GITHUB_API_URL = "https://api.github.com";
const selectors = {
user: {
name: "h1.vcard-names > span.vcard-fullname",
location: "li[itemprop='homeLocation'] span",
followers: ".js-profile-editable-area a[href$='?tab=followers'] > span",
following: ".js-profile-editable-area a[href$='?tab=following'] > span",
achievement: "img.achievement-badge-sidebar",
},
repo: {
list: "div#user-repositories-list li",
listForked: ':contains("Forked")',
listLanguage: "span[itemprop='programmingLanguage']",
listStars: "a[href$='stargazers']",
listForks: "a[href$='forks']",
langList: ".Layout-sidebar h2:contains('Languages')",
},
};
const github = {
async getUser(username: string) {
const response = await this.fetch(username);
const $ = cheerio.load(response);
const name = $(selectors.user.name).text().trim();
if (typeof name !== "string" || !name?.length) {
throw new Error("User not found");
}
const location = $(selectors.user.location).text().trim();
const followers = intval($(selectors.user.followers).text().trim());
const following = intval($(selectors.user.following).text().trim());
const achievements = [] as { name: string; image?: string }[];
$(selectors.user.achievement).each((_i, el) => {
const name = $(el).attr("alt")?.split(" ")[1] || "";
const image = $(el).attr("src");
achievements.push({ name, image });
});
return { name, username, location, followers, following, achievements };
},
async getRepositories(
username: string,
params?: Partial<GetRepositoriesParams>
) {
const response = await this.fetch(username, {
params: {
tab: "repositories",
type: "public",
...params,
},
});
const $ = cheerio.load(response);
let repositories = [] as {
name: string;
uri: string;
language: string;
stars: number;
forks: number;
lastUpdate: Date;
}[];
$(selectors.repo.list).each((_i, el) => {
const isForked = $(el).find(selectors.repo.listForked).length > 0;
if (isForked) return;
const name = $(el).find("h3 > a").text().trim();
const language = $(el).find(selectors.repo.listLanguage).text().trim();
const stars = intval($(el).find(selectors.repo.listStars).text().trim());
const forks = intval($(el).find(selectors.repo.listForks).text().trim());
const lastUpdate = $(el).find("relative-time").attr("datetime");
repositories.push({
name,
uri: `${username}/${name}`,
language,
stars,
forks,
lastUpdate: dayjs(lastUpdate).toDate(),
});
});
const prevPage = intval(
$("a.prev_page")
.attr("href")
?.match(/page=(\d+)/)?.[1]
);
const nextPage = intval(
$("a.next_page")
.attr("href")
?.match(/page=(\d+)/)?.[1]
);
if (params?.fetchAll && nextPage > 1 && nextPage < 10) {
try {
const nextPageRes = await this.getRepositories(username, {
...params,
page: nextPage,
});
if (nextPageRes.repositories?.length > 0) {
repositories = [...repositories, ...nextPageRes.repositories];
}
} catch (err) {
//
}
}
return { repositories, prevPage, nextPage };
},
async getRepoDetails(repo: string) {
const response = await this.fetch(repo);
const $ = cheerio.load(response);
const languages = [] as { lang: string; amount: number }[];
$(selectors.repo.langList)
.parent()
.find("ul > li > a")
.each((_i, el) => {
const lang = $(el).children().eq(1).text().trim();
const percentage = $(el).children().eq(2).text().trim();
const amount = parseFloat(percentage?.replace(/[^0-9.]/, "")) || 0;
languages.push({ lang, amount });
});
return { languages };
},
async getRepoContributors(repo: string, options?: Partial<FetchOptions>) {
const response = await this.fetch(`repos/${repo}/stats/contributors`, {
...options,
ghApi: true,
headers: { accept: "application/json", ...(options?.headers || {}) },
});
if (!Array.isArray(response)) {
throw new Error("Invalid response: " + JSON.stringify(response));
}
const result = response
.map((item: any) => {
const { author, total, weeks } = item;
let additions = 0;
let deletions = 0;
let commits = 0;
weeks.forEach((week: any) => {
additions += week.a || 0;
deletions += week.d || 0;
commits += week.c || 0;
});
return { author, total, additions, deletions, commits };
})
.sort((a, b) => b.total - a.total);
return result;
},
async getAllData(username: string, options?: Partial<GetAllDataOptions>) {
const user = await this.getUser(username);
const repositories = [] as (Repository & {
languages: Language[];
contributors: Contributors;
})[];
const _repos = await this.getRepositories(username, {
sort: "stargazers",
fetchAll: true,
});
const repoCount = Math.min(
_repos.repositories.length,
options?.maxRepo || Number.POSITIVE_INFINITY
);
for (let idx = 0; idx < repoCount; idx++) {
const repo = _repos.repositories[idx];
const [details, contributors] = await Promise.all([
this.getRepoDetails(repo.uri),
this.getRepoContributors(repo.uri),
]);
repositories.push({
...repo,
languages: details.languages,
contributors,
});
}
return { user, repositories };
},
async fetch<T = any>(path: string, options?: Partial<FetchOptions>) {
const url = new URL(
"/" + path,
options?.ghApi ? GITHUB_API_URL : GITHUB_URL
);
if (options?.params) {
Object.entries(options.params).forEach(([key, value]) => {
url.searchParams.append(key, value as string);
});
}
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
...(options?.headers || {}),
};
if (options?.xhr) {
headers["X-Requested-With"] = "XMLHttpRequest";
}
const init = {
method: "GET",
headers,
referrer: options?.referrer || GITHUB_URL,
};
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(res.statusText);
}
const type = res.headers.get("Content-Type");
if (type?.includes("application/json")) {
return res.json() as T;
}
return res.text();
},
};
type FetchOptions = {
xhr: boolean;
ghApi: boolean;
params: any;
headers: any;
referrer: string;
};
type GetRepositoriesParams = {
page: string | number;
sort: "stargazers" | "name" | null;
fetchAll: boolean;
};
type GetAllDataOptions = {
maxRepo: number;
};
export type GithubUser = Awaited<ReturnType<typeof github.getUser>>;
export type Repository = Awaited<
ReturnType<typeof github.getRepositories>
>["repositories"][number];
export type Language = Awaited<
ReturnType<typeof github.getRepoDetails>
>["languages"][number];
export type Contributors = Awaited<
ReturnType<typeof github.getRepoContributors>
>;
export type Contributor = NonNullable<Contributors>[number];
export type Achievement = GithubUser["achievements"][number];
export default github;
+17
View File
@@ -0,0 +1,17 @@
import pino from "pino";
import { __DEV } from "./consts";
const logger = pino(
__DEV
? {
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
}
: {}
);
export default logger;
+19
View File
@@ -0,0 +1,19 @@
import { Queue } from "bullmq";
import { BULLMQ_CONNECTION, BULLMQ_JOB_NAME } from "./consts";
import logger from "./logger";
import type { JobNames } from "@server/jobs";
const queue = new Queue<any, any, JobNames>(BULLMQ_JOB_NAME, {
connection: BULLMQ_CONNECTION,
defaultJobOptions: {
attempts: 5,
backoff: {
type: "exponential",
delay: 3000,
},
},
});
queue.on("error", logger.error);
export default queue;
+10
View File
@@ -0,0 +1,10 @@
//
export const intval = (value: any) => {
if (typeof value === "number" && !Number.isNaN(value)) {
return value;
}
const num = parseInt(value);
return Number.isNaN(num) ? 0 : num;
};
+40
View File
@@ -0,0 +1,40 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { serveStatic } from "hono/bun";
import router from "./router";
import logger from "./lib/logger";
import { __DEV, __PROD } from "./lib/consts";
const HOST = import.meta.env.HOST || "127.0.0.1";
const PORT = Number(import.meta.env.PORT) || 5589;
const app = new Hono();
app.onError((err, c) => {
logger.error(err);
return c.text(err.message, 500);
});
// Allow all origin on development
if (__DEV) {
app.use(cors({ origin: "*" }));
}
// Health check
app.get("/health", (c) => c.text("OK"));
// Serve prod client app
if (__PROD) {
app.use("*", serveStatic({ root: "./dist/client" }));
}
// API router
app.route("/api", router);
Bun.serve({
fetch: app.fetch,
hostname: HOST,
port: PORT,
});
logger.info(`Server started at http://${HOST}:${PORT}`);
+34
View File
@@ -0,0 +1,34 @@
import { JWT_SECRET } from "@server/lib/consts";
import { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
import { HTTPException } from "hono/http-exception";
import * as jwt from "hono/jwt";
type AuthOptions = {
required: boolean;
};
export const auth =
(opt?: Partial<AuthOptions>) => async (c: Context, next: Next) => {
try {
const token = getCookie(c, "token");
if (!token) {
throw new Error("No token found!");
}
const jwtData: any = await jwt.verify(token, JWT_SECRET);
const userId = jwtData.id;
if (!userId) {
throw new Error("No user id found!");
}
c.set("userId", userId);
} catch (err) {
if (opt?.required) {
throw new HTTPException(401, { message: "Unauthorized!" });
}
}
return next();
};
+2
View File
@@ -0,0 +1,2 @@
export { users } from "./users";
export { repositories } from "./repositories";
+38
View File
@@ -0,0 +1,38 @@
import { Contributor, Language } from "@server/lib/github";
import { InferInsertModel, InferSelectModel, sql } from "drizzle-orm";
import { text, sqliteTable, integer, index } from "drizzle-orm/sqlite-core";
import { users } from "./users";
export const repositories = sqliteTable(
"repositories",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
uri: text("uri").notNull(),
language: text("language").notNull(),
stars: integer("stars").notNull(),
forks: integer("stars").notNull(),
lastUpdate: text("last_update").notNull(),
languages: text("languages", { mode: "json" }).$type<Language[]>(),
contributors: text("contributors", { mode: "json" }).$type<Contributor[]>(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.$onUpdate(() => sql`CURRENT_TIMESTAMP`),
},
(t) => ({
nameIdx: index("repositories_name_idx").on(t.name),
uriIdx: index("repositories_uri_idx").on(t.uri),
language: index("repositories_language_idx").on(t.language),
})
);
export type Repository = InferSelectModel<typeof repositories>;
export type CreateRepository = InferInsertModel<typeof repositories>;
+31
View File
@@ -0,0 +1,31 @@
import { Achievement } from "@server/lib/github";
import { InferInsertModel, InferSelectModel, sql } from "drizzle-orm";
import { text, sqliteTable, integer } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
username: text("username").notNull().unique(),
name: text("name").notNull(),
avatar: text("avatar"),
location: text("location"),
followers: integer("followers").notNull().default(0),
following: integer("following").notNull().default(0),
achievements: text("achievements", { mode: "json" })
.$type<Achievement[]>()
.default([]),
points: integer("points").notNull().default(0),
commits: integer("commits").notNull().default(0),
lineOfCodes: integer("line_of_codes").notNull().default(0),
githubId: integer("github_id").unique(),
accessToken: text("access_token"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.$onUpdate(() => sql`CURRENT_TIMESTAMP`),
});
export type User = InferSelectModel<typeof users>;
export type CreateUser = InferInsertModel<typeof users>;
+42
View File
@@ -0,0 +1,42 @@
import { Job, Worker } from "bullmq";
import { BULLMQ_CONNECTION, BULLMQ_JOB_NAME } from "./lib/consts";
import logger from "./lib/logger";
import { jobs } from "./jobs";
const handler = async (job: Job) => {
const jobFn = (jobs as any)[job.name];
if (jobFn) {
return jobFn(job.data);
}
return false;
};
const worker = new Worker(BULLMQ_JOB_NAME, handler, {
connection: BULLMQ_CONNECTION,
concurrency: Number(import.meta.env.QUEUE_CONCURRENCY) || 1,
removeOnComplete: { count: 0 },
removeOnFail: { count: 0 },
});
worker.on("error", logger.error);
worker.on("active", (job) => {
logger.info(`Job ${job.name}.${job.id} started.`);
});
worker.on("failed", (job, err) => {
logger.child({ jobId: job?.id }).error(err);
});
worker.on("completed", (job, result) => {
logger.info({
msg: `Job ${job.name}.${job.id} completed.`,
result,
});
});
worker.on("ready", () => {
logger.info("Worker ready!");
});
+17
View File
@@ -0,0 +1,17 @@
import { Hono } from "hono";
import { auth } from "./routes/auth";
import { leaderboard } from "./routes/leaderboard";
const router = new Hono()
.route("/auth", auth)
.route("/leaderboard", leaderboard);
export type AppType = typeof router;
declare module "hono" {
interface ContextVariableMap {
userId?: number;
}
}
export default router;
+115
View File
@@ -0,0 +1,115 @@
import db from "@server/db";
import { JWT_SECRET } from "@server/lib/consts";
import github from "@server/lib/github";
import queue from "@server/lib/queue";
import { repositories, users } from "@server/models";
import { CreateUser } from "@server/models/users";
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { setCookie } from "hono/cookie";
import * as jwt from "hono/jwt";
import { auth as authMiddleware } from "../middlewares/auth";
const { GITHUB_CLIENT_ID, GITHUB_SECRET_KEY } = import.meta.env;
export const auth = new Hono()
/**
* Redirect to github oauth
*/
.get("/login", (c) => {
return c.redirect(
"https://github.com/login/oauth/authorize?client_id=" + GITHUB_CLIENT_ID
);
})
/**
* Auth callback
*/
.get("/callback", async (c) => {
const code = c.req.query("code");
const result = await github.fetch("login/oauth/access_token", {
params: {
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_SECRET_KEY,
code,
},
headers: {
accept: "application/json",
},
});
const accessToken = result.access_token;
const ghUser = await github.fetch("user", {
ghApi: true,
headers: {
accept: "application/json",
Authorization: "Bearer " + accessToken,
},
});
const userData: CreateUser = {
username: ghUser.login,
name: ghUser.name,
avatar: ghUser.avatar_url,
location: ghUser.location,
accessToken,
githubId: ghUser.id,
followers: ghUser.followers,
following: ghUser.following,
};
const [user] = await db
.insert(users)
.values(userData)
.onConflictDoUpdate({
target: users.username,
set: userData,
})
.returning();
if (!user) {
throw new Error("Auth user failed!");
}
// Fetch latest user profile
await queue.add("fetchUserProfile", { userId: user.id });
// Fetch user repositories
const [hasRepo] = await db
.select({ id: repositories.id })
.from(repositories)
.where(eq(repositories.userId, user.id))
.limit(1);
if (!hasRepo) {
await queue.add("fetchUserRepos", { userId: user.id });
}
const authToken = await jwt.sign({ id: user.id }, JWT_SECRET);
setCookie(c, "token", authToken, { httpOnly: true });
return c.redirect("/");
})
/**
* Get authenticated user
*/
.get("/user", authMiddleware(), async (c) => {
const userId = c.get("userId");
if (!userId) {
return c.json(null);
}
const [user] = await db.select().from(users).where(eq(users.id, userId));
return c.json(user ? { ...user, accessToken: undefined } : null);
})
/**
* Logout
*/
.get("/logout", authMiddleware({ required: true }), async (c) => {
setCookie(c, "token", "", { httpOnly: true });
return c.redirect("/");
});
+80
View File
@@ -0,0 +1,80 @@
import db from "@server/db";
import { repositories, users } from "@server/models";
import { desc, eq, getTableColumns, sql } from "drizzle-orm";
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
export const leaderboard = new Hono()
/**
* Get users leaderboard
*/
.get("/", async (c) => {
const rows = await db
.select()
.from(users)
.orderBy(desc(users.points))
.limit(100);
const result = rows.map((data, idx) => ({ ...data, rank: idx + 1 }));
return c.json(result);
})
/**
* Get specific user data
*/
.get("/:username", async (c) => {
const { username } = c.req.param();
const rankSubquery = db
.select({
userId: users.id,
value: sql`rank() over (order by points desc)`.as("rank"),
})
.from(users)
.orderBy(desc(users.points))
.as("rank");
const [user] = await db
.select({
...getTableColumns(users),
accessToken: sql`null`,
rank: rankSubquery.value,
})
.from(users)
.leftJoin(rankSubquery, eq(users.id, rankSubquery.userId))
.where(eq(users.username, username));
if (!user) {
throw new HTTPException(404, { message: "User not found!" });
}
const repos = await db
.select()
.from(repositories)
.where(eq(repositories.userId, user.id))
.orderBy(desc(repositories.stars), desc(repositories.forks));
const languageMap: Record<string, number> = {};
repos
.flatMap((i) => i.languages || [])
.forEach((i) => {
if (!languageMap[i.lang]) languageMap[i.lang] = 0;
languageMap[i.lang] += i.amount;
});
const totalLangWeight = Object.values(languageMap).reduce(
(a, b) => a + b,
0
);
let languages = [] as { name: string; percent: number }[];
for (const [lang, amount] of Object.entries(languageMap)) {
languages.push({
name: lang,
percent: (amount / totalLangWeight) * 100,
});
}
languages = languages.sort((a, b) => b.percent - a.percent);
return c.json({ user, repositories: repos, languages });
});