Skip to content

Commit f6cd95e

Browse files
committed
Add support for password for plan protection
The password is stored hashed.
1 parent d108d80 commit f6cd95e

File tree

7 files changed

+219
-14
lines changed

7 files changed

+219
-14
lines changed

app/__init__.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
from flask_migrate import Migrate
77
from flask_sqlalchemy import SQLAlchemy
88
from flask_wtf import FlaskForm
9-
from wtforms import StringField, TextAreaField
9+
from werkzeug.security import check_password_hash, generate_password_hash
10+
from wtforms import PasswordField, StringField, TextAreaField
1011
from wtforms.validators import DataRequired
1112

1213
app = Flask(__name__, instance_relative_config=True)
@@ -27,6 +28,7 @@ class Plan(db.Model):
2728
sql = db.Column(db.String)
2829
is_public = db.Column(db.Boolean, default=False)
2930
delete_key = db.Column(db.String)
31+
password_hash = db.Column(db.String, default=False)
3032

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

@@ -55,6 +57,11 @@ class PlanForm(FlaskForm):
5557
title = StringField("Title")
5658
plan = TextAreaField("Plan", validators=[DataRequired()])
5759
query = TextAreaField("Query")
60+
password = StringField("Password")
61+
62+
63+
class PasswordForm(FlaskForm):
64+
password = PasswordField("Password")
5865

5966

6067
@app.route("/new", methods=["POST"])
@@ -73,14 +80,21 @@ def save(json=False):
7380
"""
7481
form = PlanForm()
7582
if form.validate_on_submit():
76-
sql = "SELECT register_plan(:title, :plan, :query, :is_public)"
83+
plan = form.plan.data
84+
password_hash = None
85+
if password := form.password.data:
86+
password_hash = generate_password_hash(password)
87+
sql = """
88+
SELECT register_plan(:title, :plan, :query, :is_public, :password_hash)
89+
"""
7790
query = db.session.execute(
7891
sql,
7992
{
8093
"title": form.title.data,
81-
"plan": form.plan.data,
94+
"plan": plan,
8295
"query": form.query.data,
8396
"is_public": False,
97+
"password_hash": password_hash,
8498
},
8599
)
86100
db.session.commit()
@@ -97,12 +111,26 @@ def plan():
97111
return redirect(url_for("plan_error"))
98112

99113

100-
@app.route("/plan/<id>")
114+
@app.route("/plan/<id>", methods=["GET"])
101115
def plan_from_db(id):
102116
plan = Plan.query.get_or_404(id, description="This plan doesn't exist.")
117+
if plan.password_hash is not None:
118+
return render_template("locked.html")
103119
return render_template("plan.html", plan=plan)
104120

105121

122+
@app.route("/plan/<id>", methods=["POST"])
123+
def plan_from_db_with_password(id):
124+
plan = Plan.query.get_or_404(id, description="This plan doesn't exist.")
125+
form = PasswordForm()
126+
form.validate_on_submit()
127+
if plan.password_hash is None or check_password_hash(
128+
plan.password_hash, form.password.data
129+
):
130+
return render_template("plan.html", plan=plan)
131+
return render_template("locked.html", invalid_password=True)
132+
133+
106134
@app.route("/plan/<id>/<key>")
107135
def delete(id, key):
108136
plan = Plan.query.get_or_404(id, description="This plan doesn't exist.")

app/static/dist/.vite/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
]
2828
},
2929
"app/static/js/index.js": {
30-
"file": "assets/index-H99ZOPuf.js",
30+
"file": "assets/index-z8Sih27D.js",
3131
"name": "index",
3232
"src": "app/static/js/index.js",
3333
"isEntry": true,
Lines changed: 9 additions & 9 deletions
Large diffs are not rendered by default.

app/static/js/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const app = createApp({
2727
const titleInput = ref("");
2828
const planInput = ref("");
2929
const queryInput = ref("");
30+
const passwordInput = ref("");
3031
const draggingPlan = ref(false);
3132
const draggingQuery = ref(false);
3233
const plans = ref([]);
@@ -62,6 +63,7 @@ const app = createApp({
6263
title: titleInput.value,
6364
plan: planInput.value,
6465
query: queryInput.value,
66+
password: passwordInput.value,
6567
createdOn: new Date(),
6668
};
6769
}
@@ -141,6 +143,7 @@ const app = createApp({
141143
title: plan.title,
142144
plan: plan.plan,
143145
query: plan.query,
146+
password: plan.password,
144147
})
145148
.then((response) => {
146149
localStorage.removeItem(plan.id);
@@ -191,6 +194,7 @@ const app = createApp({
191194
titleInput,
192195
planInput,
193196
queryInput,
197+
passwordInput,
194198
draggingPlan,
195199
draggingQuery,
196200
plans,

app/templates/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ <h2>
3737
<label for="queryInput" class="form-label">Query <span class="small text-muted">(optional)</span></label>
3838
<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>
3939
</div>
40+
<div class="mb-3">
41+
<label for="passwordInput" class="form-label">Password <span class="small text-muted">(optional)</span>
42+
<span class="badge text-bg-success ms-2"><small>New</small></span>
43+
</label>
44+
<div class="row">
45+
<div class="col-6">
46+
<input name="password" class="form-control" id="passwordInput" v-model="passwordInput" maxlength="100">
47+
</div>
48+
</div>
49+
</div>
4050
<div class="row">
4151
<div class="col d-flex">
4252
<button type="submit" class="btn btn-primary">Submit</button>

app/templates/locked.html

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{% set asset='common' %}
2+
3+
{% extends "layout.html" %}
4+
5+
{% block title %}
6+
{{ plan.title or '' + ' |' if plan else ' | ' }}
7+
{% endblock %}
8+
9+
{% block body %}
10+
<div id="app" class="d-flex flex-column h-100">
11+
<header class="header bg-dark container-fluid">
12+
<nav class="navbar navbar-expand-md navbar-dark justify-content-between">
13+
<ul class="navbar-nav">
14+
<li class="nav-item">
15+
<a class="nav-link p-0" href="{{ url_for('index') }}">
16+
<img src="{{ url_for('static', filename='img/logo_pev2.svg') }}" alt="Logo PEV2" style="height:35px;">
17+
explain.dalibo.com
18+
</a>
19+
</li>
20+
<li class="nav-item ms-3">
21+
<a class="btn btn-outline-primary text-white" href="{{ url_for('index') }}">
22+
+ New Plan
23+
</a>
24+
</li>
25+
</ul>
26+
</header>
27+
<div class="d-flex flex-column flex-grow-1 overflow-auto">
28+
<div class="flex-grow-1 overflow-auto d-flex">
29+
<div v-else class="w-100 h-100 d-flex">
30+
<div class="align-self-center mx-auto">
31+
<div class="mb-3">
32+
This plan is protected by a password.
33+
</div>
34+
<form method="POST">
35+
<div class="mb-3">
36+
<input name="password" type="password" class="form-control" maxlength="100" placeholder="Password" autocomplete="current-password"/>
37+
</div>
38+
{% if invalid_password %}
39+
<div class="mb-3 text-danger">
40+
Password is incorrect
41+
</div>
42+
{% endif %}
43+
<div class="row">
44+
<div class="col d-flex justify-content-center">
45+
<button type="submit" class="btn btn-primary">Unlock</button>
46+
</div>
47+
</div>
48+
</form>
49+
</div>
50+
</div>
51+
</div>
52+
</div>
53+
</div>
54+
{% endblock %}
55+
56+
{% block head %}
57+
<meta name="robots" content="noindex" />
58+
{% endblock %}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Add support for plans password protection.
2+
3+
Revision ID: a9ed4fe59f75
4+
Revises: 7d4af6ed6f66
5+
Create Date: 2025-06-04 11:43:31.050664
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "a9ed4fe59f75"
14+
down_revision = "7d4af6ed6f66"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
with op.batch_alter_table("plans", schema=None) as batch_op:
22+
batch_op.add_column(sa.Column("password_hash", sa.String(), nullable=True))
23+
24+
op.execute(
25+
"""
26+
DROP FUNCTION public.register_plan;
27+
28+
CREATE FUNCTION public.register_plan(in_title text, in_plan
29+
text, in_sql text, in_is_public boolean, in_password_hash text)
30+
RETURNS register_plan_return
31+
LANGUAGE plpgsql
32+
AS $function$
33+
DECLARE
34+
use_hash_length int4 := 16;
35+
reply register_plan_return;
36+
insert_sql TEXT;
37+
BEGIN
38+
insert_sql := 'INSERT INTO public.plans (id, title, plan, sql,
39+
is_public, created, delete_key, password_hash) VALUES ($1, $2, $3, $4, $5, now(), $6, $7)';
40+
reply.delete_key := get_random_string( 50 );
41+
LOOP
42+
reply.id := get_random_string(use_hash_length);
43+
BEGIN
44+
execute insert_sql using reply.id, in_title, in_plan, in_sql, in_is_public, reply.delete_key, in_password_hash;
45+
RETURN reply;
46+
EXCEPTION WHEN unique_violation THEN
47+
-- do nothing
48+
END;
49+
use_hash_length := use_hash_length + 1;
50+
IF use_hash_length >= 30 THEN
51+
raise exception 'Random string of length == 30 requested. something''s wrong.';
52+
END IF;
53+
END LOOP;
54+
END;
55+
$function$;
56+
"""
57+
)
58+
59+
# ### end Alembic commands ###
60+
61+
62+
def downgrade():
63+
# ### commands auto generated by Alembic - please adjust! ###
64+
with op.batch_alter_table("plans", schema=None) as batch_op:
65+
batch_op.drop_column("password_hash")
66+
67+
op.execute(
68+
"""
69+
--
70+
-- Name: get_random_string(integer); Type: FUNCTION; Schema: public; Owner: -
71+
--
72+
DROP FUNCTION public.register_plan;
73+
74+
CREATE FUNCTION public.register_plan(in_title text, in_plan
75+
text, in_sql text, in_is_public boolean)
76+
RETURNS register_plan_return
77+
LANGUAGE plpgsql
78+
AS $function$
79+
DECLARE
80+
use_hash_length int4 := 16;
81+
reply register_plan_return;
82+
insert_sql TEXT;
83+
BEGIN
84+
insert_sql := 'INSERT INTO public.plans (id, title, plan, sql,
85+
is_public, created, delete_key) VALUES ($1, $2, $3, $4, $5, now(), $6)';
86+
reply.delete_key := get_random_string( 50 );
87+
LOOP
88+
reply.id := get_random_string(use_hash_length);
89+
BEGIN
90+
execute insert_sql using reply.id, in_title, in_plan, in_sql, in_is_public, reply.delete_key;
91+
RETURN reply;
92+
EXCEPTION WHEN unique_violation THEN
93+
-- do nothing
94+
END;
95+
use_hash_length := use_hash_length + 1;
96+
IF use_hash_length >= 30 THEN
97+
raise exception 'Random string of length == 30 requested. something''s wrong.';
98+
END IF;
99+
END LOOP;
100+
END;
101+
$function$;
102+
"""
103+
)
104+
105+
# ### end Alembic commands ###

0 commit comments

Comments
 (0)