├── .dockerignore
├── .eslintrc.js
├── .github
└── workflows
│ └── deploy.yml
├── .gitignore
├── .prettierrc.json
├── Dockerfile
├── README.md
├── maza
├── nest-cli.json
├── package.json
├── src
├── app.module.ts
├── database
│ ├── abstract-models
│ │ └── timestamped-model.ts
│ ├── database.module.ts
│ ├── decorators
│ │ └── timestamp-property.ts
│ └── mikro-orm.config.ts
├── main.ts
├── migrations
│ ├── .snapshot-mazacofo.json
│ ├── Migration20250719141829.ts
│ ├── Migration20250719144110.ts
│ ├── Migration20250719144543.ts
│ └── Migration20250719144832.ts
├── packages
│ ├── badge
│ │ ├── badge.module.ts
│ │ ├── controllers
│ │ │ └── badge.controller.ts
│ │ └── utils
│ │ │ ├── cofo-rating-info.ts
│ │ │ └── svg.utils.ts
│ ├── client
│ │ ├── client.module.ts
│ │ ├── entities
│ │ │ └── client.entity.ts
│ │ └── services
│ │ │ └── client.service.ts
│ └── codeforces
│ │ ├── codeforces.module.ts
│ │ └── services
│ │ └── codeforces.service.ts
├── schedular
│ ├── schedular.module.ts
│ └── services
│ │ └── scheduler.service.ts
├── sentry.ts
└── utils
│ └── random-id.ts
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules/
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | project: 'tsconfig.json',
5 | tsconfigRootDir: __dirname,
6 | sourceType: 'module',
7 | },
8 | plugins: ['@typescript-eslint/eslint-plugin'],
9 | extends: [
10 | 'plugin:@typescript-eslint/recommended',
11 | 'plugin:prettier/recommended',
12 | ],
13 | root: true,
14 | env: {
15 | node: true,
16 | jest: true,
17 | },
18 | ignorePatterns: ['.eslintrc.js'],
19 | rules: {
20 | '@typescript-eslint/no-unused-vars': [
21 | 'error',
22 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
23 | ],
24 | '@typescript-eslint/interface-name-prefix': 'off',
25 | '@typescript-eslint/explicit-function-return-type': 'off',
26 | '@typescript-eslint/explicit-module-boundary-types': 'off',
27 | '@typescript-eslint/no-explicit-any': 'off',
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deployment(production)
2 |
3 | on:
4 | push:
5 | branches: [release]
6 |
7 | concurrency:
8 | group: ${{ github.workflow }}-${{ github.ref }}
9 | cancel-in-progress: true
10 |
11 | jobs:
12 | prepare:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v2
17 |
18 | - name: Set outputs
19 | id: vars
20 | run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
21 |
22 | - name: Create .env file
23 | run: |
24 | touch .env
25 |
26 | echo "MAZA_ENV=$MAZA_ENV" >> .env
27 | echo "PORT=$PORT" >> .env
28 |
29 | echo "MYSQL_HOST=$MYSQL_HOST" >> .env
30 | echo "MYSQL_USER=$MYSQL_USER" >> .env
31 | echo "MYSQL_PASSWORD=$MYSQL_PASSWORD" >> .env
32 | echo "MYSQL_DB=$MYSQL_DB" >> .env
33 |
34 | echo "SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN" >> .env
35 | env:
36 | MAZA_ENV: 'production'
37 | PORT: ${{ secrets.PORT }}
38 | MYSQL_HOST: ${{ secrets.MYSQL_HOST }}
39 | MYSQL_USER: ${{ secrets.MYSQL_USER }}
40 | MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
41 | MYSQL_DB: ${{ secrets.MYSQL_DB }}
42 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
43 |
44 | - name: Upload workspace
45 | uses: actions/upload-artifact@v4
46 | with:
47 | name: workspace
48 | include-hidden-files: true
49 | path: |
50 | .
51 | !./node_modules
52 | outputs:
53 | sha_short: ${{ steps.vars.outputs.sha_short }}
54 |
55 | build_server:
56 | needs: prepare
57 | runs-on: ubuntu-latest
58 | steps:
59 | - name: Download workspace
60 | uses: actions/download-artifact@v4
61 | with:
62 | name: workspace
63 | path: .
64 |
65 | - name: Configure AWS Credentials
66 | uses: aws-actions/configure-aws-credentials@v4
67 | with:
68 | aws-region: ap-northeast-2
69 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
70 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
71 |
72 | - name: Login to Amazon ECR
73 | id: login-ecr
74 | uses: aws-actions/amazon-ecr-login@v2
75 |
76 | - name: Setup Docker Buildx
77 | uses: docker/setup-buildx-action@v3
78 |
79 | - name: Build and Push image to ECR
80 | uses: docker/build-push-action@v6
81 | env:
82 | REGISTRY: ${{ steps.login-ecr.outputs.registry }}
83 | REPOSITORY: mazacofo
84 | IMAGE_TAG: ${{ github.sha }}
85 | with:
86 | context: .
87 | file: ./Dockerfile
88 | push: true
89 | tags: ${{ env.REGISTRY }}/${{ env.REPOSITORY }}:${{ env.IMAGE_TAG }}
90 | outputs:
91 | registry: ${{ steps.login-ecr.outputs.registry }}
92 |
93 | deploy:
94 | needs: build_server
95 | runs-on: ubuntu-latest
96 | steps:
97 | - name: Deploy to server
98 | uses: appleboy/ssh-action@v1.2.0
99 | env:
100 | REGISTRY: ${{ needs.build_server.outputs.registry }}
101 | TAG: ${{ github.sha }}
102 | REPOSITORY: mazacofo
103 | PORT: ${{ secrets.PORT }}
104 | with:
105 | host: ${{ secrets.SERVER_HOST }}
106 | username: ${{ secrets.SERVER_USERNAME }}
107 | passphrase: ${{ secrets.SERVER_PASSWORD }}
108 | key: ${{ secrets.SSH_LEED_AT_KEY }}
109 | envs: REGISTRY, TAG, REPOSITORY, PORT
110 | script: |
111 | aws ecr get-login-password --region ap-northeast-2 \
112 | | docker login --username AWS --password-stdin 889566267001.dkr.ecr.ap-northeast-2.amazonaws.com
113 |
114 | docker pull $REGISTRY/$REPOSITORY:$TAG
115 |
116 | if [ "$( docker container inspect -f '{{.ID}}' $REPOSITORY )" ]; then
117 | docker container stop $REPOSITORY
118 | docker container rm $REPOSITORY -f
119 | fi
120 | docker run -d --name $REPOSITORY --restart=always --network=mysql_network -p $PORT:$PORT $REGISTRY/$REPOSITORY:$TAG
121 |
122 | docker image prune --all --force
123 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules/
3 | npm-debug.log*
4 | yarn-debug.log*
5 | yarn-error.log*
6 |
7 | # Runtime data
8 | pids
9 | *.pid
10 | *.seed
11 | *.pid.lock
12 |
13 | # Coverage directory used by tools like istanbul
14 | coverage/
15 | *.lcov
16 |
17 | # nyc test coverage
18 | .nyc_output
19 |
20 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 |
23 | # Bower dependency directory (https://bower.io/)
24 | bower_components
25 |
26 | # node-waf configuration
27 | .lock-wscript
28 |
29 | # Compiled binary addons (https://nodejs.org/api/addons.html)
30 | build/Release
31 |
32 | # Dependency directories
33 | node_modules/
34 | jspm_packages/
35 |
36 | # TypeScript cache
37 | *.tsbuildinfo
38 |
39 | # Optional npm cache directory
40 | .npm
41 |
42 | # Optional eslint cache
43 | .eslintcache
44 |
45 | # Microbundle cache
46 | .rpt2_cache/
47 | .rts2_cache_cjs/
48 | .rts2_cache_es/
49 | .rts2_cache_umd/
50 |
51 | # Optional REPL history
52 | .node_repl_history
53 |
54 | # Output of 'npm pack'
55 | *.tgz
56 |
57 | # Yarn Integrity file
58 | .yarn-integrity
59 |
60 | # dotenv environment variables file
61 | .env
62 | .env.test
63 | .env.production
64 |
65 | # parcel-bundler cache (https://parceljs.org/)
66 | .cache
67 | .parcel-cache
68 |
69 | # Next.js build output
70 | .next
71 |
72 | # Nuxt.js build / generate output
73 | .nuxt
74 | dist
75 |
76 | # Gatsby files
77 | .cache/
78 | public
79 |
80 | # Storybook build outputs
81 | .out
82 | .storybook-out
83 |
84 | # Temporary folders
85 | tmp/
86 | temp/
87 |
88 | # Logs
89 | logs
90 | *.log
91 |
92 | # IDE
93 | .vscode/
94 | .idea/
95 | *.swp
96 | *.swo
97 |
98 | # OS
99 | .DS_Store
100 | Thumbs.db
101 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
5 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # STEP 1: Install dependencies
2 | FROM node:22.12-alpine AS deps
3 | WORKDIR /app
4 | COPY package.json yarn.lock ./
5 | RUN yarn --frozen-lockfile
6 |
7 | # STEP 2: Build
8 | FROM node:22.12-alpine AS builder
9 | WORKDIR /app
10 | COPY --from=deps /app/node_modules ./node_modules
11 | COPY . .
12 | RUN yarn build
13 |
14 | # Step 3: Expose
15 | FROM node:22.12-alpine AS runner
16 | RUN apk add --no-cache tzdata vim
17 | ENV TZ=Asia/Seoul
18 | RUN cp /usr/share/zoneinfo/$TZ /etc/localtime
19 | ENV NODE_ENV=production
20 |
21 | WORKDIR /app
22 | COPY --from=builder /app/dist ./dist
23 | COPY --from=deps /app/node_modules ./node_modules
24 | COPY package.json yarn.lock tsconfig.* .env ./
25 |
26 | CMD ["sh", "-c", "yarn mikro-orm migration:up --config './dist/database/mikro-orm.config.js' ; yarn start:prod"]
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mazacofo
2 |
3 | Codeforces rating badge generator for Github Readme
4 |
5 | ## Features
6 |
7 | ### Automatic Updates
8 |
9 | The application automatically updates Codeforces data every minute using a cron task.
10 |
11 | ### Badge Styles
12 |
13 | - **Regular**: Full-size badge with detailed information
14 | - **Mini**: Compact badge showing only rating
15 | - **Legendary Grandmaster**: Special styling for top-tier users
16 |
17 | ## Usage
18 |
19 | ```markdown
20 | [](https://codeforces.com/profile/{your handle})
21 | ```
22 |
23 | See [#api-endpoints](#api-endpoints) for more options.
24 |
25 | ## Screenshots
26 |
27 | #### Newbie
28 |
29 |
30 |
31 | #### Pupil
32 |
33 |
34 |
35 | #### Specialist
36 |
37 |
38 |
39 | #### Expert
40 |
41 |
42 |
43 | #### Candidate Master
44 |
45 |
46 |
47 | #### Master & International Master
48 |
49 |
50 |
51 |
52 | #### Grandmaster & International GrandMaster
53 |
54 |
55 |
56 |
57 | #### Legendary Grandmaster
58 |
59 |
60 |
61 | ## Development
62 |
63 | 1. Clone the repository:
64 |
65 | ```bash
66 | git clone
67 | cd mazacofo
68 | ```
69 |
70 | 2. Install dependencies:
71 |
72 | ```bash
73 | yarn
74 | ```
75 |
76 | 3. Create a `.env` file with your database configuration:
77 |
78 | ```env
79 | PORT=2021
80 |
81 | MAZA_ENV=development
82 |
83 | MYSQL_HOST=localhost
84 | MYSQL_USER=your_username
85 | MYSQL_PW=your_password
86 | MYSQL_DB=mazacofo
87 | ```
88 |
89 | 4. Set up the database:
90 |
91 | ```bash
92 | # Create the database
93 | mysql -u your_username -p -e "CREATE DATABASE mazacofo;"
94 |
95 | # Run migrations (if needed)
96 | npm run migration:run
97 | ```
98 |
99 | 5. Run development server
100 |
101 | ```bash
102 | ./maza run server
103 | ```
104 |
105 | ### API Endpoints
106 |
107 | ```
108 | GET /?id={handle}&mini={true|false}
109 | ```
110 |
111 | Parameters:
112 |
113 | - `id`: Codeforces handle (required)
114 | - `mini`: Show mini badge (optional)
115 |
--------------------------------------------------------------------------------
/maza:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | source ./.env
4 |
5 | bind="./maza"
6 |
7 | y(){
8 | export SENTRYCLI_SKIP_DOWNLOAD=1
9 | yarn $@
10 | }
11 |
12 | run() {
13 | local target=${1:-all}
14 | shift
15 |
16 | case $target in
17 | server)
18 | y start:dev
19 | ;;
20 | esac
21 | }
22 | migrate() {
23 | local action=${1:-all}
24 | shift
25 |
26 | case $action in
27 | generate)
28 | y mikro-orm migration:create
29 | ;;
30 | run)
31 | y mikro-orm migration:up
32 | ;;
33 | down)
34 | y mikro-orm migration:down
35 | ;;
36 | esac
37 | }
38 | concurrently() {
39 | # `--raw`:
40 | # Output only raw output of processes, disables prettifying and concurrently coloring.
41 | #
42 | # `--kill-others`:
43 | # It will kill other processes if one exits or dies and prevent the development server from
44 | # starting without dependencies like a database.
45 | #
46 | # `| cat`:
47 | # The Create React App(CRA)'s command will automatically clear the previous console output,
48 | # and that's troublesome because it also clears the other console output written by Docker and Django.
49 | # So far, there is no option for disabling this CRA's behavior, but it can be achieved by piping
50 | # the CRA's output to the cat commmand. See https://github.com/facebook/create-react-app/issues/794.
51 | yarn concurrently --raw --kill-others-on-fail "$@"
52 | }
53 |
54 | if [ "$#" -eq "0" ]; then
55 | help
56 | exit 0
57 | fi
58 |
59 | trap exit SIGINT
60 |
61 | eval "$@"
62 |
--------------------------------------------------------------------------------
/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true
7 | }
8 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mazacofo",
3 | "version": "1.1.0",
4 | "description": "Codeforces rating badge generator",
5 | "license": "MIT",
6 | "scripts": {
7 | "build": "NODE_OPTIONS=--max_old_space_size=4096 nest build && yarn sentry:sourcemaps",
8 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
9 | "start": "NODE_OPTIONS=--max_old_space_size=4096 nest start",
10 | "start:dev": "NODE_OPTIONS=--max_old_space_size=4096 nest start --watch",
11 | "start:debug": "NODE_OPTIONS=--max_old_space_size=4096 nest start --debug --watch",
12 | "start:prod": "NODE_OPTIONS=--max_old_space_size=4096 node dist/main",
13 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
14 | "test": "jest",
15 | "test:watch": "jest --watch",
16 | "test:cov": "jest --coverage",
17 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
18 | "test:e2e": "jest --config ./test/jest-e2e.json",
19 | "sentry:sourcemaps": "sentry-cli sourcemaps inject --org kodori --project mazacofo ./dist && sentry-cli sourcemaps upload --org kodori --project mazacofo ./dist"
20 | },
21 | "dependencies": {
22 | "@mikro-orm/core": "^6.1.1",
23 | "@mikro-orm/mysql": "^6.1.1",
24 | "@mikro-orm/nestjs": "^6.1.1",
25 | "@nestjs/common": "^10.0.0",
26 | "@nestjs/core": "^10.0.0",
27 | "@nestjs/platform-express": "^10.0.0",
28 | "@nestjs/schedule": "^4.0.0",
29 | "@sentry/nestjs": "^9.40.0",
30 | "@sentry/profiling-node": "^9.40.0",
31 | "axios": "^1.6.0",
32 | "dayjs": "^1.11.13",
33 | "dotenv": "^16.3.1",
34 | "nanoid": "^5.1.5",
35 | "reflect-metadata": "^0.1.13",
36 | "rxjs": "^7.8.1"
37 | },
38 | "devDependencies": {
39 | "@mikro-orm/cli": "^6.4.16",
40 | "@mikro-orm/migrations": "^6.4.16",
41 | "@nestjs/cli": "^10.0.0",
42 | "@nestjs/schematics": "^10.0.0",
43 | "@nestjs/testing": "^10.0.0",
44 | "@sentry/cli": "^2.50.0",
45 | "@types/express": "^4.17.17",
46 | "@types/jest": "^29.5.2",
47 | "@types/node": "^20.3.1",
48 | "@types/supertest": "^2.0.12",
49 | "@typescript-eslint/eslint-plugin": "^6.0.0",
50 | "@typescript-eslint/parser": "^6.0.0",
51 | "eslint": "^8.42.0",
52 | "eslint-config-prettier": "^9.0.0",
53 | "eslint-plugin-prettier": "^5.0.0",
54 | "jest": "^29.5.0",
55 | "prettier": "^3.0.0",
56 | "source-map-support": "^0.5.21",
57 | "supertest": "^6.3.3",
58 | "ts-jest": "^29.1.0",
59 | "ts-loader": "^9.4.3",
60 | "ts-node": "^10.9.1",
61 | "tsconfig-paths": "^4.2.0",
62 | "typescript": "^5.1.3"
63 | },
64 | "jest": {
65 | "moduleFileExtensions": [
66 | "js",
67 | "json",
68 | "ts"
69 | ],
70 | "rootDir": "src",
71 | "testRegex": ".*\\.spec\\.ts$",
72 | "transform": {
73 | "^.+\\.(t|j)s$": "ts-jest"
74 | },
75 | "collectCoverageFrom": [
76 | "**/*.(t|j)s"
77 | ],
78 | "coverageDirectory": "../coverage",
79 | "testEnvironment": "node"
80 | },
81 | "mikro-orm": {
82 | "useTsNode": true,
83 | "configPaths": [
84 | "./src/database/mikro-orm.config.ts"
85 | ]
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { APP_FILTER } from '@nestjs/core';
3 | import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup';
4 |
5 | import { CodeforcesModule } from './packages/codeforces/codeforces.module';
6 | import { BadgeModule } from './packages/badge/badge.module';
7 | import { ClientModule } from './packages/client/client.module';
8 | import { DatabaseModule } from './database/database.module';
9 | import { SchedularModule } from './schedular/schedular.module';
10 |
11 | @Module({
12 | imports: [
13 | SentryModule.forRoot(),
14 | DatabaseModule,
15 | CodeforcesModule,
16 | BadgeModule,
17 | ClientModule,
18 | SchedularModule,
19 | ],
20 | controllers: [],
21 | providers: [{ provide: APP_FILTER, useClass: SentryGlobalFilter }],
22 | })
23 | export class AppModule {}
24 |
--------------------------------------------------------------------------------
/src/database/abstract-models/timestamped-model.ts:
--------------------------------------------------------------------------------
1 | import dayjs, { Dayjs } from 'dayjs';
2 | import { sql } from '@mikro-orm/core';
3 |
4 | import { TimestampProperty } from '../decorators/timestamp-property';
5 |
6 | export abstract class TimestampedModel {
7 | @TimestampProperty({ default: sql.now() })
8 | createdAt: Dayjs = dayjs();
9 |
10 | @TimestampProperty({
11 | onUpdate: () => dayjs(),
12 | default: sql.now(),
13 | })
14 | updatedAt: Dayjs = dayjs();
15 | }
16 |
--------------------------------------------------------------------------------
/src/database/database.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 |
3 | import { FlushMode } from '@mikro-orm/core';
4 | import { MikroOrmModule } from '@mikro-orm/nestjs';
5 |
6 | import mikroOrmConfig from './mikro-orm.config';
7 |
8 | @Module({
9 | imports: [
10 | MikroOrmModule.forRootAsync({
11 | useFactory: () => ({
12 | // Ref: https://wanago.io/2022/06/06/api-nestjs-transactions-postgresql-mikroorm
13 | flushMode: FlushMode.COMMIT,
14 | debug: process.env.BINDING_ENV === 'development',
15 | ...mikroOrmConfig,
16 | }),
17 | }),
18 | ],
19 | })
20 | export class DatabaseModule {}
21 |
--------------------------------------------------------------------------------
/src/database/decorators/timestamp-property.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 |
3 | import {
4 | BeforeCreate,
5 | BeforeUpdate,
6 | Property,
7 | BeforeUpsert,
8 | OnInit,
9 | OnLoad,
10 | AfterCreate,
11 | AfterUpdate,
12 | AfterUpsert,
13 | PropertyOptions,
14 | } from '@mikro-orm/core';
15 |
16 | import { randomID } from '@/utils/random-id';
17 |
18 | /**
19 | * Automatically converts timestamp value when load/unloading Memory <-> DB.
20 | *
21 | * - BeforeCreate, BeforeUpdate, BeforeUpsert
22 | * : Memory(Dayjs) -> DB(Timestamp String)
23 | * - AfterCreate, AfterUpdate, AfterUpsert, OnInit, OnLoad
24 | * : DB(Timestamp String) -> Memory(Dayjs)
25 | */
26 | export function TimestampProperty(options?: PropertyOptions