Exemple #1
0
def settings_info_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        repo = repo.new_repo
    valid = Validation(request)
    desc = valid.optional("description", default=repo.description)
    visibility = valid.optional("visibility",
                                cls=RepoVisibility,
                                default=repo.visibility)
    repo.visibility = visibility
    repo.description = desc
    db.session.commit()
    return redirect("/{}/{}/settings/info".format(owner_name, repo_name))
 def edit_POST(task):
     record = GitHubPRToBuild._GitHubPRToBuildRecord.query.filter(
         GitHubPRToBuild._GitHubPRToBuildRecord.task_id ==
         task.id).one_or_none()
     valid = Validation(request)
     automerge = valid.optional("automerge", cls=bool, default=False)
     secrets = valid.optional("secrets", cls=bool, default=False)
     record.automerge = bool(automerge)
     record.secrets = bool(secrets)
     if not record.private:
         record.secrets = False
     db.session.commit()
     session["saved"] = True
     return redirect(url_for("html.edit_task", task_id=task.id))
Exemple #3
0
def ticket_edit_POST(owner, name, ticket_id):
    tracker, _ = get_tracker(owner, name)
    if not tracker:
        abort(404)
    ticket, access = get_ticket(tracker, ticket_id)
    if not ticket:
        abort(404)
    if not TicketAccess.edit in access:
        abort(401)

    valid = Validation(request)
    title = valid.require("title", friendly_name="Title")
    desc = valid.optional("description")

    valid.expect(not title or 3 <= len(title) <= 2048,
                 "Title must be between 3 and 2048 characters.",
                 field="title")
    valid.expect(not desc or len(desc) < 16384,
                 "Description must be no more than 16384 characters.",
                 field="description")

    if not valid.ok:
        return render_template("edit_ticket.html",
                               tracker=tracker,
                               ticket=ticket,
                               **valid.kwargs)

    ticket.title = title
    ticket.description = desc
    db.session.commit()

    return redirect(ticket_url(ticket))
Exemple #4
0
def submit_POST():
    valid = Validation(request)
    _manifest = valid.require("manifest", friendly_name="Manifest")
    max_len = Job.manifest.prop.columns[0].type.length
    note = valid.optional("note", default="Submitted on the web")
    valid.expect(not _manifest or len(_manifest) < max_len,
                 "Manifest must be less than {} bytes".format(max_len),
                 field="manifest")
    if not valid.ok:
        return render_template("submit.html", **valid.kwargs)
    try:
        manifest = Manifest(yaml.safe_load(_manifest))
    except Exception as ex:
        valid.error(str(ex), field="manifest")
        return render_template("submit.html", **valid.kwargs)
    job = Job(current_user, _manifest)
    job.note = note
    db.session.add(job)
    db.session.flush()
    for task in manifest.tasks:
        t = Task(job, task.name)
        db.session.add(t)
        db.session.flush()  # assigns IDs for ordering purposes
    queue_build(job, manifest)  # commits the session
    return redirect("/~" + current_user.username + "/job/" + str(job.id))
 def edit_POST(task):
     record = GitLabCommitToBuild._GitLabCommitToBuildRecord.query.filter(
         GitLabCommitToBuild._GitLabCommitToBuildRecord.task_id == task.id
     ).one_or_none()
     valid = Validation(request)
     secrets = valid.optional("secrets", cls=bool, default=False)
     record.secrets = bool(secrets)
     db.session.commit()
     return redirect(url_for("html.edit_task", task_id=task.id))
Exemple #6
0
def ticket_comment_POST(owner, name, ticket_id):
    tracker, access = get_tracker(owner, name)
    if not tracker:
        abort(404)
    ticket, access = get_ticket(tracker, ticket_id)
    if not ticket:
        abort(404)

    valid = Validation(request)
    text = valid.optional("comment")
    resolve = valid.optional("resolve")
    resolution = valid.optional("resolution")
    reopen = valid.optional("reopen")

    valid.expect(not text or 3 <= len(text) <= 16384,
                 "Comment must be between 3 and 16384 characters.",
                 field="comment")
    valid.expect(text or resolve or reopen,
                 "Comment is required",
                 field="comment")

    if (resolve or reopen) and TicketAccess.edit not in access:
        abort(403)

    if resolve:
        try:
            resolution = TicketResolution(int(resolution))
        except Exception as ex:
            abort(400, "Invalid resolution")
    else:
        resolution = None

    if not valid.ok:
        ctx = get_ticket_context(ticket, tracker, access)
        return render_template("ticket.html", **ctx, **valid.kwargs)

    comment = add_comment(current_user,
                          ticket,
                          text=text,
                          resolve=resolve,
                          resolution=resolution,
                          reopen=reopen)

    return redirect(ticket_url(ticket, comment))
Exemple #7
0
 def edit_POST(task):
     record = GitLabMRToBuild._GitLabMRToBuildRecord.query.filter(
         GitLabMRToBuild._GitLabMRToBuildRecord.task_id ==
         task.id).one_or_none()
     valid = Validation(request)
     # TODO: Check if the repo is public/private and enable secrets if so
     secrets = valid.optional("secrets", cls=bool, default=False)
     record.secrets = bool(secrets)
     db.session.commit()
     return redirect(url_for("html.edit_task", task_id=task.id))
Exemple #8
0
def create():
    if not current_app.repo_api:
        abort(501)
    valid = Validation(request)
    repo = current_app.repo_api.create_repo(valid, current_user)
    if not valid.ok:
        return render_template("create.html", **valid.kwargs)
    another = valid.optional("another")
    if another == "on":
        return redirect("/create?another")
    else:
        return redirect("/~{}/{}".format(current_user.username, repo.name))
Exemple #9
0
def jobs_POST():
    valid = Validation(request)
    _manifest = valid.require("manifest", cls=str)
    max_len = Job.manifest.prop.columns[0].type.length
    valid.expect(not _manifest or len(_manifest) < max_len,
                 "Manifest must be less than {} bytes".format(max_len),
                 field="manifest")
    note = valid.optional("note", cls=str)
    read = valid.optional("access:read", ["*"], list)
    write = valid.optional("access:write", [current_token.user.username], list)
    secrets = valid.optional("secrets", cls=bool, default=True)
    tags = valid.optional("tags", [], list)
    valid.expect(
        all(re.match(r"^[A-Za-z0-9_.-]+$", tag) for tag in tags),
        "Invalid tag name, tags must use lowercase alphanumeric characters, underscores, dashes, or dots",
        field="tags")
    triggers = valid.optional("triggers", list(), list)
    execute = valid.optional("execute", True, bool)
    if not valid.ok:
        return valid.response
    try:
        manifest = Manifest(yaml.safe_load(_manifest))
    except Exception as ex:
        valid.error(str(ex))
        return valid.response
    # TODO: access controls
    job = Job(current_token.user, _manifest)
    job.note = note
    if tags:
        job.tags = "/".join(tags)
    job.secrets = secrets
    db.session.add(job)
    db.session.flush()
    for task in manifest.tasks:
        t = Task(job, task.name)
        db.session.add(t)
        db.session.flush()  # assigns IDs for ordering purposes
    for index, trigger in enumerate(triggers):
        _valid = Validation(trigger)
        action = _valid.require("action", TriggerType)
        condition = _valid.require("condition", TriggerCondition)
        if not _valid.ok:
            _valid.copy(valid, "triggers[{}]".format(index))
            return valid.response
        # TODO: Validate details based on trigger type
        t = Trigger(job)
        t.trigger_type = action
        t.condition = condition
        t.details = json.dumps(trigger)
        db.session.add(t)
    if execute:
        queue_build(job, manifest)  # commits the session
    else:
        db.session.commit()
    return {"id": job.id}
Exemple #10
0
def create_POST():
    valid = Validation(request)
    name = valid.require("tracker_name", friendly_name="Name")
    desc = valid.optional("tracker_desc")
    if not valid.ok:
        return render_template("tracker-create.html", **valid.kwargs), 400

    valid.expect(2 < len(name) < 256,
                 "Must be between 2 and 256 characters",
                 field="tracker_name")
    valid.expect(not valid.ok or name[0] in string.ascii_lowercase,
                 "Must begin with a lowercase letter",
                 field="tracker_name")
    valid.expect(not valid.ok or name_re.match(name),
                 "Only lowercase alphanumeric characters or -.",
                 field="tracker_name")
    valid.expect(not desc or len(desc) < 4096,
                 "Must be less than 4096 characters",
                 field="tracker_desc")
    if not valid.ok:
        return render_template("tracker-create.html", **valid.kwargs), 400

    tracker = (Tracker.query.filter(
        Tracker.owner_id == current_user.id).filter(
            Tracker.name == name)).first()
    valid.expect(not tracker,
                 "A tracker by this name already exists",
                 field="tracker_name")
    if not valid.ok:
        return render_template("tracker-create.html", **valid.kwargs), 400

    tracker = Tracker()
    tracker.owner_id = current_user.id
    tracker.name = name
    tracker.description = desc
    db.session.add(tracker)
    db.session.flush()

    sub = TicketSubscription()
    sub.tracker_id = tracker.id
    sub.user_id = current_user.id
    db.session.add(sub)
    db.session.commit()

    if "create-configure" in valid:
        return redirect(
            url_for(".settings_details_GET",
                    owner=current_user.username,
                    name=name))

    return redirect(tracker_url(tracker))
Exemple #11
0
def settings_access_POST(owner_name, repo_name):
    owner, repo = check_access(owner_name, repo_name, UserAccess.manage)
    if isinstance(repo, BaseRedirectMixin):
        repo = repo.new_repo
    valid = Validation(request)
    user = valid.require("user", friendly_name="User")
    mode = valid.optional("access", cls=AccessMode, default=AccessMode.ro)
    if not valid.ok:
        return render_template("settings_access.html",
                               owner=owner,
                               repo=repo,
                               **valid.kwargs)
    # TODO: Group access
    if user[0] == "~":
        user = user[1:]
    User = current_app.User
    user = User.query.filter(User.username == user).first()
    valid.expect(
        user,
        "I don't know this user. Have they logged into this service before?",
        field="user")
    valid.expect(
        not user or user.id != current_user.id,
        "You can't adjust your own access controls. You always have full read/write access.",
        field="user")
    if not valid.ok:
        return render_template("settings_access.html",
                               owner=owner,
                               repo=repo,
                               **valid.kwargs)
    Access = current_app.Access
    grant = (Access.query.filter(Access.repo_id == repo.id,
                                 Access.user_id == user.id)).first()
    if not grant:
        grant = Access()
        grant.repo_id = repo.id
        grant.user_id = user.id
        db.session.add(grant)
    grant.mode = mode
    db.session.commit()
    return redirect("/{}/{}/settings/access".format(owner.canonical_name,
                                                    repo.name))
Exemple #12
0
def settings_POST(owner_name, list_name):
    owner, ml, access = get_list(owner_name, list_name)
    if not ml:
        abort(404)
    if ml.owner_id != current_user.id:
        abort(401)

    valid = Validation(request)
    list_desc = valid.optional("list_desc")
    if list_desc == "":
        list_desc = None
    valid.expect(not list_desc or 16 < len(list_desc) < 2048,
                 "Description must be between 16 and 2048 characters.",
                 field="list_desc")

    if not valid.ok:
        return render_template("list-settings.html",
                               list=ml,
                               owner=owner,
                               access_type_list=ListAccess,
                               access_help_map=access_help_map,
                               **valid.kwargs)

    def process(perm):
        bitfield = ListAccess.none
        for access in ListAccess:
            if access in [ListAccess.none]:
                continue
            if valid.optional("perm_{}_{}".format(perm, access.name)) != None:
                bitfield |= access
        return bitfield

    ml.description = list_desc
    ml.nonsubscriber_permissions = process("nonsub")
    ml.subscriber_permissions = process("sub")
    ml.account_permissions = process("account")

    db.session.commit()

    return redirect(
        url_for("archives.archive", owner_name=owner_name,
                list_name=list_name))
Exemple #13
0
def settings_details_POST(owner, name):
    tracker, access = get_tracker(owner, name)
    if not tracker:
        abort(404)
    if current_user.id != tracker.owner_id:
        abort(403)

    valid = Validation(request)
    desc = valid.optional("tracker_desc", default=tracker.description)
    valid.expect(not desc or len(desc) < 4096,
                 "Must be less than 4096 characters",
                 field="tracker_desc")
    if not valid.ok:
        return render_template("tracker-details.html",
                               tracker=tracker,
                               **valid.kwargs), 400

    tracker.description = desc
    db.session.commit()
    return redirect(tracker_url(tracker))
Exemple #14
0
def create_list_POST():
    valid = Validation(request)
    list_name = valid.require("list_name", friendly_name="Name")
    list_desc = valid.optional("list_desc")
    if not valid.ok:
        return render_template("create.html", **valid.kwargs)

    valid.expect(re.match(r'^[a-z._-][a-z0-9._-]*$', list_name),
                 "Name must match [a-z._-][a-z0-9._-]*",
                 field="list_name")
    existing = (List.query.filter(List.owner_id == current_user.id).filter(
        List.name.ilike(list_name)).first())
    valid.expect(not existing,
                 "This name is already in use.",
                 field="list_name")
    valid.expect(not list_desc or 16 < len(list_desc) < 2048,
                 "Description must be between 16 and 2048 characters.",
                 field="list_desc")
    if not valid.ok:
        return render_template("create.html", **valid.kwargs)

    ml = List()
    ml.owner_id = current_user.id
    ml.name = list_name
    ml.description = list_desc
    db.session.add(ml)
    db.session.commit()

    # Auto-subscribe the owner
    sub = Subscription()
    sub.user_id = current_user.id
    sub.list_id = ml.id
    sub.confirmed = True
    db.session.add(sub)
    db.session.commit()

    return redirect(
        url_for("archives.archive",
                owner_name=current_user.canonical_name,
                list_name=ml.name))
Exemple #15
0
def login_POST():
    if current_user:
        return redirect("/")
    valid = Validation(request)

    username = valid.require("username", friendly_name="Username")
    password = valid.require("password", friendly_name="Password")
    return_to = valid.optional("return_to", "/")

    if not valid.ok:
        return render_template("login.html", valid=valid), 400

    user = User.query.filter(User.username == username.lower()).one_or_none()

    valid.expect(user is not None, "Username or password incorrect")

    if valid.ok:
        valid.expect(
            bcrypt.checkpw(password.encode('utf-8'),
                           user.password.encode('utf-8')),
            "Username or password incorrect")

    if not valid.ok:
        metrics.meta_logins_failed.inc()
        return render_template("login.html", username=username, valid=valid)

    factors = UserAuthFactor.query \
        .filter(UserAuthFactor.user_id == user.id).all()

    if any(factors):
        session['extra_factors'] = [f.id for f in factors]
        session['authorized_user'] = user.id
        session['return_to'] = return_to
        return get_challenge(factors[0])

    login_user(user, remember=True)
    audit_log("logged in")
    db.session.commit()
    metrics.meta_logins_success.inc()
    return redirect(return_to)
Exemple #16
0
def invoice_POST(invoice_id):
    invoice = Invoice.query.filter(Invoice.id == invoice_id).one_or_none()
    if not invoice:
        abort(404)
    if (invoice.user_id != current_user.id 
            and current_user.user_type != UserType.admin):
        abort(401)
    valid = Validation(request)
    bill_to = valid.optional("address-to")
    if not bill_to:
        bill_to = "~" + invoice.user.username
    bill_from = [l for l in [
        cfg("meta.sr.ht::billing", "address-line1", default=None),
        cfg("meta.sr.ht::billing", "address-line2", default=None),
        cfg("meta.sr.ht::billing", "address-line3", default=None),
        cfg("meta.sr.ht::billing", "address-line4", default=None)
    ] if l]

    # Split bill_to to first row (rendered as heading) and others
    [bill_from_head, *bill_from_tail] = bill_from or [None]

    html = render_template("billing-invoice-pdf.html",
        invoice=invoice,
        amount=f"${invoice.cents / 100:.2f}",
        source=invoice.source,
        created=invoice.created.strftime("%Y-%m-%d"),
        valid_thru=invoice.valid_thru.strftime("%Y-%m-%d"),
        bill_to=bill_to,
        bill_from_head=bill_from_head,
        bill_from_tail=bill_from_tail)

    pdf = HTML(string=html).write_pdf()

    filename = f"invoice_{invoice.id}.pdf"
    headers = [('Content-Disposition', f'attachment; filename="{filename}"')]
    return Response(pdf, mimetype="application/pdf", headers=headers)
Exemple #17
0
def tracker_submit_POST(owner, name):
    tracker, access = get_tracker(owner, name, True)
    if not tracker:
        abort(404)
    if not TicketAccess.submit in access:
        abort(403)

    valid = Validation(request)
    title = valid.require("title", friendly_name="Title")
    desc = valid.optional("description")
    another = valid.optional("another")

    valid.expect(not title or 3 <= len(title) <= 2048,
                 "Title must be between 3 and 2048 characters.",
                 field="title")
    valid.expect(not desc or len(desc) < 16384,
                 "Description must be no more than 16384 characters.",
                 field="description")

    if not valid.ok:
        db.session.commit()  # Unlock tracker row
        return return_tracker(tracker, access, **valid.kwargs), 400

    ticket = Ticket()
    ticket.submitter_id = current_user.id
    ticket.tracker_id = tracker.id
    ticket.scoped_id = tracker.next_ticket_id
    tracker.next_ticket_id += 1
    ticket.title = title
    ticket.description = desc
    db.session.add(ticket)
    tracker.updated = datetime.utcnow()
    # TODO: Handle unique constraint failure (contention) and retry?
    db.session.commit()
    event = Event()
    event.event_type = EventType.created
    event.user_id = current_user.id
    event.ticket_id = ticket.id
    db.session.add(event)
    db.session.flush()

    ticket_url = url_for("ticket.ticket_GET",
                         owner=tracker.owner.canonical_name,
                         name=name,
                         ticket_id=ticket.scoped_id)

    subscribed = False
    for sub in tracker.subscriptions:
        notification = EventNotification()
        notification.user_id = sub.user_id
        notification.event_id = event.id
        db.session.add(notification)

        if sub.user_id == ticket.submitter_id:
            subscribed = True
            continue
        notify(sub,
               "new_ticket",
               "{}/{}/#{}: {}".format(tracker.owner.canonical_name,
                                      tracker.name, ticket.scoped_id,
                                      ticket.title),
               headers={
                   "From": "~{} <{}>".format(current_user.username,
                                             notify_from),
                   "Sender": smtp_user,
               },
               ticket=ticket,
               ticket_url=ticket_url)

    if not subscribed:
        sub = TicketSubscription()
        sub.ticket_id = ticket.id
        sub.user_id = current_user.id
        db.session.add(sub)

    db.session.commit()

    if another:
        session["another"] = True
        return redirect(
            url_for(".tracker_GET",
                    owner=tracker.owner.canonical_name,
                    name=name))
    else:
        return redirect(ticket_url)
Exemple #18
0
def profile_POST():
    valid = Validation(request)

    user = User.query.filter(User.id == current_user.id).one()

    email = valid.optional("email", user.email)
    email = email.strip()
    url = valid.optional("url", user.url)
    location = valid.optional("location", user.location)
    bio = valid.optional("bio", user.bio)

    valid.expect(not url or 0 <= len(url) <= 256,
                 "URL must fewer than 256 characters.", "url")
    valid.expect(not url or valid_url(url),
                 "URL must be a valid http or https URL", "url")
    valid.expect(not location or 0 <= len(location) <= 256,
                 "Location must fewer than 256 characters.", "location")
    valid.expect(not bio or 0 <= len(bio) <= 4096,
                 "Bio must fewer than 4096 characters.", "bio")

    if not valid.ok:
        return render_template("profile.html",
                               email=email,
                               url=url,
                               location=location,
                               bio=bio,
                               valid=valid), 400

    user.url = url
    user.location = location
    user.bio = bio

    new_email = user.email != email
    if new_email:
        valid.expect(
            len(email) <= 256, "Email must be no more than 256 characters.",
            "email")
        prev = User.query.filter(User.email == email).first()
        valid.expect(not prev, "This email address is already in use.",
                     "email")
        if not valid.ok:
            return render_template("profile.html",
                                   email=email,
                                   url=url,
                                   location=location,
                                   bio=bio,
                                   valid=valid), 400
        user.new_email = email
        user.gen_confirmation_hash()
        send_email('update_email_old',
                   user.email,
                   'Your {} email address is changing'.format(site_name),
                   new_email=email)
        send_email('update_email_new',
                   user.new_email,
                   'Confirm your {} email address change'.format(site_name),
                   new_email=email)

    audit_log("updated profile")
    db.session.commit()
    UserWebhook.deliver(UserWebhook.Events.profile_update, user.to_dict(),
                        UserWebhook.Subscription.user_id == user.id)

    return render_template("profile.html", new_email=new_email)
Exemple #19
0
def secrets_POST():
    valid = Validation(request)

    name = valid.optional("name")
    secret_type = valid.require("secret_type", friendly_name="Secret Type")
    valid.expect(not name or 3 < len(name) < 512,
                 "Name must be between 3 and 512 characters",
                 field="name")
    if secret_type is not None:
        try:
            secret_type = SecretType(secret_type)
        except:
            valid.error("{} is not a valid secret type".format(secret_type),
                        field="secret_type")

    if secret_type == SecretType.plaintext_file:
        _secret = valid.optional("secret")
        secret_file = valid.optional("file-file", max_file_size=16384)
        for f in ["secret", "file-file"]:
            valid.expect(bool(_secret) ^ bool(secret_file),
                         "Either secret text or file have to be provided",
                         field=f)
        if _secret:
            _secret = _secret.replace('\r\n', '\n')
            if not _secret.endswith('\n'):
                _secret += '\n'
        else:
            _secret = secret_file
    else:
        _secret = valid.require("secret", friendly_name="Secret")

    if isinstance(_secret, str):
        _secret = _secret.encode()

    if not valid.ok:
        return render_template("secrets.html", **valid.kwargs)

    secret = Secret(current_user, secret_type)

    if secret_type == SecretType.pgp_key:
        try:
            key, _ = pgpy.PGPKey.from_blob(_secret)
            if key.is_protected:
                valid.error("PGP key cannot be passphrase protected.",
                            field="secret")
        except Exception as ex:
            valid.error("Invalid PGP key.", field="secret")
    elif secret_type == SecretType.plaintext_file:
        file_path = valid.require("file-path", friendly_name="Path")
        file_mode = valid.require("file-mode", friendly_name="Mode")
        if not valid.ok:
            return render_template("secrets.html", **valid.kwargs)
        try:
            file_mode = int(file_mode, 8)
        except:
            valid.error("Must be specified in octal", field="file-mode")
        if not valid.ok:
            return render_template("secrets.html", **valid.kwargs)
        secret.path = file_path
        secret.mode = file_mode
    if not valid.ok:
        return render_template("secrets.html", **valid.kwargs)

    secret.name = name
    secret.secret = _secret

    db.session.add(secret)
    db.session.commit()

    session["message"] = "Successfully added secret {}.".format(secret.uuid)
    return redirect("/secrets")
Exemple #20
0
def register_POST():
    valid = Validation(request)
    is_open = cfg("meta.sr.ht::settings", "registration") == "yes"

    username = valid.require("username", friendly_name="Username")
    email = valid.require("email", friendly_name="Email address")
    password = valid.require("password", friendly_name="Password")
    invite_hash = valid.optional("invite_hash")
    invite = None

    if not valid.ok:
        return render_template("register.html",
                               is_open=(is_open or invite_hash is not None),
                               **valid.kwargs), 400

    if not is_open:
        if not invite_hash:
            abort(401)
        else:
            invite = (Invite.query.filter(
                Invite.invite_hash == invite_hash).filter(
                    Invite.recipient_id == None)).one_or_none()
            if not invite:
                abort(401)

    email = email.strip()
    user = User.query.filter(User.username == username).first()
    valid.expect(user is None, "This username is already in use.", "username")
    user = User.query.filter(User.email == email).first()
    valid.expect(user is None, "This email address is already in use.",
                 "email")
    valid.expect(2 <= len(username) <= 30,
                 "Username must contain between 2 and 30 characters.",
                 "username")
    valid.expect(re.match("^[a-z_]", username),
                 "Username must start with a lowercase letter or underscore.",
                 "username")
    valid.expect(
        re.match("^[a-z0-9_]+$", username),
        "Username may contain only lowercase letters, numbers and "
        "underscores", "username")
    valid.expect(username not in username_blacklist,
                 "This username is not available", "username")
    valid.expect(
        len(email) <= 256, "Email must be no more than 256 characters.",
        "email")
    valid.expect(8 <= len(password) <= 512,
                 "Password must be between 8 and 512 characters.", "password")

    if not valid.ok:
        return render_template("register.html",
                               is_open=(is_open or invite_hash is not None),
                               **valid.kwargs), 400

    user = User(username)
    user.email = email
    user.password = bcrypt.hashpw(password.encode('utf-8'),
                                  salt=bcrypt.gensalt()).decode('utf-8')
    user.invites = cfg("meta.sr.ht::settings", "user-invites", default=0)

    send_email(
        'confirm',
        user.email,
        'Confirm your {} account'.format(site_name),
        headers={
            "From":
            f"{cfg('sr.ht', 'owner-name')} <*****@*****.**>",
            "To":
            "{} <{}>".format(user.username, user.email),
            "Reply-To":
            f"{cfg('sr.ht', 'owner-name')} <{cfg('sr.ht', 'owner-email')}>",
        },
        user=user)

    db.session.add(user)
    if invite:
        db.session.flush()
        invite.recipient_id = user.id
    metrics.meta_registrations.inc()
    db.session.commit()
    return redirect("/registered")