forked from sunwoo1524/krll
v2
This commit is contained in:
commit
df363e2297
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
venv/
|
||||||
|
|
||||||
|
postgres/
|
4
.env.example
Normal file
4
.env.example
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
POSTGRES_HOST=db:5432
|
||||||
|
POSTGRES_DATABASE=postgres
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=changeme!
|
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
venv/
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
postgres/
|
9
Dockerfile
Normal file
9
Dockerfile
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
FROM python:latest
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
||||||
|
|
||||||
|
ENTRYPOINT gunicorn main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
|
18
docker-compose.example.yml
Normal file
18
docker-compose.example.yml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
version: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:latest
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
volumes:
|
||||||
|
- ./postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- 8000:8000
|
||||||
|
depends_on:
|
||||||
|
- db
|
33
frontend/index.html
Normal file
33
frontend/index.html
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Krll URL Shortener</title>
|
||||||
|
<link rel="icon" href="favicon.ico" type="image/x-icon">
|
||||||
|
<link rel="stylesheet" href="static/root.css">
|
||||||
|
<link rel="stylesheet" href="static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="input-container">
|
||||||
|
<input id="url" placeholder="Loooooooooooong URL" />
|
||||||
|
<button id="to-shorten-btn">Shorten</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-container" id="result-container">
|
||||||
|
<p id="error">Your URL is invalid!</p>
|
||||||
|
<p id="short-url"></p>
|
||||||
|
<p id="copied">Copied!</p>
|
||||||
|
<img id="qr-code" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>스팸이나 불법적인 용도로 사용하지 말아주세요.</p>
|
||||||
|
<p>남용 신고 이메일 <a href="mailto:maengkkong1524@naver.com">maengkkong1524@naver.com</a></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="static/script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
25
frontend/static/root.css
Normal file
25
frontend/static/root.css
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: 'Poppins', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
--background-color: light-dark(white, rgb(29, 29, 29));
|
||||||
|
--input-background-color: light-dark(white, rgb(53, 53, 53));
|
||||||
|
|
||||||
|
--text-color: light-dark(black, white);
|
||||||
|
--footer-text-color: light-dark(rgb(90, 90, 90), rgb(173, 173, 173));
|
||||||
|
--button-text-color: white;
|
||||||
|
--link-color: light-dark(#2e2e2e, #7a7a7a);
|
||||||
|
|
||||||
|
--border-color: light-dark(lightgray, gray);
|
||||||
|
--border-highlight-color: darkgray;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
97
frontend/static/script.js
Normal file
97
frontend/static/script.js
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
const url_input = document.getElementById("url");
|
||||||
|
const to_shorten_btn = document.getElementById("to-shorten-btn");
|
||||||
|
const result_container = document.getElementById("result-container");
|
||||||
|
const short_url = document.getElementById("short-url");
|
||||||
|
const qr_code = document.getElementById("qr-code");
|
||||||
|
const error_message = document.getElementById("error");
|
||||||
|
const copy_message = document.getElementById("copied");
|
||||||
|
|
||||||
|
to_shorten_btn.addEventListener("click", async () => {
|
||||||
|
const original_url = url_input.value
|
||||||
|
// check if the url input is empty
|
||||||
|
if (original_url.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Easter egg: rick roll
|
||||||
|
if (rickroll.check(original_url)){
|
||||||
|
window.location.replace("https://youtu.be/dQw4w9WgXcQ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// shorten the long url
|
||||||
|
const response = await fetch("/api/urls", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: original_url
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// check if there is problems
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 400) {
|
||||||
|
// the url is invalid
|
||||||
|
error_message.style.display = "block";
|
||||||
|
setTimeout(() => error_message.style.display = "none", 5000);
|
||||||
|
} else {
|
||||||
|
// there is some problems in the server!
|
||||||
|
alert("Sorry, an error has occured in the server...");
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const result = `https://krll.me/${data.key}`
|
||||||
|
|
||||||
|
// show the short url
|
||||||
|
short_url.innerText = result;
|
||||||
|
|
||||||
|
// get the qr code of the short url and show it
|
||||||
|
qr_code.src = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${result}`;
|
||||||
|
qr_code.alt = result;
|
||||||
|
});
|
||||||
|
|
||||||
|
short_url.addEventListener("click", event => {
|
||||||
|
// copy the short url to clipboard
|
||||||
|
navigator.clipboard.writeText(event.target.innerText)
|
||||||
|
.then(() => {
|
||||||
|
// show a message
|
||||||
|
copy_message.style.opacity = "1";
|
||||||
|
setTimeout(() => copy_message.style.opacity = "0", 3000);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert("An error has occurred...\n" + error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const rickroll = {
|
||||||
|
check: (url)=>{
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
let videoId = "";
|
||||||
|
if (urlObj.host.split(".").slice(-2)[0] === "youtube") {
|
||||||
|
if (urlObj.pathname.split("/")[1] === "watch") {
|
||||||
|
videoId = urlObj.searchParams.get("v");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (urlObj.host === "youtu.be") {
|
||||||
|
videoId = urlObj.pathname.slice(1);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (videoId ==="dQw4w9WgXcQ") return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
90
frontend/static/style.css
Normal file
90
frontend/static/style.css
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
main {
|
||||||
|
padding-top: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction:column;
|
||||||
|
align-items: center;
|
||||||
|
height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 500px) {
|
||||||
|
.input-container {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container input {
|
||||||
|
font-size: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px;
|
||||||
|
border: solid 1px var(--border-color);
|
||||||
|
border-radius: 5px 0 0 5px;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--input-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--border-highlight-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container button {
|
||||||
|
font-size: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 5px 5px 0;
|
||||||
|
color: var(--button-text-color);
|
||||||
|
background-color: #6F59F5;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-container p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-container #error {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-container #short-url {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-container #copied {
|
||||||
|
opacity: 0;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer p {
|
||||||
|
margin: 3px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--footer-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer p a {
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
33
main.py
Normal file
33
main.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from src.database import engine
|
||||||
|
from src import models
|
||||||
|
from src.routes.url import url_route
|
||||||
|
|
||||||
|
|
||||||
|
models.Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
origins = [
|
||||||
|
"*",
|
||||||
|
]
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(url_route.routes)
|
||||||
|
|
||||||
|
# frontend v2
|
||||||
|
app.mount("/static", StaticFiles(directory="./frontend/static"))
|
||||||
|
@app.get("/")
|
||||||
|
def frontend():
|
||||||
|
return FileResponse("./frontend/index.html")
|
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
fastapi[all]
|
||||||
|
gunicorn
|
||||||
|
sqlalchemy
|
||||||
|
psycopg2
|
||||||
|
python-dotenv
|
23
src/database.py
Normal file
23
src/database.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from .env import POSTGRES_HOST, POSTGRES_DATABASE, POSTGRES_USER, POSTGRES_PASSWORD
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}/{POSTGRES_DATABASE}"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
10
src/env.py
Normal file
10
src/env.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
POSTGRES_DATABASE = os.environ.get("POSTGRES_DATABASE")
|
||||||
|
POSTGRES_USER = os.environ.get("POSTGRES_USER")
|
||||||
|
POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD")
|
||||||
|
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
|
10
src/models.py
Normal file
10
src/models.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from sqlalchemy import Column, String
|
||||||
|
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class URL(Base):
|
||||||
|
__tablename__ = "url"
|
||||||
|
|
||||||
|
key = Column(String, primary_key=True, nullable=False)
|
||||||
|
original_url = Column(String, nullable=False)
|
24
src/routes/url/url_crud.py
Normal file
24
src/routes/url/url_crud.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ...models import URL
|
||||||
|
|
||||||
|
|
||||||
|
def storeKeyOfURL(db: Session, url: str, key: str):
|
||||||
|
url = URL(
|
||||||
|
key=key,
|
||||||
|
original_url=url
|
||||||
|
)
|
||||||
|
db.add(url)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def getOriginalURL(db: Session, key: str):
|
||||||
|
url = db.query(URL).filter(URL.key == key).first()
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def getKeyOfURL(db: Session, url: str):
|
||||||
|
key = db.query(URL).filter(URL.original_url == url).first()
|
||||||
|
return key
|
53
src/routes/url/url_route.py
Normal file
53
src/routes/url/url_route.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import random, string
|
||||||
|
|
||||||
|
from ...database import get_db
|
||||||
|
from .url_schemas import URL, ShortenURLRes
|
||||||
|
from .url_crud import storeKeyOfURL, getOriginalURL, getKeyOfURL
|
||||||
|
|
||||||
|
|
||||||
|
routes = APIRouter(
|
||||||
|
tags=["url"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/api/urls")
|
||||||
|
def shortenURL(url: URL, db: Session = Depends(get_db)) -> ShortenURLRes:
|
||||||
|
# check the URL is valid
|
||||||
|
try:
|
||||||
|
result = urlparse(url.url)
|
||||||
|
if not all([result.scheme, result.netloc]):
|
||||||
|
raise HTTPException(status_code=400, detail="INVALID_URL")
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="INVALID_URL")
|
||||||
|
|
||||||
|
# get the URL of key from database
|
||||||
|
# if it is not found, generate a new key
|
||||||
|
# short url: https://host/key
|
||||||
|
db_key = getKeyOfURL(db, url=url.url)
|
||||||
|
key = None
|
||||||
|
if db_key is None:
|
||||||
|
letters = string.ascii_letters
|
||||||
|
letters += "".join(str(i) for i in range(1, 10))
|
||||||
|
key = "".join(random.choice(letters) for i in range(6))
|
||||||
|
storeKeyOfURL(db, url.url, key)
|
||||||
|
else:
|
||||||
|
key = db_key.key
|
||||||
|
|
||||||
|
res = ShortenURLRes(
|
||||||
|
key=key,
|
||||||
|
original_url=url.url
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/{key}")
|
||||||
|
def redirect(key: str, db: Session = Depends(get_db)):
|
||||||
|
# redirect to original URL if the key is exist
|
||||||
|
original_url = getOriginalURL(db, key)
|
||||||
|
if original_url is None:
|
||||||
|
return HTTPException(status_code=404, detail="URL_NOT_FOUND")
|
||||||
|
return RedirectResponse(original_url.original_url, status_code=301)
|
10
src/routes/url/url_schemas.py
Normal file
10
src/routes/url/url_schemas.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class URL(BaseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class ShortenURLRes(BaseModel):
|
||||||
|
key: str
|
||||||
|
original_url: str
|
Loading…
Reference in a new issue