diff --git a/.env.example b/.env.example
deleted file mode 100644
index 72c6647..0000000
--- a/.env.example
+++ /dev/null
@@ -1,4 +0,0 @@
-BASE_URL="" # Redirect link for when people visit old/dead/non-existant link
-IP_TO_LOCATION="" # "True" or "False" - whether or not you need the IP to location feature,
- # If not needed, you can leave the API_KEY blank
-API_KEY="" # API Key for IP2Location.io
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index bf9a7af..2e48e60 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,4 +10,4 @@ COPY . .
RUN pip install -r requirements.txt
ENTRYPOINT [ "python" ]
-CMD [ "-u", "app/linklogger.py" ]
\ No newline at end of file
+CMD [ "-u", "linklogger.py" ]
\ No newline at end of file
diff --git a/README.md b/README.md
index 32453c1..3cc7020 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,56 @@
+
+ LinkLogger
+
-# LinkLogger API
+
+ Link shortener and IP logger
+
-A simple API for you to create redirect links on my domain (link.pkrm.dev) and log all IPs that click on the link. Essentially just grabify with no GUI.
+
-Feel free to submit an issue for any problems you experience or if you have an idea for a new feature. If you have a fix for anything, please submit a pull request for review.
+# Overview
+### Create an account now at [link.pkrm.dev](https://link.pkrm.dev/signup)
-## Want to self-host?
+
-#### Bare metal
-Feel free to fork this code and run it yourself, simply install the dependencies, create your `.env` file and run the `linklogger.py` file.
+LinkLogger is simple and public API to create redirect links and log IPs. Every visit to a registered short link will log the users IP address, location, user agent, browser, and OS before redirecting them to a specific URL.
+
+Just like Grabify, but unrestricted and with no real web UI.
+
+Feel free to submit an issue for any problems you experience or if you have an idea for a new feature. If you have a fix for anything, feel free to submit a pull request for review.
+
+# API Reference
+View the API reference and try out the endpoints at the [docs page](https://link.pkrm.dev/api/docs)
+
+# Want to self-host?
+
+## Bare metal
+To run LinkLogger on bare metal, follow the steps below.
+
+*NOTE: For information on each configuration variable, look at the `Configuration` section of this page.
+
+1. Install Python and Pip
+2. Clone this repository
+3. Install the requirements with `pip install -r requirements.txt`
+4. Run the `linklogger.py` file
+5. Input information into the newly created `config.ini` file.
+6. Re-run the `linklogger.py` file.
+
+## Docker
+To run LinkLogger in Docker, use the [docker-compose.yaml](/docker-compose.yaml) as a template for the contianer.
+
+## Config
+Below are all of the configuration variables that are used within LinkLogger.
-#### Docker
-Use the docker-compose below as an example of running LinkLogger in docker.
-```yaml
-version: '3.3'
-services:
- linklogger:
- container_name: linklogger
- image: ghcr.io/packetparker/linklogger:latest
- network_mode: host
- environment:
- - BASE_URL=https://your.domain
- - IP_TO_LOCATION=True
- - API_KEY=Your Key
- volumes:
- - /local/file/path:/data
- restart: unless-stopped
-```
Variable | Description | Requirement
---|---|---
-BASE_URL | Redirect link for when people visit old/dead/non-existant link | **Required**
-IP_TO_LOCATION | "True"/"False" Whether or not you want to IP to Location feature (requires IP2Location.io account) | **Required**
-API_KEY | IP2Location.io API Key | **Required** *unless IP_TO_LOCATION is "False"*
-
-## API Reference
-
-#### View the API reference and try out the endpoints at the [docs page](https://link.pkrm.dev/docs)
\ No newline at end of file
+BASE_URL | `URL`: Redirect URL for when people visit old, dead, or non-existant links | **Required**
+IP_TO_LOCATION | `BOOLEAN`: Whether or not you want toe IP to Location feature *(requires IP2Location.io account)* | **Required**
+API_KEY | `KEY`: IP2Location.io API Key | **Required** *only if IP_TO_LOCATION is set to True*
\ No newline at end of file
diff --git a/api/main.py b/api/main.py
new file mode 100644
index 0000000..c869bc0
--- /dev/null
+++ b/api/main.py
@@ -0,0 +1,59 @@
+from fastapi import FastAPI, Depends, HTTPException, Security
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import RedirectResponse
+import string
+import random
+
+from api.routes.links_route import router as links_router
+from api.util.db_dependency import get_db
+from api.util.check_api_key import check_api_key
+from models import User
+
+
+metadata_tags = [
+ {"name": "links", "description": "Operations for managing links"},
+]
+
+app = FastAPI(
+ title="LinkLogger API",
+ version="1.0",
+ summary="Public API for a combined link shortener and IP logger",
+ license_info={
+ "name": "The Unlicense",
+ "identifier": "Unlicense",
+ "url": "https://unlicense.org",
+ },
+ openapi_tags=metadata_tags,
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+ allow_credentials=True,
+)
+
+# Import routes
+app.include_router(links_router)
+
+# Regenerate the API key for the user
+@app.post("/regenerate")
+async def login(api_key: str = Security(check_api_key), db = Depends(get_db)):
+ print(api_key['value'])
+
+ user = db.query(User).filter(User.api_key == api_key['value']).first()
+ if not user:
+ raise HTTPException(status_code=401, detail="Invalid API key")
+
+ # Generate a new API key
+ new_api_key = ''.join(random.choices(string.ascii_letters + string.digits, k=20))
+ user.api_key = new_api_key
+ db.commit()
+
+ return {"status": "success", "new_api_key": new_api_key}
+
+# Redirect /api -> /api/docs
+@app.get("/")
+async def redirect_to_docs():
+ return RedirectResponse(url="/api/docs")
\ No newline at end of file
diff --git a/api/routes/links_route.py b/api/routes/links_route.py
new file mode 100644
index 0000000..fd0a77a
--- /dev/null
+++ b/api/routes/links_route.py
@@ -0,0 +1,143 @@
+from fastapi import APIRouter, status, Path, Depends, Security, Request
+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 api.util.check_api_key import check_api_key
+from models import Link, Record
+from api.schemas.links_schemas import URLSchema
+
+
+router = APIRouter(prefix="/links", tags=["links"])
+
+
+@router.get("/", summary="Get all of the links associated with your account")
+async def get_links(
+ db=Depends(get_db),
+ api_key: str = Security(check_api_key),
+):
+ links = db.query(Link).filter(Link.owner == api_key["owner"]).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,
+ db=Depends(get_db),
+ api_key: str = Security(check_api_key),
+):
+ # 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)
+ )
+ new_link = Link(
+ link=link_path,
+ owner=api_key["owner"],
+ redirect_link=url.url,
+ expire_date=datetime.datetime.now() + datetime.timedelta(days=30),
+ )
+ db.add(new_link)
+ db.commit()
+ break
+ except:
+ continue
+
+ return {
+ "response": "Link successfully created",
+ "expire_date": new_link.expire_date,
+ "link": new_link.link,
+ }
+
+
+@router.delete("/{link}", summary="Delete a link")
+async def delete_link(
+ link: Annotated[str, Path(title="Link to delete")],
+ db=Depends(get_db),
+ api_key: str = Security(check_api_key),
+):
+ # 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 != api_key["owner"]:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Link not associated with your account",
+ )
+
+ # Get and delete all records associated with the link
+ records = db.query(Record).filter(Record.link == link.link).all()
+ for record in records:
+ db.delete(record)
+ # Delete the link
+ db.delete(link)
+ db.commit()
+
+ return {"response": "Link successfully deleted", "link": link.link}
+
+
+@router.get("/{link}/records", summary="Get all of the IP log records associated with a link")
+async def get_link_records(
+ link: Annotated[str, Path(title="Link to get records for")],
+ db=Depends(get_db),
+ api_key: str = Security(check_api_key),
+):
+ # 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 != api_key["owner"]:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Link not associated with your account",
+ )
+
+ # Get and return all of the records associated with the link
+ records = db.query(Record).filter(Record.link == link.link).all()
+ return records
+
+
+@router.delete("/{link}/records", summary="Delete all of the IP log records associated with a link")
+async def delete_link_records(
+ link: Annotated[str, Path(title="Link to delete records for")],
+ db=Depends(get_db),
+ api_key: str = Security(check_api_key),
+):
+ # 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 != api_key["owner"]:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Link not associated with your account",
+ )
+
+ # Get all of the records associated with the link and delete them
+ records = db.query(Record).filter(Record.link == link.link).all()
+ for record in records:
+ db.delete(record)
+ db.commit()
+
+ return {"response": "Records successfully deleted", "link": link.link}
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/util/check_api_key.py b/api/util/check_api_key.py
new file mode 100644
index 0000000..9c4c22e
--- /dev/null
+++ b/api/util/check_api_key.py
@@ -0,0 +1,21 @@
+from fastapi import Security, HTTPException, Depends, status
+from fastapi.security import APIKeyHeader
+
+from models import User
+from api.util.db_dependency import get_db
+
+"""
+Make sure the provided API key is valid, then return the user's ID
+"""
+api_key_header = APIKeyHeader(name="X-API-Key")
+
+
+def check_api_key(
+ api_key_header: str = Security(api_key_header), db=Depends(get_db)
+) -> str:
+ response = db.query(User).filter(User.api_key == api_key_header).first()
+ if not response:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key"
+ )
+ return {"value": api_key_header, "owner": response.id}
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/validate_login_information.py b/api/util/validate_login_information.py
new file mode 100644
index 0000000..55bbb2e
--- /dev/null
+++ b/api/util/validate_login_information.py
@@ -0,0 +1,20 @@
+import bcrypt
+from fastapi import Depends
+
+from api.util.db_dependency import get_db
+from models import User
+
+"""
+Validate the login information provided by the user
+"""
+
+
+def validate_login_information(
+ username: str, password: str, db=Depends(get_db)
+) -> bool:
+ user = db.query(User).filter(User.username == username).first()
+ if not user:
+ return False
+ if bcrypt.checkpw(password.encode("utf-8"), user.password.encode("utf-8")):
+ return True
+ return False
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("/", 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 @@
+
+
+
+
+
+ LinkLogger | Login
+
+
+
+
+
Your API Key: {{ api_key }}
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+ LinkLogger | Login
+
+
+