diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0783579 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Build an intermediate image for the Python requirements +FROM python:3.9-slim-buster as intermediate + +# Install Git and Python requirements +RUN apt update && apt install -y build-essential +COPY requirements.txt . +RUN python -m pip install --user -r requirements.txt + +# Start from a fresh image +FROM python:3.9-slim-buster + +# Keeps Python from generating .pyc files in the container +ENV PYTHONDONTWRITEBYTECODE=1 + +# Turns off buffering for easier container logging +ENV PYTHONUNBUFFERED=1 + +RUN useradd -u 4000 -d /app appuser +WORKDIR /app + +# Copy over the Python requirements from the intermediate container +COPY --from=intermediate /root/.local /app/.local + +RUN chown -R appuser: /app + +USER appuser + +# Set the main execution command +ENTRYPOINT [".local/bin/waitress-serve", "main:app"] +# HEALTHCHECK --interval=5s --timeout=30s --start-period=5s --retries=3 CMD [ "/app/healthcheck.sh" ] +EXPOSE 8080/tcp + +# Copy in the code +COPY slinky slinky/ +COPY templates templates/ +COPY main.py . diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..55f691f --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,21 @@ +--- +version: "3" +services: + slinky: + container_name: slinky + build: + context: . + image: slinky:latest + networks: + - traefik + labels: + - traefik.enable=true + - traefik.http.routers.slinky.entrypoints=web + - traefik.http.routers.slinky.service=slinky + - traefik.http.services.slinky.loadbalancer.server.port=8080 + restart: unless-stopped + +networks: + traefik: + external: + name: traefik_internal diff --git a/main.py b/main.py index d9a5362..2b6e1e4 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,23 @@ -import sys +""" +Main Flask-based app for Slinky +""" +from flask import Flask, render_template +from flask_bootstrap import Bootstrap -from flask.wrappers import Response +from slinky.web import slinky_webapp + +app = Flask(__name__) +app.register_blueprint(slinky_webapp) + +Bootstrap(app) -def url_home() -> Response: - ... +@app.route('/') +def index() -> str: + """ + Index/Landing page - -if __name__ == '__main__': - - def main() -> int: - # Start Flask - return 0 - - sys.exit(main()) + Returns: + str: string of page content + """ + return render_template('index.html') diff --git a/requirements.txt b/requirements.txt index 7e10602..84548f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,5 @@ flask +flask_bootstrap +flask_wtf +waitress +Flask-SQLAlchemy diff --git a/slinky/__init__.py b/slinky/__init__.py index 6899629..0085bd3 100644 --- a/slinky/__init__.py +++ b/slinky/__init__.py @@ -1,7 +1,58 @@ +""" +Main code +""" + import random import string +from datetime import datetime +from typing import Optional + +from slinky import db + def random_string(length: int = 4) -> str: + """ + Create a random, alphanumeric string of the length specified + + Args: + length (int, optional): length of string to generate. Defaults to 4. + + Returns: + str: random alphanumeric string + """ allowed_chars: str = string.ascii_letters + string.digits return ''.join(random.SystemRandom().choice(allowed_chars) for _ in range(length)) + + +def add_shortcode( + url: str, + length: int = 4, + fixed_views: int = 0, + expiry: datetime = datetime.max, +) -> str: + """ + Add a shortcode to the DB + + Args: + url (str): URL to redirect to + fixed_views (int, optional): number of views to serve before expiring. + Defaults to 0 (no limit). + expiry (int, optional): date of expiry. Defaults to 0 (no limit). + + Returns: + str: shortcode for the redirect + """ + session = db.session() + shortcode = random_string(length=length) + dbentry = db.ShortURL( + shortcode=shortcode, + url=url, + fixed_views=fixed_views, + expiry=expiry, + ) + session.add(dbentry) + session.commit() + session.close() + + return shortcode diff --git a/slinky/db.py b/slinky/db.py new file mode 100644 index 0000000..3a4c1de --- /dev/null +++ b/slinky/db.py @@ -0,0 +1,35 @@ +""" +DB component +""" +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): # pylint: disable=too-few-public-methods + """ + Class to describe the DB schema for ShortURLs + """ + + __tablename__ = 'shorturl' + + id = Column(Integer, primary_key=True) + shortcode = Column(String(128), unique=True, nullable=False) + url = Column(String(2048), unique=False, nullable=False) + fixed_views = Column(Integer, unique=False, nullable=False) + expiry = Column(Integer, unique=False, nullable=False) + + +def session() -> Session: + """ + Create a DB session + + Returns: + Session: the DB session object + """ + engine = create_engine('sqlite:////tmp/test.db') + Base.metadata.create_all(engine) + new_session = sessionmaker(bind=engine) + return new_session() diff --git a/slinky/templates/add.html b/slinky/templates/add.html new file mode 100644 index 0000000..7f30f7a --- /dev/null +++ b/slinky/templates/add.html @@ -0,0 +1,39 @@ +{% include '_head.html' %} + + +
+
+

Add a shortcode

+
+
+
+ {{ form.url.label }} {{ form.url }}
+ {{ form.length.label }} {{ form.length }}
+ {{ form.fixed_views.label }} {{ form.fixed_views }} (0 = unlimited)
+ {{ form.expiry.label}} {{ form.expiry(class='datepicker') }} (leave as default for unlimited)
+ + +
+ +
+ {% if shortcode -%} + + + + + + + + + + + +
Short URL
{{request.host_url}}{{ shortcode }}
+ {% endif -%} +
+
+ +{% include '_tail.html' %} diff --git a/slinky/web.py b/slinky/web.py new file mode 100644 index 0000000..2ac9886 --- /dev/null +++ b/slinky/web.py @@ -0,0 +1,79 @@ +""" +Web component +""" + +from datetime import datetime + +from flask import Blueprint, render_template +from flask_wtf import FlaskForm +from wtforms import DateTimeLocalField, IntegerField, StringField +from wtforms.validators import DataRequired, Length + +from slinky import add_shortcode + +slinky_webapp = Blueprint('webapp', __name__, template_folder='templates') + + +class ShortURLForm(FlaskForm): # type: ignore[misc] + """ + Web form definition + """ + + url = StringField( + 'URL', + validators=[DataRequired(), Length(1, 2048)], + render_kw={ + 'size': 64, + 'maxlength': 2048, + 'placeholder': 'e.g. www.example.com', + }, + ) + fixed_views = IntegerField( + 'Fixed number of views', + validators=[DataRequired()], + render_kw={ + 'size': 3, + 'value': 0, + }, + ) + length = IntegerField( + 'Shortcode length', + validators=[DataRequired()], + render_kw={ + 'size': 3, + 'value': 4, + }, + ) + expiry = DateTimeLocalField( + 'Expiry', + format='%Y-%m-%dT%H:%M', + render_kw={ + 'size': 8, + 'maxlength': 10, + }, + ) + + +@slinky_webapp.route('/add', methods=['GET', 'POST']) +def add() -> str: + """ + Create and add a new shorturl + + Returns: + str: shortcode for the URL + """ + shortcode = '' + url = '' + + form = ShortURLForm(meta={'csrf': False}) + + if form.is_submitted(): + url = form.url.data.strip() + length = form.length.data + fixed_views = form.fixed_views.data + expiry = form.expiry.data or datetime.max + + if url: + shortcode = add_shortcode(url, length, fixed_views, expiry) + + return render_template('add.html', form=form, shortcode=shortcode) diff --git a/templates/_head.html b/templates/_head.html new file mode 100644 index 0000000..2da7b90 --- /dev/null +++ b/templates/_head.html @@ -0,0 +1,95 @@ + + + + + + + + + Slinky + + + + + + + + + + + + + + + + + diff --git a/templates/_tail.html b/templates/_tail.html new file mode 100644 index 0000000..d42b00e --- /dev/null +++ b/templates/_tail.html @@ -0,0 +1,12 @@ + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..d50d15b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,2 @@ +{% include '_head.html' -%} +{% include '_tail.html' -%}