Microservice for redirect handling

This commit is contained in:
Sven Heidemann 2025-01-09 16:52:34 +01:00
parent ff341ddcb5
commit 9d5ca8b123
20 changed files with 284 additions and 237 deletions

View File

@ -1,49 +0,0 @@
from flask import redirect, jsonify, request, Response
from api.route import Route
from core.logger import Logger
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
from data.schemas.public.short_url_visit import ShortUrlVisit
from data.schemas.public.short_url_visit_dao import shortUrlVisitDao
logger = Logger(__name__)
@Route.get(f"/api/find/<path:path>")
async def find(path: str):
from_db = await shortUrlDao.find_single_by({ShortUrl.short_url: path})
if from_db is None:
return jsonify(None)
return jsonify(from_db.to_dto())
@Route.get(f"/api/redirect/<path:path>")
async def handle_short_url(path: str):
if path.startswith("api/"):
path = path.replace("api/", "")
from_db = await shortUrlDao.find_single_by({ShortUrl.short_url: path})
if from_db is None:
return {"error": "Short URL not found"}, 404
try:
await shortUrlVisitDao.create(
ShortUrlVisit(
0,
from_db.id,
request.headers.get("User-Agent")
)
)
except Exception as e:
logger.error(f"Failed to update short url {from_db.short_url} with error", e)
return _do_redirect(from_db.target_url)
def _do_redirect(url: str) -> Response:
# todo: multiple protocols like ts3://
if not url.startswith("http://") and not url.startswith("https://"):
url = f"http://{url}"
return redirect(url)

View File

@ -1,4 +1,9 @@
from core.database.database_settings import DatabaseSettings
from core.database.db_context import DBContext
from core.environment import Environment
from core.logger import DBLogger
logger = DBLogger(__name__)
class Database:
@ -14,3 +19,30 @@ class Database:
@classmethod
def connect(cls):
cls._db.connect()
@classmethod
async def startup_db(cls):
from data.service.migration_service import MigrationService
logger.info("Init DB")
db = DBContext()
host = Environment.get("DB_HOST", str)
port = Environment.get("DB_PORT", int)
user = Environment.get("DB_USER", str)
password = Environment.get("DB_PASSWORD", str)
database = Environment.get("DB_DATABASE", str)
if None in [host, port, user, password, database]:
logger.fatal(
"DB settings are not set correctly",
EnvironmentError("DB settings are not set correctly"),
)
await db.connect(
DatabaseSettings(
host=host, port=port, user=user, password=password, database=database
)
)
Database.init(db)
migrations = MigrationService(db)
await migrations.migrate()

117
api/src/redirector.py Normal file
View File

@ -0,0 +1,117 @@
import asyncio
import sys
import eventlet
from eventlet import wsgi
from flask import Flask, request, Response, redirect, render_template
from core.database.database import Database
from core.environment import Environment
from core.logger import Logger
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
from data.schemas.public.short_url_visit import ShortUrlVisit
from data.schemas.public.short_url_visit_dao import shortUrlVisitDao
logger = Logger(__name__)
class Redirector(Flask):
def __init__(self, *args, **kwargs):
Flask.__init__(self, *args, **kwargs)
app = Redirector(__name__)
@app.route("/<path:path>")
async def _handle_request(path: str):
short_url = await _find_short_url_by_url(path)
if short_url is None:
return render_template("404.html"), 404
user_agent = request.headers.get("User-Agent", "").lower()
if "wheregoes" in user_agent or "someothertool" in user_agent:
return await _handle_short_url(path, short_url)
if short_url.loading_screen:
return render_template(
"redirect.html",
key=short_url.short_url,
target_url=_get_redirect_url(short_url.target_url),
)
return await _handle_short_url(path, short_url)
async def _handle_short_url(path: str, short_url: ShortUrl):
if path.startswith("api/"):
path = path.replace("api/", "")
try:
await shortUrlVisitDao.create(
ShortUrlVisit(0, short_url.id, request.headers.get("User-Agent"))
)
except Exception as e:
logger.error(f"Failed to update short url {short_url.short_url} with error", e)
return _do_redirect(short_url.target_url)
async def _find_short_url_by_url(url: str) -> ShortUrl:
return await shortUrlDao.find_single_by({ShortUrl.short_url: url})
def _get_redirect_url(url: str) -> str:
# todo: multiple protocols like ts3://
if not url.startswith("http://") and not url.startswith("https://"):
url = f"http://{url}"
return url
def _do_redirect(url: str) -> Response:
return redirect(_get_redirect_url(url))
async def configure():
Logger.set_level(Environment.get("LOG_LEVEL", str, "info"))
Environment.set_environment(Environment.get("ENVIRONMENT", str, "production"))
logger.info(f"Environment: {Environment.get_environment()}")
app.debug = Environment.get_environment() == "development"
await Database.startup_db()
def main():
if sys.platform == "win32":
from asyncio import WindowsSelectorEventLoopPolicy
asyncio.set_event_loop_policy(WindowsSelectorEventLoopPolicy())
loop = asyncio.new_event_loop()
loop.run_until_complete(configure())
loop.close()
port = Environment.get("PORT", int, 5001)
logger.info(f"Start API on port: {port}")
if Environment.get_environment() == "development":
logger.info(f"Playground: http://localhost:{port}/")
wsgi.server(eventlet.listen(("0.0.0.0", port)), app, log_output=False)
if __name__ == "__main__":
main()
# ((
# ( )
# ; / ,
# / \/
# / |
# / ~/
# / ) ) ~ edraft
# ___// | /
# --' \_~-,

View File

@ -19,30 +19,6 @@ logger = Logger(__name__)
class Startup:
@staticmethod
async def _startup_db():
logger.info("Init DB")
db = DBContext()
host = Environment.get("DB_HOST", str)
port = Environment.get("DB_PORT", int)
user = Environment.get("DB_USER", str)
password = Environment.get("DB_PASSWORD", str)
database = Environment.get("DB_DATABASE", str)
if None in [host, port, user, password, database]:
logger.fatal(
"DB settings are not set correctly",
EnvironmentError("DB settings are not set correctly"),
)
await db.connect(
DatabaseSettings(
host=host, port=port, user=user, password=password, database=database
)
)
Database.init(db)
migrations = MigrationService(db)
await migrations.migrate()
@staticmethod
async def _seed_data():
@ -69,7 +45,7 @@ class Startup:
app.debug = Environment.get_environment() == "development"
await cls._startup_db()
await Database.startup_db()
await FileService.clean_files()
await cls._seed_data()

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

27
api/src/static/styles.css Normal file
View File

@ -0,0 +1,27 @@
body {
background-color: #1e293b;
padding: 0;
margin: 0;
}
h1 {
color: #a2271f;
font-size: 3rem !important;
font-weight: 800 !important;
}
.bg-2 {
background-color: #0f172a;
}
.tile {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 40px;
padding: 35px;
border-radius: 20px;
}

View File

@ -0,0 +1,18 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Not found</title>
<link rel="stylesheet" href="/static/styles.css">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div class="w-full h-full flex flex-col justify-center items-center">
<div class="bg-2 tile">
<h1 class="flex justify-center items-center">404 - Not found</h1>
<img src="/static/not_found.gif" alt="">
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,39 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Redirecting {{ key }}</title>
<link rel="stylesheet" href="/static/styles.css">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div class="w-full h-full flex flex-col justify-center items-center">
<div class="flex items-center justify-center">
<div class="relative w-screen h-screen bg-cover bg-center"
style="background-image: url('/static/custom/background.jpg')"></div>
<div class="absolute w-1/3 h-2/5 rounded-xl p-5 flex flex-col gap-5 justify-center items-center">
<div class="absolute inset-0 bg-black opacity-70 rounded-xl"></div>
<div class="relative logo flex justify-center items-center">
<img class="h-48" src="/static/custom/logo.png" alt="logo">
</div>
<h1 class="relative text-3xl text-white">Redirecting...</h1>
<p class="relative text-white">You will be redirected in <span id="countdown">5</span> seconds.</p>
</div>
</div>
</div>
<script>
let countdown = 5;
const countdownElement = document.getElementById('countdown');
const interval = setInterval(() => {
countdown--;
countdownElement.textContent = countdown;
if (countdown <= 0) {
clearInterval(interval);
window.location.href = '{{ target_url }}';
}
}, 1000);
</script>
</body>
</html>

View File

@ -1 +1 @@
0.2.1
0.3.0

View File

@ -1,23 +1,22 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { NotFoundComponent } from "src/app/components/error/not-found/not-found.component";
import { AuthGuard } from "src/app/core/guard/auth.guard";
import { HomeComponent } from "src/app/components/home/home.component";
import { RedirectComponent } from "src/app/components/redirect/redirect.component";
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { NotFoundComponent } from 'src/app/components/error/not-found/not-found.component';
import { AuthGuard } from 'src/app/core/guard/auth.guard';
import { HomeComponent } from 'src/app/components/home/home.component';
const routes: Routes = [
{
path: "",
path: '',
component: HomeComponent,
},
{
path: "admin",
path: 'admin',
loadChildren: () =>
import("./modules/admin/admin.module").then((m) => m.AdminModule),
import('./modules/admin/admin.module').then(m => m.AdminModule),
canActivate: [AuthGuard],
},
{ path: "404", component: NotFoundComponent },
{ path: "**", component: RedirectComponent },
{ path: '404', component: NotFoundComponent },
{ path: '**', redirectTo: '/404', pathMatch: 'full' },
];
@NgModule({

View File

@ -1,28 +1,27 @@
import { APP_INITIALIZER, ErrorHandler, NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { APP_INITIALIZER, ErrorHandler, NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { KeycloakService } from "keycloak-angular";
import { initializeKeycloak } from "./core/init-keycloak";
import { HttpClient } from "@angular/common/http";
import { environment } from "../environments/environment";
import { FooterComponent } from "src/app/components/footer/footer.component";
import { HeaderComponent } from "src/app/components/header/header.component";
import { NotFoundComponent } from "src/app/components/error/not-found/not-found.component";
import { TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { TranslateHttpLoader } from "@ngx-translate/http-loader";
import { Logger } from "src/app/service/logger.service";
import { SharedModule } from "src/app/modules/shared/shared.module";
import { SpinnerComponent } from "src/app/components/spinner/spinner.component";
import { ConfirmationService, MessageService } from "primeng/api";
import { DialogService } from "primeng/dynamicdialog";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { SidebarComponent } from "./components/sidebar/sidebar.component";
import { ErrorHandlingService } from "src/app/service/error-handling.service";
import { HomeComponent } from "./components/home/home.component";
import { RedirectComponent } from "./components/redirect/redirect.component";
import { SettingsService } from "src/app/service/settings.service";
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { KeycloakService } from 'keycloak-angular';
import { initializeKeycloak } from './core/init-keycloak';
import { HttpClient } from '@angular/common/http';
import { environment } from '../environments/environment';
import { FooterComponent } from 'src/app/components/footer/footer.component';
import { HeaderComponent } from 'src/app/components/header/header.component';
import { NotFoundComponent } from 'src/app/components/error/not-found/not-found.component';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { Logger } from 'src/app/service/logger.service';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { SpinnerComponent } from 'src/app/components/spinner/spinner.component';
import { ConfirmationService, MessageService } from 'primeng/api';
import { DialogService } from 'primeng/dynamicdialog';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { SidebarComponent } from './components/sidebar/sidebar.component';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { HomeComponent } from './components/home/home.component';
import { SettingsService } from 'src/app/service/settings.service';
if (environment.production) {
Logger.enableProductionMode();
@ -36,7 +35,7 @@ export function HttpLoaderFactory(http: HttpClient) {
export function appInitializerFactory(
keycloak: KeycloakService,
settings: SettingsService,
settings: SettingsService
): () => Promise<void> {
return (): Promise<void> =>
new Promise<void>((resolve, reject) => {
@ -44,7 +43,7 @@ export function appInitializerFactory(
.loadSettings()
.then(() => initializeKeycloak(keycloak, settings))
.then(() => resolve())
.catch((error) => reject(error));
.catch(error => reject(error));
});
}
@ -57,7 +56,6 @@ export function appInitializerFactory(
SpinnerComponent,
SidebarComponent,
HomeComponent,
RedirectComponent,
],
imports: [
BrowserModule,
@ -65,7 +63,7 @@ export function appInitializerFactory(
AppRoutingModule,
SharedModule,
TranslateModule.forRoot({
defaultLanguage: "en",
defaultLanguage: 'en',
loader: {
provide: TranslateLoader,
useFactory: HttpLoaderFactory,

View File

@ -1,16 +0,0 @@
<div class="flex items-center justify-center">
<div class="relative w-screen h-screen bg-cover bg-center"
style="background-image: url({{settings.loadingScreen.background}})"></div>
<div class="absolute w-1/3 h-2/5 rounded-xl p-5 flex flex-col gap-5 justify-center items-center">
<div class="absolute inset-0 bg-black opacity-70 rounded-xl"></div>
<div class="relative logo flex justify-center items-center">
<img class="h-48"
[src]="settings.loadingScreen.logo"
alt="logo">
</div>
<h1 class="relative text-3xl text-white">Redirecting...</h1>
<p class="relative text-white" *ngIf="secs > -1">You will be redirected in {{ secs }} seconds.</p>
</div>
</div>

View File

@ -1,5 +0,0 @@
.bg-image {
background-position: top center;
background-repeat: no-repeat;
background-size: cover;
}

View File

@ -1,22 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RedirectComponent } from "./redirect.component";
describe("RedirectComponent", () => {
let component: RedirectComponent;
let fixture: ComponentFixture<RedirectComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RedirectComponent],
}).compileComponents();
fixture = TestBed.createComponent(RedirectComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,69 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { SidebarService } from 'src/app/service/sidebar.service';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { ShortUrlDto } from 'src/app/model/entities/short-url';
import { SettingsService } from 'src/app/service/settings.service';
import { AppSettings } from 'src/app/model/config/app-settings';
import { GuiService } from 'src/app/service/gui.service';
@Component({
selector: 'app-redirect',
templateUrl: './redirect.component.html',
styleUrl: './redirect.component.scss',
})
export class RedirectComponent implements OnInit {
defaultSecs = 5;
secs = -1;
settings: AppSettings = this.settingsService.settings;
constructor(
private sidebar: SidebarService,
private router: Router,
private http: HttpClient,
private settingsService: SettingsService,
private gui: GuiService
) {
this.sidebar.hide();
this.gui.hide();
}
ngOnInit() {
this.handleUrl();
}
handleUrl() {
let shortUrl = this.router.url;
if (shortUrl === '/') {
return;
}
if (shortUrl.startsWith('/')) {
shortUrl = shortUrl.substring(1);
}
this.http
.get<ShortUrlDto | undefined>(`${this.settings.api.url}/find/${shortUrl}`)
.subscribe(async response => {
if (!response) {
await this.router.navigate(['/404']);
return;
}
if (!response.loadingScreen) {
window.location.href = `${this.settings.api.url}/redirect/${shortUrl}`;
return;
}
this.secs = this.defaultSecs;
setInterval(() => {
this.secs--;
if (this.secs === 0) {
window.location.href = `${this.settings.api.url}/redirect/${shortUrl}`;
}
}, 1000);
});
}
}

View File

@ -1,4 +1,4 @@
import { Theme } from "src/app/model/view/theme";
import { Theme } from 'src/app/model/view/theme';
export interface AppSettings {
termsUrl: string;
@ -23,4 +23,5 @@ export interface KeycloakSettings {
export interface ApiSettings {
url: string;
redirector: string;
}

View File

@ -8,6 +8,7 @@ import { ShortUrlsDataService } from 'src/app/modules/admin/short-urls/short-url
import { ShortUrlsColumns } from 'src/app/modules/admin/short-urls/short-urls.columns';
import { AuthService } from 'src/app/service/auth.service';
import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { SettingsService } from 'src/app/service/settings.service';
@Component({
selector: 'app-short-urls',
@ -43,7 +44,8 @@ export class ShortUrlsPage
constructor(
private toast: ToastService,
private confirmation: ConfirmationDialogService,
private auth: AuthService
private auth: AuthService,
private settings: SettingsService
) {
super(true, {
read: [PermissionsEnum.shortUrls],
@ -137,11 +139,13 @@ export class ShortUrlsPage
}
open(url: string) {
window.open(url, '_blank');
window.open(`${this.settings.settings.api.redirector}/${url}`, '_blank');
}
copy(val: string) {
navigator.clipboard.writeText(`${window.origin}/${val}`).then(() => {
navigator.clipboard
.writeText(`${this.settings.settings.api.redirector}/${val}`)
.then(() => {
this.toast.info('common.copied', 'common.copied_to_clipboard');
});
}

View File

@ -9,15 +9,12 @@
}
],
"api": {
"url": ""
"url": "",
"redirector": ""
},
"keycloak": {
"url": "",
"realm": "",
"clientId": ""
},
"loadingScreen": {
"background": "https://www.sh-edraft.de/wp-content/uploads/2020/03/IMG_20170731_213039-scaled.jpg",
"logo": "https://www.sh-edraft.de/wp-content/uploads/2020/04/klein_transparent-e1735827600932.png"
}
}