~sirn/fanboi2

7f2bbd1c899ff8dba0bba80f28c57f8d0a5d9f7c — Kridsada Thanabulpong 2 years ago 834edf0
Add admin panel (#26)

119 files changed, 13303 insertions(+), 588 deletions(-)

M .gitignore
M README.rst
M Vagrantfile
A assets/admin/stylesheets/admin.scss
A assets/admin/stylesheets/themes/debug.scss
A assets/admin/stylesheets/themes/obsidian.scss
A assets/admin/stylesheets/themes/topaz.scss
M assets/app/stylesheets/api.scss
M assets/app/stylesheets/app.scss
M assets/app/stylesheets/board.scss
M assets/app/stylesheets/post.scss
A assets/app/stylesheets/themes/_variables_obsidian.scss
A assets/app/stylesheets/themes/_variables_topaz.scss
M assets/app/stylesheets/themes/debug.scss
M assets/app/stylesheets/themes/obsidian.scss
M assets/app/stylesheets/themes/topaz.scss
M assets/app/stylesheets/topic.scss
M assets/gulpfile.js
M assets/package.json
M assets/yarn.lock
M fanboi2/__init__.py
A fanboi2/auth.py
M fanboi2/filters/akismet.py
M fanboi2/filters/dnsbl.py
M fanboi2/filters/proxy.py
M fanboi2/forms.py
M fanboi2/helpers/formatters.py
M fanboi2/helpers/partials.py
M fanboi2/interfaces.py
M fanboi2/models/__init__.py
A fanboi2/models/_type.py
M fanboi2/models/board.py
A fanboi2/models/group.py
M fanboi2/models/post.py
M fanboi2/models/rule.py
M fanboi2/models/setting.py
M fanboi2/models/topic.py
A fanboi2/models/user.py
A fanboi2/models/user_session.py
M fanboi2/serializers.py
M fanboi2/services/__init__.py
M fanboi2/services/board.py
M fanboi2/services/filter_.py
M fanboi2/services/identity.py
M fanboi2/services/page.py
M fanboi2/services/post.py
M fanboi2/services/rule.py
M fanboi2/services/setting.py
M fanboi2/services/topic.py
A fanboi2/services/user.py
M fanboi2/tasks/post.py
M fanboi2/tasks/topic.py
A fanboi2/templates/admin/_layout.mako
A fanboi2/templates/admin/bans/_nav.mako
A fanboi2/templates/admin/bans/all.mako
A fanboi2/templates/admin/bans/edit.mako
A fanboi2/templates/admin/bans/inactive.mako
A fanboi2/templates/admin/bans/new.mako
A fanboi2/templates/admin/bans/show.mako
A fanboi2/templates/admin/boards/_nav.mako
A fanboi2/templates/admin/boards/all.mako
A fanboi2/templates/admin/boards/edit.mako
A fanboi2/templates/admin/boards/new.mako
A fanboi2/templates/admin/boards/show.mako
A fanboi2/templates/admin/boards/topics/_nav.mako
A fanboi2/templates/admin/boards/topics/all.mako
A fanboi2/templates/admin/boards/topics/delete.mako
A fanboi2/templates/admin/boards/topics/edit.mako
A fanboi2/templates/admin/boards/topics/new.mako
A fanboi2/templates/admin/boards/topics/posts/delete.mako
A fanboi2/templates/admin/boards/topics/posts/delete_error.mako
A fanboi2/templates/admin/boards/topics/show.mako
A fanboi2/templates/admin/dashboard.mako
A fanboi2/templates/admin/login.mako
A fanboi2/templates/admin/pages/_nav.mako
A fanboi2/templates/admin/pages/all.mako
A fanboi2/templates/admin/pages/delete.mako
A fanboi2/templates/admin/pages/delete_internal.mako
A fanboi2/templates/admin/pages/edit.mako
A fanboi2/templates/admin/pages/edit_internal.mako
A fanboi2/templates/admin/pages/new.mako
A fanboi2/templates/admin/pages/show.mako
A fanboi2/templates/admin/pages/show_internal.mako
A fanboi2/templates/admin/settings/_nav.mako
A fanboi2/templates/admin/settings/all.mako
A fanboi2/templates/admin/settings/show.mako
A fanboi2/templates/admin/setup.mako
M fanboi2/templates/api/_boards.mako
M fanboi2/templates/api/_other.mako
M fanboi2/templates/api/_pages.mako
M fanboi2/templates/api/_posts.mako
M fanboi2/templates/api/_topics.mako
M fanboi2/templates/api/show.mako
M fanboi2/templates/bad_request.mako
M fanboi2/templates/boards/all.mako
M fanboi2/templates/boards/new.mako
M fanboi2/templates/boards/show.mako
M fanboi2/templates/not_found.mako
M fanboi2/templates/pages/_subheader.mako
A fanboi2/templates/partials/_datetime.mako
A fanboi2/templates/partials/_ident.mako
M fanboi2/templates/partials/_layout.mako
M fanboi2/templates/partials/_post.mako
M fanboi2/templates/topics/_subheader.mako
M fanboi2/templates/topics/show.mako
M fanboi2/templates/topics/show_error.mako
A fanboi2/tests/test_auth.py
M fanboi2/tests/test_forms.py
M fanboi2/tests/test_helpers.py
M fanboi2/tests/test_integrations.py
M fanboi2/tests/test_models.py
M fanboi2/tests/test_serializers.py
M fanboi2/tests/test_services.py
M fanboi2/tests/test_tasks.py
A fanboi2/views/admin.py
M migration/env.py
A migration/versions/11d55f8444d4_create_user_tables.py
A migration/versions/199110fb8ce5_add_ident_columns.py
M setup.py
M .gitignore => .gitignore +1 -0
@@ 16,6 16,7 @@ tmp/
# Assets
/assets/*/
!/assets/app/
!/assets/admin/
!/assets/vendor/
!/assets/legacy/


M README.rst => README.rst +1 -1
@@ 41,7 41,7 @@ Then setup the application::

  $ fbctl serve

And you're done! Please visit `http://localhost:6543/panel <http://localhost:6543/panel>`_ to perform initial configuration.
And you're done! Please visit `http://localhost:6543/admin/ <http://localhost:6543/admin/>`_ to perform initial configuration.

Development
-----------

M Vagrantfile => Vagrantfile +1 -0
@@ 63,6 63,7 @@ Vagrant.configure("2") do |config|
    echo 'SERVER_HOST="0.0.0.0"; export SERVER_HOST' >> $HOME/.profile
    echo 'SERVER_PORT=6543; export SERVER_PORT' >> $HOME/.profile
    echo "SESSION_SECRET=$(openssl rand -hex 32); export SESSION_SECRET" >> $HOME/.profile
    echo "AUTH_SECRET=$(openssl rand -hex 32); export AUTH_SECRET" >> $HOME/.profile

    cd /vagrant
    $HOME/python3.6/bin/pip3 install -e .

A assets/admin/stylesheets/admin.scss => assets/admin/stylesheets/admin.scss +218 -0
@@ 0,0 1,218 @@
@import "../../app/stylesheets/variables";
@import "../../app/stylesheets/mixins";

/* Admin table
 * ------------------------------------------------------------------------ */

.admin-table {
    border: 1px solid;
    border-bottom: none;
    display: block;
    font-size: $font-size;
    line-height: $font-size;
    margin: 0 0 $spacing-vertical;
    max-width: 100%;
    width: 100%;

    @media (min-width: $bound-tablet) {
        display: table;
        table-layout: fixed;
    }
}

.admin-table-header {
    display: none;

    .admin-table-item {
        &.title {
            border-bottom: none;
        }
    }

    @media (min-width: $bound-tablet) {
        display: table-row-group;
    }
}

.admin-table-body {
    display: block;

    @media (min-width: $bound-tablet) {
        display: table-row-group;
    }
}

.admin-table-row {
    display: block;
    margin: 0;
    width: 100%;

    @media (min-width: $bound-tablet) {
        display: table-row;
        width: auto;
    }
}

.admin-table-item {
    border-bottom: 1px solid;
    box-sizing: border-box;
    display: block;
    line-height: $line-height-content;
    margin: 0;
    overflow-x: auto;
    padding: $spacing-vertical-small $spacing-horizontal-small;
    text-align: left;

    pre {
        margin: 0;
    }

    @media (min-width: $bound-tablet) {
        display: table-cell;
        padding: $spacing-vertical-small $spacing-horizontal;
        vertical-align: top;

        &.lead { width: 200px; }
        &.tail { width: 320px; }
    }
}

/* Admin cascade
 * ------------------------------------------------------------------------ */

.admin-cascade {
    border: 1px solid;
    margin: 0 0 $spacing-vertical;
}

.admin-cascade-header {
    font-size: $font-size;
    line-height: $line-height-content;
    padding: $spacing-vertical-small $spacing-horizontal-small;
    border-bottom: 1px solid;

    @media (min-width: $bound-tablet) {
        padding: $spacing-vertical-small $spacing-horizontal;
    }

    &.small {
        font-size: $font-size-small;
        line-height: $line-height-content;
    }
}

.admin-cascade-body {
    font-size: $font-size;
    line-height: $line-height-content;
    padding: $spacing-vertical-small $spacing-horizontal-small;
    overflow-x: auto;

    @media (min-width: $bound-tablet) {
        padding: $spacing-vertical-small $spacing-horizontal;
    }

    p {
        margin: 0;
    }
}

.admin-cascade-footer {
    font-size: $font-size;
    line-height: $line-height-content;
    border-top: 1px solid;
    padding: $spacing-vertical-small $spacing-horizontal-small;

    @media (min-width: $bound-tablet) {
        padding: $spacing-vertical-small $spacing-horizontal;
    }
}

/* Admin post
 * ------------------------------------------------------------------------ */

.admin-post-body {
    margin: 0 0 (-$spacing-vertical);

    p {
        margin: 0 0 $spacing-vertical;
    }

    p.thumbnails {
        @include clearfix();
        margin: 0 0 $spacing-vertical;
    }

    a.thumbnail {
        display: block;
        float: left;
        height: 90px;
        margin: 0 $spacing-horizontal-smaller $spacing-vertical-smaller 0;
        overflow: hidden;
        position: relative;
        text-align: center;
        width: 90px;

        img {
            left: 50%;
            max-height: 90px;
            object-fit: cover;
            position: absolute;
            top: 50%;
            transform: translate(-50%, -50%);
        }
    }
}

.admin-post-info {
    display: block;

    &.number {
        border: 1px solid;
        display: inline-block;
        font-size: $font-size-small;
        line-height: $font-size-small;
        padding: $spacing-vertical-smaller $spacing-horizontal-small;
        width: auto;
    }

    &.name {
        display: inline;
    }

    &.date {
    }

    &.ident {
    }

    &.ident-admin {
    }

    &.ip-address {
    }

    @media (min-width: $bound-tablet) {
        display: inline;
        margin: 0 $spacing-horizontal-small 0 0;
    }
}

/* Admin embed
 * ------------------------------------------------------------------------ */

.admin-embed {
    margin: 0;
    padding: $spacing-vertical $spacing-horizontal;
}

/* Admin button
 * ------------------------------------------------------------------------ */

.admin-button-context {
    display: inline;

    @media (min-width: $bound-tablet) {
        display: block;
        float: right;
    }
}

A assets/admin/stylesheets/themes/debug.scss => assets/admin/stylesheets/themes/debug.scss +0 -0
A assets/admin/stylesheets/themes/obsidian.scss => assets/admin/stylesheets/themes/obsidian.scss +99 -0
@@ 0,0 1,99 @@
@import "../../../app/stylesheets/variables";
@import "../../../app/stylesheets/themes/variables_obsidian";

.theme-obsidian {

    /* admin.scss
     * -------------------------------------------------------------------- */

    .admin-table {
        border-color: $color-gray;
        overflow: hidden;
    }

    .admin-table-body {
        .admin-table-item {
            &.title {
                font-weight: normal;
            }
        }

        @media (min-width: $bound-tablet) {
            .admin-table-item {
                &.title {
                    background-color: $color-gray-dark;
                    border-color: $color-gray;
                }
            }
        }
    }

    .admin-table-item {
        border-color: $color-gray;

        &.title {
            color: $color-gray-light;
            border-color: $color-gray-darker;
            background-color: $color-gray-darker;
        }
    }

    .admin-cascade {
        border-color: $color-gray;
    }

    .admin-cascade-header {
        background-color: $color-gray-darker;
        border-color: $color-gray-darker;
        color: $color-gray-light;
    }

    .admin-cascade-body {
        color: $color-gray-light;
    }

    .admin-cascade-footer {
        border-color: $color-gray;
        color: $color-gray-light;
    }

    .admin-post-body {
        color: $color-text;
    }

    .admin-post-info {
        &.number {
            border-color: $color-gray-light;
            font-weight: bold;

            &.bumped {
                background-color: $color-gray-light;
                color: $color-gray-darker;
            }
        }

        &.name {
        }

        &.date {
            font-weight: bold;
        }

        &.ident {
        }

        &.ident-admin {
            font-weight: bold;
            color: $color-brand;
        }

        &.ip-address {
            font-weight: bold;
        }
    }

    .admin-embed {
        background-color: $color-gray;
    }

}

A assets/admin/stylesheets/themes/topaz.scss => assets/admin/stylesheets/themes/topaz.scss +101 -0
@@ 0,0 1,101 @@
@import "../../../app/stylesheets/variables";
@import "../../../app/stylesheets/themes/variables_topaz";

.theme-topaz {

    /* admin.scss
     * -------------------------------------------------------------------- */

    .admin-table {
        border-color: $color-tint-light;
        overflow: hidden;
    }

    .admin-table-body {
        .admin-table-item {
            &.title {
                font-weight: normal;
            }
        }

        @media (min-width: $bound-tablet) {
            .admin-table-item {
                &.title {
                    background-color: #fff;
                    border-color: $color-tint-light;
                }
            }
        }
    }

    .admin-table-item {
        border-color: $color-tint-light;

        &.title {
            color: $color-gray-dark;
            border-color: #fff;
            background-color: $color-tint-lighter;
        }
    }

    .admin-cascade {
        border-color: $color-tint-light;
    }

    .admin-cascade-header {
        background-color: $color-tint-lighter;
        border-color: $color-tint-lighter;
        color: $color-gray-dark;
    }

    .admin-cascade-body {
        color: $color-gray-dark;
    }

    .admin-cascade-footer {
        border-color: $color-tint-light;
        color: $color-gray-dark;
    }

    .admin-post-body {
        color: $color-text;
    }

    .admin-post-info {
        &.number {
            background-color: #fff;
            border-color: $color-gray-light;
            font-weight: bold;

            &.bumped {
                background-color: $color-gray;
                border-color: $color-gray;
                color: #fff;
            }
        }

        &.name {
        }

        &.date {
            font-weight: bold;
        }

        &.ident {
        }

        &.ident-admin {
            font-weight: bold;
            color: $color-brand;
        }

        &.ip-address {
            font-weight: bold;
        }
    }

    .admin-embed {
        background-color: $color-tint-lighter;
    }

}

M assets/app/stylesheets/api.scss => assets/app/stylesheets/api.scss +4 -16
@@ 83,6 83,7 @@

    @media (min-width: $bound-tablet) {
        display: table;
        table-layout: fixed;
    }
}



@@ 91,6 92,9 @@

    @media (min-width: $bound-tablet) {
        display: table-row-group;

        .lead { width: 120px; }
        .sublead { width: 80px; }
    }
}



@@ 99,10 103,6 @@

    @media (min-width: $bound-tablet) {
        display: table-row-group;

        .api-table-item.title {
            width: 120px;
        }
    }
}



@@ 124,7 124,6 @@
    line-height: $line-height-content;
    overflow-x: auto;
    padding: $spacing-vertical-small $spacing-horizontal-small;
    word-wrap: break-word;

    ul,
    p {


@@ 133,22 132,11 @@

    &.title {
        line-height: $line-height-content;
        margin: 0 0 $spacing-vertical-smaller;
        text-align: left;
    }

    &.argument {
    }

    &.type {
    }

    @media (min-width: $bound-tablet) {
        display: table-cell;
        vertical-align: top;

        &.type {
            width: 80px;
        }
    }
}

M assets/app/stylesheets/app.scss => assets/app/stylesheets/app.scss +105 -22
@@ 32,6 32,38 @@ body {
    }
}

/* Generic columns
 * ------------------------------------------------------------------------ */

.cols {
    display: flex;
    flex-direction: column-reverse;

    @media (min-width: $bound-tablet) {
        flex-direction: row;
    }
}

.cols-column {
    $sidebar-width: 180px;
    overflow: hidden;
    width: 100%;

    @media (min-width: $bound-tablet) {
        &.sidebar {
            min-width: $sidebar-width;
            max-width: $sidebar-width;
            margin: 0 $spacing-horizontal 0 0;
        }
    }
}

/* Notice bar
 * ------------------------------------------------------------------------ */

.noticebar {
}

/* Site header
 * ------------------------------------------------------------------------ */



@@ 332,6 364,11 @@ body {
    border-bottom: 1px solid;
    display: block;
    padding: $spacing-vertical-large 0 $form-spacing;

    &.noshade {
        border-bottom: none;
        padding: 0;
    }
}

.form-item {


@@ 381,17 418,20 @@ body {
    border-radius: 0;       /* WebKit on mobile */
    outline: none;          /* WebKit on desktop */

    &.font-monospaced { font-family: monospace; }
    &.font-smaller    { font-size: $font-size-smaller; }
    &.font-small      { font-size: $font-size-small; }
    &.font-content    { font-size: $font-size-content; }
    &.font-large      { font-size: $font-size-large; }
    &.font-larger     { font-size: $font-size-larger; }

    &.block {
        display: block;
        max-width: 100%;
        min-width: 100%;
        resize: vertical;
        width: 100%;
    }

    &.smaller { font-size: $font-size-smaller; }
    &.small   { font-size: $font-size-small; }
    &.content { font-size: $font-size-content; }
    &.large   { font-size: $font-size-large; }
    &.larger  { font-size: $font-size-larger; }
}

textarea.input {


@@ 415,17 455,10 @@ textarea.input {
    padding: $spacing-vertical-input $spacing-horizontal-input;
    font-size: $font-size-input;

    &.default {
    }

    &.muted {
    }

    &.brand {
    }

    &.green {
    }
    &.default {}
    &.muted   {}
    &.brand   {}
    &.green   {}

    &.block {
        display: block;


@@ 433,20 466,21 @@ textarea.input {
        width: 100%;
    }

    &.static {
    }
    &.static {}
}

/* Code block
 * ------------------------------------------------------------------------ */

.codeblock {
    font-family: monospace;
    margin: 0;
    overflow-y: auto;
    padding: $spacing-vertical-small $spacing-horizontal-small;
    white-space: pre-wrap;
    word-break: break-all;
    word-wrap: break-word;

    &.noshade {
        padding: 0;
    }
}

/* Content


@@ 469,3 503,52 @@ textarea.input {
        padding: 0 $spacing-horizontal-larger 0;
    }
}

/* Menu
 * ------------------------------------------------------------------------ */

.menu {
    border-bottom: 1px solid;
    border-top: 1px solid;
    margin: 0 (-$spacing-horizontal);

    ul {
        margin: 0;
        padding: 0;
    }

    @media (min-width: $bound-tablet) {
        border-left: 1px solid;
        border-right: 1px solid;
        margin: 0;
    }
}

.menu-header {
    font-size: $font-size;
    line-height: $line-height-content;
    margin: 0;
    padding: $spacing-vertical-small $spacing-horizontal;
}

.menu-actions {
    list-style: none;
}

.menu-actions-item {
    margin: 0;
    font-size: $font-size;
    line-height: $line-height-content;
    border-bottom: 1px solid;

    &:last-child {
        border-bottom: none;
    }

    a {
        box-sizing: border-box;
        display: inline-block;
        padding: $spacing-vertical-small $spacing-horizontal;
        width: 100%;
    }
}

M assets/app/stylesheets/board.scss => assets/app/stylesheets/board.scss +1 -1
@@ 9,7 9,7 @@

.board-agreement-notice {
    font-size: $font-size-small;
    line-height: $font-size-content;
    line-height: $line-height-content;

    p {
        margin: 0 0 $spacing-vertical-small;

M assets/app/stylesheets/post.scss => assets/app/stylesheets/post.scss +4 -0
@@ 72,6 72,10 @@ $post-date-size: round($font-size-small);
            position: inherit;
        }
    }

    &.ident-admin {
        padding: $post-item-spacing $post-item-spacing;
    }
}

.post-body {

A assets/app/stylesheets/themes/_variables_obsidian.scss => assets/app/stylesheets/themes/_variables_obsidian.scss +17 -0
@@ 0,0 1,17 @@
$color-gray-base:        #161b1e;
$color-gray-darker:      lighten($color-gray-base, 3.0%);
$color-gray-dark:        lighten($color-gray-base, 5.0%);
$color-gray:             lighten($color-gray-base, 8.0%);
$color-gray-light:       lighten($color-gray-base, 44.0%);
$color-gray-lighter:     lighten($color-gray-base, 80.0%);

$color-muted-base:       #293033;
$color-muted-darker:     lighten($color-muted-base, 2.0%);
$color-muted-dark:       lighten($color-muted-base, 10.0%);
$color-muted:            lighten($color-muted-base, 20.0%);
$color-muted-light:      lighten($color-muted-base, 30.0%);
$color-muted-lighter:    lighten($color-muted-base, 50.0%);

$color-background:       $color-gray-base;
$color-background-light: lighten($color-gray-base, 1.5%);
$color-text:             #cfcfcf;

A assets/app/stylesheets/themes/_variables_topaz.scss => assets/app/stylesheets/themes/_variables_topaz.scss +18 -0
@@ 0,0 1,18 @@
$color-gray-base:        #1b2326;
$color-gray-darker:      lighten($color-gray-base, 22.0%);
$color-gray-dark:        lighten($color-gray-base, 33.0%);
$color-gray:             lighten($color-gray-base, 44.0%);
$color-gray-light:       lighten($color-gray-base, 70.0%);
$color-gray-lighter:     lighten($color-gray-base, 80.0%);

$color-tint-base:        #f2f5f7;
$color-tint-darker:      darken($color-tint-base, 28%);
$color-tint-dark:        darken($color-tint-base, 10%);
$color-tint:             darken($color-tint-base, 6%);
$color-tint-light:       darken($color-tint-base, 4%);
$color-tint-lighter:     $color-tint-base;

$color-background:       #f0f0f0;
$color-background-dark:  darken($color-background, 3%);

$color-text:             #333;

M assets/app/stylesheets/themes/debug.scss => assets/app/stylesheets/themes/debug.scss +21 -0
@@ 11,6 11,10 @@
        background-color: rgba(#fff, 0.65);
    }

    .noticebar {
        background-color: #9adefc;
    }

    .header {
        background-color: #114422;
    }


@@ 49,6 53,23 @@
        background-color: #999;
    }

    .menu {
        background-color: #e9cd36;
    }

    .menu-header {
        background-color: #e39960;
    }

    .menu-actions {
        background-color: #dc94a4;
    }

    .menu-actions-item {
        background-color: #f9c8aa;
    }


    /* api.scss
     * -------------------------------------------------------------------- */


M assets/app/stylesheets/themes/obsidian.scss => assets/app/stylesheets/themes/obsidian.scss +70 -18
@@ 1,22 1,5 @@
@import "../variables";

$color-gray-base:        #161b1e;
$color-gray-darker:      lighten($color-gray-base, 3.0%);
$color-gray-dark:        lighten($color-gray-base, 5.0%);
$color-gray:             lighten($color-gray-base, 8.0%);
$color-gray-light:       lighten($color-gray-base, 44.0%);
$color-gray-lighter:     lighten($color-gray-base, 80.0%);

$color-muted-base:       #293033;
$color-muted-darker:     lighten($color-muted-base, 2.0%);
$color-muted-dark:       lighten($color-muted-base, 10.0%);
$color-muted:            lighten($color-muted-base, 20.0%);
$color-muted-light:      lighten($color-muted-base, 30.0%);
$color-muted-lighter:    lighten($color-muted-base, 50.0%);

$color-background:       $color-gray-base;
$color-background-light: lighten($color-gray-base, 1.5%);
$color-text:             #cfcfcf;
@import "variables_obsidian";

/* Obsidian theme, the dark theme
 * ------------------------------------------------------------------------ */


@@ 37,6 20,20 @@ $color-text:             #cfcfcf;
    /* app.scss
     * -------------------------------------------------------------------- */

    .noticebar {
        background-color: #fff;
        color: $color-gray-dark;
        font-weight: bold;

        &.error {
            color: $color-red;
        }

        &.success {
            color: $color-green;
        }
    }

    .header {
        background-color: $color-brand;
        border-color: $color-brand;


@@ 203,12 200,38 @@ $color-text:             #cfcfcf;
    .codeblock {
        background-color: $color-gray-dark;
        color: $color-gray-light;

        &.noshade {
            background: none;
        }
    }

    .content {
        h1, h2, h3, h4, h5 { color: $color-brand; }
    }

    .menu {
        background-color: $color-gray-darker;
        border-color: $color-gray;

        @media (min-width: $bound-tablet) {
            border-radius: 2px;
            overflow: hidden;
        }
    }

    .menu-header {
        color: $color-gray-light;
    }

    .menu-actions {
        background-color: $color-gray-dark;
    }

    .menu-actions-item {
        border-color: $color-gray;
    }

    /* api.scss
     * -------------------------------------------------------------------- */



@@ 288,6 311,29 @@ $color-text:             #cfcfcf;
        border-color: $color-gray-dark;
    }

    .topic-subheader {
        background-color: $color-background-light;
        border-color: $color-gray-dark;

        a {
            color: $color-gray-light;

            &:hover {
                color: $color-gray-lighter;
            }
        }
    }

    .topic-subheader-item {
        &.number {
            background-color: $color-muted-dark;
            color: $color-muted-lighter;
            border-color: $color-muted-dark;
            font-weight: bold;
        }
    }


    .topic-footer {
        background-color: $color-background-light;
        border-color: $color-gray-dark;


@@ 351,6 397,12 @@ $color-text:             #cfcfcf;
        &.date {
            font-weight: normal;
        }

        &.ident-admin {
            background-color: $color-gray-dark;
            color: $color-brand;
        }

    }

    .post-body {

M assets/app/stylesheets/themes/topaz.scss => assets/app/stylesheets/themes/topaz.scss +65 -20
@@ 1,23 1,5 @@
@import "../variables";

$color-gray-base:        #1b2326;
$color-gray-darker:      lighten($color-gray-base, 22.0%);
$color-gray-dark:        lighten($color-gray-base, 33.0%);
$color-gray:             lighten($color-gray-base, 44.0%);
$color-gray-light:       lighten($color-gray-base, 70.0%);
$color-gray-lighter:     lighten($color-gray-base, 80.0%);

$color-tint-base:        #f2f5f7;
$color-tint-darker:      darken($color-tint-base, 28%);
$color-tint-dark:        darken($color-tint-base, 10%);
$color-tint:             darken($color-tint-base, 6%);
$color-tint-light:       darken($color-tint-base, 4%);
$color-tint-lighter:     $color-tint-base;

$color-background:       #f0f0f0;
$color-background-dark:  darken($color-background, 3%);

$color-text:             #333;
@import "variables_topaz";

/* Topaz theme, the default
 * ------------------------------------------------------------------------ */


@@ 39,6 21,20 @@ $color-text:             #333;
    /* app.scss
     * -------------------------------------------------------------------- */

    .noticebar {
        background-color: #fff;
        color: $color-gray-dark;
        font-weight: bold;

        &.error {
            color: $color-red;
        }

        &.success {
            color: $color-green;
        }
    }

    .header {
        background-color: $color-brand;
        border-color: $color-brand;


@@ 220,12 216,38 @@ $color-text:             #333;
    .codeblock {
        background-color: $color-gray-lighter;
        color: $color-gray-darker;

        &.noshade {
            background: none;
        }
    }

    .content {
        h1, h2, h3, h4, h5 { color: $color-brand; }
    }

    .menu {
        background-color: $color-tint-lighter;
        border-color: $color-tint-light;

        @media (min-width: $bound-tablet) {
            border-radius: 2px;
            overflow: hidden;
        }
    }

    .menu-header {
        color: $color-gray-dark;
    }

    .menu-actions {
        background-color: #fff;
    }

    .menu-actions-item {
        border-color: $color-tint-light;
    }

    /* api.scss
     * -------------------------------------------------------------------- */



@@ 247,7 269,7 @@ $color-text:             #333;
    }

    .api-request-verb {
        border-radius: 3px;
        border-radius: 2px;
        font-weight: bold;
        color: #fff;



@@ 306,6 328,24 @@ $color-text:             #333;
        color: $color-gray-dark;
    }

    .topic-subheader {
        background-color: $color-tint-light;
        border-color: #fff;

        a {
            color: $color-gray-dark;
        }
    }

    .topic-subheader-item {
        &.number {
            background-color: $color-gray;
            border-color: $color-gray;
            color: #fff;
            font-weight: bold;
        }
    }

    .topic-footer {
        background-color: $color-tint-light;
        border-color: $color-tint;


@@ 374,6 414,11 @@ $color-text:             #333;
            color: $color-tint-darker;
        }

        &.ident-admin {
            background-color: #fff;
            color: $color-brand;
        }

        @media (min-width: $bound-tablet) {
            &.date {
                font-weight: inherit;

M assets/app/stylesheets/topic.scss => assets/app/stylesheets/topic.scss +28 -0
@@ 1,4 1,5 @@
@import "variables";
@import "mixins";

/* Topic
 * ------------------------------------------------------------------------ */


@@ 28,6 29,33 @@
    margin: 0 0 $spacing-vertical-small;
}

.topic-subheader {
    border-bottom: 1px solid;
    font-size: $font-size-small;
    line-height: $font-size-small;

    a {
        @include clearfix();
        display: block;
        padding: $spacing-vertical 0;
    }
}

.topic-subheader-item {
    $item-spacing: 5px;

    /* Matching metrics with .post-header-items */
    display: block;
    float: left;
    margin: 0 $spacing-horizontal-small 0 0;
    padding: $item-spacing 0;

    &.number {
        border: 1px solid;
        padding: ($item-spacing - 1) ($item-spacing - 1);
    }
}

.topic-footer {
    border-bottom: 1px solid;
    margin: 0;

M assets/gulpfile.js => assets/gulpfile.js +23 -0
@@ 34,6 34,15 @@ var paths = {
        }
    },

    /* Path for storing admin-specific assets. */
    admin: {
        assets: 'admin/assets/*',
        stylesheets: [
            'admin/stylesheets/*.scss',
            'admin/stylesheets/themes/*.scss'
        ]
    },

    /* Path for storing third-party assets. */
    vendor: {
        assets: 'vendor/assets/*',


@@ 61,6 70,7 @@ var paths = {
gulp.task('assets', function(){
    return es.merge([
            gulp.src(paths.app.assets),
            gulp.src(paths.admin.assets),
            gulp.src(paths.vendor.assets),
            gulp.src(paths.legacy.assets)]).
        pipe(gulp.dest(paths.dest));


@@ 96,6 106,17 @@ gulp.task('styles/app', ['assets'], function(){
        pipe(gulp.dest(paths.dest));
});

gulp.task('styles/admin', ['assets'], function(){
    return gulp.
        src(paths.admin.stylesheets).
        pipe(sourcemaps.init()).
            pipe(sass().on('error', sass.logError)).
            pipe(concat('admin.css')).
            pipe(postcss(postcssProcessors)).
        pipe(sourcemaps.write('.')).
        pipe(gulp.dest(paths.dest));
});

gulp.task('styles/vendor', function(){
    return gulp.
        src(paths.vendor.stylesheets).


@@ 108,6 129,7 @@ gulp.task('styles/vendor', function(){

gulp.task('styles', [
    'styles/app',
    'styles/admin',
    'styles/vendor'
]);



@@ 182,6 204,7 @@ gulp.task('default', ['assets', 'styles', 'javascripts']);

gulp.task('watch', ['default'], function(){
    gulp.watch(paths.app.stylesheets, ['styles/app']);
    gulp.watch(paths.admin.stylesheets, ['styles/admin']);
    gulp.watch(paths.vendor.stylesheets, ['styles/vendor']);

    gulp.watch(paths.app.javascripts.glob, ['javascripts/app']);

M assets/package.json => assets/package.json +1 -1
@@ 17,7 17,7 @@
    "gulp": "^3.9.0",
    "gulp-concat": "^2.6.0",
    "gulp-postcss": "^6.0.1",
    "gulp-sass": "^2.1.0",
    "gulp-sass": "^4.0.1",
    "gulp-sequence": "^0.4.6",
    "gulp-sourcemaps": "^1.6.0",
    "gulp-uglify": "^2.0.0",

M assets/yarn.lock => assets/yarn.lock +198 -40
@@ 54,6 54,12 @@ ansi-align@^1.1.0:
  dependencies:
    string-width "^1.0.1"

ansi-colors@^1.0.1:
  version "1.1.0"
  resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-1.1.0.tgz#6374b4dd5d4718ff3ce27a671a3b1cad077132a9"
  dependencies:
    ansi-wrap "^0.1.0"

ansi-escapes@^1.0.0:
  version "1.4.0"
  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"


@@ 62,10 68,24 @@ ansi-regex@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.0.0.tgz#c5061b6e0ef8a81775e50f5d66151bf6bf371107"

ansi-regex@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"

ansi-styles@^2.2.1:
  version "2.2.1"
  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"

ansi-styles@^3.2.1:
  version "3.2.1"
  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
  dependencies:
    color-convert "^1.9.0"

ansi-wrap@^0.1.0:
  version "0.1.0"
  resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf"

any-promise@^1.0.0, any-promise@^1.1.0, any-promise@^1.3.0:
  version "1.3.0"
  resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"


@@ 91,10 111,18 @@ arr-diff@^2.0.0:
  dependencies:
    arr-flatten "^1.0.1"

arr-diff@^4.0.0:
  version "4.0.0"
  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"

arr-flatten@^1.0.1:
  version "1.0.1"
  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.0.1.tgz#e5ffe54d45e19f32f216e91eb99c8ce892bb604b"

arr-union@^3.1.0:
  version "3.1.0"
  resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"

array-differ@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031"


@@ 166,6 194,10 @@ assert@~1.3.0:
  dependencies:
    util "0.10.3"

assign-symbols@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"

astw@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/astw/-/astw-2.0.0.tgz#08121ac8288d35611c0ceec663f6cd545604897d"


@@ 497,6 529,14 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
    strip-ansi "^3.0.0"
    supports-color "^2.0.0"

chalk@^2.3.0:
  version "2.4.1"
  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e"
  dependencies:
    ansi-styles "^3.2.1"
    escape-string-regexp "^1.0.5"
    supports-color "^5.3.0"

cipher-base@^1.0.0, cipher-base@^1.0.1:
  version "1.0.3"
  resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.3.tgz#eeabf194419ce900da3018c207d212f2a6df0a07"


@@ 568,6 608,16 @@ code-point-at@^1.0.0:
  version "1.1.0"
  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"

color-convert@^1.9.0:
  version "1.9.1"
  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed"
  dependencies:
    color-name "^1.1.1"

color-name@^1.1.1:
  version "1.1.3"
  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"

columnify@^1.5.2:
  version "1.5.4"
  resolved "https://registry.yarnpkg.com/columnify/-/columnify-1.5.4.tgz#4737ddf1c7b69a8a7c340570782e947eec8e78bb"


@@ 953,7 1003,7 @@ es6-symbol@3, es6-symbol@^3.0.2, es6-symbol@~3.1:
    d "~0.1.1"
    es5-ext "~0.10.11"

escape-string-regexp@^1.0.2:
escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
  version "1.0.5"
  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"



@@ 1007,6 1057,13 @@ expand-tilde@^1.2.1, expand-tilde@^1.2.2:
  dependencies:
    os-homedir "^1.0.1"

extend-shallow@^3.0.2:
  version "3.0.2"
  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8"
  dependencies:
    assign-symbols "^1.0.0"
    is-extendable "^1.0.1"

extend@3, extend@^3.0.0, extend@~3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.0.tgz#5a474353b9f3353ddd8176dfd37b91c83a46f1d4"


@@ 1252,7 1309,7 @@ glob@^5.0.15:
    once "^1.3.0"
    path-is-absolute "^1.0.0"

glob@^6.0.1:
glob@^6.0.1, glob@^6.0.4:
  version "6.0.4"
  resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
  dependencies:


@@ 1391,13 1448,16 @@ gulp-postcss@^6.0.1:
    postcss "^5.2.0"
    vinyl-sourcemaps-apply "^0.2.0"

gulp-sass@^2.1.0:
  version "2.3.2"
  resolved "https://registry.yarnpkg.com/gulp-sass/-/gulp-sass-2.3.2.tgz#82b7ab90fe902cdc34c04f180d92f2c34902dd52"
gulp-sass@^4.0.1:
  version "4.0.1"
  resolved "https://registry.yarnpkg.com/gulp-sass/-/gulp-sass-4.0.1.tgz#7f43d117eb2d303524968a1b48494af1bc64d1d9"
  dependencies:
    gulp-util "^3.0"
    chalk "^2.3.0"
    lodash.clonedeep "^4.3.2"
    node-sass "^3.4.2"
    node-sass "^4.8.3"
    plugin-error "^1.0.1"
    replace-ext "^1.0.0"
    strip-ansi "^4.0.0"
    through2 "^2.0.0"
    vinyl-sourcemaps-apply "^0.2.0"



@@ 1436,7 1496,7 @@ gulp-uglify@^2.0.0:
    uglify-save-license "^0.4.1"
    vinyl-sourcemaps-apply "^0.2.0"

gulp-util@>=3.0.0, gulp-util@^3.0, gulp-util@^3.0.0, gulp-util@^3.0.7:
gulp-util@>=3.0.0, gulp-util@^3.0.0, gulp-util@^3.0.7:
  version "3.0.8"
  resolved "https://registry.yarnpkg.com/gulp-util/-/gulp-util-3.0.8.tgz#0054e1e744502e27c04c187c3ecc505dd54bbb4f"
  dependencies:


@@ 1506,6 1566,10 @@ has-flag@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"

has-flag@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"

has-gulplog@^0.1.0:
  version "0.1.0"
  resolved "https://registry.yarnpkg.com/has-gulplog/-/has-gulplog-0.1.0.tgz#6414c82913697da51590397dafb12f22967811ce"


@@ 1620,7 1684,7 @@ inherits@1:
  version "1.0.2"
  resolved "https://registry.yarnpkg.com/inherits/-/inherits-1.0.2.tgz#ca4309dadee6b54cc0b8d247e8d7c7a0975bdc9b"

inherits@2, inherits@^2.0.1, inherits@~2.0.0, inherits@~2.0.1:
inherits@2, inherits@^2.0.1, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3:
  version "2.0.3"
  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"



@@ 1700,6 1764,12 @@ is-extendable@^0.1.1:
  version "0.1.1"
  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"

is-extendable@^1.0.1:
  version "1.0.1"
  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
  dependencies:
    is-plain-object "^2.0.4"

is-extglob@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"


@@ 1753,6 1823,12 @@ is-plain-obj@^1.0.0:
  version "1.1.0"
  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"

is-plain-object@^2.0.4:
  version "2.0.4"
  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
  dependencies:
    isobject "^3.0.1"

is-posix-bracket@^0.1.0:
  version "0.1.1"
  resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"


@@ 1819,6 1895,10 @@ isobject@^2.0.0, isobject@^2.1.0:
  dependencies:
    isarray "1.0.0"

isobject@^3.0.1:
  version "3.0.1"
  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"

isstream@~0.1.2:
  version "0.1.2"
  resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"


@@ 1829,6 1909,10 @@ jodid25519@^1.0.0:
  dependencies:
    jsbn "~0.1.0"

js-base64@^2.1.8:
  version "2.4.3"
  resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.3.tgz#2e545ec2b0f2957f41356510205214e98fad6582"

js-base64@^2.1.9:
  version "2.1.9"
  resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.1.9.tgz#f0e80ae039a4bd654b5f281fc93f04a914a7fcce"


@@ 2005,7 2089,7 @@ lodash._root@^3.0.0:
  version "3.0.1"
  resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692"

lodash.assign@^4.0.3, lodash.assign@^4.0.6, lodash.assign@^4.2.0:
lodash.assign@^4.2.0:
  version "4.2.0"
  resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7"



@@ 2059,6 2143,10 @@ lodash.memoize@~3.0.3:
  version "3.0.4"
  resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f"

lodash.mergewith@^4.6.0:
  version "4.6.1"
  resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927"

lodash.pick@^4.2.1:
  version "4.4.0"
  resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3"


@@ 2284,9 2372,9 @@ multipipe@^0.1.2:
  dependencies:
    duplexer2 "0.0.2"

nan@^2.3.2:
  version "2.5.0"
  resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.0.tgz#aa8f1e34531d807e9e27755b234b4a6ec0c152a8"
nan@^2.10.0:
  version "2.10.0"
  resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f"

natives@^1.1.0:
  version "1.1.0"


@@ 2315,9 2403,9 @@ node-gyp@^3.3.1:
    tar "^2.0.0"
    which "1"

node-sass@^3.4.2:
  version "3.13.1"
  resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-3.13.1.tgz#7240fbbff2396304b4223527ed3020589c004fc2"
node-sass@^4.8.3:
  version "4.9.0"
  resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.9.0.tgz#d1b8aa855d98ed684d6848db929a20771cc2ae52"
  dependencies:
    async-foreach "^0.1.3"
    chalk "^1.1.1"


@@ 2328,13 2416,16 @@ node-sass@^3.4.2:
    in-publish "^2.0.0"
    lodash.assign "^4.2.0"
    lodash.clonedeep "^4.3.2"
    lodash.mergewith "^4.6.0"
    meow "^3.7.0"
    mkdirp "^0.5.1"
    nan "^2.3.2"
    nan "^2.10.0"
    node-gyp "^3.3.1"
    npmlog "^4.0.0"
    request "^2.61.0"
    sass-graph "^2.1.1"
    request "~2.79.0"
    sass-graph "^2.2.4"
    stdout-stream "^1.4.0"
    "true-case-path" "^1.0.2"

node-status-codes@^1.0.0:
  version "1.0.0"


@@ 2601,6 2692,15 @@ pinkie@^2.0.0:
  version "2.0.4"
  resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"

plugin-error@^1.0.1:
  version "1.0.1"
  resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-1.0.1.tgz#77016bd8919d0ac377fdcdd0322328953ca5781c"
  dependencies:
    ansi-colors "^1.0.1"
    arr-diff "^4.0.0"
    arr-union "^3.1.0"
    extend-shallow "^3.0.2"

popsicle-proxy-agent@^3.0.0:
  version "3.0.0"
  resolved "https://registry.yarnpkg.com/popsicle-proxy-agent/-/popsicle-proxy-agent-3.0.0.tgz#b9133c55d945759ab7ee61b7711364620d3aeadc"


@@ 2675,6 2775,10 @@ process-nextick-args@^1.0.6, process-nextick-args@~1.0.6:
  version "1.0.7"
  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3"

process-nextick-args@~2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"

process@~0.11.0:
  version "0.11.9"
  resolved "https://registry.yarnpkg.com/process/-/process-0.11.9.tgz#7bd5ad21aa6253e7da8682264f1e11d11c0318c1"


@@ 2796,6 2900,18 @@ readable-stream@^2.0.0, "readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.
    string_decoder "~0.10.x"
    util-deprecate "~1.0.1"

readable-stream@^2.0.1:
  version "2.3.6"
  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
  dependencies:
    core-util-is "~1.0.0"
    inherits "~2.0.3"
    isarray "~1.0.0"
    process-nextick-args "~2.0.0"
    safe-buffer "~5.1.1"
    string_decoder "~1.1.1"
    util-deprecate "~1.0.1"

readable-stream@~1.1.9:
  version "1.1.14"
  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"


@@ 2874,7 2990,7 @@ replace-ext@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb"

request@2, request@^2.61.0:
request@2, request@~2.79.0:
  version "2.79.0"
  resolved "https://registry.yarnpkg.com/request/-/request-2.79.0.tgz#4dfe5bf6be8b8cdc37fcf93e04b65577722710de"
  dependencies:


@@ 2949,13 3065,25 @@ ripemd160@^1.0.0:
  version "1.0.1"
  resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-1.0.1.tgz#93a4bbd4942bc574b69a8fa57c71de10ecca7d6e"

sass-graph@^2.1.1:
  version "2.1.2"
  resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.1.2.tgz#965104be23e8103cb7e5f710df65935b317da57b"
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
  version "5.1.2"
  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"

sass-graph@^2.2.4:
  version "2.2.4"
  resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49"
  dependencies:
    glob "^7.0.0"
    lodash "^4.0.0"
    yargs "^4.7.1"
    scss-tokenizer "^0.2.3"
    yargs "^7.0.0"

scss-tokenizer@^0.2.3:
  version "0.2.3"
  resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1"
  dependencies:
    js-base64 "^2.1.8"
    source-map "^0.4.2"

semver-diff@^2.0.0:
  version "2.1.0"


@@ 3056,6 3184,12 @@ source-map@^0.1.38:
  dependencies:
    amdefine ">=0.0.4"

source-map@^0.4.2:
  version "0.4.4"
  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b"
  dependencies:
    amdefine ">=0.0.4"

sparkles@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.0.tgz#1acbbfb592436d10bbe8f785b7cc6f82815012c3"


@@ 3095,6 3229,12 @@ sshpk@^1.7.0:
    jsbn "~0.1.0"
    tweetnacl "~0.14.0"

stdout-stream@^1.4.0:
  version "1.4.0"
  resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.0.tgz#a2c7c8587e54d9427ea9edb3ac3f2cd522df378b"
  dependencies:
    readable-stream "^2.0.1"

stream-browserify@^2.0.0:
  version "2.0.1"
  resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db"


@@ 3144,7 3284,7 @@ string-template@~0.2.0:
  version "0.2.1"
  resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"

string-width@^1.0.1:
string-width@^1.0.1, string-width@^1.0.2:
  version "1.0.2"
  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
  dependencies:


@@ 3156,6 3296,12 @@ string_decoder@~0.10.0, string_decoder@~0.10.x:
  version "0.10.31"
  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"

string_decoder@~1.1.1:
  version "1.1.1"
  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
  dependencies:
    safe-buffer "~5.1.0"

stringstream@~0.0.4:
  version "0.0.5"
  resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878"


@@ 3166,6 3312,12 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1:
  dependencies:
    ansi-regex "^2.0.0"

strip-ansi@^4.0.0:
  version "4.0.0"
  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
  dependencies:
    ansi-regex "^3.0.0"

strip-bom@2.X, strip-bom@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"


@@ 3217,6 3369,12 @@ supports-color@^3.1.2:
  dependencies:
    has-flag "^1.0.0"

supports-color@^5.3.0:
  version "5.4.0"
  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.4.0.tgz#1c6b337402c2137605efe19f10fec390f6faab54"
  dependencies:
    has-flag "^3.0.0"

syntax-error@^1.1.1:
  version "1.1.6"
  resolved "https://registry.yarnpkg.com/syntax-error/-/syntax-error-1.1.6.tgz#b4549706d386cc1c1dc7c2423f18579b6cade710"


@@ 3303,6 3461,12 @@ trim-newlines@^1.0.0:
  version "1.0.0"
  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"

"true-case-path@^1.0.2":
  version "1.0.2"
  resolved "https://registry.yarnpkg.com/true-case-path/-/true-case-path-1.0.2.tgz#7ec91130924766c7f573be3020c34f8fdfd00d62"
  dependencies:
    glob "^6.0.4"

tsconfig@^2.2.0:
  version "2.2.0"
  resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-2.2.0.tgz#7fc16e2790dab70c049bd861c1c532bb770e0837"


@@ 3630,10 3794,6 @@ window-size@0.1.0:
  version "0.1.0"
  resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"

window-size@^0.2.0:
  version "0.2.0"
  resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075"

wordwrap@0.0.2:
  version "0.0.2"
  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f"


@@ 3687,31 3847,29 @@ yallist@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.0.0.tgz#306c543835f09ee1a4cb23b7bce9ab341c91cdd4"

yargs-parser@^2.4.1:
  version "2.4.1"
  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-2.4.1.tgz#85568de3cf150ff49fa51825f03a8c880ddcc5c4"
yargs-parser@^5.0.0:
  version "5.0.0"
  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a"
  dependencies:
    camelcase "^3.0.0"
    lodash.assign "^4.0.6"

yargs@^4.7.1:
  version "4.8.1"
  resolved "https://registry.yarnpkg.com/yargs/-/yargs-4.8.1.tgz#c0c42924ca4aaa6b0e6da1739dfb216439f9ddc0"
yargs@^7.0.0:
  version "7.1.0"
  resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8"
  dependencies:
    camelcase "^3.0.0"
    cliui "^3.2.0"
    decamelize "^1.1.1"
    get-caller-file "^1.0.1"
    lodash.assign "^4.0.3"
    os-locale "^1.4.0"
    read-pkg-up "^1.0.1"
    require-directory "^2.1.1"
    require-main-filename "^1.0.1"
    set-blocking "^2.0.0"
    string-width "^1.0.1"
    string-width "^1.0.2"
    which-module "^1.0.0"
    window-size "^0.2.0"
    y18n "^3.2.1"
    yargs-parser "^2.4.1"
    yargs-parser "^5.0.0"

yargs@~3.10.0:
  version "3.10.0"

M fanboi2/__init__.py => fanboi2/__init__.py +5 -1
@@ 25,12 25,14 @@ NO_VALUE = NoValue()


ENV_SETTINGS_MAP = (
    ('AUTH_SECRET', 'auth.secret', NO_VALUE, None),
    ('CELERY_BROKER_URL', 'celery.broker', NO_VALUE, None),
    ('DATABASE_URL', 'sqlalchemy.url', NO_VALUE, None),
    ('MEMCACHED_URL', 'dogpile.arguments.url', NO_VALUE, None),
    ('GEOIP_PATH', 'geoip.path', None, None),
    ('MEMCACHED_URL', 'dogpile.arguments.url', NO_VALUE, None),
    ('REDIS_URL', 'redis.url', NO_VALUE, None),
    ('SERVER_DEV', 'server.development', False, asbool),
    ('SERVER_SECURE', 'server.secure', False, asbool),
    ('SESSION_SECRET', 'session.secret', NO_VALUE, None),
)



@@ 147,6 149,7 @@ def make_config(settings):  # pragma: no cover
    config.add_request_method(tagged_static_path)
    config.add_route('robots', '/robots.txt')

    config.include('fanboi2.auth')
    config.include('fanboi2.cache')
    config.include('fanboi2.filters')
    config.include('fanboi2.geoip')


@@ 156,6 159,7 @@ def make_config(settings):  # pragma: no cover
    config.include('fanboi2.services')
    config.include('fanboi2.tasks')

    config.include('fanboi2.views.admin', route_prefix='/admin')
    config.include('fanboi2.views.api', route_prefix='/api')
    config.include('fanboi2.views.pages', route_prefix='/pages')
    config.include('fanboi2.views.boards', route_prefix='/')

A fanboi2/auth.py => fanboi2/auth.py +49 -0
@@ 0,0 1,49 @@
from pyramid.authentication import AuthTktAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy
from pyramid.security import ALL_PERMISSIONS
from pyramid.security import Allow

from .interfaces import IUserLoginService


SESSION_TOKEN_VALIDITY = 3600
SESSION_TOKEN_REISSUE = 300


class Root(object):  # pragma: no cover
    __acl__ = [
        (Allow, 'g:admin', ALL_PERMISSIONS),
    ]

    def __init__(self, request):
        self.request = request


def groupfinder(userid, request):
    """Resolve the given :param:`userid` (the session token) into a list of
    group names prefixed with ``g:`` to indicate group permissions.
    """
    if userid is None:
        return None
    user_login_svc = request.find_service(IUserLoginService)
    groups = user_login_svc.groups_from_token(userid, request.client_addr)
    if groups is None:
        return None
    user_login_svc.mark_seen(userid, request.client_addr)
    return ['g:%s' % (g,) for g in groups]


def includeme(config):  # pragma: no cover
    authz_policy = ACLAuthorizationPolicy()
    authn_policy = AuthTktAuthenticationPolicy(
        config.registry.settings['auth.secret'],
        callback=groupfinder,
        timeout=SESSION_TOKEN_VALIDITY,
        reissue_time=SESSION_TOKEN_REISSUE,
        cookie_name='_auth',
        http_only=True,
        secure=config.registry.settings['server.secure'])

    config.set_authentication_policy(authn_policy)
    config.set_authorization_policy(authz_policy)
    config.set_root_factory(Root)

M fanboi2/filters/akismet.py => fanboi2/filters/akismet.py +0 -2
@@ 8,8 8,6 @@ from . import register_filter
class Akismet(object):
    """Basic integration between Pyramid and Akismet."""

    __default_settings__ = None

    def __init__(self, key, services={}):
        self.key = key


M fanboi2/filters/dnsbl.py => fanboi2/filters/dnsbl.py +0 -5
@@ 8,11 8,6 @@ from . import register_filter
class DNSBL(object):
    """Utility class for checking IP address against DNSBL providers."""

    __default_settings__ = (
        'proxies.dnsbl.sorbs.net',
        'xbl.spamhaus.org',
    )

    def __init__(self, providers, services={}):
        if not providers:
            providers = tuple()

M fanboi2/filters/proxy.py => fanboi2/filters/proxy.py +0 -12
@@ 97,18 97,6 @@ class ProxyDetector(object):
    """Base class for dispatching proxy detection into multiple providers."""

    __use_services__ = ('cache',)
    __default_settings__ = {
        'blackbox': {
            'enabled': False,
            'url': 'http://proxy.mind-media.com/block/proxycheck.php',
        },
        'getipintel': {
            'enabled': False,
            'url':  'http://check.getipintel.net/check.php',
            'email': None,
            'flags': None,
        },
    }

    def __init__(self, settings=None, services={}):
        if not settings:

M fanboi2/forms.py => fanboi2/forms.py +105 -1
@@ 1,6 1,10 @@
import json
import ipaddress

from wtforms import TextField, TextAreaField, Form, BooleanField
from wtforms import PasswordField, IntegerField, SelectField
from wtforms.validators import Length as _Length
from wtforms.validators import Required, ValidationError
from wtforms.validators import Required, EqualTo, ValidationError


class Length(_Length):


@@ 45,3 49,103 @@ class PostForm(Form):
    """
    body = TextAreaField('Body', validators=[Required(), Length(5, 4000)])
    bumped = BooleanField('Bump this topic', default=True)


class AdminLoginForm(Form):
    """A :class:`Form` for logging into a moderation system."""
    username = TextField('Username', validators=[Required()])
    password = PasswordField('Password', validators=[Required()])


class AdminSetupForm(Form):
    """A :class:`Form` for creating an initial user."""
    username = TextField('Username', validators=[
        Required(),
        Length(2, 32)])
    password = PasswordField('Password', validators=[
        Required(),
        Length(8, 64)])
    password_confirm = PasswordField(
        'Password confirmation',
        validators=[
            Required(),
            EqualTo('password', message='Password must match.')])
    name = TextField('Name', validators=[
        Required(),
        Length(2, 64)])


class AdminSettingForm(Form):
    """A :class:`Form` for updating settings."""
    value = TextAreaField('Value', validators=[Required()])

    def validate_value(form, field):
        """Custom field validator that ensure value is a valid JSON."""
        try:
            json.loads(field.data)
        except json.decoder.JSONDecodeError:
            raise ValidationError('Must be a valid JSON.')


class AdminRuleBanForm(Form):
    """A :class:`Form` for creating and updating bans."""
    ip_address = TextField('IP address', validators=[Required()])
    description = TextField('Description')
    duration = IntegerField('Duration', default=0)
    scope = TextField('Scope')
    active = BooleanField('Active', default=True)

    def validate_ip_address(form, field):
        """Custom field validator that ensure IP address is valid."""
        try:
            ipaddress.ip_network(field.data)
        except ValueError:
            raise ValidationError('Must be a valid IP address.')


class AdminBoardForm(Form):
    """A :class:`Form` for updating a board."""
    title = TextField('Title', validators=[Required()])
    description = TextField('Description', validators=[Required()])
    status = SelectField('Status', validators=[Required()], choices=[
        ('open', 'Open'),
        ('restricted', 'Restricted'),
        ('locked', 'Locked'),
        ('archived', 'Archived')])

    agreements = TextAreaField('Agreements', validators=[Required()])
    settings = TextAreaField('Settings', validators=[Required()])

    def validate_settings(form, field):
        """Custom field validator that ensure value is a valid JSON."""
        try:
            json.loads(field.data)
        except json.decoder.JSONDecodeError:
            raise ValidationError('Must be a valid JSON.')


class AdminBoardNewForm(AdminBoardForm):
    """A :class:`Form` for creating a board."""
    slug = TextField('Slug', validators=[Required()])


class AdminPageForm(Form):
    """A :class:`Form` for creating and updating pages."""
    body = TextAreaField('Body', validators=[Required()])


class AdminPublicPageForm(AdminPageForm):
    """A :class:`Form` for updating public pages."""
    title = TextField('Title', validators=[Required()])


class AdminPublicPageNewForm(AdminPublicPageForm):
    """A :class:`Form` for creating public pages."""
    slug = TextField('Slug', validators=[Required()])


class AdminTopicForm(Form):
    """A :class:`Form` for updating topic."""
    status = SelectField('Status', validators=[Required()], choices=[
        ('open', 'Open'),
        ('locked', 'Locked')])

M fanboi2/helpers/formatters.py => fanboi2/helpers/formatters.py +11 -0
@@ 1,4 1,5 @@
import html
import json
import re
import urllib
import urllib.parse as urlparse


@@ 300,6 301,16 @@ def format_isotime(context, request, dt):
    return isodate.datetime_isoformat(dt.astimezone(pytz.utc))


def format_json(context, request, data):
    """Format the given data structure into JSON string.

    :param context: A :class:`mako.runtime.Context` object.
    :param request: A :class:`pyramid.request.Request` object.
    :param data: A data to format to JSON.
    """
    return json.dumps(data, indent=4, sort_keys=True)


def unquoted_path(context, request, *args, **kwargs):
    """Returns an unquoted path for specific arguments.


M fanboi2/helpers/partials.py => fanboi2/helpers/partials.py +4 -35
@@ 1,18 1,9 @@
from markupsafe import Markup
from sqlalchemy.orm.exc import NoResultFound

from ..interfaces import IPageQueryService
from .formatters import format_markdown


def _get_cache_key(slug):
    """Returns a cache key for given partial.

    :param slug: An internal page slug.
    """
    return 'helpers.partials:slug=%s' % (slug,)


def get_partial(request, slug):
    """Returns a content of internal page.



@@ 20,32 11,10 @@ def get_partial(request, slug):
    :param slug: An internal page slug.
    """
    page_query_svc = request.find_service(IPageQueryService)
    cache_region = request.find_service(name='cache')

    def _creator():
        try:
            page = page_query_svc.internal_page_from_slug(slug)
        except NoResultFound:
            return
        if page:
            return page.body

    return cache_region.get_or_create(
        _get_cache_key(slug),
        _creator,
        expiration_time=43200)


def reload_partial(request, slug):
    """Delete the key for the given slug from the cache to force
    reloading of partials the next time it is accessed.

    :param request: A :class:`pyramid.request.Request` object.
    :param slug: An internal page slug.
    """
    cache_region = request.find_service(name='cache')
    cache_key = _get_cache_key(slug)
    return cache_region.delete(cache_key)
    try:
        return page_query_svc.internal_body_from_slug(slug)
    except ValueError:
        return None


def global_css(context, request):

M fanboi2/interfaces.py => fanboi2/interfaces.py +134 -6
@@ 1,7 1,15 @@
from zope.interface import Interface


class IBoardCreateService(Interface):
    def create(slug, title, description, status, agreements, settings):
        pass


class IBoardQueryService(Interface):
    def list_all():
        pass

    def list_active():
        pass



@@ 9,6 17,11 @@ class IBoardQueryService(Interface):
        pass


class IBoardUpdateService(Interface):
    def update(slug, **kwargs):
        pass


class IFilterService(Interface):
    def evaluate(payload={}):
        pass


@@ 19,24 32,56 @@ class IIdentityService(Interface):
        pass


class IPageCreateService(Interface):
    def create(slug, title, body):
        pass


class IPageDeleteService(Interface):
    def delete(slug):
        pass


class IPageQueryService(Interface):
    def list_public():
        pass

    def public_page_from_slug(page_slug):
    def list_internal():
        pass

    def public_page_from_slug(slug):
        pass

    def internal_page_from_slug(slug):
        pass

    def internal_page_from_slug(page_slug):
    def internal_body_from_slug(slug):
        pass


class IPageUpdateService(Interface):
    def update(slug, **kwargs):
        pass

    def update_internal(slug, **kwargs):
        pass


class IPostCreateService(Interface):
    def enqueue(topic_id, body, bumped, ip_address, payload={}):
    def enqueue(topic_id, body, bumped, ip_address):
        pass

    def create(topic_id, body, bumped, ip_address, payload={}):
        pass

    def create_with_user(topic_id, user_id, body, bumped, ip_address):
        pass


class IPostDeleteService(Interface):
    def delete_from_topic_id(topic_id, number):
        pass


class IPostQueryService(Interface):
    def list_from_topic_id(topic_id, query=None):


@@ 57,16 102,48 @@ class IRateLimiterService(Interface):
        pass


class IRuleBanCreateService(Interface):
    def create(
            ip_address,
            description=None,
            duration=None,
            scope=None,
            active=True):
        pass


class IRuleBanQueryService(Interface):
    def list_active():
        pass

    def list_inactive():
        pass

    def is_banned(ip_address, scopes):
        pass

    def rule_ban_from_id(id):
        pass


class IRuleBanUpdateService(Interface):
    def update(rule_ban_id, **kwargs):
        pass


class ISettingQueryService(Interface):
    def value_from_key(key):
    def list_all():
        pass

    def value_from_key(key, use_cache=True, safe_keys=False):
        pass

    def reload_cache(key):
        pass

    def reload(key):

class ISettingUpdateService(Interface):
    def update(key, value):
        pass




@@ 79,7 156,15 @@ class ITopicCreateService(Interface):
    def enqueue(board_slug, title, body, ip_address, payload={}):
        pass

    def create(board_slug, title, body, ip_address, payload={}):
    def create(board_slug, title, body, ip_address):
        pass

    def create_with_user(board_slug, user_id, title, body, ip_address):
        pass


class ITopicDeleteService(Interface):
    def delete(topic_id):
        pass




@@ 90,5 175,48 @@ class ITopicQueryService(Interface):
    def list_recent_from_board_slug(board_slug):
        pass

    def list_recent():
        pass

    def topic_from_id(topic_id):
        pass


class ITopicUpdateService(Interface):
    def update(topic_id, **kwargs):
        pass


class IUserCreateService(Interface):
    def create(username, password, parent, groups):
        pass


class IUserLoginService(Interface):
    def authenticate(username, password):
        pass

    def user_from_token(token, ip_address):
        pass

    def groups_from_token(token, ip_address):
        pass

    def revoke_token(token, ip_address):
        pass

    def mark_seen(token, ip_address, revocation=3600):
        pass

    def token_for(username, ip_address):
        pass


class IUserQueryService(Interface):
    def user_from_id(id):
        pass


class IUserSessionQueryService(Interface):
    def list_recent_from_user_id(user_id):
        pass

M fanboi2/models/__init__.py => fanboi2/models/__init__.py +10 -1
@@ 7,6 7,7 @@ import zope.sqlalchemy
from ._base import Base
from ._versioned import make_history_event, setup_versioned
from .board import Board
from .group import Group
from .page import Page
from .post import Post
from .rule import Rule


@@ 14,11 15,14 @@ from .rule_ban import RuleBan
from .setting import Setting
from .topic import Topic
from .topic_meta import TopicMeta
from .user import User
from .user_session import UserSession


__all__ = [
    'Base',
    'Board',
    'Group',
    'Page',
    'Post',
    'Rule',


@@ 26,11 30,14 @@ __all__ = [
    'Setting',
    'Topic',
    'TopicMeta',
    'User',
    'UserSession',
]


_MODELS = {
    'board': Board,
    'group': Group,
    'page': Page,
    'post': Post,
    'rule': Rule,


@@ 38,6 45,8 @@ _MODELS = {
    'setting': Setting,
    'topic': Topic,
    'topic_meta': TopicMeta,
    'user': User,
    'user_session': UserSession,
}




@@ 83,7 92,7 @@ def includeme(config):  # pragma: no cover

    def dbsession_factory(context, request):
        dbsession = dbmaker()
        init_dbsession(dbmaker, tm=request.tm)
        init_dbsession(dbsession, tm=request.tm)
        return dbsession

    config.register_service_factory(dbsession_factory, name='db')

A fanboi2/models/_type.py => fanboi2/models/_type.py +23 -0
@@ 0,0 1,23 @@
from sqlalchemy.sql.sqltypes import Enum


BoardStatusEnum = Enum(
    'open',
    'restricted',
    'locked',
    'archived',
    name='board_status')


IdentTypeEnum = Enum(
    'none',
    'ident',
    'ident_admin',
    name='ident_type')


TopicStatusEnum = Enum(
    'open',
    'locked',
    'archived',
    name='topic_status')

M fanboi2/models/board.py => fanboi2/models/board.py +2 -9
@@ 2,10 2,11 @@ from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import synonym
from sqlalchemy.sql import func
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import DateTime, Enum, Integer, String
from sqlalchemy.sql.sqltypes import DateTime, Integer, String
from sqlalchemy.sql.sqltypes import Text, Unicode, JSON

from ._base import Base, Versioned
from ._type import BoardStatusEnum


DEFAULT_BOARD_CONFIG = {


@@ 16,14 17,6 @@ DEFAULT_BOARD_CONFIG = {
}


BoardStatusEnum = Enum(
    'open',
    'restricted',
    'locked',
    'archived',
    name='board_status')


class Board(Versioned, Base):
    """Model class for board. This model serve as a category to topic and
    also holds settings regarding how posts are created and displayed. It

A fanboi2/models/group.py => fanboi2/models/group.py +14 -0
@@ 0,0 1,14 @@
from sqlalchemy.sql.schema import Column, UniqueConstraint
from sqlalchemy.sql.sqltypes import Integer, String

from ._base import Base


class Group(Base):
    """Model class for group-based ACL."""

    __tablename__ = 'group'
    __table_args__ = (UniqueConstraint('name'),)

    id = Column(Integer, primary_key=True)
    name = Column(String, nullable=False)

M fanboi2/models/post.py => fanboi2/models/post.py +2 -0
@@ 5,6 5,7 @@ from sqlalchemy.sql.schema import Column, ForeignKey, UniqueConstraint
from sqlalchemy.sql.sqltypes import Integer, DateTime, String, Text, Boolean

from ._base import Base, Versioned
from ._type import IdentTypeEnum


class Post(Versioned, Base):


@@ 22,6 23,7 @@ class Post(Versioned, Base):
    topic_id = Column(Integer, ForeignKey('topic.id'), nullable=False)
    ip_address = Column(INET, nullable=False)
    ident = Column(String(32), nullable=True)
    ident_type = Column(IdentTypeEnum, default='none', nullable=False)
    number = Column(Integer, nullable=False)
    name = Column(String, nullable=False)
    body = Column(Text, nullable=False)

M fanboi2/models/rule.py => fanboi2/models/rule.py +8 -0
@@ 37,3 37,11 @@ class Rule(Base):
            cls.ip_address.op('>>=')(ip_address),
            or_(cls.active_until == None,  # noqa: E712
                cls.active_until >= func.now()))

    @property
    def duration(self):
        """Returns the duration of this ban in days."""
        if not self.active_until:
            return 0
        secs = (self.active_until - self.created_at).total_seconds()
        return round(secs/86400)

M fanboi2/models/setting.py => fanboi2/models/setting.py +14 -0
@@ 7,6 7,20 @@ from ._base import Base, Versioned
DEFAULT_SETTINGS = {
    'app.time_zone': 'UTC',
    'app.ident_size': 10,
    'ext.filters.akismet': None,
    'ext.filters.dnsbl': ('proxies.dnsbl.sorbs.net', 'xbl.spamhaus.org'),
    'ext.filters.proxy': {
        'blackbox': {
            'enabled': False,
            'url': 'http://proxy.mind-media.com/block/proxycheck.php',
        },
        'getipintel': {
            'enabled': False,
            'url':  'http://check.getipintel.net/check.php',
            'email': None,
            'flags': None,
        },
    }
}



M fanboi2/models/topic.py => fanboi2/models/topic.py +3 -4
@@ 3,9 3,10 @@ import re
from sqlalchemy.orm import backref, relationship
from sqlalchemy.sql import desc, func, select
from sqlalchemy.sql.schema import Column, ForeignKey
from sqlalchemy.sql.sqltypes import Integer, DateTime, Enum, Unicode
from sqlalchemy.sql.sqltypes import Integer, DateTime, Unicode

from ._base import Base, Versioned
from ._type import TopicStatusEnum
from .post import Post
from .topic_meta import TopicMeta



@@ 23,9 24,7 @@ class Topic(Versioned, Base):
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())
    board_id = Column(Integer, ForeignKey('board.id'), nullable=False)
    title = Column(Unicode(255), nullable=False)
    status = Column(Enum('open', 'locked', 'archived', name='topic_status'),
                    default='open',
                    nullable=False)
    status = Column(TopicStatusEnum, default='open', nullable=False)

    board = relationship('Board',
                         backref=backref('topics',

A fanboi2/models/user.py => fanboi2/models/user.py +47 -0
@@ 0,0 1,47 @@
from sqlalchemy.orm import backref, relationship
from sqlalchemy.sql import func
from sqlalchemy.sql.schema import Column, Table, ForeignKey, UniqueConstraint
from sqlalchemy.sql.sqltypes import Integer, DateTime, String, Boolean

from ._base import Base
from ._type import IdentTypeEnum


user_group = Table(
    'user_group',
    Base.metadata,
    Column('user_id', Integer, ForeignKey('user.id')),
    Column('group_id', Integer, ForeignKey('group.id')),
)


class User(Base):
    """Model class that provides a user."""

    __tablename__ = 'user'
    __table_args__ = (UniqueConstraint('username'),)

    id = Column(Integer, primary_key=True)
    created_at = Column(DateTime(timezone=True), default=func.now())
    parent_id = Column(Integer, ForeignKey('user.id'))
    username = Column(String, nullable=False)
    encrypted_password = Column(String, nullable=False)
    deactivated = Column(Boolean, nullable=False, index=True, default=False)

    ident = Column(String, nullable=False)
    ident_type = Column(IdentTypeEnum, default='ident', nullable=False)
    name = Column(String, nullable=False)

    parent = relationship('User',
                          remote_side=[id],
                          backref=backref('children',
                                          lazy='dynamic',
                                          cascade='all,delete',
                                          order_by='User.id'))

    groups = relationship('Group',
                          secondary='user_group',
                          order_by='Group.name',
                          backref=backref('users',
                                          lazy='dynamic',
                                          order_by='User.id'))

A fanboi2/models/user_session.py => fanboi2/models/user_session.py +28 -0
@@ 0,0 1,28 @@
from sqlalchemy.orm import backref, relationship
from sqlalchemy.sql import func
from sqlalchemy.sql.schema import Column, ForeignKey, UniqueConstraint
from sqlalchemy.sql.sqltypes import Integer, DateTime, String
from sqlalchemy.dialects.postgresql import INET
from ._base import Base


class UserSession(Base):
    """Model class that provides a user session for managing logins."""

    __tablename__ = 'user_session'
    __table_args__ = (UniqueConstraint('token'),)

    id = Column(Integer, primary_key=True)
    created_at = Column(DateTime(timezone=True), default=func.now())
    last_seen_at = Column(DateTime(timezone=True))
    revoked_at = Column(DateTime(timezone=True))
    user_id = Column(Integer, ForeignKey('user.id'))
    token = Column(String, nullable=False)
    ip_address = Column(INET, nullable=False)

    user = relationship('User',
                        backref=backref(
                            'sessions',
                            lazy='dynamic',
                            cascade='all,delete',
                            order_by='desc(UserSession.created_at)'))

M fanboi2/serializers.py => fanboi2/serializers.py +1 -0
@@ 106,6 106,7 @@ def _post_serializer(obj, request):
        'bumped': obj.bumped,
        'created_at': obj.created_at,
        'ident': obj.ident,
        'ident_type': obj.ident_type,
        'name': obj.name,
        'number': obj.number,
        'topic_id': obj.topic_id,

M fanboi2/services/__init__.py => fanboi2/services/__init__.py +200 -12
@@ 1,31 1,63 @@
from ..interfaces import \
    IBoardCreateService,\
    IBoardQueryService,\
    IBoardUpdateService,\
    IFilterService,\
    IIdentityService,\
    IPageCreateService,\
    IPageDeleteService,\
    IPageQueryService,\
    IPageUpdateService,\
    IPostCreateService,\
    IPostDeleteService,\
    IPostQueryService,\
    IRateLimiterService,\
    IRuleBanQueryService, \
    IRuleBanCreateService,\
    IRuleBanQueryService,\
    IRuleBanUpdateService,\
    ISettingQueryService,\
    ISettingUpdateService,\
    ITaskQueryService,\
    ITopicCreateService,\
    ITopicQueryService

from .board import BoardQueryService
    ITopicDeleteService,\
    ITopicQueryService,\
    ITopicUpdateService,\
    IUserCreateService,\
    IUserLoginService,\
    IUserQueryService,\
    IUserSessionQueryService

from .board import BoardCreateService, BoardQueryService
from .board import BoardUpdateService
from .filter_ import FilterService
from .identity import IdentityService
from .page import PageQueryService
from .post import PostCreateService, PostQueryService
from .page import PageCreateService, PageDeleteService
from .page import PageQueryService, PageUpdateService
from .post import PostCreateService, PostDeleteService
from .post import PostQueryService
from .rate_limiter import RateLimiterService
from .rule import RuleBanQueryService
from .setting import SettingQueryService
from .rule import RuleBanCreateService, RuleBanQueryService
from .rule import RuleBanUpdateService
from .setting import SettingQueryService, SettingUpdateService
from .task import TaskQueryService
from .topic import TopicCreateService, TopicQueryService
from .topic import TopicCreateService, TopicDeleteService
from .topic import TopicQueryService, TopicUpdateService
from .user import UserCreateService, UserLoginService
from .user import UserQueryService, UserSessionQueryService


def includeme(config):  # pragma: no cover

    # Board Create

    def board_create_factory(context, request):
        dbsession = request.find_service(name='db')
        return BoardCreateService(dbsession)

    config.register_service_factory(
        board_create_factory,
        IBoardCreateService)

    # Board Query

    def board_query_factory(context, request):


@@ 36,6 68,16 @@ def includeme(config):  # pragma: no cover
        board_query_factory,
        IBoardQueryService)

    # Board Update

    def board_update_factory(context, request):
        dbsession = request.find_service(name='db')
        return BoardUpdateService(dbsession)

    config.register_service_factory(
        board_update_factory,
        IBoardUpdateService)

    # Filter

    def filter_factory(context, request):


@@ 62,28 104,77 @@ def includeme(config):  # pragma: no cover
        identity_factory,
        IIdentityService)

    # Page Create

    def page_create_factory(context, request):
        dbsession = request.find_service(name='db')
        cache_region = request.find_service(name='cache')
        return PageCreateService(dbsession, cache_region)

    config.register_service_factory(
        page_create_factory,
        IPageCreateService)

    # Page Delete

    def page_delete_factory(context, request):
        dbsession = request.find_service(name='db')
        cache_region = request.find_service(name='cache')
        return PageDeleteService(dbsession, cache_region)

    config.register_service_factory(
        page_delete_factory,
        IPageDeleteService)

    # Page Query

    def page_query_factory(context, request):
        dbsession = request.find_service(name='db')
        return PageQueryService(dbsession)
        cache_region = request.find_service(name='cache')
        return PageQueryService(dbsession, cache_region)

    config.register_service_factory(
        page_query_factory,
        IPageQueryService)

    # Page Update

    def page_update_factory(context, request):
        dbsession = request.find_service(name='db')
        cache_region = request.find_service(name='cache')
        return PageUpdateService(dbsession, cache_region)

    config.register_service_factory(
        page_update_factory,
        IPageUpdateService)

    # Post Create

    def post_create_factory(context, request):
        dbsession = request.find_service(name='db')
        identity_svc = request.find_service(IIdentityService)
        setting_query_svc = request.find_service(ISettingQueryService)
        return PostCreateService(dbsession, identity_svc, setting_query_svc)
        user_query_svc = request.find_service(IUserQueryService)
        return PostCreateService(
            dbsession,
            identity_svc,
            setting_query_svc,
            user_query_svc)

    config.register_service_factory(
        post_create_factory,
        IPostCreateService)

    # Post Delete

    def post_delete_factory(context, request):
        dbsession = request.find_service(name='db')
        return PostDeleteService(dbsession)

    config.register_service_factory(
        post_delete_factory,
        IPostDeleteService)

    # Post Query

    def post_query_factory(context, request):


@@ 104,6 195,16 @@ def includeme(config):  # pragma: no cover
        rate_limiter_factory,
        IRateLimiterService)

    # RuleBan create

    def rule_ban_create_factory(context, request):
        dbsession = request.find_service(name='db')
        return RuleBanCreateService(dbsession)

    config.register_service_factory(
        rule_ban_create_factory,
        IRuleBanCreateService)

    # RuleBan query

    def rule_ban_query_factory(context, request):


@@ 114,6 215,16 @@ def includeme(config):  # pragma: no cover
        rule_ban_query_factory,
        IRuleBanQueryService)

    # RuleBan update

    def rule_ban_update_factory(context, request):
        dbsession = request.find_service(name='db')
        return RuleBanUpdateService(dbsession)

    config.register_service_factory(
        rule_ban_update_factory,
        IRuleBanUpdateService)

    # Setting Query

    def setting_query_factory(context, request):


@@ 125,6 236,17 @@ def includeme(config):  # pragma: no cover
        setting_query_factory,
        ISettingQueryService)

    # Setting Update

    def setting_update_factory(context, request):
        dbsession = request.find_service(name='db')
        cache_region = request.find_service(name='cache')
        return SettingUpdateService(dbsession, cache_region)

    config.register_service_factory(
        setting_update_factory,
        ISettingUpdateService)

    # Task Query

    def task_query_factory(context, request):


@@ 140,12 262,27 @@ def includeme(config):  # pragma: no cover
        dbsession = request.find_service(name='db')
        identity_svc = request.find_service(IIdentityService)
        setting_query_svc = request.find_service(ISettingQueryService)
        return TopicCreateService(dbsession, identity_svc, setting_query_svc)
        user_query_svc = request.find_service(IUserQueryService)
        return TopicCreateService(
            dbsession,
            identity_svc,
            setting_query_svc,
            user_query_svc)

    config.register_service_factory(
        topic_create_factory,
        ITopicCreateService)

    # Topic Delete

    def topic_delete_factory(context, request):
        dbsession = request.find_service(name='db')
        return TopicDeleteService(dbsession)

    config.register_service_factory(
        topic_delete_factory,
        ITopicDeleteService)

    # Topic Query

    def topic_query_factory(context, request):


@@ 155,3 292,54 @@ def includeme(config):  # pragma: no cover
    config.register_service_factory(
        topic_query_factory,
        ITopicQueryService)

    # Topic Update

    def topic_update_factory(context, request):
        dbsession = request.find_service(name='db')
        return TopicUpdateService(dbsession)

    config.register_service_factory(
        topic_update_factory,
        ITopicUpdateService)

    # User create

    def user_create_factory(context, request):
        dbsession = request.find_service(name='db')
        identity_svc = request.find_service(IIdentityService)
        return UserCreateService(dbsession, identity_svc)

    config.register_service_factory(
        user_create_factory,
        IUserCreateService)

    # User Login

    def user_login_factory(context, request):
        dbsession = request.find_service(name='db')
        return UserLoginService(dbsession)

    config.register_service_factory(
        user_login_factory,
        IUserLoginService)

    # User Query

    def user_query_factory(context, request):
        dbsession = request.find_service(name='db')
        return UserQueryService(dbsession)

    config.register_service_factory(
        user_query_factory,
        IUserQueryService)

    # User Query

    def user_session_query_factory(context, request):
        dbsession = request.find_service(name='db')
        return UserSessionQueryService(dbsession)

    config.register_service_factory(
        user_session_query_factory,
        IUserSessionQueryService)

M fanboi2/services/board.py => fanboi2/services/board.py +66 -1
@@ 1,6 1,34 @@
from ..models import Board


class BoardCreateService(object):
    """Board create service provides a service for creating board."""

    def __init__(self, dbsession):
        self.dbsession = dbsession

    def create(self, slug, title, description, status, agreements, settings):
        """Create a new board.

        :param slug: An identifier of the board. Also used in URL.
        :param title: Name of the board.
        :param description: Descripton explaining what the board is about.
        :param status: Status of the board.
        :param agreements: Term of use for the board.
        :param settings: Settings for the board.
        """
        board = Board(
            slug=slug,
            title=title,
            description=description,
            status=status,
            agreements=agreements,
            settings=settings)

        self.dbsession.add(board)
        return board


class BoardQueryService(object):
    """Board query service provides a service for querying a board
    or a collection of boards from the database.


@@ 9,12 37,20 @@ class BoardQueryService(object):
    def __init__(self, dbsession):
        self.dbsession = dbsession

    def list_all(self):
        """Query all boards."""
        return list(
            self.dbsession.query(Board).
            order_by(Board.title).
            all())

    def list_active(self):
        """Query all boards that are not archived."""
        return list(
            self.dbsession.query(Board).
            order_by(Board.title).
            filter(Board.status != 'archived'))
            filter(Board.status != 'archived').
            all())

    def board_from_slug(self, board_slug):
        """Query a board from the given board slug.


@@ 24,3 60,32 @@ class BoardQueryService(object):
        return self.dbsession.query(Board).\
            filter_by(slug=board_slug).\
            one()


class BoardUpdateService(object):
    """Board update service provides a service for updating board."""

    def __init__(self, dbsession):
        self.dbsession = dbsession

    def update(self, slug, **kwargs):
        """Update the given board slug with the given :param:`kwargs`.
        This method will raise ``NoResultFound`` if the given slug does not
        already exists. Slug cannot be updated.

        :param slug: The board identifier.
        :param **kwargs: Attributes to update.
        """
        board = self.dbsession.query(Board).filter_by(slug=slug).one()

        for key in (
                'title',
                'description',
                'status',
                'agreements',
                'settings'):
            if key in kwargs:
                setattr(board, key, kwargs[key])

        self.dbsession.add(board)
        return board

M fanboi2/services/filter_.py => fanboi2/services/filter_.py +0 -3
@@ 36,10 36,7 @@ class FilterService(object):
                    services[s] = self.service_query_fn(name=s)

            settings_name = "ext.filters.%s" % (name,)
            settings_default = getattr(cls, '__default_settings__', None)
            settings = setting_query_svc.value_from_key(settings_name)
            if settings is None:
                settings = settings_default

            f = cls(settings, services)
            if f.should_reject(payload):

M fanboi2/services/identity.py => fanboi2/services/identity.py +1 -1
@@ 16,7 16,7 @@ class IdentityService(object):
        self.ident_size = ident_size

    def _get_key(self, **kwargs):
        return 'services.identity.%s' % (
        return 'services.identity:%s' % (
            (','.join('%s=%s' % (k, v) for k, v in sorted(kwargs.items()))),)

    def identity_for(self, **kwargs):

M fanboi2/services/page.py => fanboi2/services/page.py +200 -7
@@ 1,4 1,99 @@
from ..models import Page
from ..models.page import INTERNAL_PAGES


def _get_cache_key(namespace, slug):
    """Returns a cache key for given namespace and slug.

    :param namespace: A namespace for a page.
    :param slug: A page slug.
    """
    return 'services.page:namespace=%s,slug=%s' % (
        namespace,
        slug)


class PageCreateService(object):
    """Page create service provides a service for creating public page."""

    def __init__(self, dbsession, cache_region):
        self.dbsession = dbsession
        self.cache_region = cache_region

    def create(self, slug, title, body):
        """Create a new public page.

        :param slug: The identifier of the page. Also used in URL.
        :param title: The title of the page.
        :param body: The content of the page.
        """
        page = Page(
            namespace='public',
            slug=slug,
            title=title,
            body=body,
            formatter='markdown')

        self.dbsession.add(page)
        return page

    def create_internal(self, slug, body, _internal_pages=INTERNAL_PAGES):
        """Create an internal page. Internal pages must be defined within
        the list of internal pages otherwise this method will raise
        :type:`ValueError`

        :param slug: The internal identifier of the page.
        :param body: The content of the internal page.
        """
        _pages = {p[0]: p[1] for p in _internal_pages}
        if slug not in _pages:
            raise ValueError(slug)

        page = Page(
            namespace='internal',
            slug=slug,
            title=slug,
            body=body,
            formatter=_pages[slug])

        self.dbsession.add(page)
        self.cache_region.delete(_get_cache_key('internal', slug))
        return page


class PageDeleteService(object):
    """Page create service provides a service for deleting page."""

    def __init__(self, dbsession, cache_region):
        self.dbsession = dbsession
        self.cache_region = cache_region

    def delete(self, slug):
        """Delete a public page. This method will raise ``NoResultFound``
        if the given slug does not already exists.

        :param slug: An identifier of the page.
        """
        page = self.dbsession.query(Page).\
            filter_by(slug=slug, namespace='public').\
            one()

        self.dbsession.delete(page)
        return page

    def delete_internal(self, slug):
        """Delete an internal page. This method will raise ``NoResultFound``
        if the given slug does not already exists.

        :param slug: An identifier of the page.
        """
        page = self.dbsession.query(Page).\
            filter_by(slug=slug, namespace='internal').\
            one()

        self.dbsession.delete(page)
        self.cache_region.delete(_get_cache_key('internal', slug))
        return page


class PageQueryService(object):


@@ 6,8 101,9 @@ class PageQueryService(object):
    or a collection of pages from the database.
    """

    def __init__(self, dbsession):
    def __init__(self, dbsession, cache_region):
        self.dbsession = dbsession
        self.cache_region = cache_region

    def list_public(self):
        """Query all public pages."""


@@ 16,20 112,117 @@ class PageQueryService(object):
            order_by(Page.title).
            filter_by(namespace='public'))

    def public_page_from_slug(self, page_slug):
    def list_internal(self, _internal_pages=INTERNAL_PAGES):
        """Query all internal pages. This method may return an unpersisted
        page object in case the internal page has been defined but has not
        been created.
        """
        db_pages = self.dbsession.query(Page).\
            filter_by(namespace='internal').\
            all()
        seen_slug = []
        pages = []
        for page in db_pages:
            pages.append(page)
            seen_slug.append(page.slug)
        for slug, formatter in _internal_pages:
            if slug not in seen_slug:
                pages.append(Page(
                    slug=slug,
                    formatter=formatter,
                    namespace='internal'))
        return sorted(pages, key=lambda p: p.slug)

    def public_page_from_slug(self, slug):
        """Query a public page from the given page slug.

        :param page_slug: A slug :type:`str` identifying the page.
        """
        return self.dbsession.query(Page).\
            filter_by(namespace='public', slug=page_slug).\
            filter_by(namespace='public', slug=slug).\
            one()

    def internal_page_from_slug(self, page_slug):
        """Query an internal page from the given page slug.
    def internal_page_from_slug(self, slug, _internal_pages=INTERNAL_PAGES):
        """Query an internal page for the given page slug. This method will
        raise :type:`ValueError` if the given slug is not within the list of
        permitted pages.

        :param page_slug: A slug :type:`str` identifying the page.
        :param slug: A slug :type:`str` identifying the page.
        """
        _pages = {p[0]: p[1] for p in _internal_pages}
        if slug not in _pages:
            raise ValueError(slug)

        return self.dbsession.query(Page).\
            filter_by(namespace='internal', slug=page_slug).\
            filter_by(namespace='internal', slug=slug).\
            one()

    def internal_body_from_slug(self, slug, _internal_pages=INTERNAL_PAGES):
        """Query an internal page for the given page slug and returns its
        body. This method will cache the page content for at most 12 hours.

        Returns :type:`None` if slug for the page does not already exists.

        :param slug: A slug :type:`str` identifying the page.
        """
        if slug not in (p[0] for p in _internal_pages):
            raise ValueError(slug)

        def _creator():
            page = self.dbsession.query(Page).\
                filter_by(namespace='internal', slug=slug).\
                first()
            if page:
                return page.body

        return self.cache_region.get_or_create(
            _get_cache_key('internal', slug),
            _creator,
            expiration_time=43200)


class PageUpdateService(object):
    """Page create service provides a service for updating page."""

    def __init__(self, dbsession, cache_region):
        self.dbsession = dbsession
        self.cache_region = cache_region

    def update(self, slug, **kwargs):
        """Update a public page matching the given :param:`slug`. This method
        will raise ``NoResultFound`` if the given slug does not already
        exists.

        :param slug: The page identifier.
        :param **kwargs: Attributes to update.
        """
        page = self.dbsession.query(Page).\
            filter_by(namespace='public', slug=slug).\
            one()

        for key in ('title', 'body'):
            if key in kwargs:
                setattr(page, key, kwargs[key])

        self.dbsession.add(page)
        return page

    def update_internal(self, slug, **kwargs):
        """Update an internal page matching the given :param:`slug`.
        This method will raise ``NoResultFound`` if the given slug does not
        already exists.

        :param slug: The page identifier.
        :param **kwargs: Attributes to update.
        """
        page = self.dbsession.query(Page).\
            filter_by(namespace='internal', slug=slug).\
            one()

        for key in ('body',):
            if key in kwargs:
                setattr(page, key, kwargs[key])

        self.dbsession.add(page)
        self.cache_region.delete(_get_cache_key('internal', slug))
        return page

M fanboi2/services/post.py => fanboi2/services/post.py +102 -23
@@ 11,10 11,16 @@ from ..tasks import add_post
class PostCreateService(object):
    """Post create service provides a service for creating a post."""

    def __init__(self, dbsession, identity_svc, setting_query_svc):
    def __init__(
            self,
            dbsession,
            identity_svc,
            setting_query_svc,
            user_query_svc):
        self.dbsession = dbsession
        self.identity_svc = identity_svc
        self.setting_query_svc = setting_query_svc
        self.user_query_svc = user_query_svc

    def enqueue(self, topic_id, body, bumped, ip_address, payload={}):
        """Enqueues the post creation to the posting queue. Posts that are


@@ 34,55 40,72 @@ class PostCreateService(object):
            ip_address,
            payload=payload)

    def create(self, topic_id, body, bumped, ip_address):
        """Creates a post and associate related metadata. Unlike ``enqueue``,
        this method performs the actual creation of the topic.

        :param topic_id: A topic ID :type:`int` to lookup the post.
        :param body: A :type:`str` topic body.
        :param bumped: A :type:`bool` whether to bump the topic.
        :param ip_address: An IP address of the topic creator.
    def _prepare_c(
            self,
            topic_id,
            bumped,
            allowed_board_status,
            allowed_topic_status):
        """Internal method performing preparatory work to create a new post.
        Returns a 3-tuple of ``(board, topic, topic_meta)``.

        :param topic_id: A topic ID :type:`int` to prepare.
        :param bumped: A :type:`bool` whether the topic will be bumped.
        :param allowed_board_status: Tuple of board status to allow posting.
        :param allowed_topic_status: Tuple of topic status to allow posting.
        """

        # Preflight

        topic = self.dbsession.query(Topic).\
            with_for_update().\
            get(topic_id)
            filter_by(id=topic_id).\
            one()

        topic_meta = topic.meta
        board = topic.board

        if topic.status != 'open':
        if topic.status not in allowed_topic_status:
            raise StatusRejectedError(topic.status)

        if board.status not in ('open', 'restricted'):
        board = topic.board
        if board.status not in allowed_board_status:
            raise StatusRejectedError(board.status)

        # Update topic meta

        topic_meta = topic.meta
        topic_meta.post_count = topic_meta.post_count + 1
        topic_meta.posted_at = func.now()
        if bumped is None or bumped:
            topic_meta.bumped_at = func.now()

        self.dbsession.add(topic_meta)

        # Update topic

        max_posts = board.settings['max_posts']

        if topic.status == 'open' and topic_meta.post_count >= max_posts:
            topic.status = 'archived'
            self.dbsession.add(topic)

        # Create post
        return board, topic, topic_meta

    def create(self, topic_id, body, bumped, ip_address):
        """Creates a new post and associate related metadata. Unlike
        ``enqueue``, this method performs the actual creation of the topic.

        :param topic_id: A topic ID :type:`int` to lookup the post.
        :param body: A :type:`str` topic body.
        :param bumped: A :type:`bool` whether to bump the topic.
        :param ip_address: An IP address of the topic creator.
        """
        board, topic, topic_meta = self._prepare_c(
            topic_id,
            bumped,
            allowed_board_status=('open', 'restricted'),
            allowed_topic_status=('open',))

        ident = None
        ident_type = 'none'
        if board.settings['use_ident']:
            time_zone = self.setting_query_svc.value_from_key('app.time_zone')
            tz = pytz.timezone(time_zone)
            timestamp = datetime.datetime.now(tz).strftime("%Y%m%d")
            ident_type = 'ident'
            ident = self.identity_svc.identity_for(
                board=board.slug,
                ip_address=ip_address,


@@ 95,13 118,69 @@ class PostCreateService(object):
            bumped=bumped,
            name=board.settings['name'],
            ident=ident,
            ident_type=ident_type,
            ip_address=ip_address)

        self.dbsession.add(post)
        return post

    def create_with_user(self, topic_id, user_id, body, bumped, ip_address):
        """Creates a new post similar to :meth:`create` but with user ID
        associated to it.

        This method will make the post delegate ident and name from the user
        as well as allow posting in board or topic that are not archived.

        :param topic_id: A topic ID :type:`int` to lookup the post.
        :param user_id: A user ID :type:`int` to post as.
        :param body: A :type:`str` topic body.
        :param bumped: A :type:`bool` whether to bump the topic.
        :param ip_address: An IP address of the topic creator.
        """
        user = self.user_query_svc.user_from_id(user_id)
        board, topic, topic_meta = self._prepare_c(
            topic_id,
            bumped,
            allowed_board_status=('open', 'restricted', 'locked'),
            allowed_topic_status=('open', 'locked'))

        ident = user.ident
        ident_type = user.ident_type
        name = user.name

        post = Post(
            topic=topic,
            number=topic_meta.post_count,
            body=body,
            bumped=bumped,
            name=name,
            ident=ident,
            ident_type=ident_type,
            ip_address=ip_address)

        self.dbsession.add(post)
        return post


        # Finalize
class PostDeleteService(object):
    """Post delete service provides a service for deleting a post from
    the database.
    """

    def __init__(self, dbsession):
        self.dbsession = dbsession

    def delete_from_topic_id(self, topic_id, number):
        """Delete post matching the given number from the given topic.

        :param topic_id: A topic ID :type:`int` to delete the post.
        :param number: A post number in the topic.
        """
        post = self.dbsession.query(Post).\
            filter_by(topic_id=topic_id, number=number).\
            one()

        self.dbsession.flush()
        self.dbsession.delete(post)
        return post



M fanboi2/services/rule.py => fanboi2/services/rule.py +115 -0
@@ 1,12 1,82 @@
import datetime

from sqlalchemy.sql import desc, func, or_, and_

from ..models import RuleBan


class RuleBanCreateService(object):
    """Rule ban create service provides a service for creating ban list."""

    def __init__(self, dbsession):
        self.dbsession = dbsession

    def create(
            self,
            ip_address,
            description=None,
            duration=None,
            scope=None,
            active=True):
        """Create a new rule ban.

        :param ip_address: An IP address or IP network to ban.
        :param description: Optional description.
        :param duration: Duration in days to auto-expire the ban.
        :param scope: Scope to apply the ban to (e.g. ``board:meta``)
        :param active: Boolean flag whether the ban should be active.
        """
        if not description:
            description = None

        active_until = None
        if duration:
            duration_delta = datetime.timedelta(days=duration)
            active_until = datetime.datetime.now() + duration_delta

        if not scope:
            scope = None

        rule_ban = RuleBan(
            ip_address=ip_address,
            description=description,
            scope=scope,
            active_until=active_until,
            active=bool(active))

        self.dbsession.add(rule_ban)
        return rule_ban


class RuleBanQueryService(object):
    """Rule ban query service provides a service for query the ban list."""

    def __init__(self, dbsession):
        self.dbsession = dbsession

    def list_active(self):
        """Returns a list of bans that are currently active."""
        return list(
            self.dbsession.query(RuleBan).
            filter(
                and_(RuleBan.active == True,  # noqa: E712
                     or_(RuleBan.active_until == None,  # noqa: E712
                         RuleBan.active_until >= func.now()))).
            order_by(desc(RuleBan.active_until),
                     desc(RuleBan.created_at)).
            all())

    def list_inactive(self):
        """Returns a list of bans that are currently inactive."""
        return list(
            self.dbsession.query(RuleBan).
            filter(
                or_(RuleBan.active == False,  # noqa: E712
                    RuleBan.active_until <= func.now())).
            order_by(desc(RuleBan.active_until),
                     desc(RuleBan.created_at)).
            all())

    def is_banned(self, ip_address, scopes=None):
        """Verify whether the IP address is in the ban list.



@@ 17,3 87,48 @@ class RuleBanQueryService(object):
            filter(RuleBan.listed(ip_address, scopes)).\
            exists()
        return self.dbsession.query(q).scalar()

    def rule_ban_from_id(self, id_):
        """Retrieve a :class:`RuleBan` matching the given :param:`id`
        from the database or raise ``NoResultFound`` if not exists.

        :param id_: ID of the RuleBan to retrieve.
        """
        return self.dbsession.query(RuleBan).filter_by(id=id_).one()


class RuleBanUpdateService(object):
    """Rule ban create service provides a service for updating ban list."""

    def __init__(self, dbsession):
        self.dbsession = dbsession

    def update(self, id_, **kwargs):
        """Update the given rule ban ID with the given :param:`kwargs`.
        This method will raise ``NoResultFound`` if the given ID does not
        exists.

        :param id_: ID of the RuleBan to update.
        :param **kwargs: Attributes to update.
        """
        rule_ban = self.dbsession.query(RuleBan).filter_by(id=id_).one()

        if 'duration' in kwargs and kwargs['duration'] != rule_ban.duration:
            active_until = None
            if kwargs['duration']:
                duration_delta = datetime.timedelta(days=kwargs['duration'])
                active_until = rule_ban.created_at + duration_delta
            rule_ban.active_until = active_until

        if 'active' in kwargs:
            rule_ban.active = bool(kwargs['active'])

        for key in ('ip_address', 'description', 'scope'):
            if key in kwargs:
                value = kwargs[key]
                if not value:
                    value = None
                setattr(rule_ban, key, value)

        self.dbsession.add(rule_ban)
        return rule_ban

M fanboi2/services/setting.py => fanboi2/services/setting.py +68 -15
@@ 1,6 1,14 @@
from ..models.setting import DEFAULT_SETTINGS, Setting


def _get_cache_key(key):
    """Returns a cache key for given key.

    :param key: The setting key.
    """
    return 'services.setting:key=%s' % (key,)


class SettingQueryService(object):
    """Setting query service provides a service for querying a runtime
    application settings that are not part of startup process, such as


@@ 11,35 19,80 @@ class SettingQueryService(object):
        self.dbsession = dbsession
        self.cache_region = cache_region

    def _get_cache_key(self, key):
        """Returns a cache key for given key.

        :param key: The setting key.
    def list_all(self, _default=DEFAULT_SETTINGS):
        """Returns a 2-tuple of all settings. This method only return
        the safe settings that are explicitly defined.
        """
        return 'services.settings:key=%s' % (key,)
        db_settings = self.dbsession.query(Setting).all()
        seen_keys = []
        safe_keys = _default.keys()
        safe_settings = []
        for setting in db_settings:
            if setting.key in safe_keys:
                safe_settings.append((setting.key, setting.value))
                seen_keys.append(setting.key)
        for key in safe_keys:
            if key not in seen_keys:
                safe_settings.append((key, _default.get(key, None)))
        return sorted(safe_settings)

    def value_from_key(self, key, _default=DEFAULT_SETTINGS):
    def value_from_key(
            self,
            key,
            use_cache=True,
            safe_keys=False,
            _default=DEFAULT_SETTINGS):
        """Returns a setting value either from a cache, from a database
        or the default value. The value returned by this method will be
        cached for 3600 seconds (1 hour).

        :param key: The setting key.
        :param key: The :type:`str` setting key.
        :param use_cache: A :type:`bool` flag whether to load from cache.
        """
        def _creator_fn():
            setting = self.dbsession.query(Setting).filter_by(key=key).first()
            if not setting:
                return _default.get(key, None)
            return setting.value
        return self.cache_region.get_or_create(
            self._get_cache_key(key),
            _creator_fn,
            expiration_time=3600)
        if safe_keys and key not in _default:
            raise KeyError(key)
        if use_cache:
            return self.cache_region.get_or_create(
                _get_cache_key(key),
                _creator_fn,
                expiration_time=3600)
        return _creator_fn()

    def reload(self, key):
        """Delete the given key from the cache to force reloading
    def reload_cache(self, key):
        """Replace the given key from the cache to force reloading
        of the settings the next time it is accessed.

        :param key: The setting key.
        """
        cache_key = self._get_cache_key(key)
        return self.cache_region.delete(cache_key)
        return self.cache_region.delete(_get_cache_key(key))


class SettingUpdateService(object):
    """Setting update service provides a service for updating a runtime
    application settings that are not part of startup process.
    """

    def __init__(self, dbsession, cache_region):
        self.dbsession = dbsession
        self.cache_region = cache_region

    def update(self, key, value):
        """Update the given setting key with the given value. The value
        may be any data structure that are JSON-serializable. This method
        will automatically invalidate cache for the given key.

        :param key: The setting key.
        :param value: The value to set.
        """
        setting = self.dbsession.query(Setting).filter_by(key=key).first()
        if setting is None:
            setting = Setting(key=key)
        setting.value = value
        self.dbsession.add(setting)
        self.cache_region.delete(_get_cache_key(key))
        return setting

M fanboi2/services/topic.py => fanboi2/services/topic.py +150 -18
@@ 1,6 1,6 @@
import datetime

from sqlalchemy.orm import contains_eager
from sqlalchemy.orm import contains_eager, joinedload
from sqlalchemy.sql import or_, and_, func, desc
import pytz



@@ 12,10 12,16 @@ from ..tasks import add_topic
class TopicCreateService(object):
    """Topic create service provides a service for creating a topic."""

    def __init__(self, dbsession, identity_svc, setting_query_svc):
    def __init__(
            self,
            dbsession,
            identity_svc,
            setting_query_svc,
            user_query_svc):
        self.dbsession = dbsession
        self.identity_svc = identity_svc
        self.setting_query_svc = setting_query_svc
        self.user_query_svc = user_query_svc

    def enqueue(self, board_slug, title, body, ip_address, payload={}):
        """Enqueues the topic creation to the posting queue. Topics that are


@@ 35,25 41,34 @@ class TopicCreateService(object):
            ip_address,
            payload=payload)

    def create(self, board_slug, title, body, ip_address):
        """Creates a topic and associate related metadata. Unlike ``enqueue``,
        this method performs the actual creation of the topic.
    def _prepare_c(self, board_slug, allowed_board_status):
        """Internal method performing preparatory work to creat a new topic.
        Returns a board.

        :param board_slug: The slug :type:`str` identifying a board.
        :param title: A :type:`str` topic title.
        :param body: A :type:`str` topic body.
        :param ip_address: An IP address of the topic creator.
        :param board_slug: A slug :type:`str` identifying a board.
        """

        # Preflight

        board = self.dbsession.query(Board).\
            filter(Board.slug == board_slug).\
            one()

        if board.status != 'open':
        if board.status not in allowed_board_status:
            raise StatusRejectedError(board.status)

        return board

    def create(self, board_slug, title, body, ip_address):
        """Creates a new topic and associate related metadata. Unlike
        ``enqueue``, this method performs the actual creation of the topic.

        :param board_slug: A slug :type:`str` identifying a board.
        :param title: A :type:`str` topic title.
        :param body: A :type:`str` topic body.
        :param ip_address: An IP address of the topic creator.
        """
        board = self._prepare_c(
            board_slug,
            allowed_board_status=('open',))

        # Create topic

        topic = Topic(


@@ 78,10 93,12 @@ class TopicCreateService(object):
        # Create post

        ident = None
        ident_type = 'none'
        if board.settings['use_ident']:
            time_zone = self.setting_query_svc.value_from_key('app.time_zone')
            tz = pytz.timezone(time_zone)
            timestamp = datetime.datetime.now(tz).strftime("%Y%m%d")
            ident_type = 'ident'
            ident = self.identity_svc.identity_for(
                board=topic.board.slug,
                ip_address=ip_address,


@@ 94,13 111,89 @@ class TopicCreateService(object):
            bumped=True,
            name=board.settings['name'],
            ident=ident,
            ident_type=ident_type,
            ip_address=ip_address)

        self.dbsession.add(post)
        return topic

    def create_with_user(self, board_slug, user_id, title, body, ip_address):
        """Creates a topic similar to :meth:`create` but with user ID
        associated to it.

        # Finalize
        This method will make the post delegate ident and name from the user
        as well as allow posting in board or topic that are not archived.

        :param board_slug: A slug :type:`str` identifying a board.
        :param user_id: A user ID :type:`int` to post as.
        :param title: A :type:`str` topic title.
        :param body: A :type:`str` topic body.
        :param ip_address: An IP address of the topic creator.
        """
        user = self.user_query_svc.user_from_id(user_id)
        board = self._prepare_c(
            board_slug,
            allowed_board_status=('open', 'restricted', 'locked'))

        # Create topic

        topic = Topic(
            board=board,
            title=title,
            created_at=func.now(),
            updated_at=func.now(),
            status='open')

        self.dbsession.add(topic)

        # Create topic meta

        topic_meta = TopicMeta(
            topic=topic,
            post_count=1,
            posted_at=func.now(),
            bumped_at=func.now())

        self.dbsession.add(topic_meta)

        self.dbsession.flush()
        # Create post

        ident = user.ident
        ident_type = user.ident_type
        name = user.name

        post = Post(
            topic=topic,
            number=topic_meta.post_count,
            body=body,
            bumped=True,
            name=name,
            ident=ident,
            ident_type=ident_type,
            ip_address=ip_address)

        self.dbsession.add(post)
        return topic


class TopicDeleteService(object):
    """Topic delete service provides a service for deleting topic and
    associated metadata.
    """

    def __init__(self, dbsession):
        self.dbsession = dbsession

    def delete(self, topic_id):
        """Delete the topic matching the given :param:`topic_id`.

        :param topic_id: An :type:`int` ID of the topic to delete.
        """
        topic = self.dbsession.query(Topic).\
            filter_by(id=topic_id).\
            one()

        self.dbsession.delete(topic)
        return topic




@@ 136,12 229,27 @@ class TopicQueryService(object):
        """
        return list(self._list_q(board_slug))

    def list_recent_from_board_slug(self, board_slug):
        """Query recent 10 topics for the given board slug.
    def list_recent_from_board_slug(self, board_slug, _limit=10):
        """Query recent topics for the given board slug.

        :param board_slug: The slug :type:`str` identifying a board.
        """
        return list(self._list_q(board_slug).limit(10))
        return list(self._list_q(board_slug).limit(_limit))

    def list_recent(self, _limit=100):
        """Query recent topics regardless of the board."""
        anchor = datetime.datetime.now() - datetime.timedelta(days=7)
        return list(
            self.dbsession.query(Topic).
            join(Topic.meta).
            options(contains_eager(Topic.meta), joinedload(Topic.board)).
            filter(and_(or_(Topic.status == "open",
                            and_(Topic.status != "open",
                                 TopicMeta.bumped_at >= anchor)))).
            order_by(desc(func.coalesce(
                TopicMeta.bumped_at,
                Topic.created_at))).
            limit(_limit))

    def topic_from_id(self, topic_id):
        """Query a topic from the given topic ID.


@@ 151,3 259,27 @@ class TopicQueryService(object):
        return self.dbsession.query(Topic).\
            filter_by(id=topic_id).\
            one()


class TopicUpdateService(object):
    """Topic update service provides a service for updating topic."""

    def __init__(self, dbsession):
        self.dbsession = dbsession

    def update(self, topic_id, **kwargs):
        """Update a topic matching the given :param:`topic_id`.

        :param topic_id: The ID :type:`int` of the topic to update.
        :param **kwargs: Attributes to update.
        """
        topic = self.dbsession.query(Topic).\
            filter_by(id=topic_id).\
            one()

        for key in ('status',):
            if key in kwargs:
                setattr(topic, key, kwargs[key])

        self.dbsession.add(topic)
        return topic

A fanboi2/services/user.py => fanboi2/services/user.py +244 -0
@@ 0,0 1,244 @@
import datetime
import secrets

from passlib.context import CryptContext
from sqlalchemy.sql import and_, or_, func, desc
from sqlalchemy.orm import joinedload

from ..auth import SESSION_TOKEN_VALIDITY
from ..models import User, UserSession, Group


ARGON2_MEMORY_COST = 1024
ARGON2_PARALLELISM = 2
ARGON2_ROUNDS = 6


def _create_crypt_context():
    return CryptContext(
        schemes=['argon2'],
        deprecated=['auto'],
        truncate_error=True,
        argon2__memory_cost=ARGON2_MEMORY_COST,
        argon2__parallelism=ARGON2_PARALLELISM,
        argon2__rounds=ARGON2_ROUNDS)


class UserCreateService(object):
    """User create service provides a service for creating user."""

    def __init__(self, dbsession, identity_svc):
        self.dbsession = dbsession
        self.identity_svc = identity_svc
        self.crypt_context = _create_crypt_context()

    def create(self, parent_id, username, password, name, groups=[]):
        """Creates a user. :param:`parent_id` must be present for all users
        except the root user, usually the user who created this specific user.

        :param parent_id: An :type:`int` ID of the user who created this user.
        :param username: A username.
        :param password: A password.
        :param name: A default name to use when posted in board.
        :param groups: Group the user belongs to.
        """
        ident_type = 'ident'
        if 'admin' in groups:
            ident_type = 'ident_admin'

        user = User(
            username=username,
            name=name,
            ident_type=ident_type,
            ident=self.identity_svc.identity_for(username=username),
            encrypted_password=self.crypt_context.hash(password),
            parent_id=parent_id)

        for g in groups:
            group = self.dbsession.query(Group).filter_by(name=g).first()
            if not group:
                group = Group(name=g)
            user.groups.append(group)

        self.dbsession.add(user)
        return user


class UserLoginService(object):
    """User login service provides a service for managing user logins."""

    def __init__(self, dbsession):
        self.dbsession = dbsession
        self.crypt_context = _create_crypt_context()
        self.sessions_map = {}

    def _generate_token(self):
        """Generates a secure random token."""
        return secrets.token_urlsafe(48)

    def authenticate(self, username, password):
        """Returns :type:`True` if the given username and password combination
        could be authenticated or :type:`False` otherwise.

        :param username: A username :type:`str` to authenticate.
        :param password: A password :type:`str` to authenticate.
        """
        user = self.dbsession.query(User).\
            filter(and_(User.deactivated == False,  # noqa: E711
                        User.username == username)).\
            first()
        if not user:
            return False

        ok, new_hash = self.crypt_context.verify_and_update(
            password,
            user.encrypted_password)
        if not ok:
            return False

        if new_hash is not None:
            user.encrypted_password = new_hash
            self.dbsession.add(user)
        return True

    def _user_session_c(self, token, ip_address):
        """Internal method for querying user session object and cache
        it throughout the request lifecycle.

        :param token: A user login token :type:`str`.
        :param ip_address: IP address of the user.
        """
        if not (token, ip_address) in self.sessions_map:
            user_session = self.dbsession.query(UserSession).\
                options(joinedload(UserSession.user)).\
                filter(and_(UserSession.token == token,
                            UserSession.ip_address == ip_address,
                            or_(UserSession.revoked_at == None,  # noqa: E711
                                UserSession.revoked_at >= func.now()))).\
                first()
            self.sessions_map[(token, ip_address)] = user_session
        return self.sessions_map[(token, ip_address)]

    def _user_c(self, token, ip_address):
        """Internal method for querying user object from a session
        and cache it throughout the request lifecycle.

        :param token: A user login token :type:`str`.
        :param ip_address: IP address of the user.
        """
        user_session = self._user_session_c(token, ip_address)
        if user_session is None:
            return None
        user = user_session.user
        if user.deactivated:
            return None
        return user

    def user_from_token(self, token, ip_address):
        """Returns a :class:`User` by looking up the given :param:`token`
        or :type:`None` if the token does not exists or has been revoked.

        :param token: A user login token :type:`str`.
        :param ip_address: IP address of the user.
        """
        return self._user_c(token, ip_address)

    def groups_from_token(self, token, ip_address):
        """Return list of group names by looking up the given :param:`token`
        or :type:`None` if the token does not exists or has been revoked.

        :param token: A user login token :type:`str`.
        :param ip_address: IP address of the user.
        """
        user = self._user_c(token, ip_address)
        if user is None:
            return None
        return [g.name for g in user.groups]

    def revoke_token(self, token, ip_address):
        """Revoke the given token. This method should be called when the user
        is logging out.

        :param token: A user login token :type:`str`.
        :param ip_address: IP address of the user.
        """
        user_session = self._user_session_c(token, ip_address)
        if user_session is None:
            return None
        user_session.revoked_at = datetime.datetime.now()
        self.dbsession.add(user_session)
        return user_session

    def mark_seen(self, token, ip_address, revocation=SESSION_TOKEN_VALIDITY):
        """Mark the given token as seen and extend the token validity period
        by the given :param:`revocation` seconds.

        :param token: A user login token :type:`str`.
        :param ip_address: IP address of the user.
        :param revocation: Number of seconds until the token is invalidated.
        """
        user_session = self._user_session_c(token, ip_address)
        if user_session is None:
            return None
        if user_session.user.deactivated:
            return None
        revoke_delta = datetime.timedelta(seconds=revocation)
        user_session.last_seen_at = datetime.datetime.now()
        user_session.revoked_at = datetime.datetime.now() + revoke_delta
        self.dbsession.add(user_session)
        return user_session

    def token_for(self, username, ip_address):
        """Create a new token for the given :param:`username`.

        :param username: A username to create token for.
        :param ip_address: IP address that used to retrieve this token.
        """
        user = self.dbsession.query(User).\
            filter(and_(User.deactivated == False,  # noqa: E711
                        User.username == username)).\
            one()

        user_session = UserSession(
            user=user,
            ip_address=ip_address,
            token=self._generate_token())

        self.dbsession.add(user_session)
        return user_session.token


class UserQueryService(object):
    """User query service provides a service for querying users."""

    def __init__(self, dbsession):
        self.dbsession = dbsession

    def user_from_id(self, id):
        """Returns a user matching ID. Raises :class:`NoResultFound` is
        user could not be found.

        :param id: A user `type`:int: id.
        """
        return self.dbsession.query(User).filter_by(id=id).one()


class UserSessionQueryService(object):
    """User session query service provides a service for querying
    user sessions.
    """

    def __init__(self, dbsession):
        self.dbsession = dbsession

    def list_recent_from_user_id(self, user_id):
        """Query recent sessions for the given user ID.

        :param user_id: A user `type`:int: id.
        """
        return list(
            self.dbsession.query(UserSession).
            filter_by(user_id=user_id).
            order_by(desc(UserSession.last_seen_at)).
            limit(5).
            all())

M fanboi2/tasks/post.py => fanboi2/tasks/post.py +3 -0
@@ 28,6 28,7 @@ def add_post(
            request=_request,
            registry=_registry) as env:
        request = env['request']
        dbsession = request.find_service(name='db')

        filter_svc = request.find_service(IFilterService)
        filter_result = filter_svc.evaluate(payload={


@@ 46,4 47,6 @@ def add_post(
                ip_address)
        except StatusRejectedError as e:
            return 'failure', e.name, e.status

        dbsession.flush()
        return 'post', post.id

M fanboi2/tasks/topic.py => fanboi2/tasks/topic.py +3 -0
@@ 27,6 27,7 @@ def add_topic(
            request=_request,
            registry=_registry) as env:
        request = env['request']
        dbsession = request.find_service(name='db')

        filter_svc = request.find_service(IFilterService)
        filter_result = filter_svc.evaluate(payload={


@@ 45,4 46,6 @@ def add_topic(
                ip_address)
        except StatusRejectedError as e:
            return 'failure', e.name, e.status

        dbsession.flush()
        return 'topic', topic.id

A fanboi2/templates/admin/_layout.mako => fanboi2/templates/admin/_layout.mako +39 -0
@@ 0,0 1,39 @@
<%inherit file='../partials/_layout.mako' />
<%def name='header()'><link rel="stylesheet" href="${request.tagged_static_path('fanboi2:static/admin.css')}"></%def>
% if hasattr(self, 'subheader_title') and hasattr(self, 'subheader_body'):
<header class="subheader">
    <div class="container">
        <h2 class="subheader-title">${self.subheader_title()}</h2>
        <div class="subheader-body"><p>${self.subheader_body()}</p></div>
    </div>
</header>
% endif
<div class="sheet">
    <div class="container">
        <div class="cols">
            <div class="cols-column sidebar">
                <div class="sheet-body">
                    <div class="menu">
                        <h3 class="menu-header">My Actions</h3>
                        <ul class="menu-actions">
                            <li class="menu-actions-item"><a href="${request.route_path('admin_dashboard')}">Dashboard</a></li>
                        </ul>
                        <h3 class="menu-header">Moderation</h3>
                        <ul class="menu-actions">
                            <li class="menu-actions-item"><a href="${request.route_path('admin_bans')}">Bans</a></li>
                            <li class="menu-actions-item"><a href="${request.route_path('admin_boards')}">Boards</a></li>
                        </ul>
                        <h3 class="menu-header">System</h3>
                        <ul class="menu-actions">
                            <li class="menu-actions-item"><a href="${request.route_path('admin_pages')}">Pages</a></li>
                            <li class="menu-actions-item"><a href="${request.route_path('admin_settings')}">Settings</a></li>
                        </ul>
                    </div>
                </div>
            </div>
            <div class="cols-column">
                ${next.body()}
            </div>
        </div>
    </div>
</div>
\ No newline at end of file

A fanboi2/templates/admin/bans/_nav.mako => fanboi2/templates/admin/bans/_nav.mako +5 -0
@@ 0,0 1,5 @@
<div class="sheet-body">
    <a class="button" href="${request.route_path('admin_bans')}">Active Bans</a>
    <a class="button" href="${request.route_path('admin_bans_inactive')}">Inactive Bans</a>
    <a class="button green" href="${request.route_path('admin_ban_new')}">New Ban</a>
</div>
\ No newline at end of file

A fanboi2/templates/admin/bans/all.mako => fanboi2/templates/admin/bans/all.mako +44 -0
@@ 0,0 1,44 @@
<%namespace name='datetime' file='../../partials/_datetime.mako' />
<%inherit file='../_layout.mako' />
<%def name='title()'>Bans - Admin Panel</%def>
<%def name='subheader_title()'>Bans</%def>
<%def name='subheader_body()'>Manage IP bans.</%def>
<h2 class="sheet-title">All Bans</h2>
<%include file='_nav.mako' />
<div class="sheet-body">
    <table class="admin-table">
        <thead class="admin-table-header">
            <tr class="admin-table-row">
                <th class="admin-table-item title">IP address</th>
                <th class="admin-table-item title sublead">Scope</th>
                <th class="admin-table-item title tail">Active until</th>
            </tr>
        </thead>
        <tbody class="admin-table-body">
            % for ban in bans:
            <tr class="admin-table-row">
                <th class="admin-table-item title"><a href="${request.route_path('admin_ban', ban=ban.id)}">${ban.ip_address}</a></th>
                <td class="admin-table-item">
                % if ban.scope:
                    ${ban.scope}
                % else:
                    <em>Global</em>
                % endif
                </td>
                <td class="admin-table-item">
                % if ban.active_until:
                    ${datetime.render_datetime(ban.active_until)}
                    % if ban.duration == 1:
                        (1 day)
                    % else:
                        (${ban.duration} days)
                    % endif
                % else:
                    <em>Indefinite</em>
                % endif
                </td>
            </tr>
            % endfor
        </tbody>
    </table>
</div>
\ No newline at end of file

A fanboi2/templates/admin/bans/edit.mako => fanboi2/templates/admin/bans/edit.mako +43 -0
@@ 0,0 1,43 @@
<%inherit file='../_layout.mako' />
<%def name='title()'>Bans - Admin Panel</%def>
<%def name='subheader_title()'>Bans</%def>
<%def name='subheader_body()'>Manage IP bans.</%def>
<h2 class="sheet-title">${ban.ip_address}</h2>
<%include file='_nav.mako' />
<form class="form noshade" action="${request.route_path('admin_ban_edit', ban=ban.id)}" method="post">
    <input type="hidden" name="csrf_token" value="${get_csrf_token()}">
    <div class="form-item${' error' if form.ip_address.errors else ''}">
        <label class="form-item-label" for="${form.ip_address.id}">IP address</label>
        ${form.ip_address(class_="input block font-large", rows=6)}
        % if form.ip_address.errors:
            <span class="form-item-error">${form.ip_address.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.description.errors else ''}">
        <label class="form-item-label" for="${form.description.id}">Description</label>
        ${form.description(class_="input block font-large", rows=6)}
        % if form.description.errors:
            <span class="form-item-error">${form.description.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.duration.errors else ''}">
        <label class="form-item-label" for="${form.duration.id}">Duration</label>
        ${form.duration(class_="input block font-large", rows=6)}
        % if form.duration.errors:
            <span class="form-item-error">${form.duration.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.scope.errors else ''}">
        <label class="form-item-label" for="${form.scope.id}">Scope</label>
        ${form.scope(class_="input block font-large", rows=6)}
        % if form.scope.errors:
            <span class="form-item-error">${form.scope.errors[0]}</span>
        % endif
    </div>
    <div class="form-item">
        <button class="button brand" type="submit">Update Ban</button>
        <span class="form-item-inline">
            ${form.active()} <label for="${form.active.id}">${form.active.label.text}</label>
        </span>
    </div>
</form>
\ No newline at end of file

A fanboi2/templates/admin/bans/inactive.mako => fanboi2/templates/admin/bans/inactive.mako +44 -0
@@ 0,0 1,44 @@
<%namespace name='datetime' file='../../partials/_datetime.mako' />
<%inherit file='../_layout.mako' />
<%def name='title()'>Bans - Admin Panel</%def>
<%def name='subheader_title()'>Bans</%def>
<%def name='subheader_body()'>Manage IP bans.</%def>
<h2 class="sheet-title">Inactive Bans</h2>
<%include file='_nav.mako' />
<div class="sheet-body">
    <table class="admin-table">
        <thead class="admin-table-header">
            <tr class="admin-table-row">
                <th class="admin-table-item title">IP address</th>
                <th class="admin-table-item title sublead">Scope</th>
                <th class="admin-table-item title tail">Active until</th>
            </tr>
        </thead>
        <tbody class="admin-table-body">
            % for ban in bans:
            <tr class="admin-table-row">
                <th class="admin-table-item title"><a href="${request.route_path('admin_ban', ban=ban.id)}">${ban.ip_address}</a></th>
                <td class="admin-table-item">
                % if ban.scope:
                    ${ban.scope}
                % else:
                    <em>Global</em>
                % endif
                </td>
                <td class="admin-table-item">
                % if ban.active_until:
                    ${datetime.render_datetime(ban.active_until)}
                    % if ban.duration == 1:
                        (1 day)
                    % else:
                        (${ban.duration} days)
                    % endif
                % else:
                    <em>Indefinite</em>
                % endif
                </td>
            </tr>
            % endfor
        </tbody>
    </table>
</div>
\ No newline at end of file

A fanboi2/templates/admin/bans/new.mako => fanboi2/templates/admin/bans/new.mako +43 -0
@@ 0,0 1,43 @@
<%inherit file='../_layout.mako' />
<%def name='title()'>Bans - Admin Panel</%def>
<%def name='subheader_title()'>Bans</%def>
<%def name='subheader_body()'>Manage IP bans.</%def>
<h2 class="sheet-title">New Ban</h2>
<%include file='_nav.mako' />
<form class="form noshade" action="${request.route_path('admin_ban_new')}" method="post">
    <input type="hidden" name="csrf_token" value="${get_csrf_token()}">
    <div class="form-item${' error' if form.ip_address.errors else ''}">
        <label class="form-item-label" for="${form.ip_address.id}">IP address</label>
        ${form.ip_address(class_="input block font-large", rows=6)}
        % if form.ip_address.errors:
            <span class="form-item-error">${form.ip_address.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.description.errors else ''}">
        <label class="form-item-label" for="${form.description.id}">Description</label>
        ${form.description(class_="input block font-large", rows=6)}
        % if form.description.errors:
            <span class="form-item-error">${form.description.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.duration.errors else ''}">
        <label class="form-item-label" for="${form.duration.id}">Duration</label>
        ${form.duration(class_="input block font-large", rows=6)}
        % if form.duration.errors:
            <span class="form-item-error">${form.duration.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.scope.errors else ''}">
        <label class="form-item-label" for="${form.scope.id}">Scope</label>
        ${form.scope(class_="input block font-large", rows=6)}
        % if form.scope.errors:
            <span class="form-item-error">${form.scope.errors[0]}</span>
        % endif
    </div>
    <div class="form-item">
        <button class="button green" type="submit">Create Ban</button>
        <span class="form-item-inline">
            ${form.active()} <label for="${form.active.id}">${form.active.label.text}</label>
        </span>
    </div>
</form>
\ No newline at end of file

A fanboi2/templates/admin/bans/show.mako => fanboi2/templates/admin/bans/show.mako +58 -0
@@ 0,0 1,58 @@
<%namespace name='datetime' file='../../partials/_datetime.mako' />
<%inherit file='../_layout.mako' />
<%def name='title()'>Bans - Admin Panel</%def>
<%def name='subheader_title()'>Bans</%def>
<%def name='subheader_body()'>Manage IP bans.</%def>
<h2 class="sheet-title">${ban.ip_address}</h2>
<%include file='_nav.mako' />
<div class="sheet-body">
    <table class="admin-table">
        <tbody class="admin-table-body">
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">IP address</th>
                <td class="admin-table-item">
                    ${ban.ip_address}
                    % if not ban.active:
                        — <strong>Inactive</strong>
                    % endif
                </td>
            </tr>
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">Description</th>
                <td class="admin-table-item">
                % if ban.description:
                    ${ban.description}
                % else:
                    <em>No description</em>
                % endif
                </td>
            </tr>
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">Active until</th>
                <td class="admin-table-item">
                % if ban.active_until:
                    ${datetime.render_datetime(ban.active_until)}
                    % if ban.duration == 1:
                        (1 day)
                    % else:
                        (${ban.duration} days)
                    % endif
                % else:
                    <em>Indefinite</em>
                % endif
                </td>
            </tr>
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">Scope</th>
                <td class="admin-table-item">
                % if ban.scope:
                    ${ban.scope}
                % else:
                    <em>Global</em>
                % endif
                </td>
            </tr>
        </tbody>
    </table>
    <a class="button brand" href="${request.route_path('admin_ban_edit', ban=ban.id)}">Edit Ban</a>
</div>
\ No newline at end of file

A fanboi2/templates/admin/boards/_nav.mako => fanboi2/templates/admin/boards/_nav.mako +9 -0
@@ 0,0 1,9 @@
<div class="sheet-body">
    <a class="button" href="${request.route_path('admin_boards')}">All Boards</a>
    <a class="button green" href="${request.route_path('admin_board_new')}">New Board</a>
    % if board:
    <div class="admin-button-context">
        <a class="button muted admin-button-context" href="${request.route_path('admin_board_topics', board=board.slug)}">All Topics</a>
    </div>
    % endif
</div>
\ No newline at end of file

A fanboi2/templates/admin/boards/all.mako => fanboi2/templates/admin/boards/all.mako +37 -0
@@ 0,0 1,37 @@
<%namespace name='datetime' file='../../partials/_datetime.mako' />
<%inherit file='../_layout.mako' />
<%def name='title()'>Boards - Admin Panel</%def>
<%def name='subheader_title()'>Boards</%def>
<%def name='subheader_body()'>Manage boards.</%def>
<h2 class="sheet-title">All Boards</h2>
<%include file='_nav.mako' />
<div class="sheet-body">
    <table class="admin-table">
        <thead class="admin-table-header">
            <tr class="admin-table-row">
                <th class="admin-table-item title">Title</th>
                <th class="admin-table-item title sublead">Status</th>
                <th class="admin-table-item title tail">Last updated</th>
            </tr>
        </thead>
        <tbody class="admin-table-body">
            % for board in boards:
            <tr class="admin-table-row">
                <th class="admin-table-item title"><a href="${request.route_path('admin_board_topics', board=board.slug)}">${board.title}</a></th>
                <td class="admin-table-item">
                    % if board.status == 'open':
                    Open
                    % elif board.status == 'locked':
                    Locked
                    % elif board.status == 'restricted':
                    Restricted
                    % elif board.status == 'archived':
                    Archived
                    % endif
                </td>
                <td class="admin-table-item">${datetime.render_datetime(board.updated_at or board.created_at)}</td>
            </tr>
            % endfor
        </tbody>
    </table>
</div>
\ No newline at end of file

A fanboi2/templates/admin/boards/edit.mako => fanboi2/templates/admin/boards/edit.mako +47 -0
@@ 0,0 1,47 @@
<%inherit file='../_layout.mako' />
<%def name='title()'>Boards - Admin Panel</%def>
<%def name='subheader_title()'>Boards</%def>
<%def name='subheader_body()'>Manage boards.</%def>
<h2 class="sheet-title">${board.title}</h2>
<%include file='_nav.mako' />
<form class="form noshade" action="${request.route_path('admin_board_edit', board=board.slug)}" method="post">
    <input type="hidden" name="csrf_token" value="${get_csrf_token()}">
    <div class="form-item${' error' if form.title.errors else ''}">
        <label class="form-item-label" for="${form.title.id}">Title</label>
        ${form.title(class_="input block font-large")}
        % if form.title.errors:
            <span class="form-item-error">${form.title.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.status.errors else ''}">
        <label class="form-item-label" for="${form.status.id}">Status</label>
        ${form.status()}
        % if form.status.errors:
            <span class="form-item-error">${form.status.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.description.errors else ''}">
        <label class="form-item-label" for="${form.description.id}">Description</label>
        ${form.description(class_="input block font-large")}
        % if form.description.errors:
            <span class="form-item-error">${form.description.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.agreements.errors else ''}">
        <label class="form-item-label" for="${form.agreements.id}">Agreements</label>
        ${form.agreements(class_="input block font-content font-monospaced", rows=6)}
        % if form.agreements.errors:
            <span class="form-item-error">${form.agreements.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.settings.errors else ''}">
        <label class="form-item-label" for="${form.settings.id}">Settings</label>
        ${form.settings(class_="input block font-content font-monospaced", rows=6)}
        % if form.settings.errors:
            <span class="form-item-error">${form.settings.errors[0]}</span>
        % endif
    </div>
    <div class="form-item">
        <button class="button brand" type="submit">Update Board</button>
    </div>
</form>
\ No newline at end of file

A fanboi2/templates/admin/boards/new.mako => fanboi2/templates/admin/boards/new.mako +54 -0
@@ 0,0 1,54 @@
<%inherit file='../_layout.mako' />
<%def name='title()'>Boards - Admin Panel</%def>
<%def name='subheader_title()'>Boards</%def>
<%def name='subheader_body()'>Manage boards.</%def>
<h2 class="sheet-title">New Board</h2>
<%include file='_nav.mako' />
<form class="form noshade" action="${request.route_path('admin_board_new')}" method="post">
    <input type="hidden" name="csrf_token" value="${get_csrf_token()}">
    <div class="form-item${' error' if form.slug.errors else ''}">
        <label class="form-item-label" for="${form.slug.id}">Slug</label>
        ${form.slug(class_="input block font-large")}
        % if form.slug.errors:
            <span class="form-item-error">${form.slug.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.title.errors else ''}">
        <label class="form-item-label" for="${form.title.id}">Title</label>
        ${form.title(class_="input block font-large")}
        % if form.title.errors:
            <span class="form-item-error">${form.title.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.status.errors else ''}">
        <label class="form-item-label" for="${form.status.id}">Status</label>
        ${form.status()}
        % if form.status.errors:
            <span class="form-item-error">${form.status.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.description.errors else ''}">
        <label class="form-item-label" for="${form.description.id}">Description</label>
        ${form.description(class_="input block font-large")}
        % if form.description.errors:
            <span class="form-item-error">${form.description.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.agreements.errors else ''}">
        <label class="form-item-label" for="${form.agreements.id}">Agreements</label>
        ${form.agreements(class_="input block font-content font-monospaced", rows=6)}
        % if form.agreements.errors:
            <span class="form-item-error">${form.agreements.errors[0]}</span>
        % endif
    </div>
    <div class="form-item${' error' if form.settings.errors else ''}">
        <label class="form-item-label" for="${form.settings.id}">Settings</label>
        ${form.settings(class_="input block font-content font-monospaced", rows=6)}
        % if form.settings.errors:
            <span class="form-item-error">${form.settings.errors[0]}</span>
        % endif
    </div>
    <div class="form-item">
        <button class="button green" type="submit">Create Board</button>
    </div>
</form>
\ No newline at end of file

A fanboi2/templates/admin/boards/show.mako => fanboi2/templates/admin/boards/show.mako +66 -0
@@ 0,0 1,66 @@
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<%inherit file='../_layout.mako' />
<%def name='title()'>Boards - Admin Panel</%def>
<%def name='subheader_title()'>Boards</%def>
<%def name='subheader_body()'>Manage boards.</%def>
<h2 class="sheet-title">${board.title}</h2>
<%include file='_nav.mako' />
<div class="sheet-body">
    <table class="admin-table">
        <tbody class="admin-table-body">
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">Slug</th>
                <td class="admin-table-item">
                    ${board.slug}
                </td>
            </tr>
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">Title</th>
                <td class="admin-table-item">
                    ${board.title}
                </td>
            </tr>
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">Status</th>
                <td class="admin-table-item">
                    % if board.status == 'open':
                    Open
                    % elif board.status == 'locked':
                    Locked
                    % elif board.status == 'restricted':
                    Restricted
                    % elif board.status == 'archived':
                    Archived
                    % endif
                </td>
            </tr>
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">Description</th>
                <td class="admin-table-item">
                    % if board.description:
                    ${board.description}
                    % else:
                    <em>No description</em>
                    % endif
                </td>
            </tr>
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">Settings</th>
                <td class="admin-table-item"><pre class="codeblock noshade">${formatters.format_json(request, board.settings)}</pre></td>
            </tr>
        </tbody>
    </table>
</div>
<h2 class="sheet-title">Agreements</h2>
<div class="sheet-body content">
    % if board.agreements:
    <div class="admin-embed">
        ${formatters.format_markdown(request, board.agreements)}
    </div>
    % else:
    <p><em>No agreements</em></p>
    % endif
</div>
<div class="sheet-body">
    <a class="button brand" href="${request.route_path('admin_board_edit', board=board.slug)}">Edit Board</a>
</div>
\ No newline at end of file

A fanboi2/templates/admin/boards/topics/_nav.mako => fanboi2/templates/admin/boards/topics/_nav.mako +7 -0
@@ 0,0 1,7 @@
<div class="sheet-body">
    <a class="button" href="${request.route_path('admin_board_topics', board=board.slug)}">All Topics</a>
    <a class="button green" href="${request.route_path('admin_board_topic_new', board=board.slug)}">New Topic</a>
    <div class="admin-button-context">
        <a class="button muted" href="${request.route_path('admin_board', board=board.slug)}">Board</a>
    </div>
</div>
\ No newline at end of file

A fanboi2/templates/admin/boards/topics/all.mako => fanboi2/templates/admin/boards/topics/all.mako +23 -0
@@ 0,0 1,23 @@
<%namespace name='datetime' file='../../../partials/_datetime.mako' />
<%inherit file='../../_layout.mako' />
<%def name='title()'>${board.title} - Admin Panel</%def>
<%def name='subheader_title()'>Topics</%def>
<%def name='subheader_body()'>Manage topics.</%def>
<h2 class="sheet-title">${board.title}</h2>
<%include file='_nav.mako' />
% for topic in topics:
<div class="admin-cascade">
    <div class="admin-cascade-header">
        % if topic.status == 'locked':
        Locked:
        % elif topic.status == 'archived':
        Archived:
        % endif
        <strong><a href="${request.route_path('admin_board_topic_posts', board=board.slug, topic=topic.id, query='recent')}">${topic.title}</a></strong>
    </div>
    <div class="admin-cascade-body">
        <p>Last updated ${datetime.render_datetime(topic.meta.posted_at)}</p>
        <p>Total of <strong>${topic.meta.post_count} posts</strong></p>
    </div>
</div>
% endfor
\ No newline at end of file

A fanboi2/templates/admin/boards/topics/delete.mako => fanboi2/templates/admin/boards/topics/delete.mako +52 -0
@@ 0,0 1,52 @@
<%namespace name='datetime' file='../../../partials/_datetime.mako' />
<%inherit file='../../_layout.mako' />
<%def name='title()'>${board.title} - Admin Panel</%def>
<%def name='subheader_title()'>Topics</%def>
<%def name='subheader_body()'>Manage topics.</%def>
<h2 class="sheet-title">${topic.title}</h2>
<%include file='_nav.mako' />
<div class="sheet-body">
    <table class="admin-table">
        <tbody class="admin-table-body">
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">Board</th>
                <td class="admin-table-item">${board.title}</td>
            </tr>
            <tr class="admin-table-row">
                <th class="admin-table-item title lead">Status</th>
                <td class="admin-table-item">
                    % if topic.status == 'open':