Bring code up-to-date

This commit is contained in:
Scott Wallace 2024-11-27 09:50:37 +00:00
parent b939ad415d
commit 97512598f5
Signed by: scott
SSH key fingerprint: SHA256:+LJug6Dj01Jdg86CILGng9r0lJseUrpI0xfRqdW9Uws
8 changed files with 186 additions and 187 deletions

10
main.py
View file

@ -1,20 +1,18 @@
"""
Main Flask-based app for Slinky
"""
from flask import Flask, Response, render_template
from flask_bootstrap import Bootstrap # type: ignore[import]
from werkzeug.middleware.proxy_fix import ProxyFix
from slinky.web import protect, slinky_webapp
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1) # type: ignore[assignment]
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1)
app.register_blueprint(slinky_webapp)
Bootstrap(app)
@app.route('/')
@app.route("/")
@protect
def index() -> Response:
"""
@ -23,4 +21,4 @@ def index() -> Response:
Returns:
str: string of page content
"""
return Response(render_template('index.html'), 200)
return Response(render_template("index.html"), 200)

View file

@ -1,5 +1,4 @@
flask
flask_bootstrap
flask_wtf
psycopg2-binary
pyyaml

View file

@ -6,9 +6,6 @@ import random
import string
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
import sqlalchemy # type: ignore[import]
from slinky import db
@ -38,7 +35,7 @@ def random_string(length: int = 4) -> str:
"""
allowed_chars: str = string.ascii_letters + string.digits
return ''.join(random.SystemRandom().choice(allowed_chars) for _ in range(length))
return "".join(random.SystemRandom().choice(allowed_chars) for _ in range(length))
class Slinky:
@ -52,8 +49,8 @@ class Slinky:
def add( # pylint: disable=too-many-arguments
self,
shortcode: str = '',
url: str = '',
shortcode: str = "",
url: str = "",
length: int = 4,
fixed_views: int = -1,
expiry: datetime = datetime.max,
@ -78,7 +75,7 @@ class Slinky:
shortcode = random_string(length=length)
if self.get_by_shortcode(shortcode).url:
raise ValueError(f'Shortcode {shortcode} already exists')
raise ValueError(f"Shortcode {shortcode} already exists")
dbentry = db.ShortURL(
shortcode=shortcode,
@ -114,7 +111,7 @@ class Slinky:
)
self.session.close()
return ret_sc
return Shortcode(0, '', '', 0, '1970-01-01 00:00:00.000000')
return Shortcode(0, "", "", 0, "1970-01-01 00:00:00.000000")
def remove_view(self, sc_id: int) -> None:
"""

View file

@ -1,21 +1,22 @@
"""
DB component
"""
from dataclasses import dataclass
from sqlalchemy import Column, Integer, String, create_engine # type: ignore[import]
from sqlalchemy.ext.declarative import declarative_base # type: ignore[import]
from sqlalchemy.orm import Session, sessionmaker # type: ignore[import]
from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, sessionmaker
Base = declarative_base()
class ShortURL(Base): # type: ignore[misc, valid-type] # pylint: disable=too-few-public-methods
class ShortURL(Base): # pylint: disable=too-few-public-methods
"""
Class to describe the DB schema for ShortURLs
"""
__tablename__ = 'shorturl'
__tablename__ = "shorturl"
id = Column(Integer, primary_key=True)
shortcode = Column(String(128), unique=True, nullable=False)

View file

@ -10,71 +10,70 @@ from typing import Any, Callable
import yaml
from flask import Blueprint, Response, render_template, request
from flask_wtf import FlaskForm # type: ignore[import]
from wtforms import HiddenField # type: ignore[import]
from wtforms import DateTimeLocalField, IntegerField, StringField
from wtforms.validators import DataRequired, Length # type: ignore[import]
from flask_wtf import FlaskForm
from wtforms import DateTimeLocalField, HiddenField, IntegerField, StringField
from wtforms.validators import DataRequired, Length
from slinky import Slinky, random_string
slinky_webapp = Blueprint('webapp', __name__, template_folder='templates')
slinky_webapp = Blueprint("webapp", __name__, template_folder="templates")
with open('config.yaml', encoding='utf-8-sig') as conffile:
with open("config.yaml", encoding="utf-8-sig") as conffile:
cfg = yaml.safe_load(conffile)
class DelForm(FlaskForm): # type: ignore[misc]
class DelForm(FlaskForm):
"""
Delete form definition
"""
delete = HiddenField('delete')
delete = HiddenField("delete")
class AddForm(FlaskForm): # type: ignore[misc]
class AddForm(FlaskForm):
"""
Add form definition
"""
shortcode = StringField(
'Shortcode',
"Shortcode",
validators=[DataRequired(), Length(1, 2048)],
render_kw={
'size': 64,
'maxlength': 2048,
"size": 64,
"maxlength": 2048,
},
)
url = StringField(
'URL',
"URL",
validators=[DataRequired(), Length(1, 2048)],
render_kw={
'size': 64,
'maxlength': 2048,
'placeholder': 'https://www.example.com',
"size": 64,
"maxlength": 2048,
"placeholder": "https://www.example.com",
},
)
fixed_views = IntegerField(
'Fixed number of views',
"Fixed number of views",
validators=[DataRequired()],
render_kw={
'size': 3,
'value': -1,
"size": 3,
"value": -1,
},
)
length = IntegerField(
'Shortcode length',
"Shortcode length",
validators=[DataRequired()],
render_kw={
'size': 3,
'value': 4,
"size": 3,
"value": 4,
},
)
expiry = DateTimeLocalField(
'Expiry',
format='%Y-%m-%dT%H:%M',
"Expiry",
format="%Y-%m-%dT%H:%M",
render_kw={
'size': 8,
'maxlength': 10,
"size": 8,
"maxlength": 10,
},
)
@ -92,18 +91,26 @@ def protect(func: Callable[..., Response]) -> Callable[..., Response]:
@wraps(func)
def check_ip(*args: Any, **kwargs: Any) -> Response:
remote_addr = request.remote_addr
if "x-forwarded-for" in request.headers:
remote_addr = request.headers["x-forwarded-for"]
if "x-real-ip" in request.headers:
remote_addr = request.headers["x-real-ip"]
if (
os.environ.get('FLASK_ENV', '') != 'development'
and request.headers['X-Forwarded-For'] not in cfg['allowed_ips']
os.environ.get("FLASK_ENV", "") != "development"
and remote_addr not in cfg["allowed_ips"]
):
logging.warning('Protected URL access attempt from %s', request.remote_addr)
return Response('Not found', 404)
logging.warning("Protected URL access attempt from %s", remote_addr)
return Response("Not found", 404)
return func(*args, **kwargs)
return check_ip
@slinky_webapp.route('/<path:path>', strict_slashes=False)
@slinky_webapp.route("/<path:path>", strict_slashes=False)
def try_path_as_shortcode(path: str) -> Response:
"""
Try the initial path as a shortcode, redirect if found
@ -112,27 +119,27 @@ def try_path_as_shortcode(path: str) -> Response:
Response: redirect if found, otherwise 404
"""
should_redirect = True
slinky = Slinky(cfg['db'])
slinky = Slinky(cfg["db"])
shortcode = slinky.get_by_shortcode(path)
if shortcode.url:
if shortcode.fixed_views == 0:
logging.warning('Shortcode out of views')
logging.warning("Shortcode out of views")
should_redirect = False
elif shortcode.fixed_views > 0:
slinky.remove_view(shortcode.id)
if datetime.fromisoformat(shortcode.expiry) < datetime.now():
logging.warning('Shortcode expired')
logging.warning("Shortcode expired")
should_redirect = False
if should_redirect:
return Response(
'Redirecting...', status=302, headers={'location': shortcode.url}
"Redirecting...", status=302, headers={"location": shortcode.url}
)
return Response('Not found', 404)
return Response("Not found", 404)
@slinky_webapp.route('/_/add', methods=['GET', 'POST'])
@slinky_webapp.route("/_/add", methods=["GET", "POST"])
@protect
def add() -> Response:
"""
@ -141,27 +148,27 @@ def add() -> Response:
Returns:
Response: HTTP response
"""
slinky = Slinky(cfg['db'])
slinky = Slinky(cfg["db"])
for attempts in range(50):
shortcode = random_string()
if slinky.get_by_shortcode(shortcode).url:
logging.warning(
'Shortcode already exists. Retrying (%s/50).',
"Shortcode already exists. Retrying (%s/50).",
attempts,
)
else:
break
else:
return Response(
render_template('error.html', msg='Could not create a unique shortcode'),
render_template("error.html", msg="Could not create a unique shortcode"),
500,
)
url = ''
final_url = ''
url = ""
final_url = ""
form = AddForm(meta={'csrf': False})
form = AddForm(meta={"csrf": False})
if form.is_submitted():
shortcode = form.shortcode.data.strip()
@ -170,25 +177,25 @@ def add() -> Response:
fixed_views = form.fixed_views.data
expiry = form.expiry.data or datetime.max
if url:
try:
shortcode = slinky.add(
shortcode=shortcode,
url=url,
length=length,
fixed_views=fixed_views,
expiry=expiry,
)
except ValueError as error:
logging.warning(error)
return Response(render_template('error.html', msg=error), 400)
if url:
try:
shortcode = slinky.add(
shortcode=shortcode,
url=url,
length=length,
fixed_views=fixed_views,
expiry=expiry,
)
except ValueError as error:
logging.warning(error)
return Response(render_template("error.html", msg=error), 400)
if form.is_submitted():
final_url = f'{request.host_url}/{shortcode}'
if form.is_submitted():
final_url = f"{request.host_url}/{shortcode}"
return Response(
render_template(
'add.html',
"add.html",
form=form,
shortcode=shortcode,
final_url=final_url,
@ -197,7 +204,7 @@ def add() -> Response:
)
@slinky_webapp.route('/_/list', methods=['GET', 'POST'])
@slinky_webapp.route("/_/list", methods=["GET", "POST"])
@protect
def lister() -> Response:
"""
@ -206,19 +213,19 @@ def lister() -> Response:
Returns:
Response: HTTP response
"""
form = DelForm(meta={'csrf': False})
slinky = Slinky(cfg['db'])
form = DelForm(meta={"csrf": False})
slinky = Slinky(cfg["db"])
if form.is_submitted():
slinky.delete_by_shortcode(form.delete.data.strip())
return Response(
render_template('list.html', form=form, shortcodes=slinky.get_all()),
render_template("list.html", form=form, shortcodes=slinky.get_all()),
200,
)
@slinky_webapp.route('/_/edit/<int:id>', methods=['GET', 'POST'])
@slinky_webapp.route("/_/edit/<int:id>", methods=["GET", "POST"])
@protect
def edit(id: int) -> Response: # pylint: disable=invalid-name,redefined-builtin
"""
@ -227,15 +234,15 @@ def edit(id: int) -> Response: # pylint: disable=invalid-name,redefined-builtin
Returns:
Response: HTTP response
"""
form = DelForm(meta={'csrf': False})
slinky = Slinky(cfg['db'])
form = DelForm(meta={"csrf": False})
slinky = Slinky(cfg["db"])
logging.debug('Editing: %d', id)
logging.debug("Editing: %d", id)
if form.is_submitted():
slinky.delete_by_shortcode(form.delete.data.strip())
return Response(
render_template('edit.html', form=form, shortcodes=slinky.get_all()),
render_template("edit.html", form=form, shortcodes=slinky.get_all()),
200,
)

View file

@ -1,98 +1,97 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<title>Slinky</title>
<title>Slinky</title>
<link rel="canonical" href="https://getbootstrap.com/docs/5.0/examples/navbar-static/">
<link rel="canonical" href="https://getbootstrap.com/docs/5.0/examples/navbar-static/">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous">
<link rel="stylesheet" href="{{url_for('bootstrap.static', filename='datepicker.css')}}">
<meta name="theme-color" content="#7952b3">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<meta name="theme-color" content="#7952b3">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.badge a {
color: white;
text-decoration: none;
}
.badge.text-dark a {
color: black;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
}
[role=status] {
display: none;
}
.badge a {
color: white;
text-decoration: none;
}
.tooltip>.tooltip-inner {
max-width: 60em !important;
text-align: left !important;
}
.badge.text-dark a {
color: black;
}
.highlight {
background-color: #f48024;
color: #1d1d1e;
border-radius: 2px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
padding: 0 1px 0 1px;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
.checkboxlist ul {
list-style-type: none;
margin: 0;
padding: 1em;
}
</style>
[role=status] {
display: none;
}
<!-- Custom styles for this template -->
<!-- <link href="navbar-top.css" rel="stylesheet"> -->
<script>
// <![CDATA[
function waiting() {
document.querySelectorAll('[role="status"]')[0].style.display = "inline-block";
}
// ]]>
</script>
</head>
.tooltip>.tooltip-inner {
max-width: 60em !important;
text-align: left !important;
}
<body>
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/">Slinky</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item">
<a class="nav-link" href="/_/add">Add</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/_/list">List</a>
</li>
</ul>
.highlight {
background-color: #f48024;
color: #1d1d1e;
border-radius: 2px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
padding: 0 1px 0 1px;
}
.checkboxlist ul {
list-style-type: none;
margin: 0;
padding: 1em;
}
</style>
<!-- Custom styles for this template -->
<!-- <link href="navbar-top.css" rel="stylesheet"> -->
<script>
// <![CDATA[
function waiting() {
document.querySelectorAll('[role="status"]')[0].style.display = "inline-block";
}
// ]]>
</script>
</head>
<body>
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/">Slinky</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item">
<a class="nav-link" href="/_/add">Add</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/_/list">List</a>
</li>
</ul>
</div>
</div>
</div>
</nav>
</nav>

View file

@ -1,6 +1,6 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf" crossorigin="anonymous">
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
<script>
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {

View file

@ -5,7 +5,7 @@ Test Slinky web interface
from unittest import TestCase, mock
from flask import Flask
from flask_bootstrap import Bootstrap # type: ignore[import]
from slinky.web import slinky_webapp
@ -15,45 +15,43 @@ class TestWeb(TestCase):
"""
def setUp(self) -> None:
self.app = Flask(__name__, template_folder='../templates')
self.app = Flask(__name__, template_folder="../templates")
self.app.register_blueprint(slinky_webapp)
self.app_context = self.app.app_context()
self.app_context.push()
self.client = self.app.test_client()
Bootstrap(self.app)
mock.patch.dict('slinky.web.cfg', {'db': 'sqlite:///tests/test.db'}).start()
mock.patch.dict("slinky.web.cfg", {"db": "sqlite:///tests/test.db"}).start()
def test_simple_redirect(self) -> None:
"""
Ensure simple redirect works
"""
response = self.client.get('/egie')
response = self.client.get("/egie")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.location, 'https://example.com')
self.assertEqual(response.location, "https://example.com")
def test_fixed_views(self) -> None:
"""
Ensure depleted fixed views returns a 404
"""
response = self.client.get('/egig')
response = self.client.get("/egig")
self.assertEqual(response.status_code, 404)
def test_expiry(self) -> None:
"""
Ensure expired redirect returns a 404
"""
response = self.client.get('/egif')
response = self.client.get("/egif")
self.assertEqual(response.status_code, 404)
def test_no_unique_shortcode(self) -> None:
"""
Ensure non-unique shortcode generation returns a 500 error
"""
with mock.patch('slinky.web.random_string', return_value='egie'):
with mock.patch("slinky.web.random_string", return_value="egie"):
response = self.client.get(
'/_/add', headers={'x-forwarded-for': '127.0.0.1'}
"/_/add", headers={"x-forwarded-for": "127.0.0.1"}
)
self.assertEqual(response.status_code, 500)
@ -61,9 +59,9 @@ class TestWeb(TestCase):
"""
Test the condition where the random_string() returns an existing shortcode
"""
with mock.patch('slinky.web.random_string', side_effect=['egie', 'egiz']):
with mock.patch("slinky.web.random_string", side_effect=["egie", "egiz"]):
response = self.client.get(
'/_/add',
headers={'x-forwarded-for': '127.0.0.1'},
"/_/add",
headers={"x-forwarded-for": "127.0.0.1"},
)
self.assertEqual(response.status_code, 200)