aboutsummaryrefslogtreecommitdiff
path: root/api
diff options
context:
space:
mode:
Diffstat (limited to 'api')
-rw-r--r--api/main.py93
-rw-r--r--api/routes/auth_routes.py46
-rw-r--r--api/routes/links_routes.py172
-rw-r--r--api/routes/user_routes.py129
-rw-r--r--api/schemas/auth_schemas.py15
-rw-r--r--api/schemas/links_schemas.py5
-rw-r--r--api/schemas/user_schemas.py11
-rw-r--r--api/static/js/api.js26
-rw-r--r--api/templates/dashboard.html160
-rw-r--r--api/templates/login.html105
-rw-r--r--api/templates/signup.html112
-rw-r--r--api/util/authentication.py97
-rw-r--r--api/util/check_password_reqs.py26
-rw-r--r--api/util/db_dependency.py9
-rw-r--r--api/util/log.py84
15 files changed, 1090 insertions, 0 deletions
diff --git a/api/main.py b/api/main.py
new file mode 100644
index 0000000..c8c1296
--- /dev/null
+++ b/api/main.py
@@ -0,0 +1,93 @@
+from fastapi import FastAPI, Depends, Request, Path
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse, RedirectResponse
+from api.routes.auth_routes import router as auth_router
+from api.routes.links_routes import router as links_router
+from api.routes.user_routes import router as user_router
+from typing import Annotated
+from fastapi.exceptions import HTTPException
+from starlette.status import HTTP_404_NOT_FOUND
+
+from api.util.db_dependency import get_db
+from api.util.log import log
+from models import Link
+
+
+app = FastAPI(
+ title="LinkLogger API",
+ version="2.0",
+ summary="Public API for a combined link shortener and IP logger",
+ license_info={
+ "name": "The Unlicense",
+ "identifier": "Unlicense",
+ "url": "https://unlicense.org",
+ },
+)
+
+origins = [
+ "http://localhost:3000",
+ "http://127.0.0.1:3000",
+ "localhost:3000",
+ "127.0.0.1:3000",
+ # f"{CUSTOM_DOMAIN}"
+]
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=origins,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# Import routes
+app.include_router(auth_router, prefix="/api")
+app.include_router(links_router, prefix="/api")
+app.include_router(user_router, prefix="/api")
+
+
+@app.get("/c/{link}")
+async def log_redirect(
+ link: Annotated[str, Path(title="Redirect link")],
+ request: Request,
+ db=Depends(get_db),
+):
+ link = link.upper()
+ # Links must be 5 characters long
+ if len(link) != 5:
+ return RedirectResponse(url="/login")
+
+ # Make sure the link exists in the database
+ link_record: Link = db.query(Link).filter(Link.link == link).first()
+ if not link_record:
+ db.close()
+ return RedirectResponse(url="/login")
+ else:
+ # Get the IP and log the request
+ if request.headers.get("X-Real-IP"):
+ ip = request.headers.get("X-Real-IP").split(",")[0]
+ else:
+ ip = request.client.host
+ user_agent = request.headers.get("User-Agent")
+ log(link, ip, user_agent)
+ db.close()
+ return RedirectResponse(url=link_record.redirect_link)
+
+
+# Redirect /api -> /api/docs
+@app.get("/api")
+async def redirect_to_docs():
+ return RedirectResponse(url="/docs")
+
+
+# Custom handler for 404 errors
+@app.exception_handler(HTTP_404_NOT_FOUND)
+async def custom_404_handler(request: Request, exc: HTTPException):
+ # If the request is from /api, return a JSON response
+ if request.url.path.startswith("/api"):
+ return JSONResponse(
+ status_code=404,
+ content={"message": "Resource not found"},
+ )
+ # Otherwise, redirect to the login page
+ return RedirectResponse(url="/login")
diff --git a/api/routes/auth_routes.py b/api/routes/auth_routes.py
new file mode 100644
index 0000000..c51557f
--- /dev/null
+++ b/api/routes/auth_routes.py
@@ -0,0 +1,46 @@
+from fastapi import Depends, APIRouter, status, HTTPException
+from fastapi.security import OAuth2PasswordRequestForm
+from fastapi.responses import Response, JSONResponse
+from datetime import timedelta
+from typing import Annotated
+
+from api.util.authentication import (
+ create_access_token,
+ authenticate_user,
+)
+from api.util.db_dependency import get_db
+
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+
+@router.post("/token", summary="Authenticate and get an access token")
+async def login_for_access_token(
+ form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
+ response: Response,
+ db=Depends(get_db),
+):
+ """
+ Return an access token for the user, if the given authentication details are correct
+ """
+ user = authenticate_user(db, form_data.username, form_data.password)
+
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Incorrect username or password",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ access_token_expires = timedelta(minutes=1)
+ access_token = create_access_token(
+ data={"sub": user.id, "username": user.username},
+ expires_delta=access_token_expires,
+ )
+ response = JSONResponse(content={"success": True})
+ response.set_cookie(
+ key="access_token",
+ value=access_token,
+ httponly=True, # Prevents client-side access
+ # secure=True, # Cookies are only sent over HTTPS
+ )
+ return response
diff --git a/api/routes/links_routes.py b/api/routes/links_routes.py
new file mode 100644
index 0000000..5ed565b
--- /dev/null
+++ b/api/routes/links_routes.py
@@ -0,0 +1,172 @@
+from fastapi import APIRouter, status, Path, Depends
+from fastapi.exception_handlers import HTTPException
+from typing import Annotated
+import string
+import random
+import datetime
+import validators
+
+from api.util.db_dependency import get_db
+from models import Link, Log
+from api.schemas.links_schemas import URLSchema
+from api.schemas.auth_schemas import User
+from api.util.authentication import get_current_user
+
+
+router = APIRouter(prefix="/links", tags=["links"])
+
+
+@router.get("", summary="Get all of the links associated with your account")
+async def get_links(
+ current_user: Annotated[User, Depends(get_current_user)],
+ db=Depends(get_db),
+):
+ # Get and sort links by expiration date descending
+ links = (
+ db.query(Link)
+ .filter(Link.owner == current_user.id)
+ .order_by(Link.expire_date.desc())
+ .all()
+ )
+ if not links:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="No links found"
+ )
+ return links
+
+
+@router.post("", summary="Create a new link")
+async def create_link(
+ url: URLSchema,
+ current_user: Annotated[User, Depends(get_current_user)],
+ db=Depends(get_db),
+):
+ # Check if the URL is valid
+ if not validators.url(url.url):
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail="Invalid URL",
+ )
+ # Create the new link and add it to the database
+ while True:
+ try:
+ link_path = "".join(
+ random.choices(string.ascii_uppercase + "1234567890", k=5)
+ ).upper()
+ new_link = Link(
+ link=link_path,
+ owner=current_user.id,
+ redirect_link=url.url,
+ expire_date=datetime.datetime.utcnow()
+ + datetime.timedelta(days=30),
+ )
+ db.add(new_link)
+ db.commit()
+ break
+ except:
+ continue
+
+ return {"link": link_path, "expire_date": new_link.expire_date}
+
+
+@router.delete("/{link}", summary="Delete a link and all associated logs")
+async def delete_link(
+ link: Annotated[str, Path(title="Link to delete")],
+ current_user: Annotated[User, Depends(get_current_user)],
+ db=Depends(get_db),
+):
+ """
+ Delete a link and all of the logs associated with it
+ """
+ link = link.upper()
+ # Get the link and check the owner
+ link = db.query(Link).filter(Link.link == link).first()
+ if not link:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Link not found"
+ )
+ if link.owner != current_user.id:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Link not associated with your account",
+ )
+
+ # Get and delete all logsk
+ logs = db.query(Log).filter(Log.link == link.link).all()
+ for log in logs:
+ db.delete(log)
+ # Delete the link
+ db.delete(link)
+ db.commit()
+
+ return status.HTTP_204_NO_CONTENT
+
+
+@router.get("/{link}/logs", summary="Get all logs associated with a link")
+async def get_link_logs(
+ link: Annotated[str, Path(title="Link to get logs for")],
+ current_user: Annotated[User, Depends(get_current_user)],
+ db=Depends(get_db),
+):
+ """
+ Get all of the IP logs associated with a link
+ """
+ link = link.upper()
+ # Get the link and check the owner
+ link = db.query(Link).filter(Link.link == link).first()
+ if not link:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Link not found"
+ )
+ if link.owner != current_user.id:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Link not associated with your account",
+ )
+
+ # Get and return all of the logs - ordered by timestamp
+ logs = (
+ db.query(Log)
+ .filter(Log.link == link.link)
+ .order_by(Log.timestamp.desc())
+ .all()
+ )
+ return logs
+
+
+@router.delete(
+ "/{link}/logs/{log_id}",
+ summary="Delete a specific log associated with a link",
+)
+async def delete_single_log(
+ link: Annotated[str, Path(title="Link associated with the log to delete")],
+ log_id: Annotated[int, Path(title="Log ID to delete")],
+ current_user: Annotated[User, Depends(get_current_user)],
+ db=Depends(get_db),
+):
+ """
+ Delete the specified log associated with a link
+ """
+ link = link.upper()
+ # Get the link and check the owner
+ link = db.query(Link).filter(Link.link == link).first()
+ if not link:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Link not found"
+ )
+ if link.owner != current_user.id:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Link not associated with your account",
+ )
+
+ # Get the log and delete it
+ log = db.query(Log).filter(Log.id == log_id).first()
+ if not log:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail="Log not found"
+ )
+ db.delete(log)
+ db.commit()
+
+ return status.HTTP_204_NO_CONTENT
diff --git a/api/routes/user_routes.py b/api/routes/user_routes.py
new file mode 100644
index 0000000..cf9be52
--- /dev/null
+++ b/api/routes/user_routes.py
@@ -0,0 +1,129 @@
+from fastapi import APIRouter, status, Path, Depends
+from fastapi.exception_handlers import HTTPException
+from fastapi.security import OAuth2PasswordRequestForm
+from typing import Annotated
+import string
+import bcrypt
+import random
+
+from api.util.db_dependency import get_db
+from api.util.check_password_reqs import check_password_reqs
+from api.schemas.auth_schemas import User
+from api.schemas.user_schemas import *
+from models import User as UserModel
+from api.util.authentication import (
+ verify_password,
+ get_current_user,
+)
+
+
+router = APIRouter(prefix="/users", tags=["users"])
+
+
+@router.delete("/{user_id}", summary="Delete your account")
+async def delete_user(
+ user_id: Annotated[int, Path(title="Link to delete")],
+ current_user: Annotated[User, Depends(get_current_user)],
+ db=Depends(get_db),
+):
+ """
+ Delete the user account associated with the current user
+ """
+ # No editing others accounts
+ if user_id != current_user.id:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="You can only delete your own account",
+ )
+
+ # Get the user and delete them
+ user = db.query(UserModel).filter(UserModel.id == current_user.id).first()
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found",
+ )
+
+ db.delete(user)
+ db.commit()
+ return status.HTTP_204_NO_CONTENT
+
+
+@router.post("/{user_id}/password", summary="Update your account password")
+async def update_pass(
+ user_id: Annotated[int, Path(title="Link to update")],
+ update_data: UpdatePasswordSchema,
+ current_user: Annotated[User, Depends(get_current_user)],
+ db=Depends(get_db),
+):
+ """
+ Update the pass of the current user account
+ """
+ if user_id != current_user.id:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="You can only update your own account",
+ )
+
+ # Make sure that they entered the correct current password
+ if not verify_password(
+ update_data.current_password, current_user.hashed_password
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Incorrect current password",
+ )
+
+ # Make sure the password meets all of the requirements
+ check_password_reqs(update_data.new_password)
+
+ # Get the user and update the password
+ user = db.query(UserModel).filter(UserModel.id == current_user.id).first()
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found",
+ )
+ user.hashed_password = bcrypt.hashpw(
+ update_data.new_password.encode("utf-8"), bcrypt.gensalt()
+ ).decode("utf-8")
+ db.commit()
+ return status.HTTP_204_NO_CONTENT
+
+
+@router.post("/register", summary="Register a new user")
+async def get_links(
+ form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
+ db=Depends(get_db),
+):
+ """
+ Given the login data (username, password) process the registration of a new
+ user account and return either the user or an error message
+ """
+ username = form_data.username
+ password = form_data.password
+
+ # Make sure the password meets all of the requirements
+ check_password_reqs(password)
+
+ # Make sure the username isn't taken
+ user = db.query(UserModel).filter(UserModel.username == username).first()
+ if user:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Username not available",
+ )
+ # Otherwise, hash the password, create the api key, and add the new user
+ 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 = UserModel(
+ username=username, hashed_password=hashed_password, api_key=api_key
+ )
+ db.add(new_user)
+ db.commit()
+
+ return status.HTTP_201_CREATED
diff --git a/api/schemas/auth_schemas.py b/api/schemas/auth_schemas.py
new file mode 100644
index 0000000..48745ee
--- /dev/null
+++ b/api/schemas/auth_schemas.py
@@ -0,0 +1,15 @@
+from pydantic import BaseModel
+
+
+class Token(BaseModel):
+ access_token: str
+ token_type: str
+
+
+class User(BaseModel):
+ username: str
+ id: int
+
+
+class UserInDB(User):
+ hashed_password: str
diff --git a/api/schemas/links_schemas.py b/api/schemas/links_schemas.py
new file mode 100644
index 0000000..e2812fb
--- /dev/null
+++ b/api/schemas/links_schemas.py
@@ -0,0 +1,5 @@
+from pydantic import BaseModel
+
+
+class URLSchema(BaseModel):
+ url: str
diff --git a/api/schemas/user_schemas.py b/api/schemas/user_schemas.py
new file mode 100644
index 0000000..949b9a5
--- /dev/null
+++ b/api/schemas/user_schemas.py
@@ -0,0 +1,11 @@
+from pydantic import BaseModel
+
+
+class LoginDataSchema(BaseModel):
+ username: str
+ password: str
+
+
+class UpdatePasswordSchema(BaseModel):
+ current_password: str
+ new_password: str
diff --git a/api/static/js/api.js b/api/static/js/api.js
new file mode 100644
index 0000000..243edf7
--- /dev/null
+++ b/api/static/js/api.js
@@ -0,0 +1,26 @@
+// Description: This file contains functions to access the API with JWT authentication.
+
+/**
+ * Accept an API endpoint, method, and body to send to the API.
+ * - If successful, return the response
+ * - If not, return false
+ * @param {*} endpoint API endpoint
+ * @param {*} method String (GET, POST, PUT, DELETE)
+ * @param {*} body Data to send to the API
+ * @returns response.json or false
+ */
+async function accessAPI(endpoint, method, body) {
+ let response = await fetch(`/api${endpoint}`, {
+ method: method,
+ body: body,
+ });
+
+ if (response.ok) {
+ let data = await response.json();
+ data = await data;
+ return data;
+
+ }
+
+ return false;
+} \ No newline at end of file
diff --git a/api/templates/dashboard.html b/api/templates/dashboard.html
new file mode 100644
index 0000000..25372bd
--- /dev/null
+++ b/api/templates/dashboard.html
@@ -0,0 +1,160 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>LinkLogger | Dashboard</title>
+
+ <link rel="stylesheet" href="/static/css/dashboard.css">
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css" rel="stylesheet">
+ <script src="/static/js/jwt.js"></script>
+</head>
+<body>
+ <div>
+ <!-- Create a table with 4 columns with a total of 1000px width -->
+ <table>
+ <tr style="border: 2px solid #ccc;">
+ <th>Link</th>
+ <th>Visits</th>
+ <th>Redirect</th>
+ <th>Expire Date</th>
+ </tr>
+ </table>
+ </div>
+</body>
+</html>
+<script>
+ function createRow(index, link, logs) {
+ // Create the sub-table with the logs
+ let subTable = `
+ <table>
+ <tr>
+ <th>ID</th>
+ <th>Timestamp</th>
+ <th>IP</th>
+ <th>Location</th>
+ <th colspan="2">ISP</th>
+ </tr>
+ `;
+ // Loop through the logs and create a row for each one
+ logs.forEach((log, index) => {
+ let logTimestamp = new Date(log.timestamp).toLocaleString('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ }).replace(',', '');
+
+ let row = `
+ <tr id="${log.id}-log">
+ <td>${logs.length - index}</td>
+ <td>${logTimestamp}</td>
+ <td>${log.ip}</td>
+ <td>${log.location}</td>
+ <td>${log.isp}</td>
+ <td><i class="fa-solid fa-trash" id="${log.id}/${link.link}""></i></td>
+ </tr>
+ `;
+ subTable += row;
+ });
+ subTable += '</table>';
+
+ // Convert the link expire timestamp to a readable date
+ let date = new Date(link.expire_date);
+ let expireDate = date.toLocaleTimeString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ });
+
+ // Create the HTML for the row with sub-table
+ let row = `
+ <tr class="link-table-row">
+ <td>
+ <button class="link-button" id="${index}">${link.link}</button>
+ </td>
+ <td>${logs.length}</td>
+ <td>${link.redirect_link}</td>
+ <td>${expireDate}</td>
+ </tr>
+ <tr class="log-table-row" id="${index}-logTR" style="display: none;">
+ <td colspan="6">
+ ${subTable}
+ </td>
+ </tr>
+ `;
+ return row;
+ }
+
+
+
+ async function getData() {
+ const links = await accessAPI(`/links`, 'GET')
+ if (!links) {
+ throw new Error('Failed to fetch links');
+ }
+ // Links is an Array of objects with the link data
+ // Loop through the links and create a row for each one
+ // Do not use async because then the order or data in the
+ // table will change from time to time
+ for (let i = 0; i < links.length; i++) {
+ let link = links[i];
+ let logs = await accessAPI(`/links/${link.link}/logs`, 'GET')
+ if (!logs) {
+ throw new Error('Failed to fetch logs');
+ }
+ let row = createRow(i, link, logs);
+ document.querySelector('table').innerHTML += row;
+ }
+ }
+
+ // hideLogRows to all log-table-rows
+ function hideLogRows() {
+ let logTRs = document.querySelectorAll('.log-table-row');
+ logTRs.forEach(row => {
+ row.style.display = 'none';
+ });
+ }
+
+ // Add event listener to all link buttons
+ document.addEventListener('click', (event) => {
+ if (event.target.classList.contains('link-button')) {
+ let id = event.target.id;
+ let logTR = document.getElementById(`${id}-logTR`);
+ if (logTR.style.display === 'none') {
+ // Hide any open log tables
+ hideLogRows();
+ logTR.style.display = 'table-row';
+ } else {
+ logTR.style.display = 'none';
+ }
+ }
+ });
+
+ // Add an event listen to all trash bins
+ document.addEventListener('click', (event) => {
+ if (event.target.classList.contains('fa-trash')) {
+ // Confirm the user wants to delete the log
+ let confirmDelete = confirm('Are you sure you want to delete this log?');
+ if (confirmDelete) {
+ let id = event.target.id;
+ let link = id.split('/')[1];
+ let logId = id.split('/')[0];
+ fetch(`/api/links/${link}/logs/${logId}`, {
+ method: 'DELETE'
+ });
+ let logRow = document.getElementById(`${logId}-log`)
+ logRow.remove();
+ }
+ }
+ });
+
+ getData();
+</script> \ No newline at end of file
diff --git a/api/templates/login.html b/api/templates/login.html
new file mode 100644
index 0000000..8e59481
--- /dev/null
+++ b/api/templates/login.html
@@ -0,0 +1,105 @@
+<!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();
+
+ const formData = new FormData(this);
+ // Send POST request to /token containing form data
+ const response = await fetch('/api/auth/token', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (response.status != 200) {
+ document.getElementById('error').style.display = 'block';
+ } else {
+ window.location.href = '/dashboard';
+ }
+ });
+</script> \ No newline at end of file
diff --git a/api/templates/signup.html b/api/templates/signup.html
new file mode 100644
index 0000000..32962b7
--- /dev/null
+++ b/api/templates/signup.html
@@ -0,0 +1,112 @@
+<!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"></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>
+ <p>Passwords must be at least 8 characters long and contain a number, special character, and uppercase character.</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;
+ max-width: 330px;
+ }
+
+ 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);
+
+ // Send POST request
+ const response = await fetch('/api/users/register', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (response.status != 200) {
+ const data = await response.json()
+
+ document.getElementById('error').style.display = 'block';
+ document.getElementById('error').innerText = data.detail;
+ } else {
+ window.location.href = '/dashboard';
+ }
+ });
+</script> \ No newline at end of file
diff --git a/api/util/authentication.py b/api/util/authentication.py
new file mode 100644
index 0000000..43b147a
--- /dev/null
+++ b/api/util/authentication.py
@@ -0,0 +1,97 @@
+import random
+import bcrypt
+from fastapi import Depends, HTTPException, status, Request, Cookie
+from fastapi.security import OAuth2PasswordBearer
+from fastapi.responses import Response
+from jwt.exceptions import InvalidTokenError, ExpiredSignatureError
+from datetime import datetime, timedelta
+import jwt
+
+from api.util.db_dependency import get_db
+from sqlalchemy.orm import Session
+from api.schemas.auth_schemas import *
+from models import User as UserModel
+
+secret_key = random.randbytes(32)
+algorithm = "HS256"
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/token")
+
+"""
+Helper functions for authentication
+"""
+
+
+def verify_password(plain_password, hashed_password):
+ return bcrypt.checkpw(
+ plain_password.encode("utf-8"), hashed_password.encode("utf-8")
+ )
+
+
+def get_user(db, username: str):
+ """
+ Get the user object from the database
+ """
+ user = db.query(UserModel).filter(UserModel.username == username).first()
+ if user:
+ return UserInDB(**user.__dict__)
+
+
+def authenticate_user(db, username: str, password: str):
+ """
+ Determine if the correct username and password were provided
+ If so, return the user object
+ """
+ user = get_user(db, username)
+ if not user:
+ return False
+ if not verify_password(password, user.hashed_password):
+ print("WHY")
+ return False
+ return user
+
+
+def create_access_token(data: dict, expires_delta: timedelta):
+ """
+ Return an encoded JWT token with the given data
+ """
+ to_encode = data.copy()
+ expire = datetime.utcnow() + expires_delta
+ to_encode.update({"exp": expire})
+ encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=algorithm)
+ return encoded_jwt
+
+
+def raise_unauthorized():
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+
+async def get_current_user(
+ request: Request,
+ db=Depends(get_db),
+):
+ """
+ Return the current user object if the access token is valid
+
+ All failed attempts will return a 401
+ """
+ token = request.cookies.get("access_token")
+
+ try:
+ payload = jwt.decode(token, secret_key, algorithms=[algorithm])
+ id: int = payload.get("sub")
+ username: str = payload.get("username")
+ if not id or not username:
+ return raise_unauthorized()
+
+ except InvalidTokenError:
+ return raise_unauthorized()
+
+ user = get_user(db, username)
+ if user is None:
+ return raise_unauthorized()
+
+ return user
diff --git a/api/util/check_password_reqs.py b/api/util/check_password_reqs.py
new file mode 100644
index 0000000..dcb9bf8
--- /dev/null
+++ b/api/util/check_password_reqs.py
@@ -0,0 +1,26 @@
+from fastapi import HTTPException, status
+
+
+def check_password_reqs(password: str):
+ """
+ Make sure the entered password meets the security requirements:
+ 1. At least 8 characters
+ 2. At least one digit
+ 3. At least one uppercase letter
+ """
+ if len(password) < 8:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Password must be at least 8 characters",
+ )
+ if not any(char.isdigit() for char in password):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Password must contain at least one digit",
+ )
+ if not any(char.isupper() for char in password):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Password must contain at least one uppercase letter",
+ )
+ return
diff --git a/api/util/db_dependency.py b/api/util/db_dependency.py
new file mode 100644
index 0000000..a6734ea
--- /dev/null
+++ b/api/util/db_dependency.py
@@ -0,0 +1,9 @@
+from database import SessionLocal
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/api/util/log.py b/api/util/log.py
new file mode 100644
index 0000000..58a56f9
--- /dev/null
+++ b/api/util/log.py
@@ -0,0 +1,84 @@
+import requests
+import datetime
+from ua_parser import user_agent_parser
+
+from database import SessionLocal
+import config
+from models import Link, Log
+
+"""
+Create a new log whenever a link is visited
+"""
+
+
+def ip_to_location(ip):
+ if not config.IP_TO_LOCATION:
+ return "-, -", "-"
+
+ url = f"https://api.ip2location.io/?key={config.API_KEY}&ip={ip}"
+ response = requests.get(url)
+ data = response.json()
+
+ if response.status_code != 200:
+ config.LOG.error(
+ "Error with IP2Location API. Perhaps the API is down."
+ )
+ return "-, -", "-"
+
+ if "error" in data:
+ config.LOG.error(
+ "Error with IP2Location API. Likely wrong API key or insufficient"
+ " funds."
+ )
+ return "-, -", "-"
+
+ location = ""
+ # Sometimes a certain name may not be present, so always check
+ if "city_name" in data:
+ location += data["city_name"]
+
+ if "region_name" in data:
+ location += f', {data["region_name"]}'
+
+ if "country_name" in data:
+ location += f', {data["country_name"]}'
+
+ isp = data["as"]
+ return location, isp
+
+
+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()
+ )
+
+ # Get the location and ISP of the user
+ location, isp = ip_to_location(ip)
+
+ 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 and commit it to the database
+ new_log = Log(
+ owner=owner,
+ link=link,
+ timestamp=datetime.datetime.utcnow(),
+ ip=ip,
+ location=location,
+ browser=browser,
+ os=os,
+ user_agent=user_agent,
+ isp=isp,
+ )
+ db.add(new_log)
+ db.commit()
+ db.close()
+
+ # Return the redirect link in order to properly redirect the user
+ return redirect_link