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. +

+ + Code Style: Black + + + + +

-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 + + +
+

Incorrect username/password. Please try again.

+
+ + + +
+
+

Don't have an account? Create one now

+
+ + + + + + \ 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 @@ + + + + + + LinkLogger | Signup + + +
+

User already exists. Please try again

+
+ + + +
+
+

Already have an account? Log in now

+
+ + + + + + \ 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 diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..aaddace --- /dev/null +++ b/config.ini @@ -0,0 +1,5 @@ +[CONFIG] +base_url = https://pkrm.dev +ip_to_location = true +api_key = F3EB511E9B9B990AB6389CB0F98FF2A8 + diff --git a/database.py b/database.py new file mode 100644 index 0000000..544ee05 --- /dev/null +++ b/database.py @@ -0,0 +1,13 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +# Create 'data' directory at root if it doesn't exist +if not os.path.exists("data"): + os.makedirs("data") + +engine = create_engine("sqlite:///data/data.db") +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..b48b49d --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,12 @@ +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=awd + volumes: + - /path/on/system:/data + restart: on-failure \ No newline at end of file diff --git a/linklogger.py b/linklogger.py new file mode 100644 index 0000000..05f6c23 --- /dev/null +++ b/linklogger.py @@ -0,0 +1,21 @@ +from werkzeug.middleware.dispatcher import DispatcherMiddleware +from a2wsgi import ASGIMiddleware + +from validate_config import validate_config +from app.main import app as flask_app +from api.main import app as fastapi_app +from database import Base, engine + +Base.metadata.create_all(bind=engine) + +flask_app.wsgi_app = DispatcherMiddleware( + flask_app.wsgi_app, + { + "/": flask_app, + "/api": ASGIMiddleware(fastapi_app), + }, +) + +if __name__ == "__main__": + validate_config() + flask_app.run(port=5252) diff --git a/models.py b/models.py new file mode 100644 index 0000000..dad81a0 --- /dev/null +++ b/models.py @@ -0,0 +1,40 @@ +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + String, + Text, + DateTime, +) + +from database import Base + + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + username = Column(String, unique=True, nullable=False) + password = Column(Text, nullable=False) + api_key = Column(String(20), unique=True, nullable=False) + + +class Link(Base): + __tablename__ = "links" + link = Column(String, primary_key=True) + owner = Column(Integer, ForeignKey("users.id"), nullable=False) + redirect_link = Column(String, nullable=False) + expire_date = Column(DateTime, nullable=False) + + +class Record(Base): + __tablename__ = "records" + id = Column(Integer, primary_key=True) + owner = Column(Integer, ForeignKey("users.id"), nullable=False) + link = Column(String, ForeignKey("links.link"), nullable=False) + timestamp = Column(DateTime, nullable=False) + ip = Column(String, nullable=False) + location = Column(String, nullable=False) + browser = Column(String, nullable=False) + os = Column(String, nullable=False) + user_agent = Column(String, nullable=False) + isp = Column(String, nullable=False) diff --git a/requirements.txt b/requirements.txt index 917946f..d1864fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,13 @@ pydantic==2.6.2 ip2location-io==1.0.0 -python-dotenv==1.0.0 SQLAlchemy==2.0.27 ua-parser==0.18.0 validators==0.22.0 uvicorn==0.27.1 fastapi==0.110.0 -APScheduler==3.10.4 \ No newline at end of file +APScheduler==3.10.4 +Flask==3.0.3 +Flask-Login==0.6.3 +a2wsgi==1.10.4 +colorlog==6.8.2 +bcrypt==4.1.3 \ No newline at end of file diff --git a/validate_config.py b/validate_config.py new file mode 100644 index 0000000..f7e6589 --- /dev/null +++ b/validate_config.py @@ -0,0 +1,139 @@ +import configparser +import validators +import os +import sys + +from var import LOG + +""" +Validate the config of a Docker run (environment variables) +""" + + +def validate_docker_config(): + errors = 0 + + # Validate BASE_URL + try: + if not os.environ["BASE_URL"]: + LOG.error("BASE_URL is not set") + errors += 1 + elif not validators.url(os.environ["BASE_URL"]): + LOG.error("BASE_URL is not a valid URL") + errors += 1 + except KeyError: + LOG.critical("BASE_URL does not exist!") + errors += 1 + + # Validate IP_TO_LOCATION + try: + if not os.environ["IP_TO_LOCATION"]: + LOG.error("IP_TO_LOCATION is not set") + errors += 1 + elif os.environ["IP_TO_LOCATION"].upper() not in ["TRUE", "FALSE", "T", "F"]: + LOG.error("IP_TO_LOCATION is not set to TRUE or FALSE") + errors += 1 + else: + iptolocation = ( + True if os.environ["IP_TO_LOCATION"].upper() in ["TRUE", "T"] else False + ) + # Validate API_KEY if IP_TO_LOCATION is set to TRUE + if iptolocation: + try: + if not os.environ["API_KEY"]: + LOG.error("API_KEY is not set") + errors += 1 + except KeyError: + LOG.critical("API_KEY does not exist!") + errors += 1 + except KeyError: + LOG.critical("IP_TO_LOCATION does not exist!") + errors += 1 + + if errors > 0: + LOG.critical(f"{errors} error(s) found in environment variables") + sys.exit() + + +""" +Validate the config of a bare metal run (config.ini file) +""" + + +def validate_bare_metal_config(file_contents): + + config = configparser.ConfigParser() + config.read_string(file_contents) + + errors = 0 + + # Validate BASE_URL + try: + if not config["CONFIG"]["BASE_URL"]: + LOG.error("BASE_URL is not set") + errors += 1 + elif not validators.url(config["CONFIG"]["BASE_URL"]): + LOG.error("BASE_URL is not a valid URL") + errors += 1 + except ValueError: + LOG.critical("BASE_URL does not exist!") + errors += 1 + + # Validate IP_TO_LOCATION + try: + if not config["CONFIG"]["IP_TO_LOCATION"]: + LOG.error("IP_TO_LOCATION is not set") + errors += 1 + elif config["CONFIG"]["IP_TO_LOCATION"].upper() not in [ + "TRUE", + "FALSE", + "T", + "F", + ]: + LOG.error("IP_TO_LOCATION is not set to TRUE or FALSE") + errors += 1 + else: + iptolocation = ( + True + if config["CONFIG"]["IP_TO_LOCATION"].upper() in ["TRUE", "T"] + else False + ) + # Validate API_KEY if IP_TO_LOCATION is set to TRUE + if iptolocation: + try: + if not config["CONFIG"]["API_KEY"]: + LOG.error("API_KEY is not set") + errors += 1 + except ValueError: + LOG.critical("API_KEY does not exist!") + errors += 1 + except ValueError: + LOG.critical("IP_TO_LOCATION does not exist!") + errors += 1 + + if errors > 0: + LOG.critical(f"{errors} error(s) found in `config.ini`") + sys.exit() + + +def validate_config(): + # If the app is running in Docker + if "BASE_URL" in os.environ or "IP_TO_LOCATION" in os.environ: + return validate_docker_config() + + # Otherwise, the app is running on bare metal + try: + with open("config.ini", "r") as f: + file_contents = f.read() + return validate_bare_metal_config(file_contents) + except FileNotFoundError: + config = configparser.ConfigParser() + config["CONFIG"] = {"BASE_URL": "", "IP_TO_LOCATION": "", "API_KEY": ""} + + with open("config.ini", "w") as configfile: + config.write(configfile) + + LOG.error( + "`config.ini` has been created. Fill out the necessary information then re-run." + ) + sys.exit() diff --git a/var.py b/var.py new file mode 100644 index 0000000..e444629 --- /dev/null +++ b/var.py @@ -0,0 +1,59 @@ +import configparser +import logging +import os +from colorlog import ColoredFormatter + + +log_level = logging.DEBUG +log_format = "%(log_color)s%(levelname)-8s%(reset)s %(log_color)s%(message)s%(reset)s" + +logging.root.setLevel(log_level) +formatter = ColoredFormatter(log_format) + +stream = logging.StreamHandler() +stream.setLevel(log_level) +stream.setFormatter(formatter) + +LOG = logging.getLogger("pythonConfig") +LOG.setLevel(log_level) +LOG.addHandler(stream) + + +# If the app is running in Docker +if "BASE_URL" in os.environ or "IP_TO_LOCATION" in os.environ: + BASE_URL = os.environ["BASE_URL"] + IP_TO_LOCATION = ( + True if os.environ["IP_TO_LOCATION"].upper() in ["TRUE", "T"] else False + ) + if IP_TO_LOCATION: + API_KEY = os.environ["API_KEY"] + else: + API_KEY = None + +# Otherwise, the app is running on bare metal +try: + with open("config.ini", "r") as f: + config = configparser.ConfigParser() + config.read_string(f.read()) + + BASE_URL = config["CONFIG"]["BASE_URL"] + IP_TO_LOCATION = ( + True + if config["CONFIG"]["IP_TO_LOCATION"].upper() in ["TRUE", "T"] + else False + ) + if IP_TO_LOCATION: + API_KEY = config["CONFIG"]["API_KEY"] + else: + API_KEY = None +except FileNotFoundError: + config = configparser.ConfigParser() + config["CONFIG"] = {"BASE_URL": "", "IP_TO_LOCATION": "", "API_KEY": ""} + + with open("config.ini", "w") as configfile: + config.write(configfile) + + LOG.error( + "`config.ini` has been created. Fill out the necessary information then re-run." + ) + exit()