Compare commits

...

15 Commits

Author SHA1 Message Date
3f225a1ecb fix: add revised migration
All checks were successful
Update changelog / changelog (push) Successful in 25s
2025-12-11 22:37:24 +01:00
bade2f9b86 feat: add 404 handling
All checks were successful
Update changelog / changelog (push) Successful in 25s
2025-12-09 12:49:55 +01:00
58460d988d chore: add new workflows (changelog, automatic releases)
All checks were successful
Update changelog / changelog (push) Successful in 26s
Docker images will now be built on every release
2025-12-09 12:32:38 +01:00
8fb05f2662 fix: use debian-based container for image building
All checks were successful
Build and push Docker image / build (push) Successful in 2m26s
fixes https://stackoverflow.com/a/75730731
2025-12-07 20:21:45 +01:00
2e325fe018 feat: refactor docker workflow; build both amd64 and arm64 docker images
Some checks failed
Build and push Docker image / build (push) Failing after 54s
2025-12-07 20:18:24 +01:00
52dbd158a9 fix: respect debug flag in .env for swagger endpoint generation
All checks were successful
Build and push Docker image / build (push) Successful in 22s
2025-12-04 17:15:20 +01:00
f897a3e4ce chore: move docker-compose to main repo
All checks were successful
Build and push Docker image / build (push) Successful in 27s
2025-12-04 15:33:54 +01:00
bf6fb81a73 fix: add .dockerignore to not build containers with .env secrets
All checks were successful
Build and push Docker image / build (push) Successful in 24s
2025-12-03 00:19:25 +01:00
7bb2dd5434 fix: typo in Gitea workflow 2
All checks were successful
Build and push Docker image / build (push) Successful in 22s
2025-12-02 23:17:10 +01:00
fa5932d270 fix: typo in Gitea workflow
Some checks failed
Build and push Docker image / build (push) Failing after 1s
2025-12-02 23:11:17 +01:00
09f0d3ce1f feat: add Gitea workflow for Docker image building
Some checks failed
Build and push Docker image / build (push) Failing after 22s
2025-12-02 23:04:19 +01:00
6b2f93b962 feat: add a Dockerfile for image building 2025-12-02 22:59:21 +01:00
1b8c9ccaeb fix: add type hints for nullable columns 2025-12-01 17:26:54 +01:00
1e60fc23ab feat: create basic express server with swagger documentation 2025-12-01 02:24:52 +01:00
56975a0e92 feat: prepare TypeORM support for Postgres db calls 2025-12-01 01:48:24 +01:00
18 changed files with 3154 additions and 0 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.env
*/.env
*.md

13
.env.default Normal file
View File

@@ -0,0 +1,13 @@
# TypeORM specific
# Please make sure these match with docker-compose.yml, or your own postgres server.
PG_USER=kitty
PG_PASS=CHANGEME
PG_HOST=127.0.0.1
PG_PORT=5432
PG_DB=kittyurl
# Site info
PUBLIC_URL=https://example.com # Publicly accessible website root, used for rewrites. Note there is no trailing slash in the URL.
IS_PROXIED=false # Set to `true` if behind a reverse proxy, like apache/nginx.
USE_SUBDOMAINS=true # Whether to use subdomains for URL generation.
DEBUG=false # Set to `false` to disable some features not meant to be seen publicly (like swagger documentation).

View File

@@ -0,0 +1,58 @@
# Credits: https://www.vanmeeuwen.dev/blog/automating-docker-builds-with-gitea-actions
# https://gitea.com/gitea/runner-images/src/branch/main/.gitea/workflows/release.yaml
name: Build and push Docker image
run-name: Build ${{ github.ref_name }} image
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v2
with:
registry: gitea.7o7.cx
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set outputs
id: vars
env:
BRANCH_NAME: ${{ github.ref_name }}
run: |
#echo branch_name="$BRANCH_NAME" | tee -a $GITHUB_OUTPUT
#echo short_hash=$(git rev-parse --short HEAD) | tee -a $GITHUB_OUTPUT
echo current_tag=$GITHUB_REF_NAME | tee -a $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: |
linux/amd64
linux/arm64
pull: true
push: true
# Change when working on a different branch!
tags: |
gitea.7o7.cx/kittyteam/kittybe:master-${{ steps.vars.outputs.current_tag }}
gitea.7o7.cx/kittyteam/kittybe:latest
- name: Log out from registry
if: always()
run: docker logout gitea.7o7.cx

View File

@@ -0,0 +1,35 @@
# Credits: # https://gitea.com/gitea/runner-images/src/branch/main/.gitea/workflows/release.yaml
name: Release new version
run-name: Release ${{ github.ref_name }}
on:
push:
tags:
- "*"
jobs:
release:
runs-on: ubuntu-latest
container: docker.io/thegeeklab/git-sv:2.0.9
steps:
- name: Install tools
run: |
apk add -q --update --no-cache nodejs
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-tags: true
fetch-depth: 0
- name: Create changelog
run: |
git sv current-version
git sv release-notes -t ${GITHUB_REF#refs/tags/} -o CHANGELOG.md
sed -i '1,2d' CHANGELOG.md # remove version
cat CHANGELOG.md
- name: Release
uses: https://github.com/akkuman/gitea-release-action@v1
with:
body_path: CHANGELOG.md
token: "${{ secrets.REPO_RW_TOKEN }}"

View File

@@ -0,0 +1,37 @@
# Credit: https://gitea.com/gitea/helm-gitea/src/branch/main/.gitea/workflows/changelog.yml
name: Update changelog
run-name: Update changelog on push
on:
push:
branches:
- master
tags:
- "*"
jobs:
changelog:
runs-on: ubuntu-latest
container: docker.io/thegeeklab/git-sv:2.0.9
steps:
- name: Install tools
run: |
apk add -q --update --no-cache nodejs curl jq sed
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Generate upcoming changelog
run: |
git sv rn -o changelog.md
export RELEASE_NOTES=$(cat changelog.md)
export ISSUE_NUMBER=$(curl -s -H 'Authorization: token ${{ secrets.ISSUE_RW_TOKEN }}' "https://gitea.7o7.cx/api/v1/repos/kittyteam/kittyBE/issues?state=open&q=Changelog%20for%20upcoming%20version" | jq '.[].number')
echo $RELEASE_NOTES
JSON_DATA=$(echo "" | jq -Rs --arg title 'Changelog for upcoming version' --arg body "$(cat changelog.md)" '{title: $title, body: $body}')
if [ -z "$ISSUE_NUMBER" ]; then
curl -s -X POST "https://gitea.7o7.cx/api/v1/repos/kittyteam/kittyBE/issues" -H "Authorization: token ${{ secrets.ISSUE_RW_TOKEN }}" -H "Content-Type: application/json" -d "$JSON_DATA" > /dev/null
else
curl -s -X PATCH "https://gitea.7o7.cx/api/v1/repos/kittyteam/kittyBE/issues/$ISSUE_NUMBER" -H "Authorization: token ${{ secrets.ISSUE_RW_TOKEN }}" -H "Content-Type: application/json" -d "$JSON_DATA" > /dev/null
fi

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# TypeORM
.idea/
.vscode/
node_modules/
build/
tmp/
temp/
# .env
.env

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# Credit: https://www.digitalocean.com/community/tutorials/how-to-build-a-node-js-application-with-docker
FROM node:24-trixie-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .
FROM node:24-trixie-slim AS production
WORKDIR /app
RUN addgroup --gid 1001 nodejs && \
adduser --gid 1001 --uid 1001 nodejs
COPY --from=builder --chown=nodejs:nodejs /app /app
USER nodejs
EXPOSE 6567
CMD ["npm", "run", "server"]

View File

@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class RevisedMigration1765488793696 implements MigrationInterface {
name = 'RevisedMigration1765488793696'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "links" (
"id" SERIAL NOT NULL,
"subdomain" character varying,
"shortUri" character varying NOT NULL,
"fullUrl" character varying NOT NULL,
"createDate" bigint NOT NULL,
"expiryDate" bigint,
"visits" bigint NOT NULL,
"privacy" boolean NOT NULL,
"authorId" integer,
CONSTRAINT "PK_ecf17f4a741d3c5ba0b4c5ab4b6" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"name" character varying NOT NULL,
"passwordHash" character varying NOT NULL,
"role" integer NOT NULL,
"createdAt" bigint NOT NULL,
CONSTRAINT "UQ_51b8b26ac168fbe7d6f5653e6cf" UNIQUE ("name"),
CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
ALTER TABLE "links"
ADD CONSTRAINT "FK_c5287c1e74cbb62159104715543" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "links" DROP CONSTRAINT "FK_c5287c1e74cbb62159104715543"
`);
await queryRunner.query(`
DROP TABLE "users"
`);
await queryRunner.query(`
DROP TABLE "links"
`);
}
}

2689
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "kittyBE",
"version": "0.0.1",
"description": "Your go-to place for short and memorable URLs.",
"type": "commonjs",
"devDependencies": {
"@types/node": "^22.19.1",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
},
"dependencies": {
"dotenv": "^17.2.3",
"express": "^5.1.0",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"typeorm": "0.3.27"
},
"scripts": {
"start": "ts-node src/index.ts",
"typeorm": "typeorm-ts-node-commonjs",
"newMigration": "typeorm-ts-node-commonjs migration:generate -p -d ./src/data-source.ts",
"pendingMigration": "typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts",
"server": "ts-node src/app.ts"
}
}

42
src/app.ts Normal file
View File

@@ -0,0 +1,42 @@
import * as express from 'express';
import router from './routes';
import * as dotenv from "dotenv";
dotenv.config({ quiet: true });
const app = express();
const swaggerJsdocOpts = {
failOnErrors: true,
definition: {
openapi: '3.0.0',
info: {
title: 'kittyurl',
version: '0.0.1'
}
},
apis: ['./src/routes/*.ts']
};
const swaggerUi = require('swagger-ui-express');
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
app.use(express.json());
app.use(router);
if (process.env.DEBUG === "true") {
app.use('/swagger', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
}
// Handle 404s
// https://stackoverflow.com/a/9802006
app.use(function(req, res) {
res.status(404);
if (req.accepts('json')) {
res.json({ status: 'error', error: 'Not found' });
return;
}
res.type('txt').send('Not found');
});
app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.'));

18
src/data-source.ts Normal file
View File

@@ -0,0 +1,18 @@
import "reflect-metadata"
import { DataSource } from "typeorm"
import * as dotenv from "dotenv";
dotenv.config();
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.PG_HOST ?? "localhost",
port: parseInt(process.env.PG_PORT, 10) || 5432,
username: process.env.PG_USER ?? "kitty",
password: process.env.PG_PASS ?? "CHANGEME", // Please change your password inside of the .env file!
database: process.env.PG_DB ?? "kittyurl",
synchronize: false,
logging: false,
entities: [__dirname + '/entities/*.ts'],
migrations: [__dirname + '/migrations/*.ts'],
subscribers: [],
})

48
src/entities/Link.ts Normal file
View File

@@ -0,0 +1,48 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinTable } from "typeorm"
import { User } from "./User"
@Entity("links")
export class Link {
// Unique link id.
@PrimaryGeneratedColumn()
id: number
// Experimental: subdomain which should be a part of the short url.
// For instance in the URL "abc.example.com/def", abc is the subdomain.
// "def.example.com/def" won't resolve to the URL that "abc.example.com/def" does.
@Column({ nullable: true })
subdomain: string | null
// Shortened Uri.
@Column()
shortUri: string
// URL to which the user should be redirected
@Column()
fullUrl: string
// Unix timestamp of link creation date.
@Column('bigint')
createDate: number
// Unix timestamp of when the link should expire.
// If null, the link will never expire unless deleted.
@Column('bigint', { nullable: true })
expiryDate: number | null
// Aggregated amount of visits.
@Column('bigint')
visits: number
// Link privacy:
// - true, if link is private
// - false, if link can be shown in a list of recent links publicly.
@Column()
privacy: boolean
// User to which the shortened URL belongs.
@ManyToOne(() => User, (user) => user.links, { nullable: true })
@JoinTable()
author: User | null
}

32
src/entities/User.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm"
import { Link } from "./Link"
@Entity("users")
export class User {
// Unique user id.
@PrimaryGeneratedColumn()
id: number
// User name, must be unique.
@Column({ unique: true })
name: string
// Salted password hash.
@Column()
passwordHash: string
// User role:
// - 0 - means unprivileged user,
// - 1 - means administrative user.
@Column()
role: number
// Account creation date as a Unix timestamp.
@Column('bigint')
createdAt: number
// List of shortened URLs which belong to the user.
@OneToMany(() => Link, (link) => link.author)
links: Link[];
}

25
src/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import { AppDataSource } from "./data-source"
import { User } from "./entities/User"
import { smokeTest } from "./smoke-test";
AppDataSource.initialize().then(async () => {
// console.log("Inserting a new user into the database...");
// const user = new User();
// // user.firstName = "Timber";
// // user.lastName = "Saw";
// // user.age = 25;
// await AppDataSource.manager.save(user);
// console.log("Saved a new user with id: " + user.id);
// console.log("Loading users from the database...");
// const users = await AppDataSource.manager.find(User);
// console.log("Loaded users: ", users);
// console.log("Here you can setup and run express / fastify / any other framework.");
await AppDataSource.runMigrations();
await smokeTest(AppDataSource);
}).catch(error => console.log(error))

20
src/routes/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Router } from 'express';
const router = Router();
/**
* @swagger
*
* /:
* get:
* description: Hello world!
* tags: [Default]
* responses:
* 200:
* description: Returns "Hello world!"
*/
router.get('/', (req, res) => {
res.send("Hello world!");
});
export default router;

13
src/smoke-test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Connection } from "typeorm";
import { User } from "./entities/User";
export async function smokeTest(connection: Connection) {
const user = new User();
user.name = "admin";
user.role = "admin";
user.createdAt = Date.now();
user.passwordHash = "pretend this is a hash";
await connection.manager.save(user);
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"lib": [
"es2021"
],
"types": ["node"],
"target": "es2021",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./build",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true
}
}