Overhaul: Flask -> FastAPI

This commit is contained in:
Parker M. 2024-02-26 20:55:05 -06:00
parent a0bfa54406
commit bce756b9aa
No known key found for this signature in database
GPG Key ID: 95CD2E0C7E329F2A
16 changed files with 232 additions and 251 deletions

View File

@ -1,7 +1,7 @@
# LinkLogger API # LinkLogger API
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 a CLI-only version of Grabify. 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. 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.
@ -35,88 +35,4 @@ API_KEY | IP2Location.io API Key | **Required** *unless IP_TO_LOCATION is "False
## API Reference ## API Reference
#### Create account/api key #### View the API reference and try out the endpoints at the [docs page](https://link.pkrm.dev/docs)
##### Your account name functions as your API key and will only be provided to you once.
```http
GET /signup
```
```curl
curl https://link.pkrm.dev/signup
```
#### Create new link
##### Creates a randomized short link that will redirect to the link you provide while logging the IP of the visitor
```http
POST /newlink
```
```curl
curl -X POST \
-H "Content-type: application/json" \
-H "Authorization: Bearer YOUR_ACCOUNT_NAME" \
-d '{"redirect_link": "YOUR_LINK_OF_CHOICE"}' \
https://link.pkrm.dev/newlink
```
#### Get all links
##### Retrieve all of the links and their expiry dates associated with your account
```curl
curl -X POST \
-H "Authorization: Bearer YOUR_ACCOUNT_NAME" \
https://link.pkrm.dev/links
```
#### Get all logs
##### Retrieve all IP logs associated with every link on your account
```http
POST /records
```
```curl
curl -X POST \
-H "Authorization: Bearer YOUR_ACCOUNT_NAME" \
https://link.pkrm.dev/records
```
#### Delete link
##### Delete the specified link as well as all records associated with it
```http
POST /<link>/records
```
```curl
curl -X POST \
-H "Authorization: Bearer YOUR_ACCOUNT_NAME" \
https://link.pkrm.dev/<link>/delete
```
#### Renew link
##### Add 7 more days (from the current date) to the expiry value of the link
```http
POST /<link>/Renew
```
```curl
curl -X POST \
-H "Authorization: Bearer YOUR_ACCOUNT_NAME" \
https://link.pkrm.dev/<link>/renew
```
#### Link records
##### Retrieve all IP logs associated with the link
```http
POST /<link>/records
```
```curl
curl -X POST \
-H "Authorization: Bearer YOUR_ACCOUNT_NAME" \
https://link.pkrm.dev/<link>/records
```
#### Delete link records
##### Delete all of the IP logs that are associated with a specific link
```http
POST /<link>/records
```
```curl
curl -X POST \
-H "Authorization: Bearer YOUR_ACCOUNT_NAME" \
https://link.pkrm.dev/<link>/delrecords
```

View File

@ -1,16 +0,0 @@
from flask_httpauth import HTTPTokenAuth
import sqlalchemy
from db import engine
auth = HTTPTokenAuth(scheme='Bearer')
@auth.verify_token
def verify_token(token):
try:
with engine.begin() as conn:
token = conn.execute(sqlalchemy.text('SELECT * FROM accounts WHERE api_key = :api_key'), [{'api_key': token}]).fetchone()
return token[0]
except TypeError:
return False

22
app/check_api_key.py Normal file
View File

@ -0,0 +1,22 @@
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"
)

View File

@ -12,7 +12,7 @@ def init_db():
with engine.begin() as conn: with engine.begin() as conn:
conn.execute(sqlalchemy.text( conn.execute(sqlalchemy.text(
''' '''
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS keys (
api_key, PRIMARY KEY (api_key) api_key, PRIMARY KEY (api_key)
) )
''' '''
@ -21,7 +21,7 @@ def init_db():
''' '''
CREATE TABLE IF NOT EXISTS links ( CREATE TABLE IF NOT EXISTS links (
owner, link, redirect_link, expire_date, owner, link, redirect_link, expire_date,
FOREIGN KEY (owner) REFERENCES accounts(api_key), PRIMARY KEY (link) FOREIGN KEY (owner) REFERENCES keys(api_key), PRIMARY KEY (link)
) )
''' '''
)) ))

View File

@ -0,0 +1,23 @@
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

View File

@ -10,11 +10,11 @@ def delete_link(link, owner):
try: try:
link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0] link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0]
except TypeError: except TypeError:
return 'Link does not exist', 200 return 404
if owner == link_owner: if owner == link_owner:
with engine.begin() as conn: with engine.begin() as conn:
conn.execute(sqlalchemy.text('DELETE FROM links WHERE link = :link'), [{'link': link}]) conn.execute(sqlalchemy.text('DELETE FROM links WHERE link = :link'), [{'link': link}])
return 'Link has been deleted', 200 return link
else: else:
return 'You are not the owner of this link', 401 return 401

View File

@ -5,16 +5,16 @@ from db import engine
""" """
Delete all of the IP log records that are associated with a specific link Delete all of the IP log records that are associated with a specific link
""" """
def del_link_records(link, owner): def delete_link_records(link, owner):
with engine.begin() as conn: with engine.begin() as conn:
try: try:
link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0] link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0]
except TypeError: except TypeError:
return 'Link does not exist', 200 return 404
if owner == link_owner: if owner == link_owner:
with engine.begin() as conn: with engine.begin() as conn:
conn.execute(sqlalchemy.text('DELETE FROM records WHERE link = :link'), [{'link': link}]) conn.execute(sqlalchemy.text('DELETE FROM records WHERE link = :link'), [{'link': link}])
return 'Link records have been deleted', 200 return link
else: else:
return 'You are not the owner of this link', 401 return 401

View File

@ -1,24 +1,23 @@
import sqlalchemy import sqlalchemy
import tabulate
from db import engine from db import engine
""" """
Retrieve all records associated with a specific link Retrieve all records associated with a specific link
""" """
def link_records(link, owner): def get_link_records(link, owner):
with engine.begin() as conn: with engine.begin() as conn:
try: try:
link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0] link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0]
except TypeError: except TypeError:
return 'Link does not exist', 200 return 404
if owner == link_owner: if owner == link_owner:
with engine.begin() as conn: 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() 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: if not records:
return 'No records are associated with this link', 200 return 204
else: else:
return 'You are not the owner of this link', 401 return 401
return tabulate.tabulate(records, headers=['Timestamp', 'IP', 'Location', 'Browser', 'OS', 'User Agent', 'ISP']), 200 return records

View File

@ -12,12 +12,12 @@ def renew_link(link, owner):
try: try:
link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0] link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0]
except TypeError: except TypeError:
return 'Link does not exist', 200 return 404
if owner == link_owner: if owner == link_owner:
with engine.begin() as conn: with engine.begin() as conn:
expire_date = datetime.datetime.date(datetime.datetime.now()) + datetime.timedelta(days=7) 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}]) conn.execute(sqlalchemy.text('UPDATE links SET expire_date = :expire_date WHERE link = :link'), [{'expire_date': expire_date, 'link': link}])
return f'Link renewed, now expires on {expire_date}', 200 return link, expire_date
else: else:
return 'You are not the owner of this link', 401 return 401

View File

@ -39,7 +39,7 @@ ipgeolocation = ip2locationio.IPGeolocation(configuration)
""" """
Create a new log record whenever a link is visited Create a new log record whenever a link is visited
""" """
def log(link, request): def log(link, ip, user_agent):
with engine.begin() as conn: with engine.begin() as conn:
try: try:
redirect_link, owner = conn.execute(sqlalchemy.text('SELECT redirect_link, owner FROM links WHERE link = :link'), [{'link': link}]).fetchone() redirect_link, owner = conn.execute(sqlalchemy.text('SELECT redirect_link, owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()
@ -50,7 +50,7 @@ def log(link, request):
if ip_to_location == 'TRUE': if ip_to_location == 'TRUE':
# Get IP to GEO via IP2Location.io # Get IP to GEO via IP2Location.io
try: try:
data = ipgeolocation.lookup(request.access_route[-1]) data = ipgeolocation.lookup(ip)
location = f'{data["country_name"]}, {data["city_name"]}' location = f'{data["country_name"]}, {data["city_name"]}'
isp = data['as'] isp = data['as']
# Fatal error, API key is invalid or out of requests, quit # Fatal error, API key is invalid or out of requests, quit
@ -63,8 +63,6 @@ def log(link, request):
isp = '-' isp = '-'
timestamp = datetime.datetime.now() timestamp = datetime.datetime.now()
ip = request.access_route[-1]
user_agent = request.user_agent.string
ua_string = user_agent_parser.Parse(user_agent) ua_string = user_agent_parser.Parse(user_agent)
browser = ua_string['user_agent']['family'] browser = ua_string['user_agent']['family']
os = f'{ua_string["os"]["family"]} {ua_string["os"]["major"]}' os = f'{ua_string["os"]["family"]} {ua_string["os"]["major"]}'

View File

@ -13,7 +13,7 @@ Links are composed of 5 uppercase ASCII characters + numbers
""" """
def generate_link(redirect_link, owner): def generate_link(redirect_link, owner):
if not validators.url(redirect_link): if not validators.url(redirect_link):
return None return 422
with engine.begin() as conn: with engine.begin() as conn:
choices = string.ascii_uppercase + '1234567890' choices = string.ascii_uppercase + '1234567890'

View File

@ -17,7 +17,8 @@ def remove_old_data():
link = row.link link = row.link
delete_links.append({'link': link}) delete_links.append({'link': link})
with engine.begin() as conn: if delete_links:
conn.execute(sqlalchemy.text('DELETE FROM links WHERE link = :link'), delete_links) with engine.begin() as conn:
conn.execute(sqlalchemy.text('DELETE FROM records WHERE link = :link'), delete_links) conn.execute(sqlalchemy.text('DELETE FROM links WHERE link = :link'), delete_links)
conn.commit() conn.execute(sqlalchemy.text('DELETE FROM records WHERE link = :link'), delete_links)
conn.commit()

View File

@ -1,24 +0,0 @@
import sqlalchemy
from sqlalchemy import exc
import random
import string
from db import engine
"""
Generate and return a randomized account string for the user
Account strings function as API authenticaton keys and are composed
of 20 uppercase ASCII characters
"""
def generate_account():
with engine.begin() as conn:
while True:
try:
account_string = ''.join(random.choices(string.ascii_uppercase, k=20))
conn.execute(sqlalchemy.text('INSERT INTO accounts(api_key) VALUES(:api_key)'), [{'api_key': account_string}])
conn.commit()
break
except exc.IntegrityError:
continue
return account_string

View File

@ -1,19 +1,9 @@
from db import init_db from db import init_db
import threading
import schedule
import time
import uvicorn import uvicorn
from func.remove_old_data import remove_old_data from routes import app
if __name__ == '__main__': if __name__ == '__main__':
init_db() init_db()
thread = threading.Thread(target=uvicorn.run("routes:app", host='127.0.0.1', port='5252')) server = uvicorn.run(app=app, host="0.0.0.0", port=5252)
thread.start()
print('Server running on port 5252. Healthy.')
# schedule.every().day.at('00:01').do(remove_old_data)
# while True:
# schedule.run_pending()
# time.sleep(1)

View File

@ -1,120 +1,194 @@
import fastapi import fastapi
from fastapi import Security, HTTPException from fastapi import Security, HTTPException, Request
from fastapi.security import APIKeyHeader
import tabulate
import pydantic import pydantic
import sqlalchemy import sqlalchemy
from db import engine from db import engine
from auth import auth from check_api_key import check_api_key
from func.signup import generate_account from func.generate_api_key import generate_api_key
from func.newlink import generate_link from func.newlink import generate_link
from func.log import log from func.log import log
from func.delete_link import delete_link from func.link.delete import delete_link
from func.renew_link import renew_link from func.link.renew import renew_link
from func.link_records import link_records from func.link.records import get_link_records
from func.del_link_records import del_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): class Newlink(pydantic.BaseModel):
redirect_link: str redirect_link: str
app = fastapi.FastAPI() app = fastapi.FastAPI(lifespan=lifespan)
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 accounts 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"
)
@app.get("/signup") @app.post("/api/getapikey")
async def signup(): async def get_api_key():
api_key = generate_account() """
Create a new API key
"""
api_key = generate_api_key()
return {"api_key": api_key} return {"api_key": api_key}
@app.post("/newlink") @app.post("/api/genlink")
async def newlink(newlink: Newlink, api_key: str = Security(check_api_key)): 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) data = generate_link(newlink.redirect_link, api_key)
if data: if data == 422:
return {"link": data[0], "expire_date": data[1]}
else:
raise HTTPException( raise HTTPException(
status_code=fastapi.status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=fastapi.status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Malformed redirect link provided" detail="Malformed redirect link provided"
) )
return {"link": data[0], "expire_date": data[1]}
@app.post("/links")
"""
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)): 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: with engine.begin() as conn:
links = conn.execute(sqlalchemy.text("SELECT link, expire_date FROM links WHERE owner = :owner"), [{"owner": api_key}]).fetchall() 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 = [] response = []
for link, expire_date in links: for link, expire_date in links:
response.append({"link": link, "expire_date": expire_date}) response.append({"link": link, "expire_date": expire_date})
return response return response
@app.post("/records") @app.post("/api/{link}/delete")
async def records(api_key: str = Security(check_api_key)): async def link_delete(link: str, api_key: str = Security(check_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() Delete the specified link and all records associated with it
"""
if not records: data = delete_link(link, api_key)
return flask.jsonify('No records found'), 200 if data == 404:
raise HTTPException(
return tabulate.tabulate(records, headers=['Timestamp', 'IP', 'Location', 'Browser', 'OS', 'User Agent', 'ISP']), 200 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")
# Return all records associated with an account, no matter the link async def link_renew(link: str, api_key: str = Security(check_api_key)):
# """ """
# @app.route('/records', methods=['POST']) Renew a specifiec link (adds 7 more days from the current date)
# @auth.login_required """
# def records(): 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.route('/<link>', methods=['GET']) @app.get("/api/{link}/records")
# def link(link): async def link_records(link: str, api_key: str = Security(check_api_key)):
# redirect_link = log(link, flask.request) """
# return flask.redirect(redirect_link) 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.route('/<link>/delete', methods=['POST']) @app.post("/api/{link}/delrecords")
# @auth.login_required async def link_delrecords(link: str, api_key: str = Security(check_api_key)):
# def link_delete(link): """
# response = delete_link(link, auth.current_user()) Delete all IP log records for the specified link
# return flask.jsonify(msg=response[0]), response[1] """
data = delete_link_records(link, api_key)
if data == 404:
# @app.route('/<link>/renew', methods=['POST']) raise HTTPException(
# @auth.login_required status_code=fastapi.status.HTTP_404_NOT_FOUND,
# def renew_link(link): detail="Link does not exist"
# response = renew_link(link, auth.current_user()) )
# return flask.jsonify(msg=response[0]), response[1] if data == 401:
raise HTTPException(
status_code=fastapi.status.HTTP_401_UNAUTHORIZED,
# @app.route('/<link>/records', methods=['POST']) detail="Link not associated with given API key"
# @auth.login_required )
# def records_link(link): else:
# response = link_records(link, auth.current_user()) return {"link": f"The records for link {data} have been deleted"}
# # If we jsonify the tabulate string it fucks it up, so we have to return
# # it normally, this check does that
# if response[0].startswith('Timestamp'):
# return response[0], response[1]
# else:
# return flask.jsonify(msg=response[0]), response[1]
# @app.route('/<link>/delrecords', methods=['POST'])
# @auth.login_required
# def records_delete(link):
# response = del_link_records(link, auth.current_user())
# return flask.jsonify(msg=response[0]), response[1]

View File

@ -1,11 +1,9 @@
Flask==3.0.0 pydantic==2.6.2
Flask-HTTPAuth==4.8.0
ip2location-io==1.0.0 ip2location-io==1.0.0
python-dotenv==1.0.0 python-dotenv==1.0.0
SQLAlchemy==2.0.27 SQLAlchemy==2.0.27
tabulate==0.9.0
ua-parser==0.18.0 ua-parser==0.18.0
validators==0.22.0 validators==0.22.0
schedule==1.2.1 uvicorn==0.27.1
fastapi==0.110.0
# uvicorn, fastapi, pydantic APScheduler==3.10.4