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
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.
@ -35,88 +35,4 @@ API_KEY | IP2Location.io API Key | **Required** *unless IP_TO_LOCATION is "False
## API Reference
#### Create account/api key
##### 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 the API reference and try out the endpoints at the [docs page](https://link.pkrm.dev/docs)

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:
conn.execute(sqlalchemy.text(
'''
CREATE TABLE IF NOT EXISTS accounts (
CREATE TABLE IF NOT EXISTS keys (
api_key, PRIMARY KEY (api_key)
)
'''
@ -21,7 +21,7 @@ def init_db():
'''
CREATE TABLE IF NOT EXISTS links (
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:
link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0]
except TypeError:
return 'Link does not exist', 200
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 has been deleted', 200
return link
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
"""
def del_link_records(link, owner):
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 'Link does not exist', 200
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 records have been deleted', 200
return link
else:
return 'You are not the owner of this link', 401
return 401

View File

@ -1,24 +1,23 @@
import sqlalchemy
import tabulate
from db import engine
"""
Retrieve all records associated with a specific link
"""
def link_records(link, owner):
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 'Link does not exist', 200
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 'No records are associated with this link', 200
return 204
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:
link_owner = conn.execute(sqlalchemy.text('SELECT owner FROM links WHERE link = :link'), [{'link': link}]).fetchone()[0]
except TypeError:
return 'Link does not exist', 200
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 f'Link renewed, now expires on {expire_date}', 200
return link, expire_date
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
"""
def log(link, request):
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()
@ -50,7 +50,7 @@ def log(link, request):
if ip_to_location == 'TRUE':
# Get IP to GEO via IP2Location.io
try:
data = ipgeolocation.lookup(request.access_route[-1])
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
@ -63,8 +63,6 @@ def log(link, request):
isp = '-'
timestamp = datetime.datetime.now()
ip = request.access_route[-1]
user_agent = request.user_agent.string
ua_string = user_agent_parser.Parse(user_agent)
browser = ua_string['user_agent']['family']
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):
if not validators.url(redirect_link):
return None
return 422
with engine.begin() as conn:
choices = string.ascii_uppercase + '1234567890'

View File

@ -17,7 +17,8 @@ def remove_old_data():
link = row.link
delete_links.append({'link': link})
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()
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()

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
import threading
import schedule
import time
import uvicorn
from func.remove_old_data import remove_old_data
from routes import app
if __name__ == '__main__':
init_db()
thread = threading.Thread(target=uvicorn.run("routes:app", host='127.0.0.1', 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)
server = uvicorn.run(app=app, host="0.0.0.0", port=5252)

View File

@ -1,120 +1,194 @@
import fastapi
from fastapi import Security, HTTPException
from fastapi.security import APIKeyHeader
import tabulate
from fastapi import Security, HTTPException, Request
import pydantic
import sqlalchemy
from db import engine
from auth import auth
from func.signup import generate_account
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.delete_link import delete_link
from func.renew_link import renew_link
from func.link_records import link_records
from func.del_link_records import del_link_records
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()
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 = fastapi.FastAPI(lifespan=lifespan)
@app.get("/signup")
async def signup():
api_key = generate_account()
@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("/newlink")
@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:
return {"link": data[0], "expire_date": data[1]}
else:
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]}
@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)):
"""
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("/records")
async def records(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()
if not records:
return flask.jsonify('No records found'), 200
return tabulate.tabulate(records, headers=['Timestamp', 'IP', 'Location', 'Browser', 'OS', 'User Agent', 'ISP']), 200
@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"}
# """
# Return all records associated with an account, no matter the link
# """
# @app.route('/records', methods=['POST'])
# @auth.login_required
# def records():
@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.route('/<link>', methods=['GET'])
# def link(link):
# redirect_link = log(link, flask.request)
# return flask.redirect(redirect_link)
@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.route('/<link>/delete', methods=['POST'])
# @auth.login_required
# def link_delete(link):
# response = delete_link(link, auth.current_user())
# return flask.jsonify(msg=response[0]), response[1]
# @app.route('/<link>/renew', methods=['POST'])
# @auth.login_required
# def renew_link(link):
# response = renew_link(link, auth.current_user())
# return flask.jsonify(msg=response[0]), response[1]
# @app.route('/<link>/records', methods=['POST'])
# @auth.login_required
# def records_link(link):
# response = link_records(link, auth.current_user())
# # 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]
@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"}

View File

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