aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/check_api_key.py22
-rw-r--r--app/db.py37
-rw-r--r--app/func/generate_api_key.py23
-rw-r--r--app/func/link/delete.py20
-rw-r--r--app/func/link/delrecords.py20
-rw-r--r--app/func/link/records.py23
-rw-r--r--app/func/link/renew.py23
-rw-r--r--app/func/log.py72
-rw-r--r--app/func/newlink.py30
-rw-r--r--app/func/remove_old_data.py24
-rw-r--r--app/linklogger.py9
-rw-r--r--app/main.py162
-rw-r--r--app/routes.py201
-rw-r--r--app/templates/dashboard.html47
-rw-r--r--app/templates/login.html111
-rw-r--r--app/templates/signup.html111
-rw-r--r--app/util/log.py65
17 files changed, 496 insertions, 504 deletions
diff --git a/app/check_api_key.py b/app/check_api_key.py
deleted file mode 100644
index d8b92f4..0000000
--- a/app/check_api_key.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import fastapi
-from fastapi import Security, HTTPException
-from fastapi.security import APIKeyHeader
-import sqlalchemy
-
-from db import engine
-
-"""
-Make sure the provided API key is valid
-"""
-api_key_header = APIKeyHeader(name="X-API-Key")
-
-def check_api_key(api_key_header: str = Security(api_key_header)) -> str:
- with engine.begin() as conn:
- response = conn.execute(sqlalchemy.text("SELECT api_key FROM keys WHERE api_key = :api_key"), {'api_key': api_key_header}).fetchone()
- if response:
- return response[0]
- else:
- raise HTTPException(
- status_code=fastapi.status.HTTP_401_UNAUTHORIZED,
- detail="Invalid or missing API key"
- )
diff --git a/app/db.py b/app/db.py
deleted file mode 100644
index 1750472..0000000
--- a/app/db.py
+++ /dev/null
@@ -1,37 +0,0 @@
-import sqlalchemy
-import os
-
-try:
- os.mkdir('data')
-except FileExistsError:
- pass
-engine = sqlalchemy.create_engine('sqlite:///data/data.db')
-
-
-def init_db():
- with engine.begin() as conn:
- conn.execute(sqlalchemy.text(
- '''
- CREATE TABLE IF NOT EXISTS keys (
- api_key, PRIMARY KEY (api_key)
- )
- '''
- ))
- conn.execute(sqlalchemy.text(
- '''
- CREATE TABLE IF NOT EXISTS links (
- owner, link, redirect_link, expire_date,
- FOREIGN KEY (owner) REFERENCES keys(api_key), PRIMARY KEY (link)
- )
- '''
- ))
- conn.execute(sqlalchemy.text(
- '''
- CREATE TABLE IF NOT EXISTS records (
- owner, link, timestamp, ip, location, browser, os, user_agent, isp,
- FOREIGN KEY (owner) REFERENCES links(owner),
- FOREIGN KEY (link) REFERENCES links(link))
- '''
- ))
-
- conn.commit() \ No newline at end of file
diff --git a/app/func/generate_api_key.py b/app/func/generate_api_key.py
deleted file mode 100644
index a40c96a..0000000
--- a/app/func/generate_api_key.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import sqlalchemy
-from sqlalchemy import exc
-import random
-import string
-
-from db import engine
-
-"""
-Generate and return a randomized API key string for the user
-Keys are composed of 20 uppercase ASCII characters
-"""
-def generate_api_key():
- with engine.begin() as conn:
- while True:
- try:
- api_key_string = ''.join(random.choices(string.ascii_uppercase, k=20))
- conn.execute(sqlalchemy.text('INSERT INTO keys(api_key) VALUES(:api_key)'), [{'api_key': api_key_string}])
- conn.commit()
- break
- except exc.IntegrityError:
- continue
-
- return api_key_string \ No newline at end of file
diff --git a/app/func/link/delete.py b/app/func/link/delete.py
deleted file mode 100644
index 97b696e..0000000
--- a/app/func/link/delete.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import sqlalchemy
-
-from db import engine
-
-"""
-Delete the specified link from the users associated links
-"""
-def delete_link(link, owner):
- with engine.begin() as conn:
- try:
- link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0]
- except TypeError:
- return 404
-
- if owner == link_owner:
- with engine.begin() as conn:
- conn.execute(sqlalchemy.text('DELETE FROM links WHERE link = :link'), [{'link': link}])
- return link
- else:
- return 401 \ No newline at end of file
diff --git a/app/func/link/delrecords.py b/app/func/link/delrecords.py
deleted file mode 100644
index d82bfa5..0000000
--- a/app/func/link/delrecords.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import sqlalchemy
-
-from db import engine
-
-"""
-Delete all of the IP log records that are associated with a specific link
-"""
-def delete_link_records(link, owner):
- with engine.begin() as conn:
- try:
- link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0]
- except TypeError:
- return 404
-
- if owner == link_owner:
- with engine.begin() as conn:
- conn.execute(sqlalchemy.text('DELETE FROM records WHERE link = :link'), [{'link': link}])
- return link
- else:
- return 401 \ No newline at end of file
diff --git a/app/func/link/records.py b/app/func/link/records.py
deleted file mode 100644
index 56bb6d2..0000000
--- a/app/func/link/records.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import sqlalchemy
-
-from db import engine
-
-"""
-Retrieve all records associated with a specific link
-"""
-def get_link_records(link, owner):
- with engine.begin() as conn:
- try:
- link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0]
- except TypeError:
- return 404
-
- if owner == link_owner:
- with engine.begin() as conn:
- records = conn.execute(sqlalchemy.text('SELECT timestamp, ip, location, browser, os, user_agent, isp FROM records WHERE owner = :owner and link = :link'), [{'owner': owner, 'link': link}]).fetchall()
- if not records:
- return 204
- else:
- return 401
-
- return records \ No newline at end of file
diff --git a/app/func/link/renew.py b/app/func/link/renew.py
deleted file mode 100644
index bcf0550..0000000
--- a/app/func/link/renew.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import sqlalchemy
-import datetime
-
-from db import engine
-
-"""
-Renew a specified link so that the user can continue logging through that URL
-Adds 7 days from the current date
-"""
-def renew_link(link, owner):
- with engine.begin() as conn:
- try:
- link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0]
- except TypeError:
- return 404
-
- if owner == link_owner:
- with engine.begin() as conn:
- expire_date = datetime.datetime.date(datetime.datetime.now()) + datetime.timedelta(days=7)
- conn.execute(sqlalchemy.text('UPDATE links SET expire_date = :expire_date WHERE link = :link'), [{'expire_date': expire_date, 'link': link}])
- return link, expire_date
- else:
- return 401 \ No newline at end of file
diff --git a/app/func/log.py b/app/func/log.py
deleted file mode 100644
index da6594a..0000000
--- a/app/func/log.py
+++ /dev/null
@@ -1,72 +0,0 @@
-import ip2locationio
-import sqlalchemy
-import datetime
-import validators
-from ua_parser import user_agent_parser
-from dotenv import load_dotenv
-import os
-from ip2locationio.ipgeolocation import IP2LocationIOAPIError
-
-from db import engine
-
-load_dotenv()
-try:
- ip_to_location = os.getenv('IP_TO_LOCATION').upper().replace('"', '')
- if ip_to_location == 'TRUE':
- api_key = os.getenv('API_KEY').replace('"', '')
- else:
- api_key = "NO_API_KEY"
-
- base_url = os.getenv('BASE_URL').replace('"', '')
-# .env File does not exist - likely a docker run
-except AttributeError:
- ip_to_location = str(os.environ['IP_TO_LOCATION']).upper().replace('"', '')
- if ip_to_location == 'TRUE':
- api_key = str(os.environ('API_KEY')).replace('"', '')
- else:
- api_key = "NO_API_KEY"
-
- base_url = str(os.environ('BASE_URL')).replace('"', '')
-
-if not validators.url(base_url):
- print(base_url)
- print('BASE_URL varaible is malformed.')
- exit()
-
-configuration = ip2locationio.Configuration(api_key)
-ipgeolocation = ip2locationio.IPGeolocation(configuration)
-
-"""
-Create a new log record whenever a link is visited
-"""
-def log(link, ip, user_agent):
- with engine.begin() as conn:
- try:
- redirect_link, owner = conn.execute(sqlalchemy.text('SELECT redirect_link, owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()
- except TypeError:
- return base_url
-
- with engine.begin() as conn:
- if ip_to_location == 'TRUE':
- # Get IP to GEO via IP2Location.io
- try:
- data = ipgeolocation.lookup(ip)
- location = f'{data["country_name"]}, {data["city_name"]}'
- isp = data['as']
- # Fatal error, API key is invalid or out of requests, quit
- except IP2LocationIOAPIError:
- print('Invalid API key or insifficient credit. Change .env file if you do not need IP to location feature.')
- location = '-, -'
- isp = '-'
- else:
- location = '-, -'
- isp = '-'
-
- timestamp = datetime.datetime.now()
- ua_string = user_agent_parser.Parse(user_agent)
- browser = ua_string['user_agent']['family']
- os = f'{ua_string["os"]["family"]} {ua_string["os"]["major"]}'
-
- conn.execute(sqlalchemy.text('INSERT INTO records (owner, link, timestamp, ip, location, browser, os, user_agent, isp) VALUES (:owner, :link, :timestamp, :ip, :location, :browser, :os, :user_agent, :isp)'), [{'owner': owner, 'link': link, 'timestamp': timestamp, 'ip': ip, 'location': location, 'browser': browser, 'os': os, 'user_agent': user_agent, 'isp': isp}])
-
- return redirect_link \ No newline at end of file
diff --git a/app/func/newlink.py b/app/func/newlink.py
deleted file mode 100644
index 06776e6..0000000
--- a/app/func/newlink.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import validators
-import random
-import string
-import datetime
-import sqlalchemy
-from sqlalchemy import exc
-
-from db import engine
-
-"""
-Generate and return a new randomized link that is connected to the user
-Links are composed of 5 uppercase ASCII characters + numbers
-"""
-def generate_link(redirect_link, owner):
- if not validators.url(redirect_link):
- return 422
-
- with engine.begin() as conn:
- choices = string.ascii_uppercase + '1234567890'
- while True:
- try:
- link = ''.join(random.choices(choices, k=5))
- expire_date = datetime.datetime.date(datetime.datetime.now()) + datetime.timedelta(days=7)
- conn.execute(sqlalchemy.text('INSERT INTO links(owner, link, redirect_link, expire_date) VALUES (:owner, :link, :redirect_link, :expire_date)'), [{'owner': owner, 'link': link, 'redirect_link': redirect_link, 'expire_date': expire_date}])
- conn.commit()
- break
- except exc.IntegrityError:
- continue
-
- return link, expire_date
diff --git a/app/func/remove_old_data.py b/app/func/remove_old_data.py
deleted file mode 100644
index 96d08fa..0000000
--- a/app/func/remove_old_data.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import sqlalchemy
-import datetime
-
-from db import engine
-
-"""
-Remove all links and associated records when the expire date has passed
-"""
-def remove_old_data():
- with engine.begin() as conn:
- today = datetime.datetime.date(datetime.datetime.now())
- old_links = conn.execute(sqlalchemy.text('SELECT link FROM links WHERE expire_date < :today'), [{'today': today}])
-
- delete_links = []
-
- for row in old_links:
- link = row.link
- delete_links.append({'link': link})
-
- if delete_links:
- with engine.begin() as conn:
- conn.execute(sqlalchemy.text('DELETE FROM links WHERE link = :link'), delete_links)
- conn.execute(sqlalchemy.text('DELETE FROM records WHERE link = :link'), delete_links)
- conn.commit()
diff --git a/app/linklogger.py b/app/linklogger.py
deleted file mode 100644
index c0d7f15..0000000
--- a/app/linklogger.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from db import init_db
-import uvicorn
-
-from routes import app
-
-
-if __name__ == '__main__':
- init_db()
- server = uvicorn.run(app=app, host="0.0.0.0", port=5252)
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..5f6a87b
--- /dev/null
+++ b/app/main.py
@@ -0,0 +1,162 @@
+from flask_login import (
+ current_user,
+ login_user,
+ login_required,
+ logout_user,
+ LoginManager,
+ UserMixin,
+)
+from flask import Flask, redirect, render_template, request, url_for, Request
+import bcrypt
+import os
+import string
+import random
+
+from models import User
+from database import *
+from app.util.log import log
+
+
+class FlaskUser(UserMixin):
+ pass
+
+
+app = Flask(__name__)
+app.config["SECRET_KEY"] = os.urandom(24)
+
+login_manager = LoginManager()
+login_manager.init_app(app)
+
+
+@login_manager.user_loader
+def user_loader(username):
+ user = FlaskUser()
+ user.id = username
+ return user
+
+
+"""
+Handle login requests from the web UI
+"""
+
+
+@app.route("/login", methods=["GET", "POST"])
+def login():
+ if request.method == "POST":
+ username = request.form["username"]
+ password = request.form["password"]
+
+ # Get database session
+ db = SessionLocal()
+
+ user = db.query(User).filter(User.username == username).first()
+ db.close()
+ if not user:
+ return {"status": "Invalid username or password"}
+
+ if bcrypt.checkpw(password.encode("utf-8"), user.password.encode("utf-8")):
+ flask_user = FlaskUser()
+ flask_user.id = username
+ login_user(flask_user)
+ return {"status": "success"}
+
+ return {"status": "Invalid username or password"}
+ return render_template("login.html")
+
+
+"""
+Handle signup requests from the web UI
+"""
+
+
+@app.route("/signup", methods=["GET", "POST"])
+def signup():
+ if request.method == "POST":
+ username = request.form["username"]
+ password = request.form["password"]
+
+ # Get database session
+ db = SessionLocal()
+
+ user = db.query(User).filter(User.username == username).first()
+ if user:
+ db.close()
+ return {"status": "User already exists"}
+ # Add information to the database
+ hashed_password = bcrypt.hashpw(
+ password.encode("utf-8"), bcrypt.gensalt()
+ ).decode("utf-8")
+ api_key = "".join(random.choices(string.ascii_letters + string.digits, k=20))
+ new_user = User(username=username, password=hashed_password, api_key=api_key)
+ db.add(new_user)
+ db.commit()
+ db.close()
+ # Log in the newly created user
+ flask_user = FlaskUser()
+ flask_user.id = username
+ login_user(flask_user)
+
+ return {"status": "success"}
+ return render_template("signup.html")
+
+
+"""
+Load the 'dashboard' page for logged in users
+"""
+
+
+@app.route("/dashboard", methods=["GET"])
+@login_required
+def dashboard():
+ # Get database session
+ db = SessionLocal()
+
+ # Get the API key for the current user
+ user = db.query(User).filter(User.username == current_user.id).first()
+ db.close()
+ api_key = user.api_key
+
+ return render_template("dashboard.html", api_key=api_key)
+
+
+"""
+Log users out of their account
+"""
+
+
+@app.route("/logout", methods=["GET"])
+@login_required
+def logout():
+ logout_user()
+ return redirect(url_for("login"))
+
+
+"""
+Log all records for visits to shortened links
+"""
+
+
+@app.route("/<link>", methods=["GET"])
+def log_redirect(link):
+ # If the `link` is more than 5 characters, ignore
+ if len(link) > 5:
+ return
+
+ # If the `link` is one of the registered routes, ignore
+ if link in ["login", "signup", "dashboard", "logout", "api"]:
+ return
+
+ ip = request.remote_addr
+ user_agent = request.headers.get("user-agent")
+ redirect_link = log(link, ip, user_agent)
+ return redirect(redirect_link)
+
+
+@app.errorhandler(401)
+def unauthorized(e):
+ return redirect(url_for("login"))
+
+
+@app.errorhandler(404)
+def not_found(e):
+ return redirect(url_for("login"))
diff --git a/app/routes.py b/app/routes.py
deleted file mode 100644
index fa5ab57..0000000
--- a/app/routes.py
+++ /dev/null
@@ -1,201 +0,0 @@
-import fastapi
-from fastapi import Security, HTTPException, Request
-from starlette.responses import RedirectResponse
-import pydantic
-import sqlalchemy
-
-from db import engine
-from check_api_key import check_api_key
-from func.generate_api_key import generate_api_key
-from func.newlink import generate_link
-from func.log import log
-from func.link.delete import delete_link
-from func.link.renew import renew_link
-from func.link.records import get_link_records
-from func.link.delrecords import delete_link_records
-from func.remove_old_data import remove_old_data
-
-from apscheduler.schedulers.background import BackgroundScheduler
-from contextlib import asynccontextmanager
-
-
-@asynccontextmanager
-async def lifespan(app: fastapi.FastAPI):
- # Create the scheduler
- scheduler = BackgroundScheduler()
- scheduler.add_job(remove_old_data, "cron", hour="0", minute="01")
- scheduler.start()
- yield
-
-class Newlink(pydantic.BaseModel):
- redirect_link: str
-
-app = fastapi.FastAPI(lifespan=lifespan)
-
-
-@app.post("/api/getapikey")
-async def get_api_key():
- """
- Create a new API key
- """
- api_key = generate_api_key()
- return {"api_key": api_key}
-
-
-@app.post("/api/genlink")
-async def newlink(newlink: Newlink, api_key: str = Security(check_api_key)):
- """
- Generate a new link that will redirect to the specified URL and log IPs in the middle
- """
- data = generate_link(newlink.redirect_link, api_key)
- if data == 422:
- raise HTTPException(
- status_code=fastapi.status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail="Malformed redirect link provided"
- )
-
- return {"link": data[0], "expire_date": data[1]}
-
-
-"""
-Return all records associated with an API key, no matter the link
-"""
-@app.get("/api/records")
-async def records(api_key: str = Security(check_api_key)):
- """
- Get ALL IP logs records for every link tied to your API key
- """
- with engine.begin() as conn:
- records = conn.execute(sqlalchemy.text("SELECT timestamp, ip, location, browser, os, user_agent, isp FROM records WHERE owner = :owner"), [{"owner": api_key}]).fetchall()
-
- if not records:
- return {"records": "No records are associated with this API key"}
-
- response = []
- for timestamp, ip, location, browser, os, user_agent, isp in records:
- response.append({"timestamp": timestamp, "ip": ip, "location": location, "browser": browser, "os": os, "user_agent": user_agent, "isp": isp})
-
- return response
-
-
-@app.get("/{link}")
-def link(link, request: Request):
- ip = request.client.host
- user_agent = request.headers.get("user-agent")
- redirect_link = log(link, ip, user_agent)
- return fastapi.responses.RedirectResponse(url=redirect_link)
-
-
-"""
-Return all links associated with an API key
-"""
-@app.get("/api/links")
-async def links(api_key: str = Security(check_api_key)):
- """
- Retrieve all links that are currently tied to your API key
- """
- with engine.begin() as conn:
- links = conn.execute(sqlalchemy.text("SELECT link, expire_date FROM links WHERE owner = :owner"), [{"owner": api_key}]).fetchall()
-
- if not links:
- return {"links": "No links are associated with this API key"}
-
- response = []
- for link, expire_date in links:
- response.append({"link": link, "expire_date": expire_date})
- return response
-
-
-@app.post("/api/{link}/delete")
-async def link_delete(link: str, api_key: str = Security(check_api_key)):
- """
- Delete the specified link and all records associated with it
- """
- data = delete_link(link, api_key)
- if data == 404:
- raise HTTPException(
- status_code=fastapi.status.HTTP_404_NOT_FOUND,
- detail="Link does not exist"
- )
- if data == 401:
- raise HTTPException(
- status_code=fastapi.status.HTTP_401_UNAUTHORIZED,
- detail="Link not associated with given API key"
- )
- else:
- return {"link": f"The link {data} has been deleted"}
-
-
-@app.post("/api/{link}/renew")
-async def link_renew(link: str, api_key: str = Security(check_api_key)):
- """
- Renew a specifiec link (adds 7 more days from the current date)
- """
- data = renew_link(link, api_key)
- if data == 404:
- raise HTTPException(
- status_code=fastapi.status.HTTP_404_NOT_FOUND,
- detail="Link does not exist"
- )
- if data == 401:
- raise HTTPException(
- status_code=fastapi.status.HTTP_401_UNAUTHORIZED,
- detail="Link not associated with given API key"
- )
- else:
- return {"link": f"The link {data[0]} has been renewed and will expire on {data[1]}"}
-
-
-@app.get("/api/{link}/records")
-async def link_records(link: str, api_key: str = Security(check_api_key)):
- """
- Retrieve all IP log records for the specified link
- """
- data = get_link_records(link, api_key)
- if data == 404:
- raise HTTPException(
- status_code=fastapi.status.HTTP_404_NOT_FOUND,
- detail="Link does not exist"
- )
- if data == 401:
- raise HTTPException(
- status_code=fastapi.status.HTTP_401_UNAUTHORIZED,
- detail="Link not associated with given API key"
- )
- if data == 204:
- raise HTTPException(
- status_code=fastapi.status.HTTP_204_NO_CONTENT,
- detail="No records found"
- )
- else:
- response = []
- for timestamp, ip, location, browser, os, user_agent, isp in data:
- response.append({"timestamp": timestamp, "ip": ip, "location": location, "browser": browser, "os": os, "user_agent": user_agent, "isp": isp})
-
- return response
-
-
-@app.post("/api/{link}/delrecords")
-async def link_delrecords(link: str, api_key: str = Security(check_api_key)):
- """
- Delete all IP log records for the specified link
- """
- data = delete_link_records(link, api_key)
- if data == 404:
- raise HTTPException(
- status_code=fastapi.status.HTTP_404_NOT_FOUND,
- detail="Link does not exist"
- )
- if data == 401:
- raise HTTPException(
- status_code=fastapi.status.HTTP_401_UNAUTHORIZED,
- detail="Link not associated with given API key"
- )
- else:
- return {"link": f"The records for link {data} have been deleted"}
-
-
-# Redirect / -> /docs
-@app.get("/", summary="Redirect to the Swagger UI documentation")
-async def redirect_to_docs():
- return RedirectResponse(url="/docs") \ No newline at end of file
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html
new file mode 100644
index 0000000..554ec03
--- /dev/null
+++ b/app/templates/dashboard.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>LinkLogger | Login</title>
+</head>
+<body>
+ <div>
+ <!-- Create a small box that will hold the text for the users api key, next to the box should be a regenerate button -->
+ <p>Your API Key: <span id="api-key">{{ api_key }}</span></p>
+ <button onclick="window.location.href='logout'">Logout</button>
+ </div>
+</body>
+</html>
+
+<style>
+ body {
+ margin: 0;
+ padding: 0;
+ font-family: Arial, sans-serif;
+ background-color: #2c3338;
+ }
+
+ div {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ font-size: 25px;
+ color: #ccc;
+ }
+
+ button {
+ display: block;
+ margin: 10px auto;
+ width: 200px;
+ border-radius: 5px;
+ padding: 15px;
+ color: #ccc;
+ background-color: #415eac;
+ border: none;
+ font-size: 17px;
+ cursor: pointer;
+ }
+</style> \ No newline at end of file
diff --git a/app/templates/login.html b/app/templates/login.html
new file mode 100644
index 0000000..25ce3b6
--- /dev/null
+++ b/app/templates/login.html
@@ -0,0 +1,111 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>LinkLogger | Login</title>
+</head>
+<body>
+ <div>
+ <p id="error">Incorrect username/password. Please try again.</p>
+ <form action="/login" method="POST">
+ <input type="text" name="username" placeholder="Username" required>
+ <input type="password" name="password" placeholder="Password" required>
+ <button type="submit">Login</button>
+ </form>
+ <hr>
+ <p>Don't have an account? <a href="/signup">Create one now</a></p>
+ </div>
+</body>
+</html>
+
+<style>
+ body {
+ margin: 0;
+ padding: 0;
+ font-family: Arial, sans-serif;
+ background-color: #2c3338;
+ }
+
+ div {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ }
+
+ input {
+ display: block;
+ margin: 10px auto;
+ width: 300px;
+ border-radius: 5px;
+ padding: 15px;
+ color: #ccc;
+ background-color: #3b4148;
+ border: none;
+ font-size: 17px;
+ }
+
+ button {
+ display: block;
+ margin: 10px auto;
+ width: 100%;
+ border-radius: 5px;
+ padding: 15px;
+ color: #ccc;
+ background-color: #415eac;
+ border: none;
+ font-size: 17px;
+ cursor: pointer;
+ }
+
+ hr {
+ color: #606468;
+ }
+
+ p {
+ color: #606468;
+ }
+
+ #error {
+ font-size: 15px;
+ color: #f55757;
+ display: none;
+ }
+
+ a {
+ color: #ccc;
+ text-decoration: none;
+ }
+
+ a:hover {
+ text-decoration: underline;
+ }
+</style>
+
+<script>
+ document.querySelector('form').addEventListener('submit', async function(event) {
+ // Prevent default form submission
+ event.preventDefault();
+
+ // Get form data
+ const formData = new FormData(this);
+
+ console.log(formData)
+
+ // Send POST request to /login containing form data
+ const response = await fetch('/login', {
+ method: 'POST',
+ body: formData
+ });
+
+ data = await response.json()
+
+ if (data.status != "success") {
+ document.getElementById('error').style.display = 'block';
+ } else {
+ window.location.href = '/dashboard';
+ }
+ });
+</script> \ No newline at end of file
diff --git a/app/templates/signup.html b/app/templates/signup.html
new file mode 100644
index 0000000..0d2aebd
--- /dev/null
+++ b/app/templates/signup.html
@@ -0,0 +1,111 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>LinkLogger | Signup</title>
+</head>
+<body>
+ <div>
+ <p id="error">User already exists. Please try again</p>
+ <form action="/signup" method="POST">
+ <input type="text" name="username" placeholder="Username" required>
+ <input type="password" name="password" placeholder="Password" required>
+ <button type="submit">Signup</button>
+ </form>
+ <hr>
+ <p>Already have an account? <a href="/login">Log in now</a></p>
+ </div>
+</body>
+</html>
+
+<style>
+ body {
+ margin: 0;
+ padding: 0;
+ font-family: Arial, sans-serif;
+ background-color: #2c3338;
+ }
+
+ div {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ }
+
+ input {
+ display: block;
+ margin: 10px auto;
+ width: 300px;
+ border-radius: 5px;
+ padding: 15px;
+ color: #ccc;
+ background-color: #3b4148;
+ border: none;
+ font-size: 17px;
+ }
+
+ button {
+ display: block;
+ margin: 10px auto;
+ width: 100%;
+ border-radius: 5px;
+ padding: 15px;
+ color: #ccc;
+ background-color: #415eac;
+ border: none;
+ font-size: 17px;
+ cursor: pointer;
+ }
+
+ hr {
+ color: #606468;
+ }
+
+ p {
+ color: #606468;
+ }
+
+ #error {
+ font-size: 15px;
+ color: #f55757;
+ display: none;
+ }
+
+ a {
+ color: #ccc;
+ text-decoration: none;
+ }
+
+ a:hover {
+ text-decoration: underline;
+ }
+</style>
+
+<script>
+ document.querySelector('form').addEventListener('submit', async function(event) {
+ // Prevent default form submission
+ event.preventDefault();
+
+ // Get form data
+ const formData = new FormData(this);
+
+ console.log(formData)
+
+ // Send POST request to /signup containing form data
+ const response = await fetch('/signup', {
+ method: 'POST',
+ body: formData
+ });
+
+ data = await response.json()
+
+ if (data.status != "success") {
+ document.getElementById('error').style.display = 'block';
+ } else {
+ window.location.href = '/dashboard';
+ }
+ });
+</script> \ No newline at end of file
diff --git a/app/util/log.py b/app/util/log.py
new file mode 100644
index 0000000..e49080f
--- /dev/null
+++ b/app/util/log.py
@@ -0,0 +1,65 @@
+import ip2locationio
+import datetime
+from ua_parser import user_agent_parser
+from ip2locationio.ipgeolocation import IP2LocationIOAPIError
+
+from database import SessionLocal
+from var import LOG, API_KEY, IP_TO_LOCATION
+from models import Link, Record
+
+configuration = ip2locationio.Configuration(API_KEY)
+ipgeolocation = ip2locationio.IPGeolocation(configuration)
+
+"""
+Create a new log record whenever a link is visited
+"""
+
+
+def log(link, ip, user_agent):
+ db = SessionLocal()
+
+ # Get the redirect link and owner of the link
+ redirect_link, owner = (
+ db.query(Link.redirect_link, Link.owner).filter(Link.link == link).first()
+ )
+
+ if IP_TO_LOCATION == "TRUE":
+ # Get IP to GEO via IP2Location.io
+ try:
+ data = ipgeolocation.lookup(ip)
+ location = f'{data["country_name"]}, {data["city_name"]}'
+ isp = data["as"]
+ # Fatal error, API key is invalid or out of requests, quit
+ except IP2LocationIOAPIError:
+ LOG.error(
+ "Invalid API key or insufficient credits. Change the `config.ini` file if you no longer need the IP2Location API feature."
+ )
+ location = "-, -"
+ isp = "-"
+ else:
+ location = "-, -"
+ isp = "-"
+
+ timestamp = datetime.datetime.now()
+ ua_string = user_agent_parser.Parse(user_agent)
+ browser = ua_string["user_agent"]["family"]
+ os = f'{ua_string["os"]["family"]} {ua_string["os"]["major"]}'
+
+ # Create the log record and commit it to the database
+ link_record = Record(
+ owner=owner,
+ link=link,
+ timestamp=timestamp,
+ ip=ip,
+ location=location,
+ browser=browser,
+ os=os,
+ user_agent=user_agent,
+ isp=isp,
+ )
+ db.add(link_record)
+ db.commit()
+ db.close()
+
+ # Return the redirect link in order to properly redirect the user
+ return redirect_link