Commit ecd07d22 authored by Vincent Pelletier's avatar Vincent Pelletier

all: Major rework.

- Re-evaluate feature set and REST API.
- switch duration units to days, which are more meaningful than sticking to
  ISO units in this context.
- Implement the "cau" half of "caucase".
  As a consequence flask password authentication mechanism is not needed
  anymore. As HTML UI is not required internally to caucase, and as
  sqlalchemy is not used to its full extend, get rid of these
  dependencies altogether.
- Implement REST HTTP/HTTPS stand-alone server as a layer above WSGI
  application, and integrate HTTPS certificate issuance and renewal
  mechanism to simplify deployment: no middleware needed, so from
  gunicorn dependency.
- Use standard python modules for http client needs.
- Re-evaluate data retention options:
  - unsigned CSRs are kept forever
  - CRTs are stored in CSR table, and a 24 hour expiration is set
  - CA CRTs: (unchanged, expire when past validity period)
  - CRLs: (unchanged, expire when past validity period)
- Redispatch housekeeping tasks:
  - CA renewal happens when caucase is used and renewal is needed
  - CRL is flushed when re-generated
  - CSR table (containing CRTs) is cleaned when a new CSR is received
  removing completely the need for these special periodic tasks.
- Storage parameters are not stored persistently anymore, instead their
  effect (time offsets) is applied before storing (to protect against
  transient retention period reconfiguration from wiping data).
- Rework storage schema.
- Implement certificate extension propagation & filtering.
- Implement "Certificate was auto-signed" extension.
- More docstrings.
- Use a CSR as a subject & extensions template instead of only allowing
  to override the subject. Useful when renewing a certificate and when
  authenticated client wants to force (ex) a CommonName in the subject.
- Reorganise cli executable arguments to have more possible actions.
  Especially, make CA renewal systematic on command start (helps
  validating caucase URL).
- Increase the amount of sanity checks against user-provided data (ex:
  do not upload a private key which would be in the same file as the CRT
  to renew).
- Extend package classifiers.
- Get rid of revocation reason, as it seems unlikely to be filled, and
  even less likely to be read later.
- (almost) stop using pyOpenSSL. Use cryptography module instead.
  cryptography has many more features than pyOpenSSL (except for certificate
  validation, sadly), so use it. It completely removes the need to poke
  at ASN.1 ourselves, which significantly simplifies utils module, and
  certificate signature. Code is a bit more verbose when signing, but much
  simpler than before.
- add the possibility to revoke by certificate serial
- update gitignore
- include coverage configuration
- include pylint configuration
- integrate several secondary command:
  - caucase-probe to quickly check server presence and basic
    functionality, so automated deployments can easily auto-check
  - caucase-monitor to automate key initial request and renewal
  - caucase-rerequest to allow full flexibility over certificate request
    content without ever transfering private keys
- add a secure backup generation mechanism
- add a README describing the design
parent 0da27fdc
[run]
branch = true
concurrency =
thread
multiprocessing
parallel = true
/htmlcov/
/cover/
/.eggs/
/.coverage
.*.swp
*.pyc *.pyc
[MESSAGES CONTROL]
disable=C0103,C0330
# C0103 Disable "Invalid name "%s" (should match %s)"
# C0330 Disable "bad-continuation"
[FORMAT]
indent-string=" "
0.2.0 (2017-08-XX)
==================
* implement the "cau" half of "caucase"
* massive rework: removal of flask dependency, removal of HTML UI, rework of
the REST API, rework of the CLI tools, rework of the WGSI application,
incomatible redesign of the database.
0.1.4 (2017-07-21) 0.1.4 (2017-07-21)
================== ==================
* caucase web parameter 'auto-sign-csr-amount' can be used to set how many csr must be signed automatically. * caucase web parameter 'auto-sign-csr-amount' can be used to set how many csr must be signed automatically.
......
include CHANGES.txt include CHANGES.txt
recursive-include caucase/templates *.html include COPYING
recursive-include caucase/static *.css *.png *.js *.gif
This diff is collapsed.
Blocker for 1.0
===============
- After pyca/cryptography 21st release: Make is_signature_valid call mandatory in caucase.utils.load_crl .
- After pyca/cryptography later release (code not fixed yet): Enable CRL distribution point extension when it tolerates literal IPv6 in the URL.
Eventually
==========
- Become an OCSP responder (requires support in other libraries - likely pyca/cryptography).
...@@ -15,23 +15,6 @@ ...@@ -15,23 +15,6 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with caucase. If not, see <http://www.gnu.org/licenses/>. # along with caucase. If not, see <http://www.gnu.org/licenses/>.
"""
# See http://peak.telecommunity.com/DevCenter/setuptools#namespace-packages Caucase - Certificate Authority for Users, Certificate Authority for SErvices
try: """
__import__('pkg_resources').declare_namespace(__name__)
except ImportError:
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
# Use default value so SQLALCHEMY will not warn because there is not db_uri
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///ca.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
from caucase import web, storage
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -15,6 +15,9 @@ ...@@ -15,6 +15,9 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with caucase. If not, see <http://www.gnu.org/licenses/>. # along with caucase. If not, see <http://www.gnu.org/licenses/>.
"""
Caucase - Certificate Authority for Users, Certificate Authority for SErvices
"""
class CertificateAuthorityException(Exception): class CertificateAuthorityException(Exception):
"""Base exception""" """Base exception"""
...@@ -29,23 +32,9 @@ class NotFound(CertificateAuthorityException): ...@@ -29,23 +32,9 @@ class NotFound(CertificateAuthorityException):
pass pass
class Found(CertificateAuthorityException): class Found(CertificateAuthorityException):
"""Requested ID is already in use""" """Resource to create already exists"""
class BadSignature(CertificateAuthorityException):
"""Non-x509 signature check failed"""
class BadCertificateSigningRequest(CertificateAuthorityException):
"""CSR content doesn't contain all required elements"""
pass
class BadCertificate(CertificateAuthorityException):
"""Certificate is not a valid PEM content"""
pass pass
class CertificateVerificationError(CertificateAuthorityException): class CertificateVerificationError(CertificateAuthorityException):
"""Certificate is not valid, it was not signed by CA""" """Certificate is not valid, it was not signed by CA"""
pass pass
class ExpiredCertificate(CertificateAuthorityException):
"""Certificate has expired and could not be used"""
pass
\ No newline at end of file
This diff is collapsed.
.ui-table th, .ui-table td {
line-height: 1.5em;
text-align: left;
padding: .4em .5em;
vertical-align: middle;
}
.ui-overlay-a, .ui-page-theme-a, .ui-page-theme-a .ui-panel-wrapper {
background-color: #fff !important;
}
table .ui-table th, table .ui-table td {
vertical-align: middle;
}
.noshadow buton, .noshadow a, .noshadow input, .noshadow select {
-webkit-box-shadow: none !important;
-moz-box-shadow: none !important;
box-shadow: none !important;
}
.ui-error, html .ui-content .ui-error a, .ui-content a.ui-error {
color: red;
font-weight: bold;
}
html body {
overflow-x: hidden;
background: #fbfbfb;
}
.form-signin {
max-width: 330px;
padding: 15px;
margin: 0 auto;
}
.form-signin .form-signin-heading,
.form-signin .checkbox {
margin-bottom: 10px;
}
.form-signin .checkbox {
font-weight: normal;
}
.form-signin .form-control {
position: relative;
height: auto;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
padding: 10px;
font-size: 16px;
}
.form-signin .form-control:focus {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* Toggle Styles */
#wrapper {
padding-left: 0;
-webkit-transition: all 0.6s ease;
-moz-transition: all 0.6s ease;
-o-transition: all 0.6s ease;
transition: all 0.6s ease;
}
#wrapper.toggled {
padding-left: 200px;
}
#sidebar-wrapper {
z-index: 1000;
position: fixed;
left: 250px;
width: 0;
height: 100%;
margin-left: -250px;
overflow-y: auto;
background-color:#312A25 !Important;
-webkit-transition: all 0.2s ease;
-moz-transition: all 0.2s ease;
-o-transition: all 0.2s ease;
transition: all 0.2s ease;
}
#wrapper.toggled #sidebar-wrapper {
width: 0;
}
#page-content-wrapper {
width: 100%;
position: absolute;
padding: 10px;
}
#wrapper.toggled #page-content-wrapper {
position: absolute;
margin-left:-250px;
}
/* Sidebar Styles */
.nav-side-menu {
overflow: auto;
font-family: verdana;
font-size: 12px;
font-weight: 200;
background-color: #2a2f35; /* #313130 */
position: fixed;
top: 0px;
width: 300px;
height: 100%;
color: #e1ffff;
}
.nav-side-menu .brand {
background-color: #404040;
line-height: 50px;
display: block;
text-align: center;
font-size: 14px;
}
.nav-side-menu .toggle-btn {
display: none;
}
.nav-side-menu ul,
.nav-side-menu li {
list-style: none;
padding: 0px;
margin: 0px;
line-height: 35px;
cursor: pointer;
/*
.collapsed{
.arrow:before{
font-family: FontAwesome;
content: "\f053";
display: inline-block;
padding-left:10px;
padding-right: 10px;
vertical-align: middle;
float:right;
}
}
*/
}
.nav-side-menu ul :not(collapsed) .arrow:before,
.nav-side-menu li :not(collapsed) .arrow:before {
font-family: FontAwesome;
content: "\f078";
display: inline-block;
padding-left: 10px;
padding-right: 10px;
vertical-align: middle;
float: right;
}
.nav-side-menu ul .active,
.nav-side-menu li .active {
border-left: 3px solid #d19b3d;
background-color: #4f5b69;
}
.nav-side-menu ul .sub-menu li.active,
.nav-side-menu li .sub-menu li.active {
color: #d19b3d;
}
.nav-side-menu ul .sub-menu li.active a,
.nav-side-menu li .sub-menu li.active a {
color: #d19b3d;
}
.nav-side-menu ul .sub-menu li,
.nav-side-menu li .sub-menu li {
background-color: #181c20;
border: none;
line-height: 28px;
border-bottom: 1px solid #23282e;
margin-left: 0px;
}
.nav-side-menu ul .sub-menu li:hover,
.nav-side-menu li .sub-menu li:hover {
background-color: #020203;
}
.nav-side-menu ul .sub-menu li:before,
.nav-side-menu li .sub-menu li:before {
font-family: FontAwesome;
content: "\f105";
display: inline-block;
padding-left: 10px;
padding-right: 10px;
vertical-align: middle;
}
.nav-side-menu li {
padding-left: 0px;
border-left: 3px solid #2e353d;
border-bottom: 1px solid #23282e;
}
.nav-side-menu li a {
text-decoration: none;
color: #e1ffff;
display: block;
}
.nav-side-menu li a i {
padding-left: 10px;
width: 20px;
padding-right: 25px;
font-size: 18px;
}
.nav-side-menu li:hover {
border-left: 3px solid #d19b3d;
background-color: #4f5b69;
-webkit-transition: all 1s ease;
-moz-transition: all 1s ease;
-o-transition: all 1s ease;
-ms-transition: all 1s ease;
transition: all 1s ease;
}
@media (max-width: 767px) {
.nav-side-menu {
position: relative;
width: 100%;
margin-bottom: 10px;
}
.nav-side-menu .toggle-btn {
display: block;
cursor: pointer;
position: absolute;
right: 10px;
top: 10px;
z-index: 10 !important;
padding: 3px;
background-color: #ffffff;
color: #000;
width: 40px;
text-align: center;
}
.brand {
text-align: left !important;
font-size: 22px;
padding-left: 20px;
line-height: 50px !important;
}
}
@media (min-width: 767px) {
.nav-side-menu .menu-list .menu-content {
display: block;
}
#main {
width:calc(100% - 300px);
float: right;
}
}
body {
margin: 0px;
padding: 0px;
}
pre {
max-height: 600px;
}
.col-centered {
float: none;
margin: 0 auto;
}
.clickable{
cursor: pointer;
}
.table .panel-heading div {
margin-top: -18px;
font-size: 15px;
}
.table .panel-heading div span{
margin-left:5px;
}
.table .panel-body{
display: none;
}
.container .table>tbody>tr>td, .table>tbody>tr>th, .table>tfoot>tr>td, .table>tfoot>tr>th, .table>thead>tr>td, .table>thead>tr>th {
vertical-align: middle;
}
.margin-top-40 {
margin-top:40px;
}
.margin-lr-20 {
margin: 0 20px;
}
.flashes-messages div:first-child {
margin-top: 30px;
}
/* Dashboard boxes */
.dash-panel {
text-align: center;
padding: 1px 0;
}
html body a:hover > .dash-panel h4 {
text-decoration: none;
}
.dash-panel:hover {
background-color: #e6e6e6;
border-color: #adadad;
cursor: pointer;
}
.dash {
position: relative;
text-align: center;
width: 120px;
height: 55px;
margin: 10px auto 10px auto;
}
#dash-blue .number {
color: #30a5ff;
}
#dash-orange .number {
color: #ffb53e;
}
#dash-teal .number {
color: #1ebfae;
}
#dash-red .number {
color: #ef4040;
}
#dash-darkred .number {
color: #bd0849;
}
.dash .number {
display: block;
position: absolute;
font-size: 46px;
width: 120px;
}
.alert-error {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
}
$( document ).ready(function() {
$("#menu-toggle").click(function(e) {
e.preventDefault();
$("#wrapper").toggleClass("toggled");
});
});
(function(){
'use strict';
var $ = jQuery;
$.fn.extend({
filterTable: function(){
return this.each(function(){
$(this).on('keyup', function(e){
$('.filterTable_no_results').remove();
var $this = $(this),
search = $this.val().toLowerCase(),
target = $this.attr('data-filters'),
$target = $(target),
$rows = $target.find('tbody tr');
if(search === '') {
$rows.show();
} else {
$rows.each(function(){
var $this = $(this);
$this.text().toLowerCase().indexOf(search) === -1 ? $this.hide() : $this.show();
});
if($target.find('tbody tr:visible').size() === 0) {
var col_count = $target.find('tr').first().find('td').size();
var no_results = $('<tr class="filterTable_no_results"><td colspan="'+col_count+'">No results found</td></tr>');
$target.find('tbody').append(no_results);
}
}
});
});
}
});
$('[data-action="filter"]').filterTable();
})(jQuery);
$(function(){
// attach table filter plugin to inputs
$('[data-action="filter"]').filterTable();
$('.container').on('click', '.panel-heading span.filter', function(e){
var $this = $(this),
$panel = $this.parents('.panel');
$panel.find('.panel-body').slideToggle();
if($this.css('display') != 'none') {
$panel.find('.panel-body input').focus();
}
});
$('[data-toggle="tooltip"]').tooltip();
});
\ No newline at end of file
This diff is collapsed.
{% extends "layout.html" %}
{% block content %}
<form class="form-signin" method="POST" action="/admin/setpassword">
<h2 class="form-signin-heading" style="margin-bottom: 30px">Set admin password</h2>
<label for="pw" class="sr-only">Password</label>
<input type="inputPassword" name="password" id="pw" class="form-control" placeholder="password" required autofocus>
<label for="pw2" class="sr-only">Confirm Password:</label>
<input type="inputPassword" name="password2" id="pw2" class="form-control" placeholder="Confirm password" required autofocus>
<br/>
<button class="btn btn-lg btn-primary btn-block" type="submit">configure</button>
</form>
{% endblock %}
{% extends "layout.html" %}
{% block pre_content %}
<div class="row">
<div class="col-sm-7 col-md-6 col-lg-5 col-centered">
{% endblock %}
{% block post_content %}
</div>
</div>
{% endblock %}
\ No newline at end of file
{% extends 'flask_user/flask_user_base.html' %}
\ No newline at end of file
{% extends 'flask_user/flask_user_base.html' %}
\ No newline at end of file
<!-- Placed at the end of the document so the pages load faster -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<script type=application/javascript src="{{ url_for('static', filename='scripts/index.js') }}"></script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Certificate Authority web</title>
<link rel=stylesheet type=text/css href="{{ url_for('static', filename='css/styles.css') }}">
<link href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
{% extends "layout.html" %}
{% block content %}
<div class="page-header">
<h1>Certificate authority<small> Signed Certificates</small></h1>
</div>
<div class="row">
<div class="col-md-12">
<div class="panel panel-success margin-top-40 table">
<div class="panel-heading">
<h3 class="panel-title">List of signed Certificates</h3>
<div class="pull-right">
<span class="clickable filter" data-toggle="tooltip" title="Toggle table filter" data-container="body">
<i class="glyphicon glyphicon-filter"></i>
</span>
</div>
</div>
<div class="panel-body">
<input type="text" class="form-control" id="cacert-table-filter" data-action="filter" data-filters="#cacert-table" placeholder="Filter Columns" />
</div>
<table class="table table-hover" id="cacert-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Common Name</th>
<th>Signature Date</th>
<th>Expiration Date</th>
<th></th>
</tr>
</thead>
<tbody>
{% for cert in data_list -%}
<tr>
<td>{{ cert['index'] }}</td>
<td><a href="/crt/{{ cert['crt_id'] }}">{{ cert['crt_id'] }}</a></td>
<td>{{ cert['common_name'] }}</td>
<td>{{ cert['start_before'] }}</td>
<td>{{ cert['expire_after'] }}</td>
<td><a class="btn btn-default" href="/crt/{{ cert['crt_id'] }}" role="button" title="Download file"><i class="fa fa-download" aria-hidden="true"></i></a></td>
</tr>
{% endfor -%}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
\ No newline at end of file
<!DOCTYPE html>
<html>
<head>
{% include "head.html" %}
</head>
<body>
{% block body %}
<div class="container-fluid">
<div class="row">
<div class="nav-side-menu">
<div class="brand">Certificate Authority</div>
<i class="fa fa-bars fa-2x toggle-btn" data-toggle="collapse" data-target="#menu-content"></i>
<div class="menu-list">
<ul id="menu-content" class="menu-content collapse out">
{% if not session.user_id %}
<li>
<a href="/">
<i class="fa fa-home" aria-hidden="true"></i> Public Home
</a>
</li>
<li>
<a href="/user/sign-in">
<i class="fa fa-cog" aria-hidden="true"></i> Manage Certificates
</a>
</li>
{% else -%}
<li>
<a href="/admin/">
<i class="fa fa-home" aria-hidden="true"></i> Signed Certificates
</a>
</li>
<li><a href="/admin/csr_requests"><i class="fa fa-tachometer" aria-hidden="true"></i> Manage CSR
<span style="font-weight: bold; margin-left: 5px; margin-right: 5px; color: #56d8ce;">[ {{ session.count_csr }} ] </span></a></li>
<!--<li><a href="/signed_certs"><i class="fa fa-check-square" aria-hidden="true"></i> Signed Certificates</a></li>
<li><a href="/revoked_certs"><i class="fa fa-minus-square" aria-hidden="true"></i> Revoked Certificates</a></li>-->
<li><a href="/admin/profile"><i class="fa fa-user" aria-hidden="true"></i> User Profile</a></li>
<!--<li><a href="/admin/logs"><i class="fa fa-book" aria-hidden="true"></i> Certificate Authority Logs</a></li>-->
<li><a href="/admin/logout"><i class="fa fa-sign-out" aria-hidden="true"></i> Logout</a></li>
{% endif -%}
</ul>
</div>
</div>
<div class="container" id="main">
<div class="flashes-messages">
{% with messages = get_flashed_messages(with_categories=true) %}
<!-- Categories: success (green), info (blue), warning (yellow), danger (red) -->
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
{{ message|safe }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
{% block pre_content %}{% endblock %}
{% block content %}{% endblock %}
{% block post_content %}{% endblock %}
</div>
</div>
</div>
{% endblock %}
{% include "footer.html" %}
</body>
</html>
\ No newline at end of file
{% macro render_field(field, label=None, label_visible=true, right_url=None, right_label=None, readonly=false) -%}
<div class="form-group {% if field.errors %}has-error{% endif %} {{ kwargs.pop('class_', '') }}">
{% if field.type != 'HiddenField' and label_visible %}
{% if not label %}{% set label=field.label.text %}{% endif %}
<label for="{{ field.id }}" class="control-label">{{ label|safe }}</label>
{% endif %}
{{ field(class_='form-control', readonly=readonly, **kwargs) }}
{% if field.errors %}
{% for e in field.errors %}
<p class="help-block">{{ e }}</p>
{% endfor %}
{% endif %}
</div>
{%- endmacro %}
{% macro render_checkbox_field(field, label=None) -%}
{% if not label %}{% set label=field.label.text %}{% endif %}
<div class="checkbox">
<label>
{{ field(type='checkbox', **kwargs) }} {{ label }}
</label>
</div>
{%- endmacro %}
{% macro render_radio_field(field) -%}
{% for value, label, checked in field.iter_choices() %}
<div class="radio">
<label>
<input type="radio" name="{{ field.id }}" id="{{ field.id }}" value="{{ value }}"{% if checked %} checked{% endif %}>
{{ label }}
</label>
</div>
{% endfor %}
{%- endmacro %}
{% macro render_submit_field(field, label=None, tabindex=None) -%}
{% if not label %}{% set label=field.label.text %}{% endif %}
{#<button type="submit" class="form-control btn btn-lg btn-primary btn-block">{{label}}</button>#}
<input type="submit" class="btn btn-lg btn-primary btn-block" value="{{label}}"
{% if tabindex %}tabindex="{{ tabindex }}"{% endif %}
>
{%- endmacro %}
\ No newline at end of file
This diff is collapsed.
{% extends "layout.html" %}
{% block content %}
<div class="page-header">
<h1>User account information</h1>
</div>
<div style="padding: 15px">
<p>
<a class="btn btn-default" href="{{ url_for('user.change_password') }}" role="button" title="Download file">
<i class="fa fa-pencil-square-o" aria-hidden="true"></i> Change password</a>
</p>
{% from "macros.html" import render_field, render_submit_field %}
<form action="" method="POST" class="form" role="form">
<div class="row">
<div class="col-sm-6 col-md-5 col-lg-4">
{{ form.hidden_tag() }}
{{ render_field(form.username, tabindex=230, readonly=true) }}
{{ render_field(form.first_name, tabindex=240) }}
{{ render_field(form.last_name, tabindex=250) }}
{{ render_field(form.email, tabindex=260) }}
{{ render_submit_field(form.submit, tabindex=280) }}
</div>
</div>
</form>
</div>
{% endblock %}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment