Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField
from werkzeug.security import check_password_hash, generate_password_hash
from wtforms import PasswordField, StringField, TextAreaField
from wtforms.validators import DataRequired

app = Flask(__name__, instance_relative_config=True)
Expand All @@ -27,6 +28,7 @@ class Plan(db.Model):
sql = db.Column(db.String)
is_public = db.Column(db.Boolean, default=False)
delete_key = db.Column(db.String)
password_hash = db.Column(db.String, default=False)

__table_args__ = {"postgresql_partition_by": "HASH (id)"}

Expand Down Expand Up @@ -55,6 +57,11 @@ class PlanForm(FlaskForm):
title = StringField("Title")
plan = TextAreaField("Plan", validators=[DataRequired()])
query = TextAreaField("Query")
password = StringField("Password")


class PasswordForm(FlaskForm):
password = PasswordField("Password")


@app.route("/new", methods=["POST"])
Expand All @@ -73,14 +80,21 @@ def save(json=False):
"""
form = PlanForm()
if form.validate_on_submit():
sql = "SELECT register_plan(:title, :plan, :query, :is_public)"
plan = form.plan.data
password_hash = None
if password := form.password.data:
password_hash = generate_password_hash(password)
sql = """
SELECT register_plan(:title, :plan, :query, :is_public, :password_hash)
"""
query = db.session.execute(
sql,
{
"title": form.title.data,
"plan": form.plan.data,
"plan": plan,
"query": form.query.data,
"is_public": False,
"password_hash": password_hash,
},
)
db.session.commit()
Expand All @@ -97,12 +111,26 @@ def plan():
return redirect(url_for("plan_error"))


@app.route("/plan/<id>")
@app.route("/plan/<id>", methods=["GET"])
def plan_from_db(id):
plan = Plan.query.get_or_404(id, description="This plan doesn't exist.")
if plan.password_hash is not None:
return render_template("locked.html")
return render_template("plan.html", plan=plan)


@app.route("/plan/<id>", methods=["POST"])
def plan_from_db_with_password(id):
plan = Plan.query.get_or_404(id, description="This plan doesn't exist.")
form = PasswordForm()
form.validate_on_submit()
if plan.password_hash is None or check_password_hash(
plan.password_hash, form.password.data
):
return render_template("plan.html", plan=plan)
return render_template("locked.html", invalid_password=True)


@app.route("/plan/<id>/<key>")
def delete(id, key):
plan = Plan.query.get_or_404(id, description="This plan doesn't exist.")
Expand Down
2 changes: 1 addition & 1 deletion app/static/dist/.vite/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
]
},
"app/static/js/index.js": {
"file": "assets/index-H99ZOPuf.js",
"file": "assets/index-z8Sih27D.js",
"name": "index",
"src": "app/static/js/index.js",
"isEntry": true,
Expand Down

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions app/static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const app = createApp({
const titleInput = ref("");
const planInput = ref("");
const queryInput = ref("");
const passwordInput = ref("");
const draggingPlan = ref(false);
const draggingQuery = ref(false);
const plans = ref([]);
Expand Down Expand Up @@ -62,6 +63,7 @@ const app = createApp({
title: titleInput.value,
plan: planInput.value,
query: queryInput.value,
password: passwordInput.value,
createdOn: new Date(),
};
}
Expand Down Expand Up @@ -141,6 +143,7 @@ const app = createApp({
title: plan.title,
plan: plan.plan,
query: plan.query,
password: plan.password,
})
.then((response) => {
localStorage.removeItem(plan.id);
Expand Down Expand Up @@ -191,6 +194,7 @@ const app = createApp({
titleInput,
planInput,
queryInput,
passwordInput,
draggingPlan,
draggingQuery,
plans,
Expand Down
10 changes: 10 additions & 0 deletions app/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ <h2>
<label for="queryInput" class="form-label">Query <span class="small text-muted">(optional)</span></label>
<textarea name="sql" class="form-control" :class="[draggingQuery ? 'dropzone-over' : '']" id="queryInput" rows="8" v-model="queryInput" @dragenter="draggingQuery = true" @dragleave="draggingQuery = false" @drop.prevent="handleDrop" placeholder="Paste corresponding SQL query or drop a file"></textarea>
</div>
<div class="mb-3">
<label for="passwordInput" class="form-label">Password <span class="small text-muted">(optional)</span>
<span class="badge text-bg-success ms-2"><small>New</small></span>
</label>
<div class="row">
<div class="col-6">
<input name="password" class="form-control" id="passwordInput" v-model="passwordInput" maxlength="100">
</div>
</div>
</div>
<div class="row">
<div class="col d-flex">
<button type="submit" class="btn btn-primary">Submit</button>
Expand Down
58 changes: 58 additions & 0 deletions app/templates/locked.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{% set asset='common' %}

{% extends "layout.html" %}

{% block title %}
{{ plan.title or '' + ' |' if plan else ' | ' }}
{% endblock %}

{% block body %}
<div id="app" class="d-flex flex-column h-100">
<header class="header bg-dark container-fluid">
<nav class="navbar navbar-expand-md navbar-dark justify-content-between">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link p-0" href="{{ url_for('index') }}">
<img src="{{ url_for('static', filename='img/logo_pev2.svg') }}" alt="Logo PEV2" style="height:35px;">
explain.dalibo.com
</a>
</li>
<li class="nav-item ms-3">
<a class="btn btn-outline-primary text-white" href="{{ url_for('index') }}">
+ New Plan
</a>
</li>
</ul>
</header>
<div class="d-flex flex-column flex-grow-1 overflow-auto">
<div class="flex-grow-1 overflow-auto d-flex">
<div v-else class="w-100 h-100 d-flex">
<div class="align-self-center mx-auto">
<div class="mb-3">
This plan is protected by a password.
</div>
<form method="POST">
<div class="mb-3">
<input name="password" type="password" class="form-control" maxlength="100" placeholder="Password" autocomplete="current-password"/>
</div>
{% if invalid_password %}
<div class="mb-3 text-danger">
Password is incorrect
</div>
{% endif %}
<div class="row">
<div class="col d-flex justify-content-center">
<button type="submit" class="btn btn-primary">Unlock</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

{% block head %}
<meta name="robots" content="noindex" />
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Add support for plans password protection.

Revision ID: a9ed4fe59f75
Revises: 7d4af6ed6f66
Create Date: 2025-06-04 11:43:31.050664

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "a9ed4fe59f75"
down_revision = "7d4af6ed6f66"
branch_labels = None
depends_on = None


def upgrade():
with op.batch_alter_table("plans", schema=None) as batch_op:
batch_op.add_column(sa.Column("password_hash", sa.String(), nullable=True))

op.execute(
"""
DROP FUNCTION public.register_plan;

CREATE FUNCTION public.register_plan(in_title text, in_plan
text, in_sql text, in_is_public boolean, in_password_hash text)
RETURNS register_plan_return
LANGUAGE plpgsql
AS $function$
DECLARE
use_hash_length int4 := 16;
reply register_plan_return;
insert_sql TEXT;
BEGIN
insert_sql := 'INSERT INTO public.plans (id, title, plan, sql,
is_public, created, delete_key, password_hash) VALUES ($1, $2, $3, $4, $5, now(), $6, $7)';
reply.delete_key := get_random_string( 50 );
LOOP
reply.id := get_random_string(use_hash_length);
BEGIN
execute insert_sql using reply.id, in_title, in_plan, in_sql, in_is_public, reply.delete_key, in_password_hash;
RETURN reply;
EXCEPTION WHEN unique_violation THEN
-- do nothing
END;
use_hash_length := use_hash_length + 1;
IF use_hash_length >= 30 THEN
raise exception 'Random string of length == 30 requested. something''s wrong.';
END IF;
END LOOP;
END;
$function$;
"""
)


def downgrade():
with op.batch_alter_table("plans", schema=None) as batch_op:
batch_op.drop_column("password_hash")

op.execute(
"""
--
-- Name: get_random_string(integer); Type: FUNCTION; Schema: public; Owner: -
--
DROP FUNCTION public.register_plan;

CREATE FUNCTION public.register_plan(in_title text, in_plan
text, in_sql text, in_is_public boolean)
RETURNS register_plan_return
LANGUAGE plpgsql
AS $function$
DECLARE
use_hash_length int4 := 16;
reply register_plan_return;
insert_sql TEXT;
BEGIN
insert_sql := 'INSERT INTO public.plans (id, title, plan, sql,
is_public, created, delete_key) VALUES ($1, $2, $3, $4, $5, now(), $6)';
reply.delete_key := get_random_string( 50 );
LOOP
reply.id := get_random_string(use_hash_length);
BEGIN
execute insert_sql using reply.id, in_title, in_plan, in_sql, in_is_public, reply.delete_key;
RETURN reply;
EXCEPTION WHEN unique_violation THEN
-- do nothing
END;
use_hash_length := use_hash_length + 1;
IF use_hash_length >= 30 THEN
raise exception 'Random string of length == 30 requested. something''s wrong.';
END IF;
END LOOP;
END;
$function$;
"""
)