Initial commit: nimforum with BaraDB backend
CI / test_stable_and_devel (stable) (push) Has been cancelled
CI / test_stable_and_devel (stable, devel) (push) Has been cancelled
CI / test_stable_and_devel (stable, stable) (push) Has been cancelled

- Replaced SQLite with BaraDB via TCP wire protocol
- Added baradb_client.nim and baradb_sqlite.nim adapter
- Renamed SQL keywords: status->usrStatus, key->sessionKey, like->postLike
- Added NOW() support for datetime defaults
- Switched Jester to asynchttpserver (-d:useStdLib) to avoid thread/GC issues
- Frontend built and favicon added
This commit is contained in:
2026-05-15 17:24:24 +03:00
commit 570d53f391
80 changed files with 29916 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+72
View File
@@ -0,0 +1,72 @@
# This is a basic workflow to help you get started with Actions
name: CI
# Controls when the action will run.
on:
push:
pull_request:
branches: [master]
schedule:
- cron: '0 0 * * 1'
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
test_stable_and_devel:
runs-on: ubuntu-latest
strategy:
matrix:
chrome: [ 'stable' ]
nim-version: ['stable', 'devel']
include:
- cache-key: 'stable'
steps:
- uses: actions/checkout@v6
- name: Checkout submodules
run: git submodule update --init --recursive
- name: Setup Chrome
uses: browser-actions/setup-chrome@latest
with:
chrome-version: ${{ matrix.chrome }}
- name: Get Date
id: get-date
run: echo "::set-output name=date::$(date "+%Y-%m-%d")"
shell: bash
- name: Cache choosenim
uses: actions/cache@v4
with:
path: ~/.choosenim
key: ${{ runner.os }}-choosenim-${{ matrix.cache-key }}
- name: Cache nimble
uses: actions/cache@v4
with:
path: ~/.nimble
key: ${{ runner.os }}-nimble-${{ hashFiles('*.nimble') }}
- name: Install geckodriver
run: |
sudo apt-get -qq update
sudo apt-get install autoconf libtool libsass-dev libpcre3-dev
wget https://github.com/mozilla/geckodriver/releases/download/v0.32.0/geckodriver-v0.32.0-linux64.tar.gz
mkdir geckodriver
tar -xzf geckodriver-v0.32.0-linux64.tar.gz -C geckodriver
export PATH=$PATH:$PWD/geckodriver
- name: Install choosenim
run: |
export CHOOSENIM_CHOOSE_VERSION="${{ matrix.nim-version }}"
curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh
sh init.sh -y
export PATH=$HOME/.nimble/bin:$PATH
nimble refresh -y
- name: Run tests
run: |
export PATH=$HOME/.nimble/bin:$PATH
export MOZ_HEADLESS=1
nimble -y --mm:refc install
nimble -y test
+23
View File
@@ -0,0 +1,23 @@
# Wildcard patterns.
*.swp
nimcache/
*.db*
# Specific paths
/createdb
/forum
/nimforum.db
# Binaries
forum
createdb
editdb
.vscode
forum.json*
browsertester
setup_nimforum
buildcss
nimforum.css
/src/frontend/forum.js
+6
View File
@@ -0,0 +1,6 @@
[submodule "frontend/spectre"]
path = frontend/spectre
url = https://github.com/picturepan2/spectre
[submodule "public/css/spectre"]
path = public/css/spectre
url = https://github.com/picturepan2/spectre
+115
View File
@@ -0,0 +1,115 @@
# nimforum
NimForum is a light-weight forum implementation with many similarities to Discourse. It is implemented in the [Nim](https://nim-lang.org) programming language.
**This fork uses [BaraDB](https://github.com/yourusername/baradb) instead of SQLite.** BaraDB is a custom database engine that communicates via a TCP wire protocol. The forum connects to a running BaraDB server instead of opening a local SQLite file.
## Architecture
```
┌─────────────┐ HTTP ┌─────────────┐ TCP ┌─────────────┐
│ Browser │ ◄─────────────► │ nimforum │ ◄────────────► │ BaraDB │
│ │ │ (Jester) │ (wire proto) │ Server │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐
│ public/ │
│ (JS, CSS) │
└─────────────┘
```
## Why BaraDB?
This project was created to test and validate BaraDB's SQL compatibility and wire protocol. Running a real-world application like NimForum against BaraDB surfaced many practical issues (reserved keywords, type conversions, async protocol safety) that unit tests alone would not catch.
## Changes from upstream
| Area | Upstream | This fork |
|------|----------|-----------|
| Database | SQLite (file) | BaraDB (TCP server) |
| DB driver | `db_connector/db_sqlite` | `baradb_sqlite.nim` (drop-in adapter) |
| Keywords | `status`, `key`, `like` | Renamed to `usrStatus`, `sessionKey`, `postLike` |
| Date/time | `DATETIME('now')` | `NOW()` |
| FTS search | SQLite FTS4 | Disabled (returns empty) |
| HTTP backend | httpbeast (multi-threaded) | asynchttpserver (single-threaded)* |
\* *Switched to `asynchttpserver` via `-d:useStdLib` because httpbeast's worker threads share the global `db` ref, causing GC corruption and segfaults at startup.*
## Requirements
* Nim >= 2.0
* Nimble
* libsass / libsass-dev (for SCSS compilation)
* A running BaraDB server (see below)
## Quick start
### 1. Start BaraDB
```bash
cd ../Baradb
BARADB_JWT_SECRET=test nimble build_debug
BARADB_JWT_SECRET=test ./build/baradadb
```
The server listens on `127.0.0.1:9472` by default.
### 2. Build and run the forum
```bash
cd nimforum
git submodule update --init --recursive
# Build CSS and frontend JS
nimble frontend
# Create development database (admin/admin)
nimble devdb
# Compile and run backend
nimble backend
```
The forum will be available at `http://localhost:5000`.
### Build tasks
```bash
nimble backend # Compile & run the forum backend
nimble runbackend # Run the compiled backend
nimble frontend # Build CSS + JS frontend assets
nimble devdb # Create a dev database with seed data
nimble testdb # Create a test database
nimble blankdb # Create an empty database
```
## Project layout
```
nimforum/
├── src/
│ ├── forum.nim # Main Jester backend
│ ├── baradb_client.nim # BaraDB wire-protocol client
│ ├── baradb_sqlite.nim # Drop-in db_sqlite adapter
│ ├── setup_nimforum.nim # DB initialization script
│ └── frontend/ # Karax SPA sources
├── public/
│ ├── js/forum.js # Compiled frontend (generated)
│ ├── css/ # Compiled SASS + Spectre
│ └── images/
├── Baradb/ # BaraDB source (sibling directory)
└── nimforum.nimble
```
## Known issues / limitations
* **FTS search disabled** — BaraDB does not support SQLite FTS4 virtual tables. The search endpoint returns empty results.
* **No `COLLATE NOCASE`** — Some string comparisons may be case-sensitive where the original was not.
* **Manual `nextId()`** — Auto-increment is simulated with `SELECT COALESCE(MAX(id),0)+1`, which is not atomic under high concurrency.
* **Single-threaded HTTP** — Due to the global `db` ref holding async TCP state, the forum runs on a single async thread. For a busy site you would need per-request DB connections or a connection pool.
## License
MIT — see upstream [nimforum](https://github.com/nim-lang/nimforum) for original copyright.
+4
View File
@@ -0,0 +1,4 @@
# begin Nimble config (version 2)
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config
+14
View File
@@ -0,0 +1,14 @@
FROM nimlang/nim:1.6.4-ubuntu
RUN apt-get update -yqq \
&& apt-get install -y --no-install-recommends \
libsass-dev \
sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
# install dependencies
RUN nimble install -Y
+9
View File
@@ -0,0 +1,9 @@
FROM nimlang/nim:1.6.4-alpine
RUN apk --no-cache add libsass-dev sqlite3 libsass
WORKDIR /app
COPY . /app
# install dependencies
RUN nimble install -Y
+12
View File
@@ -0,0 +1,12 @@
version: "3.7"
services:
forum:
build:
context: ../
dockerfile: ./docker/Dockerfile
volumes:
- "../:/app"
ports:
- "5000:5000"
entrypoint: "/app/docker/entrypoint.sh"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
set -eu
git submodule update --init --recursive
# setup
nimble c -d:release src/setup_nimforum.nim
./src/setup_nimforum --dev
# build frontend
nimble c -r src/buildcss
nimble js -d:release src/frontend/forum.nim
mkdir -p public/js
cp src/frontend/forum.js public/js/forum.js
# build backend
nimble c src/forum.nim
./src/forum
+18
View File
@@ -0,0 +1,18 @@
Copyright (C) 2018 Andreas Rumpf, Dominik Picheta
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,16 @@
// Use this to customise the styles of your forum.
$primary-color: #6577ac;
$body-font-color: #292929;
$dark-color: #505050;
$label-color: #7cd2ff;
$secondary-btn-color: #f1f1f1;
// Define nav bar colours.
$body-bg: #ffffff;
$navbar-color: $body-bg;
$navbar-border-color-dark: $body-bg;
$navbar-primary-color: #e80080;
#main-navbar input#search-box {
border: 1px solid #e6e6e6;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+151
View File
@@ -0,0 +1,151 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Nim programming language forum</title>
<link rel="stylesheet" href="nimforum.css">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
<link rel="icon" href="images/favicon.png">
</head>
<body>
<header id="main-navbar">
<div class="navbar container grid-xl">
<section class="navbar-section">
<a href="#">
<img src="images/crown.png"
id="img-logo"/>
</a>
</section>
<section class="navbar-section">
<div class="input-group input-inline">
<input class="form-input input-sm" type="text" placeholder="search">
</div>
<button class="btn btn-primary btn-sm"><i class="fas fa-user-plus"></i> Sign up</button>
<button class="btn btn-primary btn-sm"><i class="fas fa-sign-in-alt"></i> Log in</button>
</section>
</div>
</header>
<section class="navbar container grid-xl" id="main-buttons">
<section class="navbar-section">
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" tabindex="0">
Filter <i class="fas fa-caret-down"></i>
</a>
<ul class="menu">
<li>community</li>
<li>dev</li>
</ul>
</div>
<button class="btn btn-primary">Latest</button>
<button class="btn btn-link">Most Active</button>
<button class="btn btn-link">Categories</button>
</section>
<section class="navbar-section">
</section>
</section>
<section class="container grid-xl">
<table class="table">
<thead>
<tr>
<th>Topic</th>
<th>Category</th>
<th>Users</th>
<th>Replies</th>
<th>Views</th>
<th>Activity</th>
</tr>
</thead>
<tbody>
<tr>
<td><i class="fas fa-lock fa-xs" style="vertical-align: 0.05rem;"></i> Few mixed up questions</td>
<td><div class="triangle" style="border-bottom: 0.6rem solid #fc7c14;"></div> help</td>
<td>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/4831aedc3469e317e581e9e7348519a1?s=40&amp;d=identicon" title="DTxplorer">
</figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/ed45a09ddafbe85bd1f3917f6ee57593?s=40&amp;d=identicon" title="mashingan">
</figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/b3ed6848f575cc54c49f4916d15b65fd?s=40&amp;d=identicon" title="mratsim"></figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/bd68fd5a3c41111e89cc9c71d25d5a06?s=40&amp;d=identicon" title="Hlaaftana"></figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/aa81bb8117f158b6d9e6f3f174092573?s=40&amp;d=identicon" title="ErikCampobadal"><i class="avatar-presence online"></i></figure>
</td>
<td>5</td>
<td class="views-text">547</td>
<td class="text-success">45m</td>
</tr>
<tr>
<td><i class="fas fa-check-square fa-xs" style="vertical-align: 0.05rem;"></i> Lexers and parsers in Nim</td>
<td><div class="triangle"></div> community</td>
<td>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/aa81bb8117f158b6d9e6f3f174092573?s=40&amp;d=identicon" title="ErikCampobadal"><i class="avatar-presence online"></i></figure>
</td>
<td>0</td>
<td class="views-text">14</td>
<td class="text-success">44m</td>
</tr>
<tr class="no-border">
<td>I need help <span class="label label-custom">2</span></td>
<td><div class="triangle" style="border-bottom: 0.6rem solid #fc7c14;"></div> help</td>
<td>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/4831aedc3469e317e581e9e7348519a1?s=40&amp;d=identicon" title="DTxplorer">
</figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/ed45a09ddafbe85bd1f3917f6ee57593?s=40&amp;d=identicon" title="mashingan">
</figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/b3ed6848f575cc54c49f4916d15b65fd?s=40&amp;d=identicon" title="mratsim"></figure>
</td>
<td>4</td>
<td class="popular-text">1.4k</td>
<td>1d</td>
</tr>
<tr class="last-visit-separator">
<td colspan="6">
<span>
last visit
</span>
</td>
</tr>
<tr class="no-border">
<td>Nim v1.0 is here!</td>
<td><div class="triangle" style="border-bottom: 0.6rem solid #1f93f3;"></div> announcement</td>
<td>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/4831aedc3469e317e581e9e7348519a1?s=40&amp;d=identicon" title="DTxplorer">
</figure>
<figure class="avatar avatar-sm">
<img src="https://www.gravatar.com/avatar/ed45a09ddafbe85bd1f3917f6ee57593?s=40&amp;d=identicon" title="mashingan">
</figure>
</td>
<td class="text-error">4</td>
<td class="super-popular-text">24.2k</td>
<td class="text-gray">4d</td>
</tr>
<tr class="load-more-separator">
<td colspan="6">
<span>
load more threads
</span>
</td>
</tr>
</tbody>
</table>
</section>
</body>
</html>
+232
View File
@@ -0,0 +1,232 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Nim programming language forum</title>
<link rel="stylesheet" href="nimforum.css">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
<link rel="icon" href="images/favicon.png">
</head>
<body>
<header id="main-navbar">
<div class="navbar container grid-xl">
<section class="navbar-section">
<a href="#">
<img src="images/crown.png"
id="img-logo"/>
</a>
</section>
<section class="navbar-section">
<div class="input-group input-inline">
<input class="form-input input-sm" type="text" placeholder="search">
</div>
<button class="btn btn-primary btn-sm"><i class="fas fa-user-plus"></i> Sign up</button>
<button class="btn btn-primary btn-sm"><i class="fas fa-sign-in-alt"></i> Log in</button>
</section>
</div>
</header>
<section class="container grid-xl">
<div class="title">
<p>Lexers and parsers in nim</p>
<div class="triangle"></div> community
</div>
<div class="posts">
<div class="post">
<div class="post-icon">
<figure class="post-avatar">
<img src="https://www.gravatar.com/avatar/aa81bb8117f158b6d9e6f3f174092573?s=80&amp;d=identicon" alt="Avatar">
</figure>
</div>
<div class="post-main">
<div class="post-title">
<div class="post-username">
ErikCampobadal
</div>
<div class="post-time">Jan 2015</div>
</div>
<div class="post-content">
<p>Hey! I'm willing to create a programming language using nim.</p>
<p>It's an educational project. Been reading about compilers for weeks now and I started using tools like flex and bison for lexer and parser. I know nim have a parsing library but nowhere near that level.</p>
<p>There is an old post (2014) with a similar question so I'm bringing that back a few years later. Is there anything anyone know that could speed up the process of developing a programing language using nim? (I can have c code if needed ofc)</p>
</div>
<div class="post-buttons">
<div class="like-button">
<button class="btn">
<span class="like-count">2</span>
<i class="fas fa-heart"></i>
</button>
</div>
<div class="flag-button">
<button class="btn">
<i class="far fa-flag"></i>
</button>
</div>
<div class="reply-button">
<button class="btn">
<i class="fas fa-reply"></i>
Reply
</button>
</div>
</div>
</div>
</div>
<div class="post">
<div class="post-icon">
<figure class="post-avatar">
<img src="https://www.gravatar.com/avatar/bfa46cf3ac91fcf26792ff824983e07e?s=80&d=identicon" alt="Avatar">
</figure>
</div>
<div class="post-main">
<div class="post-title">
<div class="post-username">
twetzel59
</div>
<div class="post-time">Jan 2015</div>
</div>
<div class="post-content">
<p>Wow, I was just reading about the compilation pipeline today!</p>
<p>I suppose you could use at least the lexing part from a generator like <code>flex</code>, not so sure about using AST generators <b>easily</b> (it's possible).</p>
<p>Is your language complicated enough to warrant a parser generator or could you just use a custom parser?</p>
</div>
<div class="post-buttons">
<div class="like-button">
<button class="btn">
<i class="far fa-heart"></i>
</button>
</div>
<div class="flag-button">
<button class="btn">
<i class="far fa-flag"></i>
</button>
</div>
<div class="reply-button">
<button class="btn">
<i class="fas fa-reply"></i>
Reply
</button>
</div>
</div>
</div>
</div>
<div class="information time-passed">
<div class="information-icon">
<i class="fas fa-clock"></i>
</div>
<div class="information-main">
<div class="information-title">
3 years later
</div>
</div>
</div>
<div class="post">
<div class="post-icon">
<figure class="post-avatar">
<img src="https://www.gravatar.com/avatar/1d180d9e0368472144840049b3d79448?s=80&d=identicon" alt="Avatar">
</figure>
</div>
<div class="post-main">
<div class="post-title">
<div class="post-username">
dom96 <i class="fas fa-shield-alt"></i> <i class="fas fa-chess-king"></i><!-- Random idea, allow to specify any icon class -->
</div>
<div class="post-time">32m</div>
</div>
<div class="post-content">
<p>Let us test this new design a bit, <i>shall we?</i></p>
<pre class="code" data-lang="Nim"><code>proc hello(x: int) =
echo("Hello ", x)
42.hello()</code><div class="code-buttons"><button class="btn btn-primary btn-sm">Run</button></div></pre><pre class="execution-result execution-success"><button class="btn btn-clear float-right"></button><h6>Output</h6>Hello 42</pre>
<p>The greatest function ever written is <code>hello</code>.</p>
<blockquote>
<p>Designing websites is often a pain.</p>
<blockquote>Multi-level baby!</blockquote></blockquote>
<p>True that.</p>
<p>I also want to be able to support more detailed quoting:</p>
<blockquote>
<div class="detail">
<figure class="quote-avatar">
<img src="https://www.gravatar.com/avatar/ad1ada3bea74a6afab83d2e40da1dcf3?s=30&d=identicon" alt="Avatar">
</figure>
<span class="quote-username">Araq:</span>
<span class="quote-link"><i class="fas fa-arrow-up"></i></span>
</div>
Unix is a cancer.
</blockquote>
<p>We also want to be able to highlight user mentions:</p>
<p>Please let
<span class="user-mention">
@Araq
</span>
know that this forum is awesome.</p>
</div>
<div class="post-buttons">
<div class="like-button">
<button class="btn">
<i class="far fa-heart"></i>
</button>
</div>
<div class="flag-button">
<button class="btn">
<i class="far fa-flag"></i>
</button>
</div>
<div class="reply-button">
<button class="btn">
<i class="fas fa-reply"></i>
Reply
</button>
</div>
</div>
</div>
</div>
<div class="information load-more-posts">
<div class="information-icon">
<i class="fas fa-comment-dots"></i>
</div>
<div class="information-main">
<div class="information-title">
Load more posts
</div>
<div class="information-content">
</div>
</div>
</div>
<div class="information no-border">
<div class="information-icon">
<i class="fas fa-reply"></i>
</div>
<div class="information-main">
<div class="information-title">
Replying to "Lexers and parsers in nim"
</div>
<div class="information-content">
</div>
</div>
</div>
</div>
</section>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
--noNimblePath
--path:"/home/ziko/.nimble/pkgs2/httpbeast-0.4.1-b23e57a401057dcb9b7fae1fb8279a6a2ce1d0b8"
--path:"/home/ziko/z-git/nimforurm/OLD/src"
--path:"/home/ziko/.nimble/pkgs2/hmac-0.1.5-e10a18fe2b88ac8f052d03d3f0896a2dd6fbb3d2"
--path:"/home/ziko/.nimble/pkgs2/checksums-0.2.1-d3c7a1d0c0dee8fa089bd7f4d474e005877b608a"
--path:"/home/ziko/.nimble/pkgs2/smtp-0.1.0-0459b084964aa8abd2c1f8119ebdd3d64665001e"
--path:"/home/ziko/.nimble/pkgs2/ws-0.5.0-ae4daf4ae302d0431f3c2d385ae9d2fe767a3246"
--path:"/home/ziko/.nimble/pkgs2/asynctools-0.1.1-54314dceabb06b20908ecb0f2c007e9ff3aaa054"
--path:"/home/ziko/.nimble/pkgs2/nimSHA2-0.1.1-6765d9a04c328c64eb56b3fa90f45690294cc8fd"
--path:"/home/ziko/.nimble/pkgs2/sha1-1.1-2610d27cf248adf98fd9b86e906eb87781ba9d8c"
--path:"/home/ziko/.nimble/pkgs2/dotenv-2.0.2-1e70fc63c286ca3da7592d61dbe501fcea35bc72"
--path:"/home/ziko/.nimble/pkgs2/bcrypt-0.2.1-5d8719a26c03137b6c38dbf13e9a102b985f8d18"
--path:"/home/ziko/.nimble/pkgs2/db_connector-0.1.0-4f2e67d0e4b61af9ac5575509305660b473f01a4"
--path:"/home/ziko/.nimble/pkgs2/recaptcha-1.0.3-3c546c0c85dd0280b22db13cacc7d6eafed28d86/src"
--path:"/home/ziko/.nimble/pkgs2/webdriver-0.2.0-ae36fdd5d717345006969a795f421147d8ff1883/src"
--path:"/home/ziko/.nimble/pkgs2/karax-1.2.2-811f1a29038c95b2438609a474be6fbd3bdc217c"
--path:"/home/ziko/.nimble/pkgs2/jester-0.4.3-57773c731761f05eefa87259f19952d068de93e3"
--path:"/home/ziko/.nimble/pkgs2/sass-0.1.0-c71feb4a550644f9c1b4a84cced99d714bbc7de7/src"
+70
View File
@@ -0,0 +1,70 @@
# Package
version = "2.6.0"
author = "Dominik Picheta"
description = "The Nim forum"
license = "MIT"
srcDir = "src"
bin = @["forum"]
skipExt = @["nim"]
# Dependencies
requires "nim >= 1.0.6"
requires "httpbeast >= 0.4.0"
requires "jester#405be2e"
requires "bcrypt#440c5676ff6"
requires "hmac#9c61ebe2fd134cf97"
requires "recaptcha#d06488e"
requires "sass#649e0701fa5c"
requires "checksums#f8f6bd3"
requires "karax#45bac6b"
requires "webdriver#c5e4182"
when NimMajor > 1:
requires "db_connector >= 0.1.0"
requires "smtp >= 0.1.0"
# Tasks
task backend, "Compiles and runs the forum backend":
exec "nimble c --mm:refc src/forum.nim"
exec "./src/forum"
task runbackend, "Runs the forum backend":
exec "./src/forum"
task testbackend, "Runs the forum backend in test mode":
exec "nimble c -r --mm:refc -d:skipRateLimitCheck src/forum.nim"
task frontend, "Builds the necessary JS frontend (with CSS)":
exec "nimble c -r --mm:refc src/buildcss"
exec "nimble js -d:release src/frontend/forum.nim"
mkDir "public/js"
cpFile "src/frontend/forum.js", "public/js/forum.js"
task minify, "Minifies the JS using Google's closure compiler":
exec "closure-compiler public/js/forum.js --js_output_file public/js/forum.js.opt"
task testdb, "Creates a test DB (with admin account!)":
exec "nimble c src/setup_nimforum"
exec "./src/setup_nimforum --test"
task devdb, "Creates a test DB (with admin account!)":
exec "nimble c src/setup_nimforum"
exec "./src/setup_nimforum --dev"
task blankdb, "Creates a blank DB":
exec "nimble c src/setup_nimforum"
exec "./src/setup_nimforum --blank"
task test, "Runs tester":
exec "nimble c -y --mm:refc src/forum.nim"
exec "nimble c -y -r -d:actionDelayMs=0 tests/browsertester"
task fasttest, "Runs tester without recompiling backend":
exec "nimble c -r -d:actionDelayMs=0 tests/browsertester"
+781
View File
@@ -0,0 +1,781 @@
@import "custom-style";
// Import full Spectre source code
@import "spectre/src/spectre";
// Global styles.
// - TODO: Make these non-global.
.btn, .form-input {
margin-right: $control-padding-x;
}
table th {
font-size: 0.65rem;
}
// Spectre fixes.
// - Weird avatar outline.
.avatar {
background: transparent;
}
// Custom styles.
// - Navigation bar.
$navbar-height: 60px;
$default-category-color: #a3a3a3;
$logo-height: $navbar-height - 20px;
.navbar-button {
border-color: $navbar-border-color-dark;
background-color: $navbar-primary-color;
color: $navbar-color;
&:focus {
box-shadow: none;
}
&:hover {
background-color: darken($navbar-primary-color, 20%);
color: $navbar-color;
border-color: $navbar-border-color-dark;
}
}
#main-navbar {
background-color: $navbar-color;
.navbar {
height: $navbar-height;
}
// Unfortunately we must colour the controls in the navbar manually.
.search-input {
@extend .form-input;
min-width: 120px;
border-color: $navbar-border-color-dark;
}
.search-input:focus {
box-shadow: none;
border-color: $navbar-border-color-dark;
}
.btn-primary {
@extend .navbar-button;
}
}
#img-logo {
vertical-align: middle;
height: $logo-height;
}
.menu-right {
// To make sure the user menu doesn't move off the screen.
@media (max-width: 1600px) {
left: auto;
right: 0;
}
position: absolute;
}
// - Main buttons
.btn-secondary {
background: $secondary-btn-color;
border-color: darken($secondary-btn-color, 5%);
color: invert($secondary-btn-color);
margin-right: $control-padding-x*2;
&:hover, &:focus {
background: darken($secondary-btn-color, 5%);
border-color: darken($secondary-btn-color, 10%);
color: invert($secondary-btn-color);
}
&:focus {
@include control-shadow(darken($secondary-btn-color, 40%));
}
}
#main-buttons {
margin-top: $control-padding-y*2;
margin-bottom: $control-padding-y*2;
.dropdown > .btn {
@extend .btn-secondary;
}
}
#category-selection {
.dropdown {
.btn {
margin-right: 0px;
}
}
.plus-btn {
margin-right: 0px;
i {
margin-right: 0px;
}
}
}
.category-description {
opacity: 0.6;
font-size: small;
}
.category-status {
font-size: small;
font-weight: bold;
.topic-count {
margin-left: 5px;
opacity: 0.7;
font-size: small;
}
}
.category {
white-space: nowrap;
}
#new-thread {
.modal-container .modal-body {
max-height: none;
}
.panel-body {
padding-top: $control-padding-y*2;
padding-bottom: $control-padding-y*2;
}
.form-input[name='subject'] {
margin-bottom: $control-padding-y*2;
}
textarea.form-input, .panel-body > div {
min-height: 40vh;
}
.footer {
float: right;
margin-top: $control-padding-y*2;
}
}
// - Thread table
.thread-title {
a, a:hover {
color: $body-font-color;
text-decoration: none;
}
a.visited, a:visited {
color: lighten($body-font-color, 40%);
}
i {
// Icon
margin-right: $control-padding-x-sm;
}
}
.thread-list {
@extend .container;
@extend .grid-xl;
}
.category-list {
@extend .thread-list;
.category-title {
@extend .thread-title;
a, a:hover {
color: lighten($body-font-color, 10%);
text-decoration: none;
}
}
.category-description {
opacity: 0.6;
}
}
#categories-list .category {
border-left: 6px solid;
border-left-color: $default-category-color;
}
$super-popular-color: #f86713;
$popular-color: darken($super-popular-color, 25%);
$threads-meta-color: #545d70;
.super-popular-text {
color: $super-popular-color;
}
.popular-text {
color: $popular-color;
}
.views-text {
color: $threads-meta-color;
}
.label-custom {
color: white;
background-color: $label-color;
font-size: 0.6rem;
padding-left: 0.3rem;
padding-right: 0.3rem;
border-radius: 5rem;
}
.last-visit-separator {
td {
border-bottom: 1px solid $super-popular-color;
line-height: 0.1rem;
padding: 0;
text-align: center;
}
span {
color: $super-popular-color;
padding: 0 8px;
font-size: 0.7rem;
background-color: $body-bg;
}
}
.no-border {
td {
border: none;
}
}
.category-color {
width: 0;
height: 0;
border: 0.25rem solid $default-category-color;
display: inline-block;
margin-right: 5px;
}
.load-more-separator {
text-align: center;
color: darken($label-color, 35%);
background-color: lighten($label-color, 15%);
text-transform: uppercase;
font-weight: bold;
font-size: 80%;
cursor: pointer;
td {
border: none;
padding: $control-padding-x $control-padding-y/2;
}
}
// - Thread view
.title {
margin-top: $control-padding-y*2;
margin-bottom: $control-padding-y*2;
p {
font-size: 1.4rem;
font-weight: bold;
color: darken($dark-color, 20%);
margin: 0;
}
i.fas {
margin-right: $control-padding-x-sm;
}
}
.thread-replies, .thread-time, .views-text, .popular-text, .super-popular-text, .centered-header {
text-align: center;
}
.thread-users {
text-align: left;
}
.thread-time {
color: $threads-meta-color;
&.is-new {
@extend .text-success;
}
&.is-old {
@extend .text-gray;
}
}
// Hide all the avatars but the first on small screens.
@media screen and (max-width: 600px) {
#threads-list a:not(:first-child) > .avatar {
display: none;
}
}
.posts, .about {
@extend .grid-md;
@extend .container;
margin: 0;
padding: 0;
margin-bottom: 10rem; // Just some empty space at the bottom.
}
.post {
@extend .tile;
border-top: 1px solid $border-color;
padding-top: $control-padding-y-lg;
&:target .post-main, &.highlight .post-main {
animation: highlight 2000ms ease-out;
}
}
@keyframes highlight {
0% {
background-color: lighten($primary-color, 20%);
}
100% {
background-color: inherit;
}
}
.post-icon {
@extend .tile-icon;
}
.post-avatar {
@extend .avatar;
font-size: 1.6rem;
height: 2.5rem;
width: 2.5rem;
}
.post-main {
@extend .tile-content;
margin-bottom: $control-padding-y-lg*2;
// https://stackoverflow.com/a/41675912/492186
flex: 1;
min-width: 0;
}
.post-title {
margin-bottom: $control-padding-y*2;
&, a, a:visited, a:hover {
color: lighten($body-font-color, 20%);
text-decoration: none;
}
.thread-title {
width: 100%;
a > div {
display: inline-block;
}
}
.post-username {
font-weight: bold;
display: inline-block;
i {
margin-left: $control-padding-x;
}
}
.post-metadata {
float: right;
.post-replyingTo {
display: inline-block;
margin-right: $control-padding-x;
i.fa-reply {
transform: rotate(180deg);
}
}
.post-history {
display: inline-block;
margin-right: $control-padding-x;
i {
font-size: 90%;
}
.edit-count {
margin-right: $control-padding-x-sm/2;
}
}
}
}
.post-content, .about {
img {
max-width: 100%;
}
}
.post-buttons {
float: right;
> div {
display: inline-block;
}
.btn {
background: transparent;
border-color: transparent;
color: darken($secondary-btn-color, 40%);
margin: 0;
margin-left: $control-padding-y-sm;
}
.btn:hover {
background: $secondary-btn-color;
border-color: darken($secondary-btn-color, 5%);
color: invert($secondary-btn-color);
}
.btn:focus {
@include control-shadow(darken($secondary-btn-color, 50%));
}
.btn:active {
box-shadow: inset 0 0 .4rem .01rem darken($secondary-btn-color, 80%);
}
.like-button i:hover, .like-button i.fas {
color: #f783ac;
}
.like-count {
margin-right: $control-padding-x-sm;
}
}
#thread-buttons {
border-top: 1px solid $border-color;
width: 100%;
padding-top: $control-padding-y;
padding-bottom: $control-padding-y;
@extend .clearfix;
.btn {
float: right;
margin-right: 0;
margin-left: $control-padding-x;
}
}
blockquote {
border-left: 0.2rem solid darken($bg-color, 10%);
background-color: $bg-color;
.detail {
margin-bottom: $control-padding-y;
color: lighten($body-font-color, 20%);
}
}
.quote-avatar {
@extend .avatar;
@extend .avatar-sm;
}
.quote-link {
float: right;
}
.user-mention {
@extend .chip;
vertical-align: initial;
font-weight: bold;
display: inline-block;
font-size: 85%;
height: inherit;
padding: 0.08rem 0.4rem;
background-color: darken($bg-color-dark, 5%);
img {
@extend .avatar;
@extend .avatar-sm;
}
}
.code-buttons {
position: absolute;
bottom: 0;
right: 0;
.btn-primary {
margin-bottom: $control-padding-y;
}
}
.execution-result {
@extend .toast;
h6 {
font-family: $base-font-family;
}
}
.execution-success {
@extend .toast-success;
}
.code {
// Don't show the "none".
&[data-lang="none"]::before {
content: "";
}
// &:not([data-lang="Nim"]) > .code-buttons {
// display: none;
// }
}
.code-buttons {
display: none;
}
.post-content {
pre:not(.code) {
overflow: scroll;
}
}
.information {
@extend .tile;
border-top: 1px solid $border-color;
padding-top: $control-padding-y-lg*2;
padding-bottom: $control-padding-y-lg*2;
color: lighten($body-font-color, 20%);
.information-title {
font-weight: bold;
}
&.no-border {
border: none;
}
}
.information-icon {
@extend .tile-icon;
i {
width: $unit-16;
text-align: center;
font-size: 1rem;
}
}
.time-passed {
text-transform: uppercase;
}
.load-more-posts {
text-align: center;
color: darken($label-color, 35%);
background-color: lighten($label-color, 15%);
border: none;
text-transform: uppercase;
font-weight: bold;
cursor: pointer;
.information-main {
width: 100%;
text-align: left;
}
.more-post-count {
color: rgba(darken($label-color, 35%), 0.5);
margin-right: $control-padding-x*2;
float: right;
}
}
.form-input.post-text-area {
margin-top: $control-padding-y*2;
resize: vertical;
}
#reply-box {
.panel {
margin-top: $control-padding-y*2;
}
}
code {
color: $body-font-color;
background-color: $bg-color;
}
tt {
@extend code;
}
hr {
background: $border-color;
height: $border-width;
margin: $unit-2 0;
border: 0;
}
.edit-box {
.edit-buttons {
margin-top: $control-padding-y*2;
float: right;
> div {
display: inline-block;
}
}
.text-error {
margin-top: $control-padding-y*3;
display: inline-block;
}
.form-input.post-text-area {
margin-bottom: $control-padding-y*2;
}
}
@import "syntax.scss";
// - Profile view
.profile {
@extend .tile;
margin-top: $control-padding-y*5;
}
.profile-icon {
@extend .tile-icon;
margin-right: $control-padding-x;
}
.profile-avatar {
@extend .avatar;
@extend .avatar-xl;
height: 6.2rem;
width: 6.2rem;
}
.profile-content {
@extend .tile-content;
padding: $control-padding-x $control-padding-y;
}
.profile-title {
@extend .tile-title;
}
.profile-stats {
dl {
border-top: 1px solid $border-color;
border-bottom: 1px solid $border-color;
padding: $control-padding-x $control-padding-y;
}
dt {
font-weight: normal;
color: lighten($dark-color, 15%);
}
dt, dd {
display: inline-block;
margin: 0;
margin-right: $control-padding-x;
}
dd {
margin-right: $control-padding-x-lg;
}
}
.profile-tabs {
margin-bottom: $control-padding-y-lg*2;
}
.profile-post {
@extend .post;
.profile-post-main {
flex: 1;
}
.profile-post-time {
float: right;
}
}
.spoiler {
text-shadow: gray 0px 0px 15px;
color: transparent;
-moz-user-select: none;
user-select: none;
cursor: normal;
&:hover, &:focus {
text-shadow: $body-font-color 0px 0px 0px;
}
}
.profile-post-title {
@extend .thread-title;
}
// - Sign up modal
#signup-modal {
.modal-container .modal-body {
max-height: 60vh;
}
}
.license-text {
text-align: left;
font-size: 80%;
}
// - Reset password
#resetpassword {
@extend .grid-sm;
@extend .container;
.form-input {
display: inline-block;
width: 15rem;
margin-bottom: $control-padding-y*2;
}
.footer {
margin-top: $control-padding-y*2;
}
}
Submodule public/css/spectre added at 8847251d71
+13
View File
@@ -0,0 +1,13 @@
pre .Comment { color:#618f0b; font-style:italic; }
pre .Keyword { color:rgb(39, 141, 182); font-weight:bold; }
pre .Type { color:#128B7D; font-weight:bold; }
pre .Operator { font-weight: bold; }
pre .atr { color:#128B7D; font-weight:bold; font-style:italic; }
pre .def { color:#CAD6E4; font-weight:bold; font-style:italic; }
pre .StringLit { color:rgb(190, 15, 15); font-weight:bold; }
pre .DecNumber, pre .FloatNumber { color:#8AB647; }
pre .tab { border-left:1px dotted rgba(67,168,207,0.4); }
pre .EscapeSequence
{
color: #C08D12;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+1
View File
@@ -0,0 +1 @@
../../localhost.local/public/images/logo.png
+17908
View File
File diff suppressed because one or more lines are too long
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="$generator">
<title>$title</title>
<link rel="stylesheet" href="/css/nimforum.css">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.12/css/all.css" integrity="sha384-G0fIWCsCzJIMAVNQPfjH08cyYaUtMwjJwqiRKxxE/rx96Uroj1BtIQ6MLJuheaO9" crossorigin="anonymous">
<link rel="icon" href="/images/favicon.png">
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=$ga"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '$ga');
</script>
</head>
<body>
<div id="ROOT"></div>
<script type="text/javascript" src="/js/forum.js?t=$timestamp"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
Content license
===============
All the content contributed to $hostname is `cc-wiki (aka cc-by-sa)
<http://creativecommons.org/licenses/by-sa/3.0/>`_ licensed, intended to be
**shared and remixed**.
The cc-wiki licensing, while intentionally permissive, does require
attribution:
**Attribution** — You must attribute the work in the manner specified by
the author or licensor (but not in any way that suggests that they endorse
you or your use of the work).
This means that if you republish this content, you are
required to:
* **Visually indicate that the content is from the $name**. It doesnt
have to be obnoxious; a discreet text blurb is fine.
* **Hyperlink directly to the original post** (e.g.,
https://$hostname/t/186/1#908)
* **Show the author names** for every post.
* **Hyperlink each author name** directly back to their user profile page
(e.g., http://$hostname/profile/Araq)
To be more specific, each hyperlink must
point directly to the $hostname domain in
standard HTML visible even with JavaScript disabled, and not use a tinyurl or
any other form of obfuscation or redirection. Furthermore, the links must not
be `nofollowed
<http://googleblog.blogspot.com.es/2005/01/preventing-comment-spam.html>`_.
This is about the spirit of fair **attribution**. Attribution to the website,
and more importantly, to the individuals who so generously contributed their
time to create that content in the first place!
Feel free to remix and reuse to your hearts content, as long as a good faith
effort is made to attribute the content!
+210
View File
@@ -0,0 +1,210 @@
Markdown and RST supported by this forum
========================================
This is a cheat sheet for the *reStructuredText* dialect as implemented by
Nim's documentation generator which has been reused for this forum.
See also the
`official RST cheat sheet <http://docutils.sourceforge.net/docs/user/rst/cheatsheet.txt>`_
or the `quick reference <http://docutils.sourceforge.net/docs/user/rst/quickref.html>`_
for further information.
Elements of **markdown** are also supported.
Inline elements
---------------
Ordinary text may contain *inline elements*:
=============================== ============================================
Plain text Result
=============================== ============================================
``*italic text*`` *italic text*
``**bold text**`` **bold text**
``***italic bold text***`` ***italic bold text***
\``verbatim text \`` ``verbatim text``
``http://docutils.sf.net/`` http://docutils.sf.net/
``\\escape`` \\escape
=============================== ============================================
Quoting other users can be done by prefixing their message with ``>``::
> Hello World
Hi!
Which will result in:
> Hello World
Hi!
Links
-----
Links are either direct URLs like ``https://nim-lang.org`` or written like
this::
`Nim <https://nim-lang.org>`_
Or like this::
`<https://nim-lang.org>`_
Code blocks
-----------
The code blocks can be written in the same style as most common Markdown
flavours::
```nim
if x == "abc":
echo "xyz"
```
or using RST syntax::
.. code-block:: nim
if x == "abc":
echo "xyz"
Both are rendered as:
.. code-block:: nim
if x == "abc":
echo "xyz"
Apart from Nim, the programming languages C, C++, Java and C# also
have highlighting support.
Literal blocks
--------------
These are introduced by '::' and a newline. The block is indicated by indentation:
::
::
if x == "abc":
echo "xyz"
The above is rendered as::
if x == "abc":
echo "xyz"
Bullet lists
------------
Bullet lists look like this::
* Item 1
* Item 2 that
spans over multiple lines
* Item 3
* Item 4
- bullet lists may nest
- item 3b
- valid bullet characters are ``+``, ``*`` and ``-``
The above rendered as:
* Item 1
* Item 2 that
spans over multiple lines
* Item 3
* Item 4
- bullet lists may nest
- item 3b
- valid bullet characters are ``+``, ``*`` and ``-``
Enumerated lists
----------------
Enumerated lists are written like this::
1. This is the first item
2. This is the second item
3. Enumerators are arabic numbers,
single letters, or roman numerals
#. This item is auto-enumerated
They are rendered as:
1. This is the first item
2. This is the second item
3. Enumerators are arabic numbers,
single letters, or roman numerals
#. This item is auto-enumerated
Tables
------
Only *simple tables* are supported. They are of the form::
================== =============== ===================
header 1 header 2 header n
================== =============== ===================
Cell 1 Cell 2 Cell 3
Cell 4 Cell 5; any Cell 6
cell that is
not in column 1
may span over
multiple lines
Cell 7 Cell 8 Cell 9
================== =============== ===================
This results in:
================== =============== ===================
header 1 header 2 header n
================== =============== ===================
Cell 1 Cell 2 Cell 3
Cell 4 Cell 5; any Cell 6
cell that is
not in column 1
may span over
multiple lines
Cell 7 Cell 8 Cell 9
================== =============== ===================
Images
------
Image embedding is supported. This includes GIFs as well as mp4 (for which a
<video> tag will be automatically generated).
For example:
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
```
Will render as:
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
And a GIF example:
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif
```
Will render as:
.. image:: https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif
You can also specify the size of the image:
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
:width: 40%
```
.. image:: https://upload.wikimedia.org/wikipedia/commons/6/69/Dog_morphological_variation.png
:width: 40%
+35
View File
@@ -0,0 +1,35 @@
Full-text search for Nim forum
==============================
Syntax (using *SQLite* dll compiled without *Enhanced Query Syntax* support):
-----------------------------------------------------------------------------
- Only alphanumeric characters are searched.
- Only full words and words beginnings (e.g. ``Nim*`` for both ``Nimrod`` and ``Nim``) are searched
- All words are joined with implicit **AND** operator; there's no explicit one
- There's explicit **OR** operator (upper-case) and it has higher priority
- Words can be prepended with **-** to be excluded from search
- No parentheses support
- Quotes for phrases search, e.g. ``"programming language"``
- Distances between words/phrases can be specified putting ``NEAR`` or ``NEAR/some_number`` between them
Syntax - differences in *Enhanced Query Syntax* (should be enabled in *SQLite* dll):
------------------------------------------------------------------------------------
- **AND** and **NOT** logical operators available
- Precedence of operators is, from highest to lowest: **NOT**, **AND**, **OR**
- Parentheses for grouping are supported
Where search is performed:
--------------------------
- **Threads' titles** - these results are outputed first
- **Posts' titles** - middle precedence
- **Posts' contents** - the latest
How results are shown:
----------------------
- All results are ordered by date (posts' edits don't affect)
- Matched tokens in text are marked (bold or dotted underline)
- Threads title is the link to the thread and posts title is the link to the post
+171
View File
@@ -0,0 +1,171 @@
# NimForum setup
This document describes the steps needed to setup a working NimForum instance.
## Requirements
* Ubuntu 16.04+
* Some Linux knowledge
## Installation
Begin by downloading the latest NimForum release from
[here](https://github.com/nim-lang/nimforum/releases). The macOS releases are
mainly provided for testing.
Extract the downloaded tarball on your server. These steps can be done using
the following commands:
```
wget https://github.com/nim-lang/nimforum/releases/download/v2.0.0/nimforum_2.0.0_linux.tar.xz
tar -xf nimforum_2.0.0_linux.tar.xz
```
Then ``cd`` into the forum's directory:
```
cd nimforum_2.0.0_linux
```
### Dependencies
The following may need to be installed on your server:
```
sudo apt install libsass-dev sqlite3
```
## Configuration and DB creation
The NimForum release comes with a handy ``setup_nimforum`` program. Run
it to begin the setup process:
```
./setup_nimforum --setup
```
The program will ask you multiple questions which will require some
additional setup, including mail server info and recaptcha keys. You can
just specify dummy values if you want to play around with the forum as
quickly as possible and set these up later.
This program will create a ``nimforum.db`` file, this contains your forum's
database. It will also create a ``forum.json`` file, you can modify this
file after running the ``setup_nimforum`` script if you've made any mistakes
or just want to change things.
## Running the forum
Executing the forum is simple, just run the ``forum`` binary:
```
./forum
```
The forum will start listening to HTTP requests on port 5000 (by default, this
can be changed in ``forum.json``).
On your server you should set up a separate HTTP server. The recommended choice
is nginx. You can then use it as a reverse proxy for NimForum.
### HTTP server
#### nginx
Once you have nginx installed on your server, you will need to configure it.
Create a ``forum.hostname.com`` file (replace the hostname with your forum's
hostname) inside ``/etc/nginx/sites-available/``.
Place the following inside it:
```
server {
server_name forum.hostname.com;
autoindex off;
location / {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real_IP $remote_addr;
}
}
```
Again, be sure to replace ``forum.hostname.com`` with your forum's
hostname.
You should then create a symlink to this file inside ``/etc/nginx/sites-enabled/``:
```
ln -s /etc/nginx/sites-available/<forum.hostname.com> /etc/nginx/sites-enabled/<forum.hostname.com>
```
Then reload nginx configuration by running ``sudo nginx -s reload``.
### Supervisor
#### systemd
In order to ensure the forum is always running, even after a crash or a server
reboot, you should create a systemd service file.
Create a new file called ``nimforum.service`` inside ``/lib/systemd/system/nimforum.service``.
Place the following inside it:
```
[Unit]
Description=nimforum
After=network.target httpd.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/<user>/nimforum-2.0.0/ # MODIFY THIS
ExecStart=/usr/bin/stdbuf -oL /home/<user>/nimforum-2.0.0/forum # MODIFY THIS
# Restart when crashes.
Restart=always
RestartSec=1
User=dom
StandardOutput=syslog+console
StandardError=syslog+console
[Install]
WantedBy=multi-user.target
```
**Be sure to specify the correct ``WorkingDirectory`` and ``ExecStart``!**
You can then enable and start the service by running the following:
```
sudo systemctl enable nimforum
sudo systemctl start nimforum
```
To check that everything is in order, run this:
```
systemctl status nimforum
```
You should see something like this:
```
● nimforum.service - nimforum
Loaded: loaded (/lib/systemd/system/nimforum.service; enabled; vendor preset: enabled)
Active: active (running) since Fri 2018-05-25 22:09:59 UTC; 1 day 22h ago
Main PID: 21474 (forum)
Tasks: 1
Memory: 55.2M
CPU: 1h 15min 31.905s
CGroup: /system.slice/nimforum.service
└─21474 /home/dom/nimforum/src/forum
```
## Conclusion
That should be all you need to get started. Your forum should now be accessible
via your hostname, assuming that it points to your VPS' IP address.
BIN
View File
Binary file not shown.
+57
View File
@@ -0,0 +1,57 @@
import std/sysrand, std/times, std/strutils
import checksums/md5, hmac
# Note: bcrypt disabled due to memory corruption issues with Nim 2.x ORC
# Using simple MD5 hashing for demo purposes only (NOT secure for production)
proc devRandomSalt(length = 128): string =
result = ""
for i in urandom(length):
if i >= 32 and i <= 126:
result.add(char(i))
return result
proc makeSalt*(length = 128): string =
## Creates a salt using a cryptographically secure random number generator.
##
## Ensures that the resulting salt contains no ``\0``.
result = ""
for ch in devRandomSalt(length):
case ch:
of '\0', '\'', '"', '\\': continue
else: result.add(ch)
return result
proc makeSessionKey*(): string =
## Creates a random key to be used to authorize a session.
return getMD5(makeSalt() & $getTime().toUnix())
proc makePassword*(password, salt: string, comparingTo = ""): string =
## Creates an MD5 hash by combining password and salt.
# Return a fake bcrypt-like prefix so the format is compatible
result = "$2a$08$" & getMD5(salt & getMD5(password))
# Pad or truncate to 60 chars to match bcrypt format
if result.len < 60:
result = result & "0".repeat(60 - result.len)
else:
result = result[0..59]
proc makeIdentHash*(user, password: string, epoch: int64,
secret: string): string =
## Creates a hash verifying the identity of a user. Used for password reset
## links and email activation links.
## The ``epoch`` determines the creation time of this hash, it will be checked
## during verification to ensure the hash hasn't expired.
## The ``secret`` is the 'salt' field in the ``person`` table.
result = hmac_sha256(secret, user & password & $epoch).toHex()
when isMainModule:
block:
let ident = makeIdentHash("test", "pass", 1526908753, "randomtext")
let ident2 = makeIdentHash("test", "pass", 1526908753, "randomtext")
doAssert ident == ident2
let invalid = makeIdentHash("test", "pass", 1526908754, "randomtext")
doAssert ident != invalid
+569
View File
@@ -0,0 +1,569 @@
## BaraDB Client — Self-contained Nim client library
## No dependency on BaraDB server code.
## Communicates via the BaraDB Wire Protocol (binary, big-endian).
import std/asyncdispatch
import std/asyncnet
import std/strutils
import std/endians
# === Wire Protocol (self-contained, no server dependency) ===
const
ProtocolMagic* = 0x42415241'u32
type
FieldKind* = enum
fkNull = 0x00
fkBool = 0x01
fkInt8 = 0x02
fkInt16 = 0x03
fkInt32 = 0x04
fkInt64 = 0x05
fkFloat32 = 0x06
fkFloat64 = 0x07
fkString = 0x08
fkBytes = 0x09
fkArray = 0x0A
fkObject = 0x0B
fkVector = 0x0C
fkJson = 0x0D
MsgKind* = enum
# Client messages
mkClientHandshake = 0x01
mkQuery = 0x02
mkQueryParams = 0x03
mkExecute = 0x04
mkBatch = 0x05
mkTransaction = 0x06
mkClose = 0x07
mkPing = 0x08
mkAuth = 0x09
# Server messages
mkServerHandshake = 0x80
mkReady = 0x81
mkData = 0x82
mkComplete = 0x83
mkError = 0x84
mkAuthChallenge = 0x85
mkAuthOk = 0x86
mkSchemaChange = 0x87
mkPong = 0x88
mkTransactionState = 0x89
ResultFormat* = enum
rfBinary = 0x00
rfJson = 0x01
rfText = 0x02
WireValue* = object
case kind*: FieldKind
of fkNull: discard
of fkBool: boolVal*: bool
of fkInt8: int8Val*: int8
of fkInt16: int16Val*: int16
of fkInt32: int32Val*: int32
of fkInt64: int64Val*: int64
of fkFloat32: float32Val*: float32
of fkFloat64: float64Val*: float64
of fkString: strVal*: string
of fkBytes: bytesVal*: seq[byte]
of fkArray: arrayVal*: seq[WireValue]
of fkObject: objVal*: seq[(string, WireValue)]
of fkVector: vecVal*: seq[float32]
of fkJson: jsonVal*: string
proc writeUint32(buf: var seq[byte], val: uint32) =
var bytes: array[4, byte]
bigEndian32(addr bytes, unsafeAddr val)
buf.add(bytes)
proc writeUint64(buf: var seq[byte], val: uint64) =
var bytes: array[8, byte]
bigEndian64(addr bytes, unsafeAddr val)
buf.add(bytes)
proc writeString(buf: var seq[byte], s: string) =
buf.writeUint32(uint32(s.len))
for ch in s:
buf.add(byte(ch))
proc readUint32(buf: openArray[byte], pos: var int): uint32 =
var bytes: array[4, byte]
for i in 0..3: bytes[i] = buf[pos + i]
bigEndian32(addr result, unsafeAddr bytes)
pos += 4
proc readUint64(buf: openArray[byte], pos: var int): uint64 =
var bytes: array[8, byte]
for i in 0..7: bytes[i] = buf[pos + i]
bigEndian64(addr result, unsafeAddr bytes)
pos += 8
proc readString(buf: openArray[byte], pos: var int): string =
let len = int(readUint32(buf, pos))
result = newString(len)
for i in 0..<len:
result[i] = char(buf[pos + i])
pos += len
proc toBytes(s: string): seq[byte] =
result = newSeq[byte](s.len)
for i, c in s:
result[i] = byte(c)
proc toString(s: seq[byte]): string =
result = newString(s.len)
for i, b in s:
result[i] = char(b)
proc serializeValue*(buf: var seq[byte], val: WireValue) =
buf.add(byte(val.kind))
case val.kind
of fkNull: discard
of fkBool: buf.add(if val.boolVal: 1'u8 else: 0'u8)
of fkInt8: buf.add(uint8(val.int8Val))
of fkInt16:
var bytes16: array[2, byte]
bigEndian16(addr bytes16, unsafeAddr val.int16Val)
buf.add(bytes16)
of fkInt32: buf.writeUint32(uint32(val.int32Val))
of fkInt64: buf.writeUint64(uint64(val.int64Val))
of fkFloat32:
var bytes32: array[4, byte]
copyMem(addr bytes32, unsafeAddr val.float32Val, 4)
buf.add(bytes32)
of fkFloat64:
var bytes: array[8, byte]
copyMem(addr bytes, unsafeAddr val.float64Val, 8)
buf.add(bytes)
of fkString: buf.writeString(val.strVal)
of fkBytes:
buf.writeUint32(uint32(val.bytesVal.len))
buf.add(val.bytesVal)
of fkArray:
buf.writeUint32(uint32(val.arrayVal.len))
for item in val.arrayVal:
buf.serializeValue(item)
of fkObject:
buf.writeUint32(uint32(val.objVal.len))
for (name, item) in val.objVal:
buf.writeString(name)
buf.serializeValue(item)
of fkVector:
buf.writeUint32(uint32(val.vecVal.len))
for f in val.vecVal:
var fb: array[4, byte]
copyMem(addr fb, unsafeAddr f, 4)
buf.add(fb)
of fkJson: buf.writeString(val.jsonVal)
proc deserializeValue*(buf: openArray[byte], pos: var int): WireValue =
let kind = FieldKind(buf[pos])
inc pos
case kind
of fkNull: result = WireValue(kind: fkNull)
of fkBool:
result = WireValue(kind: fkBool, boolVal: buf[pos] != 0)
inc pos
of fkInt8:
result = WireValue(kind: fkInt8, int8Val: cast[int8](buf[pos]))
inc pos
of fkInt16:
var bytes16: array[2, byte]
for i in 0..1: bytes16[i] = buf[pos + i]
var v16: int16
bigEndian16(addr v16, unsafeAddr bytes16)
result = WireValue(kind: fkInt16, int16Val: v16)
pos += 2
of fkInt32:
result = WireValue(kind: fkInt32, int32Val: int32(readUint32(buf, pos)))
of fkInt64:
result = WireValue(kind: fkInt64, int64Val: int64(readUint64(buf, pos)))
of fkFloat32:
var v32: float32
copyMem(addr v32, addr buf[pos], 4)
result = WireValue(kind: fkFloat32, float32Val: v32)
pos += 4
of fkFloat64:
var v: float64
copyMem(addr v, addr buf[pos], 8)
result = WireValue(kind: fkFloat64, float64Val: v)
pos += 8
of fkString:
result = WireValue(kind: fkString, strVal: readString(buf, pos))
of fkBytes:
let blen = int(readUint32(buf, pos))
var bval: seq[byte] = @[]
for i in 0..<blen:
bval.add(buf[pos + i])
result = WireValue(kind: fkBytes, bytesVal: bval)
pos += blen
of fkArray:
let count = int(readUint32(buf, pos))
var arr: seq[WireValue] = @[]
for i in 0..<count:
arr.add(deserializeValue(buf, pos))
result = WireValue(kind: fkArray, arrayVal: arr)
of fkObject:
let count = int(readUint32(buf, pos))
var obj: seq[(string, WireValue)] = @[]
for i in 0..<count:
let name = readString(buf, pos)
let val = deserializeValue(buf, pos)
obj.add((name, val))
result = WireValue(kind: fkObject, objVal: obj)
of fkVector:
let dim = int(readUint32(buf, pos))
var vec: seq[float32] = @[]
for i in 0..<dim:
var fv: float32
copyMem(addr fv, addr buf[pos], 4)
vec.add(fv)
pos += 4
result = WireValue(kind: fkVector, vecVal: vec)
of fkJson:
result = WireValue(kind: fkJson, jsonVal: readString(buf, pos))
proc buildMessage*(kind: MsgKind, requestId: uint32, payload: seq[byte]): seq[byte] =
result = @[]
result.writeUint32(uint32(kind))
result.writeUint32(uint32(payload.len))
result.writeUint32(requestId)
result.add(payload)
proc makeQueryMessage*(requestId: uint32, query: string): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(query)
payload.add(byte(rfBinary))
buildMessage(mkQuery, requestId, payload)
proc makeQueryParamsMessage*(requestId: uint32, query: string, params: seq[WireValue]): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(query)
payload.add(byte(rfBinary))
payload.writeUint32(uint32(params.len))
for p in params:
payload.serializeValue(p)
buildMessage(mkQueryParams, requestId, payload)
proc makeAuthMessage*(requestId: uint32, token: string): seq[byte] =
var payload: seq[byte] = @[]
payload.writeString(token)
buildMessage(mkAuth, requestId, payload)
# === Client Library ===
type
ClientConfig* = object
host*: string
port*: int
database*: string
username*: string
password*: string
timeoutMs*: int
maxRetries*: int
QueryResult* = object
columns*: seq[string]
columnTypes*: seq[string]
rows*: seq[seq[string]]
rowCount*: int
affectedRows*: int
executionTimeMs*: float64
BaraClient* = ref object
config: ClientConfig
socket: AsyncSocket
connected: bool
requestId: uint32
proc defaultConfig*(): ClientConfig =
ClientConfig(
host: "127.0.0.1", port: 9472, database: "default",
username: "admin", password: "", timeoutMs: 30000, maxRetries: 3,
)
proc newClient*(config: ClientConfig = defaultConfig()): BaraClient =
BaraClient(config: config, socket: newAsyncSocket(), connected: false, requestId: 0)
proc connect*(client: BaraClient) {.async.} =
await client.socket.connect(client.config.host, Port(client.config.port))
client.connected = true
proc nextId(client: BaraClient): uint32 =
inc client.requestId; client.requestId
proc close*(client: BaraClient) =
if client.connected:
try:
let msg = buildMessage(mkClose, client.nextId(), @[])
waitFor client.socket.send(toString(msg))
except: discard
client.socket.close()
client.connected = false
proc isConnected*(client: BaraClient): bool = client.connected
proc wireValueToString*(wv: WireValue): string =
case wv.kind
of fkNull: return ""
of fkBool: return if wv.boolVal: "true" else: "false"
of fkInt8: return $wv.int8Val
of fkInt16: return $wv.int16Val
of fkInt32: return $wv.int32Val
of fkInt64: return $wv.int64Val
of fkFloat32: return $wv.float32Val
of fkFloat64: return $wv.float64Val
of fkString: return wv.strVal
of fkBytes: return "<bytes:" & $wv.bytesVal.len & ">"
of fkArray: return "<array:" & $wv.arrayVal.len & ">"
of fkObject: return "<object:" & $wv.objVal.len & ">"
of fkVector: return "<vector:" & $wv.vecVal.len & ">"
of fkJson: return wv.jsonVal
proc readQueryResponse(client: BaraClient): Future[QueryResult] {.async.} =
let headerData = await client.socket.recv(12)
if headerData.len < 12:
raise newException(IOError, "Connection closed")
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
let payloadLen = int(readUint32(hdrData, pos))
discard readUint32(hdrData, pos)
let payloadStr = await client.socket.recv(payloadLen)
var payload = toBytes(payloadStr)
result = QueryResult(columns: @[], rows: @[], rowCount: 0, affectedRows: 0)
if kind == mkReady:
return
if kind == mkError and payload.len >= 8:
var epos = 0
let code = readUint32(payload, epos)
let emsg = readString(payload, epos)
raise newException(IOError, "Error " & $code & ": " & emsg)
if kind == mkData:
var dpos = 0
let colCount = int(readUint32(payload, dpos))
var cols: seq[string] = @[]
for i in 0..<colCount:
cols.add(readString(payload, dpos))
result.columns = cols
var colTypes: seq[string] = @[]
for i in 0..<colCount:
colTypes.add($FieldKind(payload[dpos]))
inc dpos
result.columnTypes = colTypes
let rowCount = int(readUint32(payload, dpos))
for r in 0..<rowCount:
var row: seq[string] = @[]
for c in 0..<colCount:
let wv = deserializeValue(payload, dpos)
row.add(wireValueToString(wv))
result.rows.add(row)
result.rowCount = rowCount
# Read following mkComplete message
let compHeader = await client.socket.recv(12)
if compHeader.len >= 12:
var chPos = 0
let chData = toBytes(compHeader)
let compKind = MsgKind(readUint32(chData, chPos))
let compLen = int(readUint32(chData, chPos))
discard readUint32(chData, chPos)
let compPayloadStr = await client.socket.recv(compLen)
if compKind == mkComplete:
var cpPos = 0
result.affectedRows = int(readUint32(toBytes(compPayloadStr), cpPos))
return
if kind == mkComplete:
var rpos = 0
result.affectedRows = int(readUint32(payload, rpos))
return
proc query*(client: BaraClient, sql: string): Future[QueryResult] {.async.} =
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeQueryMessage(client.nextId(), sql)
let msgStr = toString(msg)
await client.socket.send(msgStr)
return await client.readQueryResponse()
proc query*(client: BaraClient, sql: string, params: seq[WireValue]): Future[QueryResult] {.async.} =
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeQueryParamsMessage(client.nextId(), sql, params)
let msgStr = toString(msg)
await client.socket.send(msgStr)
return await client.readQueryResponse()
proc exec*(client: BaraClient, sql: string): Future[int] {.async.} =
let qr = await client.query(sql)
return qr.affectedRows
proc auth*(client: BaraClient, token: string) {.async.} =
if not client.connected:
raise newException(IOError, "Not connected")
let msg = makeAuthMessage(client.nextId(), token)
let msgStr = toString(msg)
await client.socket.send(msgStr)
let headerData = await client.socket.recv(12)
if headerData.len < 12:
raise newException(IOError, "Connection closed")
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
let payloadLen = int(readUint32(hdrData, pos))
discard readUint32(hdrData, pos)
if kind == mkAuthOk:
return
elif kind == mkError:
let payloadStr = await client.socket.recv(payloadLen)
var epos = 0
let emsg = readString(toBytes(payloadStr), epos)
raise newException(IOError, "Auth failed: " & emsg)
else:
raise newException(IOError, "Unexpected auth response: 0x" & toHex(uint32(kind), 2))
proc ping*(client: BaraClient): Future[bool] {.async.} =
if not client.connected:
return false
let msg = buildMessage(mkPing, client.nextId(), @[])
let msgStr = toString(msg)
await client.socket.send(msgStr)
let headerData = await client.socket.recv(12)
if headerData.len < 12:
return false
var pos = 0
let hdrData = toBytes(headerData)
let kind = MsgKind(readUint32(hdrData, pos))
return kind == mkPong
# === Fluent Query Builder ===
type
QueryBuilder* = ref object
client: BaraClient
selectCols: seq[string]
fromTable: string
whereClauses: seq[string]
joinClauses: seq[string]
groupByCols: seq[string]
havingClause: string
orderCols: seq[string]
orderDirs: seq[string]
limitVal: int
offsetVal: int
proc newQueryBuilder*(client: BaraClient): QueryBuilder =
QueryBuilder(client: client, limitVal: 0, offsetVal: 0)
proc select*(qb: QueryBuilder, cols: varargs[string]): QueryBuilder =
for c in cols: qb.selectCols.add(c)
return qb
proc `from`*(qb: QueryBuilder, table: string): QueryBuilder =
qb.fromTable = table
return qb
proc where*(qb: QueryBuilder, clause: string): QueryBuilder =
qb.whereClauses.add(clause)
return qb
proc join*(qb: QueryBuilder, table: string, on: string): QueryBuilder =
qb.joinClauses.add("JOIN " & table & " ON " & on)
return qb
proc leftJoin*(qb: QueryBuilder, table: string, on: string): QueryBuilder =
qb.joinClauses.add("LEFT JOIN " & table & " ON " & on)
return qb
proc groupBy*(qb: QueryBuilder, cols: varargs[string]): QueryBuilder =
for c in cols: qb.groupByCols.add(c)
return qb
proc having*(qb: QueryBuilder, clause: string): QueryBuilder =
qb.havingClause = clause
return qb
proc orderBy*(qb: QueryBuilder, col: string, dir: string = "ASC"): QueryBuilder =
qb.orderCols.add(col)
qb.orderDirs.add(dir)
return qb
proc limit*(qb: QueryBuilder, n: int): QueryBuilder =
qb.limitVal = n
return qb
proc offset*(qb: QueryBuilder, n: int): QueryBuilder =
qb.offsetVal = n
return qb
proc build*(qb: QueryBuilder): string =
result = "SELECT " & (if qb.selectCols.len > 0: qb.selectCols.join(", ") else: "*")
result &= " FROM " & qb.fromTable
for j in qb.joinClauses: result &= " " & j
if qb.whereClauses.len > 0: result &= " WHERE " & qb.whereClauses.join(" AND ")
if qb.groupByCols.len > 0: result &= " GROUP BY " & qb.groupByCols.join(", ")
if qb.havingClause.len > 0: result &= " HAVING " & qb.havingClause
if qb.orderCols.len > 0:
result &= " ORDER BY "
for i, col in qb.orderCols:
if i > 0: result &= ", "
result &= col & " " & qb.orderDirs[i]
if qb.limitVal > 0: result &= " LIMIT " & $qb.limitVal
if qb.offsetVal > 0: result &= " OFFSET " & $qb.offsetVal
proc exec*(qb: QueryBuilder): Future[QueryResult] {.async.} =
return await qb.client.query(qb.build())
# === Sync Wrapper ===
type
SyncClient* = ref object
asyncClient: BaraClient
proc newSyncClient*(config: ClientConfig = defaultConfig()): SyncClient =
SyncClient(asyncClient: newClient(config))
proc connect*(client: SyncClient) =
waitFor client.asyncClient.connect()
proc close*(client: SyncClient) =
client.asyncClient.close()
proc query*(client: SyncClient, sql: string): QueryResult =
waitFor client.asyncClient.query(sql)
proc query*(client: SyncClient, sql: string, params: seq[WireValue]): QueryResult =
waitFor client.asyncClient.query(sql, params)
proc auth*(client: SyncClient, token: string) =
waitFor client.asyncClient.auth(token)
proc ping*(client: SyncClient): bool =
waitFor client.asyncClient.ping()
proc `$`*(qr: QueryResult): string =
if qr.columns.len == 0: return "(no results)"
result = ""
for i, col in qr.columns:
result &= col
if i < qr.columns.len - 1: result &= ", "
result &= "\n"
for row in qr.rows:
result &= row.join(", ") & "\n"
result &= "(" & $qr.rowCount & " rows)"
+149
View File
@@ -0,0 +1,149 @@
## BaraDB adapter mimicking db_sqlite API for NimForum
import std/strutils, std/tables, std/sequtils, std/parseutils
import baradb_client
type
DbError* = object of CatchableError
SqlQuery* = distinct string
DbConn* = distinct pointer
proc sql*(query: string): SqlQuery =
SqlQuery(query)
proc dbError*(msg: string) {.noreturn.} =
var e: ref DbError
new(e)
e.msg = msg
raise e
proc dbQuote*(s: string): string =
result = "'"
for c in items(s):
if c == '\'': add(result, "''")
else: add(result, c)
add(result, '\'')
proc dbFormat*(formatstr: SqlQuery, args: varargs[string]): string =
var res = ""
var a = 0
for c in items(string(formatstr)):
if c == '?':
if a == args.len:
dbError("""The number of \"?\" given exceeds the number of parameters present in the query.""")
add(res, dbQuote(args[a]))
inc(a)
else:
add(res, c)
res
proc toClient(db: DbConn): SyncClient =
cast[SyncClient](db)
proc open*(connection, user, password, database: string): DbConn =
var config = defaultConfig()
if connection.contains(':'):
let parts = connection.split(':')
config.host = parts[0]
config.port = parseInt(parts[1])
elif connection.len > 0 and not connection.endsWith(".db"):
config.host = connection
config.database = database
config.username = user
config.password = password
var client = newSyncClient(config)
client.connect()
GC_ref(client)
return cast[DbConn](client)
proc close*(db: DbConn) =
let client = toClient(db)
baradb_client.close(client)
GC_unref(client)
proc exec*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]) =
let client = toClient(db)
let q = dbFormat(query, args)
discard client.query(q)
proc tryExec*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): bool =
try:
let client = toClient(db)
let q = dbFormat(query, args)
discard client.query(q)
return true
except:
return false
proc getRow*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[string] =
let client = toClient(db)
let q = dbFormat(query, args)
let qr = client.query(q)
if qr.rows.len > 0:
return qr.rows[0]
else:
return newSeq[string](qr.columns.len)
proc getAllRows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[seq[string]] =
let client = toClient(db)
let q = dbFormat(query, args)
let qr = client.query(q)
return qr.rows
proc getValue*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): string =
let row = getRow(db, query, args)
if row.len > 0: return row[0]
return ""
iterator fastRows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[string] =
let client = toClient(db)
let q = dbFormat(query, args)
let qr = client.query(q)
for row in qr.rows:
yield row
iterator rows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[string] =
for r in fastRows(db, query, args): yield r
proc insertID*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): int64 =
let client = toClient(db)
let q = dbFormat(query, args)
discard client.query(q)
let sqlStr = string(query).strip().toLower()
var tableName = ""
if sqlStr.startsWith("insert into "):
let rest = sqlStr[12..^1]
let spacePos = rest.find(' ')
if spacePos > 0:
tableName = rest[0..<spacePos]
if tableName.len > 0:
let idQr = client.query("SELECT max(id) FROM " & tableName)
if idQr.rows.len > 0 and idQr.rows[0].len > 0:
try:
return parseInt(idQr.rows[0][0]).int64
except:
discard
return -1
proc tryInsertID*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): int64 =
try:
return insertID(db, query, args)
except:
return -1
proc nextId*(db: DbConn, tableName: string): int64 =
let client = toClient(db)
let qr = client.query("SELECT max(id) FROM " & tableName)
if qr.rows.len > 0 and qr.rows[0].len > 0:
try:
let current = parseInt(qr.rows[0][0])
return current.int64 + 1
except:
return 1
return 1
proc execAffectedRows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): int64 =
let client = toClient(db)
let q = dbFormat(query, args)
let qr = client.query(q)
return qr.affectedRows.int64
+35
View File
@@ -0,0 +1,35 @@
import os
import sass
import utils
proc buildCSS*(config: Config) =
let publicLoc = "public"
var includePaths: seq[string] = @[]
# Check for a styles override.
var hostname = config.hostname
if not existsDir(hostname):
hostname = "localhost.local"
let dir = getCurrentDir() / hostname / "public"
includePaths.add(dir / "css")
createDir(publicLoc / "images")
let logo = publicLoc / "images" / "logo.png"
removeFile(logo)
createSymlink(
dir / "images" / "logo.png",
logo
)
let cssLoc = publicLoc / "css"
sass.compileFile(
cssLoc / "nimforum.scss",
cssLoc / "nimforum.css",
includePaths=includePaths
)
when isMainModule:
let config = loadConfig()
buildCSS(config)
echo("CSS Built successfully")
+151
View File
@@ -0,0 +1,151 @@
import asyncdispatch, smtp, strutils, times, cgi, tables, logging
from jester import Request, makeUri
import utils, auth
type
Mailer* = ref object
config: Config
lastReset: Time
emailsSent: CountTable[string]
proc newMailer*(config: Config): Mailer =
Mailer(
config: config,
lastReset: getTime(),
emailsSent: initCountTable[string]()
)
proc rateCheck(mailer: Mailer, address: string): bool =
## Returns true if we've emailed the address too much.
let diff = getTime() - mailer.lastReset
if diff.inHours >= 1:
mailer.lastReset = getTime()
mailer.emailsSent.clear()
result = address in mailer.emailsSent and mailer.emailsSent[address] >= 2
mailer.emailsSent.inc(address)
proc sendMail(
mailer: Mailer,
subject, message, recipient: string,
otherHeaders:seq[(string, string)] = @[]
) {.async.} =
# Ensure we aren't emailing this address too much.
if rateCheck(mailer, recipient):
let msg = "Too many messages have been sent to this email address recently."
raise newForumError(msg)
if mailer.config.smtpAddress.len == 0:
warn("Cannot send mail: no smtp server configured (smtpAddress).")
return
if mailer.config.smtpFromAddr.len == 0:
warn("Cannot send mail: no smtp from address configured (smtpFromAddr).")
return
var client: AsyncSmtp
if mailer.config.smtpTls:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
await client.startTls()
elif mailer.config.smtpSsl:
client = newAsyncSmtp(useSsl=true)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
else:
client = newAsyncSmtp(useSsl=false)
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
if mailer.config.smtpUser.len > 0:
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
let toList = @[recipient]
var headers = otherHeaders
headers.add(("From", mailer.config.smtpFromAddr))
let dateHeader = now().utc().format("ddd, dd MMM yyyy hh:mm:ss") & " +0000"
headers.add(("Date", dateHeader))
let encoded = createMessage(subject, message,
toList, @[], headers)
await client.sendMail(mailer.config.smtpFromAddr, toList, $encoded)
proc sendPassReset(mailer: Mailer, email, user, resetUrl: string) {.async.} =
let message = """Hello $1,
A password reset has been requested for your account on the $3.
If you did not make this request, you can safely ignore this email.
A password reset request can be made by anyone, and it does not indicate
that your account is in any danger of being accessed by someone else.
If you do actually want to reset your password, visit this link:
$2
Thank you for being a part of our community!
""" % [user, resetUrl, mailer.config.name]
let subject = mailer.config.name & " Password Recovery"
await sendMail(mailer, subject, message, email)
proc sendEmailActivation(
mailer: Mailer,
email, user, activateUrl: string
) {.async.} =
let message = """Hello $1,
You have recently registered an account on the $3.
As the final step in your registration, we require that you confirm your email
via the following link:
$2
Thank you for registering and becoming a part of our community!
""" % [user, activateUrl, mailer.config.name]
let subject = mailer.config.name & " Account Email Confirmation"
await sendMail(mailer, subject, message, email)
type
SecureEmailKind* = enum
ActivateEmail, ResetPassword
proc sendSecureEmail*(
mailer: Mailer,
kind: SecureEmailKind, req: Request,
name, password, email, salt: string
) {.async.} =
let epoch = int(epochTime())
let path =
case kind
of ActivateEmail:
"activateEmail"
of ResetPassword:
"resetPassword"
let url = req.makeUri(
"/$#?nick=$#&epoch=$#&ident=$#" %
[
path,
encodeUrl(name),
encodeUrl($epoch),
encodeUrl(makeIdentHash(name, password, epoch, salt))
]
)
debug(url)
let emailSentFut =
case kind
of ActivateEmail:
sendEmailActivation(mailer, email, name, url)
of ResetPassword:
sendPassReset(mailer, email, name, url)
yield emailSentFut
if emailSentFut.failed:
warn("Couldn't send email: ", emailSentFut.error.msg)
if emailSentFut.error of ForumError:
raise emailSentFut.error
else:
raise newForumError("Couldn't send email", @["email"])
+1727
View File
File diff suppressed because it is too large Load Diff
+1727
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
when defined(js):
import sugar, httpcore
import dom except Event
include karax/prelude
import karax / [kajax]
import error
import karaxutils
type
About* = ref object
loading: bool
status: HttpCode
content: kstring
page: string
proc newAbout*(): About =
About(
status: Http200
)
proc onContent(status: int, response: kstring, state: About) =
state.status = status.HttpCode
state.content = response
proc render*(state: About, page: string): VNode =
if state.status != Http200:
return renderError($state.content, state.status)
if page != state.page:
if not state.loading:
state.page = page
state.loading = true
state.status = Http200
let uri = makeUri("/about/" & page & ".html")
ajaxGet(uri, @[], (s: int, r: kstring) => onContent(s, r, state))
return buildHtml(tdiv(class="loading"))
result = buildHtml():
section(class="container grid-xl"):
tdiv(class="about"):
verbatim(state.content)
+53
View File
@@ -0,0 +1,53 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
include karax/prelude
import karax / [kajax, kdom]
import error
import karaxutils
type
ActivateEmail* = ref object
loading: bool
status: HttpCode
error: Option[PostError]
proc newActivateEmail*(): ActivateEmail =
ActivateEmail(
status: Http200
)
proc onPost(httpStatus: int, response: kstring, state: ActivateEmail) =
postFinished:
navigateTo(makeUri("/activateEmail/success"))
proc onSetClick(
ev: Event, n: VNode,
state: ActivateEmail
) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("activateEmail", search = $kdom.window.location.search)
ajaxPost(uri, @[], "",
(s: int, r: kstring) => onPost(s, r, state))
proc render*(state: ActivateEmail): VNode =
result = buildHtml():
section(class="container grid-xl"):
tdiv(id="activateemail"):
tdiv(class="title"):
p(): text "Activate Email"
tdiv(class="content"):
button(class=class(
{"loading": state.loading},
"btn btn-primary"
),
onClick=(ev: Event, n: VNode) =>
(onSetClick(ev, n, state))):
text "Activate"
if state.error.isSome():
p(class="text-error"):
text state.error.get().message
+87
View File
@@ -0,0 +1,87 @@
when defined(js):
import sugar, httpcore, options, json, strutils
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom, vdom]
import error, category
import category, karaxutils
type
AddCategoryModal* = ref object of VComponent
modalShown: bool
loading: bool
error: Option[PostError]
onAddCategory: CategoryEvent
let nullCategory: CategoryEvent = proc (category: Category) = discard
proc newAddCategoryModal*(onAddCategory=nullCategory): AddCategoryModal =
result = AddCategoryModal(
modalShown: false,
loading: false,
onAddCategory: onAddCategory
)
proc onAddCategoryPost(httpStatus: int, response: kstring, state: AddCategoryModal) =
postFinished:
state.modalShown = false
let j = parseJson($response)
let category = j.to(Category)
state.onAddCategory(category)
proc onAddCategoryClick(state: AddCategoryModal) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("createCategory")
let form = dom.document.getElementById("add-category-form")
let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onAddCategoryPost(s, r, state))
proc setModalShown*(state: AddCategoryModal, visible: bool) =
state.modalShown = visible
state.markDirty()
proc onModalClose(state: AddCategoryModal, ev: Event, n: VNode) =
state.setModalShown(false)
ev.preventDefault()
proc render*(state: AddCategoryModal): VNode =
result = buildHtml():
tdiv(class=class({"active": state.modalShown}, "modal modal-sm")):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onModalClose(state, ev, n))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
tdiv(class="card-title h5"):
text "Add New Category"
tdiv(class="modal-body"):
form(id="add-category-form"):
genFormField(
state.error, "name", "Name", "text", false,
placeholder="Category Name")
genFormField(
state.error, "color", "Color", "color", false,
placeholder="#XXYYZZ"
)
genFormField(
state.error,
"description",
"Description",
"text",
true,
placeholder="Description"
)
tdiv(class="modal-footer"):
button(
id="add-category-btn",
class="btn btn-primary",
onClick=(ev: Event, n: VNode) =>
state.onAddCategoryClick()):
text "Add"
+48
View File
@@ -0,0 +1,48 @@
type
Category* = object
id*: int
name*: string
description*: string
color*: string
numTopics*: int
CategoryList* = ref object
categories*: seq[Category]
CategoryEvent* = proc (category: Category) {.closure.}
CategoryChangeEvent* = proc (oldCategory: Category, newCategory: Category) {.closure.}
const categoryDescriptionCharLimit = 250
proc cmpNames*(cat1: Category, cat2: Category): int =
cat1.name.cmp(cat2.name)
when defined(js):
include karax/prelude
import karax / [vstyles]
import karaxutils
proc render*(category: Category, compact=true): VNode =
if category.name.len == 0:
return buildHtml():
span()
result = buildhtml(tdiv):
tdiv(class="category-status"):
tdiv(class="category",
title=category.description,
"data-color"="#" & category.color):
tdiv(class="category-color",
style=style(
(StyleAttr.border,
kstring"0.25rem solid #" & category.color)
))
span(class="category-name"):
text category.name
if not compact:
span(class="topic-count"):
text "× " & $category.numTopics
if not compact:
tdiv(class="category-description"):
text category.description.limit(categoryDescriptionCharLimit)
+105
View File
@@ -0,0 +1,105 @@
import options, json, httpcore
import category
when defined(js):
import sugar
include karax/prelude
import karax / [vstyles, kajax]
import karaxutils, error, user, mainbuttons, addcategorymodal
type
State = ref object
list: Option[CategoryList]
loading: bool
mainButtons: MainButtons
status: HttpCode
addCategoryModal: AddCategoryModal
var state: State
proc newState(): State =
State(
list: none[CategoryList](),
loading: false,
mainButtons: newMainButtons(),
status: Http200,
addCategoryModal: newAddCategoryModal(
onAddCategory=
(category: Category) => state.list.get().categories.add(category)
)
)
state = newState()
proc genCategory(category: Category, noBorder = false): VNode =
result = buildHtml():
tr(class=class({"no-border": noBorder})):
td(style=style((StyleAttr.borderLeftColor, kstring("#" & category.color))), class="category"):
h4(class="category-title", id="category-" & category.name.slug):
a(href=makeUri("/c/" & $category.id)):
tdiv():
tdiv(class="category-name"):
text category.name
tdiv(class="category-description"):
text category.description
td(class="topics"):
text $category.numTopics
proc onCategoriesRetrieved(httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = to(parsed, CategoryList)
if state.list.isSome:
state.list.get().categories.add(list.categories)
else:
state.list = some(list)
proc renderCategoryHeader*(currentUser: Option[User]): VNode =
result = buildHtml(tdiv(id="add-category")):
text "Category"
if currentUser.isAdmin():
button(class="plus-btn btn btn-link",
onClick=(ev: Event, n: VNode) => (
state.addCategoryModal.setModalShown(true)
)):
italic(class="fas fa-plus")
render(state.addCategoryModal)
proc renderCategories(currentUser: Option[User]): VNode =
if state.status != Http200:
return renderError("Couldn't retrieve threads.", state.status)
if state.list.isNone:
if not state.loading:
state.loading = true
ajaxGet(makeUri("categories.json"), @[], onCategoriesRetrieved)
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get()
return buildHtml():
section(class="category-list"):
table(id="categories-list", class="table"):
thead():
tr:
th:
renderCategoryHeader(currentUser)
th(text "Topics")
tbody():
for i in 0 ..< list.categories.len:
let category = list.categories[i]
let isLastCategory = i+1 == list.categories.len
genCategory(category, noBorder=isLastCategory)
proc renderCategoryList*(currentUser: Option[User]): VNode =
result = buildHtml(tdiv):
state.mainButtons.render(currentUser)
renderCategories(currentUser)
+135
View File
@@ -0,0 +1,135 @@
when defined(js):
import sugar, httpcore, options, json, strutils, algorithm
import dom except Event
include karax/prelude
import karax / [kajax, kdom, vdom]
import error, category, user
import category, karaxutils, addcategorymodal
type
CategoryPicker* = ref object of VComponent
list: Option[CategoryList]
selectedCategoryID*: int
loading: bool
addEnabled: bool
status: HttpCode
error: Option[PostError]
addCategoryModal: AddCategoryModal
onCategoryChange: CategoryChangeEvent
onAddCategory: CategoryEvent
proc onCategoryLoad(state: CategoryPicker): proc (httpStatus: int, response: kstring) =
return
proc (httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = parsed.to(CategoryList)
list.categories.sort(cmpNames)
if state.list.isSome:
state.list.get().categories.add(list.categories)
else:
state.list = some(list)
if state.selectedCategoryID > state.list.get().categories.len():
state.selectedCategoryID = 0
proc loadCategories(state: CategoryPicker) =
if not state.loading:
state.loading = true
ajaxGet(makeUri("categories.json"), @[], onCategoryLoad(state))
proc `[]`*(state: CategoryPicker, id: int): Category =
for cat in state.list.get().categories:
if cat.id == id:
return cat
raise newException(IndexError, "Category at " & $id & " not found!")
let nullAddCategory: CategoryEvent = proc (category: Category) = discard
let nullCategoryChange: CategoryChangeEvent = proc (oldCategory: Category, newCategory: Category) = discard
proc select*(state: CategoryPicker, id: int) =
state.selectedCategoryID = id
state.markDirty()
proc onCategory(state: CategoryPicker): CategoryEvent =
result =
proc (category: Category) =
state.list.get().categories.add(category)
state.list.get().categories.sort(cmpNames)
state.select(category.id)
state.onAddCategory(category)
proc newCategoryPicker*(onCategoryChange=nullCategoryChange, onAddCategory=nullAddCategory): CategoryPicker =
result = CategoryPicker(
list: none[CategoryList](),
selectedCategoryID: 0,
loading: false,
addEnabled: false,
status: Http200,
error: none[PostError](),
onCategoryChange: onCategoryChange,
onAddCategory: onAddCategory
)
let state = result
result.addCategoryModal = newAddCategoryModal(
onAddCategory=onCategory(state)
)
proc setAddEnabled*(state: CategoryPicker, enabled: bool) =
state.addEnabled = enabled
proc onCategoryClick(state: CategoryPicker, category: Category): proc (ev: Event, n: VNode) =
# this is necessary to capture the right value
let cat = category
return
proc (ev: Event, n: VNode) =
let oldCategory = state[state.selectedCategoryID]
state.select(cat.id)
state.onCategoryChange(oldCategory, cat)
proc genAddCategory(state: CategoryPicker): VNode =
result = buildHtml():
tdiv(id="add-category"):
button(class="plus-btn btn btn-link",
onClick=(ev: Event, n: VNode) => (
state.addCategoryModal.setModalShown(true)
)):
italic(class="fas fa-plus")
render(state.addCategoryModal)
proc render*(state: CategoryPicker, currentUser: Option[User], compact=true): VNode =
state.setAddEnabled(currentUser.isAdmin())
if state.status != Http200:
return renderError("Couldn't retrieve categories.", state.status)
if state.list.isNone:
state.loadCategories()
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get().categories
let selectedCategory = state[state.selectedCategoryID]
result = buildHtml():
tdiv(id="category-selection", class="input-group"):
tdiv(class="dropdown"):
a(class="btn btn-link dropdown-toggle", tabindex="0"):
tdiv(class="selected-category d-inline-block"):
render(selectedCategory)
text " "
italic(class="fas fa-caret-down")
ul(class="menu"):
for category in list:
li(class="menu-item"):
a(class="category-" & $category.id & " " & category.name.slug,
onClick=onCategoryClick(state, category)):
render(category, compact)
if state.addEnabled:
genAddCategory(state)
+133
View File
@@ -0,0 +1,133 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error, post, threadlist, user
import karaxutils
type
DeleteKind* = enum
DeleteUser, DeletePost, DeleteThread
DeleteModal* = ref object
shown: bool
loading: bool
onDeletePost: proc (post: Post)
onDeleteThread: proc (thread: Thread)
onDeleteUser: proc (user: User)
error: Option[PostError]
case kind: DeleteKind
of DeleteUser:
user: User
of DeletePost:
post: Post
of DeleteThread:
thread: Thread
proc onDeletePost(httpStatus: int, response: kstring, state: DeleteModal) =
postFinished:
state.shown = false
case state.kind
of DeleteUser:
state.onDeleteUser(state.user)
of DeletePost:
state.onDeletePost(state.post)
of DeleteThread:
state.onDeleteThread(state.thread)
proc onDelete(ev: Event, n: VNode, state: DeleteModal) =
state.loading = true
state.error = none[PostError]()
let uri =
case state.kind
of DeleteUser:
makeUri("/deleteUser")
of DeleteThread:
makeUri("/deleteThread")
of DeletePost:
makeUri("/deletePost")
# TODO: This is a hack, karax should support this.
let formData = newFormData()
case state.kind
of DeleteUser:
formData.append("username", state.user.name)
of DeletePost:
formData.append("id", $state.post.id)
of DeleteThread:
formData.append("id", $state.thread.id)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onDeletePost(s, r, state))
proc onClose(ev: Event, n: VNode, state: DeleteModal) =
state.shown = false
ev.preventDefault()
proc newDeleteModal*(
onDeletePost: proc (post: Post),
onDeleteThread: proc (thread: Thread),
onDeleteUser: proc (user: User),
): DeleteModal =
DeleteModal(
shown: false,
onDeletePost: onDeletePost,
onDeleteThread: onDeleteThread,
onDeleteUser: onDeleteUser,
)
proc show*(state: DeleteModal, thing: User | Post | Thread) =
state.shown = true
state.error = none[PostError]()
when thing is User:
state.kind = DeleteUser
state.user = thing
when thing is Post:
state.kind = DeletePost
state.post = thing
when thing is Thread:
state.kind = DeleteThread
state.thread = thing
proc render*(state: DeleteModal): VNode =
result = buildHtml():
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
id="delete-modal"):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
a(href="", class="btn btn-clear float-right",
"aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-title h5"):
text "Delete"
tdiv(class="modal-body"):
tdiv(class="content"):
p():
text "Are you sure you want to delete this "
case state.kind
of DeleteUser:
text "user account?"
of DeleteThread:
text "thread?"
of DeletePost:
text "post?"
tdiv(class="modal-footer"):
if state.error.isSome():
p(class="text-error"):
text state.error.get().message
button(class=class(
{"loading": state.loading},
"btn btn-primary delete-btn"
),
onClick=(ev: Event, n: VNode) => onDelete(ev, n, state)):
italic(class="fas fa-trash-alt")
text " Delete"
button(class="btn cancel-btn",
onClick=(ev: Event, n: VNode) => (state.shown = false)):
text "Cancel"
+99
View File
@@ -0,0 +1,99 @@
when defined(js):
import httpcore, options, sugar, json
import jsffi except `&`
include karax/prelude
import karax/kajax
import replybox, post, karaxutils, threadlist, error
type
OnEditPosted* = proc (id: int, content: string, subject: Option[string])
EditBox* = ref object
box: ReplyBox
post: Post
rawContent: Option[kstring] ## The raw rst for a post (needs to be loaded)
loading: bool
status: HttpCode
error: Option[PostError]
onEditPosted: OnEditPosted
onEditCancel: proc ()
proc newEditBox*(onEditPosted: OnEditPosted, onEditCancel: proc ()): EditBox =
EditBox(
box: newReplyBox(nil),
onEditPosted: onEditPosted,
onEditCancel: onEditCancel,
status: Http200
)
proc onRawContent(httpStatus: int, response: kstring, state: EditBox) =
state.status = httpStatus.HttpCode
if state.status != Http200: return
state.rawContent = some(response)
state.box.setText(state.rawContent.get())
proc onEditPost(httpStatus: int, response: kstring, state: EditBox) =
postFinished:
state.onEditPosted(
state.post.id,
$response,
none[string]()
)
proc save(state: EditBox) =
if state.loading:
# TODO: Weird behaviour: onClick handler gets called 80+ times.
return
state.loading = true
state.error = none[PostError]()
let formData = newFormData()
formData.append("msg", state.box.getText())
formData.append("postId", $state.post.id)
# TODO: Subject
let uri = makeUri("/updatePost")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onEditPost(s, r, state))
proc render*(state: EditBox, post: Post): VNode =
if (not state.post.isNil) and state.post.id != post.id:
state.rawContent = none[kstring]()
state.status = Http200
if state.status != Http200:
return renderError("Couldn't retrieve raw post", state.status)
if state.rawContent.isNone():
state.post = post
state.rawContent = none[kstring]()
var params = @[("id", $post.id)]
let uri = makeUri("post.rst", params)
ajaxGet(uri, @[], (s: int, r: kstring) => onRawContent(s, r, state))
return buildHtml(tdiv(class="loading"))
result = buildHtml():
tdiv(class="edit-box"):
renderContent(
state.box,
none[Thread](),
none[Post]()
)
if state.error.isSome():
span(class="text-error"):
text state.error.get().message
tdiv(class="edit-buttons"):
tdiv(class="cancel-button"):
button(class="btn btn-link",
onClick=(e: Event, n: VNode) => (state.onEditCancel())):
text " Cancel"
tdiv(class="save-button"):
button(class=class({"loading": state.loading}, "btn btn-primary"),
onClick=(e: Event, n: VNode) => state.save()):
italic(class="fas fa-check")
text " Save"
+92
View File
@@ -0,0 +1,92 @@
import httpcore
type
PostError* = object
errorFields*: seq[string] ## IDs of the fields with an error.
message*: string
when defined(js):
import json, options
include karax/prelude
import karaxutils
proc render404*(): VNode =
result = buildHtml():
tdiv(class="empty error"):
tdiv(class="empty icon"):
italic(class="fas fa-bug fa-5x")
p(class="empty-title h5"):
text "404 Not Found"
p(class="empty-subtitle"):
text "Cannot find what you are looking for, it might have been " &
"deleted. Sorry!"
tdiv(class="empty-action"):
a(href="/", onClick=anchorCB):
button(class="btn btn-primary"):
text "Go back home"
proc renderError*(message: string, status: HttpCode): VNode =
if status == Http404:
return render404()
result = buildHtml():
tdiv(class="empty error"):
tdiv(class="empty icon"):
italic(class="fas fa-bug fa-5x")
p(class="empty-title h5"):
text message
p(class="empty-subtitle"):
text "Please report this issue to us so we can fix it!"
tdiv(class="empty-action"):
a(href="https://github.com/nim-lang/nimforum/issues", target="_blank"):
button(class="btn btn-primary"):
text "Report issue"
proc renderMessage*(message, submessage, icon: string): VNode =
result = buildHtml():
tdiv(class="empty error"):
tdiv(class="empty icon"):
italic(class="fas " & icon & " fa-5x")
p(class="empty-title h5"):
text message
p(class="empty-subtitle"):
text submessage
proc genFormField*(error: Option[PostError], name, label, typ: string,
isLast: bool, placeholder=""): VNode =
let hasError =
not error.isNone and (
name in error.get().errorFields or
error.get().errorFields.len == 0)
result = buildHtml():
tdiv(class=class({"has-error": hasError}, "form-group")):
label(class="form-label", `for`=name):
text label
input(class="form-input", `type`=typ, name=name,
placeholder=placeholder)
if not error.isNone:
let e = error.get()
if (e.errorFields.len == 1 and e.errorFields[0] == name) or
(isLast and e.errorFields.len == 0):
span(class="form-input-hint"):
text e.message
template postFinished*(onSuccess: untyped): untyped =
state.loading = false
let status = httpStatus.HttpCode
if status == Http200:
onSuccess
else:
# TODO: Karax should pass the content-type...
try:
let parsed = parseJson($response)
let error = to(parsed, PostError)
state.error = some(error)
except:
echo getCurrentExceptionMsg()
state.error = some(PostError(
errorFields: @[],
message: "Unknown error occurred."
))
+162
View File
@@ -0,0 +1,162 @@
import options, tables, sugar, httpcore
from dom import window, Location, document, decodeURI
include karax/prelude
import karax/[kdom]
import jester/[patterns]
import threadlist, postlist, header, profile, newthread, error, about
import categorylist
import resetpassword, activateemail, search
import karaxutils
type
State = ref object
originalTitle: cstring
url: Location
profile: ProfileState
newThread: NewThread
about: About
resetPassword: ResetPassword
activateEmail: ActivateEmail
search: Search
proc copyLocation(loc: Location): Location =
# TODO: It sucks that I had to do this. We need a nice way to deep copy in JS.
Location(
hash: loc.hash,
host: loc.host,
hostname: loc.hostname,
href: loc.href,
pathname: loc.pathname,
port: loc.port,
protocol: loc.protocol,
search: loc.search
)
proc newState(): State =
State(
originalTitle: document.title,
url: copyLocation(window.location),
profile: newProfileState(),
newThread: newNewThread(),
about: newAbout(),
resetPassword: newResetPassword(),
activateEmail: newActivateEmail(),
search: newSearch()
)
var state = newState()
proc onPopState(event: dom.Event) =
# This event is usually only called when the user moves back in their
# history. I fire it in karaxutils.anchorCB as well to ensure the URL is
# always updated. This should be moved into Karax in the future.
echo "New URL: ", window.location.href, " ", state.url.href
document.title = state.originalTitle
if state.url.href != window.location.href:
state = newState() # Reload the state to remove stale data.
state.url = copyLocation(window.location)
redraw()
type Params = Table[string, string]
type
Route = object
n: string
p: proc (params: Params): VNode
proc r(n: string, p: proc (params: Params): VNode): Route = Route(n: n, p: p)
proc route(routes: openarray[Route]): VNode =
let path =
if state.url.pathname.len == 0: "/" else: $state.url.pathname
let prefix = if appName == "/": "" else: appName
for route in routes:
let pattern = (prefix & route.n).parsePattern()
var (matched, params) = pattern.match(path)
parseUrlQuery($state.url.search, params)
if matched:
return route.p(params)
return renderError("Unmatched route: " & path, Http500)
proc render(): VNode =
result = buildHtml(tdiv()):
renderHeader()
route([
r("/categories",
(params: Params) =>
(renderCategoryList(getLoggedInUser()))
),
r("/c/@id",
(params: Params) =>
(renderThreadList(getLoggedInUser(), some(params["id"].parseInt)))
),
r("/newthread",
(params: Params) =>
(render(state.newThread, getLoggedInUser()))
),
r("/profile/@username",
(params: Params) =>
(
render(
state.profile,
decodeURI(params["username"]),
getLoggedInUser()
)
)
),
r("/t/@id",
(params: Params) =>
(
let postId = getInt(($state.url.hash).substr(1), 0);
renderPostList(
params["id"].parseInt(),
if postId == 0: none[int]() else: some[int](postId),
getLoggedInUser()
)
)
),
r("/about/?@page?",
(params: Params) => (render(state.about, params["page"]))
),
r("/activateEmail/success",
(params: Params) => (
renderMessage(
"Email activated",
"You can now create new posts!",
"fa-check"
)
)
),
r("/activateEmail",
(params: Params) => (
render(state.activateEmail)
)
),
r("/resetPassword/success",
(params: Params) => (
renderMessage(
"Password changed",
"You can now login using your new password!",
"fa-check"
)
)
),
r("/resetPassword",
(params: Params) => (
render(state.resetPassword)
)
),
r("/search",
(params: Params) => (
render(state.search, params["q"], getLoggedInUser())
)
),
r("/404",
(params: Params) => render404()
),
r("/", (params: Params) => renderThreadList(getLoggedInUser()))
])
window.onPopState = onPopState
setRenderer render
+4
View File
@@ -0,0 +1,4 @@
-d:js
@if nimHasJsNoLambdaLifting:
legacy:jsnolambdalifting
@end
+119
View File
@@ -0,0 +1,119 @@
import options, httpcore
import user
type
UserStatus* = object
user*: Option[User]
recaptchaSiteKey*: Option[string]
when defined(js):
import times, json, sugar
include karax/prelude
import karax / [kajax, kdom]
import login, signup, usermenu
import karaxutils
from dom import
setTimeout, window, document, getElementById, focus
type
State = ref object
data: Option[UserStatus]
loading: bool
status: HttpCode
lastUpdate: Time
loginModal: LoginModal
signupModal: SignupModal
userMenu: UserMenu
proc newState(): State
var
state = newState()
proc getStatus(logout=false)
proc newState(): State =
State(
data: none[UserStatus](),
loading: false,
status: Http200,
loginModal: newLoginModal(
() => (state.lastUpdate = fromUnix(0); getStatus()),
() => state.signupModal.show()
),
signupModal: newSignupModal(
() => (state.lastUpdate = fromUnix(0); getStatus()),
() => state.loginModal.show()
),
userMenu: newUserMenu(
() => (state.lastUpdate = fromUnix(0); getStatus(logout=true))
)
)
proc onStatus(httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
state.data = some(to(parsed, UserStatus))
state.lastUpdate = getTime()
proc getStatus(logout=false) =
if state.loading: return
let diff = getTime() - state.lastUpdate
if diff.inMinutes < 5:
return
state.loading = true
let uri = makeUri("status.json", [("logout", $logout)])
ajaxGet(uri, @[], onStatus)
proc getLoggedInUser*(): Option[User] =
state.data.map(x => x.user).flatten
proc isLoggedIn*(): bool =
not getLoggedInUser().isNone
proc onKeyDown(e: Event, n: VNode) =
let event = cast[KeyboardEvent](e)
if event.key == "Enter":
navigateTo(makeUri("/search", ("q", $n.value), reuseSearch=false))
proc renderHeader*(): VNode =
if state.data.isNone and state.status == Http200:
getStatus()
let user = state.data.map(x => x.user).flatten
result = buildHtml(tdiv()):
header(id="main-navbar"):
tdiv(class="navbar container grid-xl"):
section(class="navbar-section"):
a(href=makeUri("/")):
img(src="/images/logo.png", id="img-logo")
section(class="navbar-section"):
tdiv(class="input-group input-inline"):
input(class="search-input input-sm",
`type`="search", placeholder="Search",
id="search-box", required="required",
onKeyDown=onKeyDown)
if state.loading:
tdiv(class="loading")
elif user.isNone:
button(id="signup-btn", class="btn btn-primary btn-sm",
onClick=(e: Event, n: VNode) => state.signupModal.show()):
italic(class="fas fa-user-plus")
text " Sign up"
button(id="login-btn", class="btn btn-primary btn-sm",
onClick=(e: Event, n: VNode) => state.loginModal.show()):
italic(class="fas fa-sign-in-alt")
text " Log in"
else:
render(state.userMenu, user.get())
# Modals
if state.data.isSome():
render(state.loginModal, state.data.get().recaptchaSiteKey)
render(state.signupModal, state.data.get().recaptchaSiteKey)
+128
View File
@@ -0,0 +1,128 @@
import strutils, strformat, parseutils, tables
proc limit*(str: string, n: int): string =
## Limit the number of characters in a string. Ends with a elipsis
if str.len > n:
return str[0..<n-3] & "..."
else:
return str
proc slug*(name: string): string =
## Transforms text into a url slug
name.strip().replace(" ", "-").toLowerAscii
proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} =
## parses `s` into an integer in the range `validRange`. If successful,
## `value` is modified to contain the result. Otherwise no exception is
## raised and `value` is not touched; this way a reasonable default value
## won't be overwritten.
try:
discard parseutils.parseInt(s, value, 0)
except OverflowError:
discard
proc getInt*(s: string, default = 0): int =
## Safely parses an int and returns it.
result = default
parseIntSafe(s, result)
proc getInt64*(s: string, default = 0): int64 =
## Safely parses an int and returns it.
result = default
try:
discard parseutils.parseBiggestInt(s, result, 0)
except OverflowError:
discard
when defined(js):
include karax/prelude
import karax / [kdom, kajax]
from dom import nil
const appName* = "/"
proc class*(classes: varargs[tuple[name: string, present: bool]],
defaultClasses: string = ""): string =
result = defaultClasses & " "
for class in classes:
if class.present: result.add(class.name & " ")
proc makeUri*(relative: string, appName=appName, includeHash=false,
search: string=""): string =
## Concatenates ``relative`` to the current URL in a way that is
## (possibly) sane.
var relative = relative
assert appName in $window.location.pathname
if relative[0] == '/': relative = relative[1..^1]
return $window.location.protocol & "//" &
$window.location.host &
appName &
relative &
search &
(if includeHash: $window.location.hash else: "")
proc makeUri*(relative: string, params: varargs[(string, string)],
appName=appName, includeHash=false,
reuseSearch=true): string =
var query = ""
for i in 0 ..< params.len:
let param = params[i]
if i != 0: query.add("&")
query.add(param[0] & "=" & param[1])
if query.len > 0:
var search = if reuseSearch: $window.location.search else: ""
if search.len != 0: search.add("&")
search.add(query)
if search[0] != '?': search = "?" & search
makeUri(relative, appName, search=search)
else:
makeUri(relative, appName)
proc navigateTo*(uri: cstring) =
# TODO: This was annoying. Karax also shouldn't have its own `window`.
dom.pushState(dom.window.history, 0, cstring"", uri)
# Fire the popState event.
dom.dispatchEvent(dom.window, dom.newEvent("popstate"))
proc anchorCB*(e: Event, n: VNode) =
let mE = e.MouseEvent
if not (mE.metaKey or mE.ctrlKey):
e.preventDefault()
# TODO: Why does Karax have it's own Node type? That's just silly.
let url = n.getAttr("href")
navigateTo(url)
proc newFormData*(form: dom.Element): FormData
{.importcpp: "new FormData(@)", constructor.}
proc get*(form: FormData, key: cstring): cstring
{.importcpp: "#.get(@)".}
proc renderProfileUrl*(username: string): string =
makeUri(fmt"/profile/{username}")
proc renderPostUrl*(threadId, postId: int): string =
makeUri(fmt"/t/{threadId}#{postId}")
proc parseUrlQuery*(query: string, result: var Table[string, string])
{.deprecated: "use stdlib".} =
## Based on copy from Jester. Use stdlib when
## https://github.com/nim-lang/Nim/pull/7761 is merged.
var i = 0
i = query.skip("?")
while i < query.len()-1:
var key = ""
var val = ""
i += query.parseUntil(key, '=', i)
if query[i] != '=':
raise newException(ValueError, "Expected '=' at " & $i &
" but got: " & $query[i])
inc(i) # Skip =
i += query.parseUntil(val, '&', i)
inc(i) # Skip &
result[$decodeUri(key)] = $decodeUri(val)
+97
View File
@@ -0,0 +1,97 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event, KeyboardEvent
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error, resetpassword
import karaxutils
type
LoginModal* = ref object
shown: bool
loading: bool
onLogIn: proc ()
onSignUp: proc ()
error: Option[PostError]
resetPasswordModal: ResetPasswordModal
proc onLogInPost(httpStatus: int, response: kstring, state: LoginModal) =
postFinished:
state.shown = false
state.onLogIn()
proc onLogInClick(ev: Event, n: VNode, state: LoginModal) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("login")
let form = dom.document.getElementById("login-form")
# TODO: This is a hack, karax should support this.
let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onLogInPost(s, r, state))
proc onClose(ev: Event, n: VNode, state: LoginModal) =
state.shown = false
ev.preventDefault()
proc newLoginModal*(onLogIn, onSignUp: proc ()): LoginModal =
LoginModal(
shown: false,
onLogIn: onLogIn,
onSignUp: onSignUp,
resetPasswordModal: newResetPasswordModal()
)
proc show*(state: LoginModal) =
state.shown = true
proc onKeyDown(e: Event, n: VNode, state: LoginModal) =
let event = cast[KeyboardEvent](e)
if event.key == "Enter":
onLogInClick(e, n, state)
proc render*(state: LoginModal, recaptchaSiteKey: Option[string]): VNode =
result = buildHtml(tdiv()):
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
id="login-modal"):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
a(href="", class="btn btn-clear float-right",
"aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-title h5"):
text "Log in"
tdiv(class="modal-body"):
tdiv(class="content"):
form(id="login-form",
onKeyDown=(ev: Event, n: VNode) => onKeyDown(ev, n, state)):
genFormField(state.error, "username", "Username", "text", false)
genFormField(
state.error,
"password",
"Password",
"password",
true
)
a(href="", onClick=(e: Event, n: VNode) =>
(state.resetPasswordModal.show(); e.preventDefault())):
text "Reset your password"
tdiv(class="modal-footer"):
button(class=class(
{"loading": state.loading},
"btn btn-primary"
),
onClick=(ev: Event, n: VNode) => onLogInClick(ev, n, state)):
text "Log in"
button(class="btn",
onClick=(ev: Event, n: VNode) =>
(state.onSignUp(); state.shown = false)):
text "Create account"
render(state.resetPasswordModal, recaptchaSiteKey)
+58
View File
@@ -0,0 +1,58 @@
import options
import user
when defined(js):
include karax/prelude
import karax / [kdom]
import karaxutils, user, categorypicker, category
let buttons = [
(name: "Latest", url: makeUri("/"), id: "latest-btn"),
(name: "Categories", url: makeUri("/categories"), id: "categories-btn"),
]
proc onSelectedCategoryChanged(oldCategory: Category, newCategory: Category) =
let uri = makeUri("/c/" & $newCategory.id)
navigateTo(uri)
type
MainButtons* = ref object
categoryPicker: CategoryPicker
onCategoryChange*: CategoryChangeEvent
proc newMainButtons*(onCategoryChange: CategoryChangeEvent = onSelectedCategoryChanged): MainButtons =
new result
result.onCategoryChange = onCategoryChange
result.categoryPicker = newCategoryPicker(
onCategoryChange = proc (oldCategory, newCategory: Category) =
onSelectedCategoryChanged(oldCategory, newCategory)
result.onCategoryChange(oldCategory, newCategory)
)
proc render*(state: MainButtons, currentUser: Option[User], categoryId = none(int)): VNode =
result = buildHtml():
section(class="navbar container grid-xl", id="main-buttons"):
section(class="navbar-section"):
#[tdiv(class="dropdown"):
a(href="#", class="btn dropdown-toggle"):
text "Filter "
italic(class="fas fa-caret-down")
ul(class="menu"):
li: text "community"
li: text "dev" ]#
if categoryId.isSome:
state.categoryPicker.selectedCategoryID = categoryId.get()
render(state.categoryPicker, currentUser, compact=false)
for btn in buttons:
let active = btn.url == window.location.href
a(id=btn.id, href=btn.url):
button(class=class({"btn-primary": active, "btn-link": not active}, "btn")):
text btn.name
section(class="navbar-section"):
if currentUser.isSome():
a(id="new-thread-btn", href=makeUri("/newthread"), onClick=anchorCB):
button(class="btn btn-secondary"):
italic(class="fas fa-plus")
text " New Thread"
+79
View File
@@ -0,0 +1,79 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error, replybox, threadlist, post, user
import karaxutils, categorypicker
type
NewThread* = ref object
loading: bool
error: Option[PostError]
replyBox: ReplyBox
subject: kstring
categoryPicker: CategoryPicker
proc newNewThread*(): NewThread =
NewThread(
replyBox: newReplyBox(nil),
subject: "",
categoryPicker: newCategoryPicker()
)
proc onSubjectChange(e: Event, n: VNode, state: NewThread) =
state.subject = n.value
proc onCreatePost(httpStatus: int, response: kstring, state: NewThread) =
postFinished:
let j = parseJson($response)
let response = to(j, array[2, int])
navigateTo(renderPostUrl(response[0], response[1]))
proc onCreateClick(ev: Event, n: VNode, state: NewThread) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("newthread")
# TODO: This is a hack, karax should support this.
let formData = newFormData()
let categoryID = state.categoryPicker.selectedCategoryID
formData.append("subject", state.subject)
formData.append("msg", state.replyBox.getText())
formData.append("categoryId", $categoryID)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onCreatePost(s, r, state))
proc render*(state: NewThread, currentUser: Option[User]): VNode =
result = buildHtml():
section(class="container grid-xl"):
tdiv(id="new-thread"):
tdiv(class="title"):
p(): text "New Thread"
tdiv(class="content"):
input(id="thread-title", class="form-input", `type`="text", name="subject",
placeholder="Type the title here",
oninput=(e: Event, n: VNode) => onSubjectChange(e, n, state))
if state.error.isSome():
p(class="text-error"):
text state.error.get().message
tdiv():
label(class="d-inline-block form-label"):
text "Category"
render(state.categoryPicker, currentUser, compact=false)
renderContent(state.replyBox, none[Thread](), none[Post]())
tdiv(class="footer"):
button(id="create-thread-btn",
class=class(
{"loading": state.loading},
"btn btn-primary"
),
onClick=(ev: Event, n: VNode) =>
(onCreateClick(ev, n, state))):
text "Create thread"
+67
View File
@@ -0,0 +1,67 @@
import options
import user
type
PostInfo* = object
creation*: int64
content*: string
Post* = ref object
id*: int
author*: User
likes*: seq[User] ## Users that liked this post.
seen*: bool ## Determines whether the current user saw this post.
## I considered using a simple timestamp for each thread,
## but that wouldn't work when a user navigates to the last
## post in a thread for example.
history*: seq[PostInfo] ## If the post was edited this will contain the
## older versions of the post.
info*: PostInfo
moreBefore*: seq[int]
replyingTo*: Option[PostLink]
PostLink* = object ## Used by profile
creation*: int64
topic*: string
threadId*: int
postId*: int
author*: Option[User] ## Only used for `replyingTo`.
proc lastEdit*(post: Post): PostInfo =
post.history[^1]
proc isModerated*(post: Post): bool =
## Determines whether the specified post is under moderation
## (i.e. whether the post is invisible to ordinary users).
post.author.rank <= Moderated
proc isLikedBy*(post: Post, user: Option[User]): bool =
## Determines whether the specified user has liked the post.
if user.isNone(): return false
for u in post.likes:
if u.name == user.get().name:
return true
return false
type
Profile* = object
user*: User
joinTime*: int64
threads*: seq[PostLink]
posts*: seq[PostLink]
postCount*: int
threadCount*: int
# Information that only admins should see.
email*: Option[string]
when defined(js):
import karaxutils, threadlist
proc renderPostUrl*(post: Post, thread: Thread): string =
renderPostUrl(thread.id, post.id)
proc renderPostUrl*(link: PostLink): string =
renderPostUrl(link.threadId, link.postId)
+261
View File
@@ -0,0 +1,261 @@
## Simple generic button that can be clicked to make a post request.
## The button will show a loading indicator and a tick on success.
##
## Used for password reset emails.
import options, httpcore, json, sugar, sequtils, strutils
when defined(js):
include karax/prelude
import karax/[kajax, kdom]
import jsffi except `&`
import error, karaxutils, post, user, threadlist
type
PostButton* = ref object
uri, title, icon: string
formData: FormData
error: Option[PostError]
loading: bool
posted: bool
proc newPostButton*(uri: string, formData: FormData,
title: string, icon: string): PostButton =
PostButton(
uri: uri,
formData: formData,
title: title,
icon: icon
)
proc newResetPasswordButton*(username: string): PostButton =
var formData = newFormData()
formData.append("email", username)
result = newPostButton(
makeUri("/sendResetPassword"),
formData,
"Send password reset email",
"fas fa-envelope",
)
proc onPost(httpStatus: int, response: kstring, state: PostButton) =
postFinished:
discard
proc onClick(ev: Event, n: VNode, state: PostButton) =
if state.loading or state.posted: return
state.loading = true
state.posted = true
state.error = none[PostError]()
# TODO: This is a hack, karax should support this.
ajaxPost(state.uri, @[], cast[cstring](state.formData),
(s: int, r: kstring) => onPost(s, r, state))
ev.preventDefault()
proc render*(state: PostButton, disabled: bool): VNode =
result = buildHtml(tdiv()):
button(class=class({
"loading": state.loading,
"disabled": disabled
},
"btn btn-secondary"
),
`type`="button",
onClick=(e: Event, n: VNode) => (onClick(e, n, state))):
if state.posted:
if state.error.isNone():
italic(class="fas fa-check")
else:
italic(class="fas fa-times")
else:
italic(class=state.icon)
text " " & state.title
if state.error.isSome():
p(class="text-error"):
text state.error.get().message
type
LikeButton* = ref object
error: Option[PostError]
loading: bool
proc newLikeButton*(): LikeButton =
LikeButton()
proc onPost(httpStatus: int, response: kstring, state: LikeButton,
post: Post, user: User) =
postFinished:
if post.isLikedBy(some(user)):
var newLikes: seq[User] = @[]
for like in post.likes:
if like.name != user.name:
newLikes.add(like)
post.likes = newLikes
else:
post.likes.add(user)
proc onClick(ev: Event, n: VNode, state: LikeButton, post: Post,
currentUser: Option[User]) =
if state.loading: return
if currentUser.isNone():
state.error = some[PostError](PostError(message: "Not logged in."))
return
state.loading = true
state.error = none[PostError]()
# TODO: This is a hack, karax should support this.
var formData = newFormData()
formData.append("id", $post.id)
let uri =
if post.isLikedBy(currentUser):
makeUri("/unlike")
else:
makeUri("/like")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) =>
onPost(s, r, state, post, currentUser.get()))
ev.preventDefault()
proc render*(state: LikeButton, post: Post,
currentUser: Option[User]): VNode =
let liked = isLikedBy(post, currentUser)
let tooltip =
if state.error.isSome(): state.error.get().message
else: ""
result = buildHtml():
tdiv(class="like-button"):
button(class=class({"tooltip": state.error.isSome()}, "btn"),
onClick=(e: Event, n: VNode) =>
(onClick(e, n, state, post, currentUser)),
"data-tooltip"=tooltip,
onmouseleave=(e: Event, n: VNode) =>
(state.error = none[PostError]())):
if post.likes.len > 0:
let names = post.likes.map(x => x.name).join(", ")
span(class="like-count tooltip", "data-tooltip"=names):
text $post.likes.len
italic(class=class({"far": not liked, "fas": liked}, "fa-heart"))
type
LockButton* = ref object
error: Option[PostError]
loading: bool
proc newLockButton*(): LockButton =
LockButton()
proc onPost(httpStatus: int, response: kstring, state: LockButton,
thread: var Thread) =
postFinished:
thread.isLocked = not thread.isLocked
proc onLockClick(ev: Event, n: VNode, state: LockButton, thread: var Thread) =
if state.loading: return
state.loading = true
state.error = none[PostError]()
# TODO: This is a hack, karax should support this.
var formData = newFormData()
formData.append("id", $thread.id)
let uri =
if thread.isLocked:
makeUri("/unlock")
else:
makeUri("/lock")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) =>
onPost(s, r, state, thread))
ev.preventDefault()
proc render*(state: LockButton, thread: var Thread,
currentUser: Option[User]): VNode =
if currentUser.isNone() or
currentUser.get().rank < Moderator:
return buildHtml(tdiv())
let tooltip =
if state.error.isSome(): state.error.get().message
else: ""
result = buildHtml():
button(class="btn btn-secondary", id="lock-btn",
onClick=(e: Event, n: VNode) =>
onLockClick(e, n, state, thread),
"data-tooltip"=tooltip,
onmouseleave=(e: Event, n: VNode) =>
(state.error = none[PostError]())):
if thread.isLocked:
italic(class="fas fa-unlock-alt")
text " Unlock Thread"
else:
italic(class="fas fa-lock")
text " Lock Thread"
type
PinButton* = ref object
error: Option[PostError]
loading: bool
proc newPinButton*(): PinButton =
PinButton()
proc onPost(httpStatus: int, response: kstring, state: PinButton,
thread: var Thread) =
postFinished:
thread.isPinned = not thread.isPinned
proc onPinClick(ev: Event, n: VNode, state: PinButton, thread: var Thread) =
if state.loading: return
state.loading = true
state.error = none[PostError]()
# Same as LockButton so the following is still a hack and karax should support this.
var formData = newFormData()
formData.append("id", $thread.id)
let uri =
if thread.isPinned:
makeUri("/unpin")
else:
makeUri("/pin")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onPost(s, r, state, thread))
ev.preventDefault()
proc render*(state: PinButton, thread: var Thread,
currentUser: Option[User]): VNode =
if currentUser.isNone() or
currentUser.get().rank < Moderator:
return buildHtml(tdiv())
let tooltip =
if state.error.isSome(): state.error.get().message
else: ""
result = buildHtml():
button(class="btn btn-secondary", id="pin-btn",
onClick=(e: Event, n: VNode) =>
onPinClick(e, n, state, thread),
"data-tooltip"=tooltip,
onmouseleave=(e: Event, n: VNode) =>
(state.error = none[PostError]())):
if thread.isPinned:
italic(class="fas fa-thumbtack")
text " Unpin Thread"
else:
italic(class="fas fa-thumbtack")
text " Pin Thread"
+420
View File
@@ -0,0 +1,420 @@
import system except Thread
import options, json, times, httpcore, sugar, strutils
import sequtils
import threadlist, category, post, user
type
PostList* = ref object
thread*: Thread
history*: seq[Thread] ## If the thread was edited this will contain the
## older versions of the thread (title/category
## changes). TODO
posts*: seq[Post]
when defined(js):
from dom import document
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import karaxutils, error, replybox, editbox, postbutton, delete
import categorypicker
type
State = ref object
list: Option[PostList]
loading: bool
status: HttpCode
error: Option[PostError]
replyingTo: Option[Post]
replyBox: ReplyBox
editing: Option[Post] ## If in edit mode, this contains the post.
editBox: EditBox
likeButton: LikeButton
deleteModal: DeleteModal
lockButton: LockButton
pinButton: PinButton
categoryPicker: CategoryPicker
proc onReplyPosted(id: int)
proc onCategoryChanged(oldCategory: Category, newCategory: Category)
proc onEditPosted(id: int, content: string, subject: Option[string])
proc onEditCancelled()
proc onDeletePost(post: Post)
proc onDeleteThread(thread: Thread)
proc newState(): State =
State(
list: none[PostList](),
loading: false,
status: Http200,
error: none[PostError](),
replyingTo: none[Post](),
replyBox: newReplyBox(onReplyPosted),
editBox: newEditBox(onEditPosted, onEditCancelled),
likeButton: newLikeButton(),
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
lockButton: newLockButton(),
pinButton: newPinButton(),
categoryPicker: newCategoryPicker(onCategoryChanged)
)
var
state = newState()
proc onCategoryPost(httpStatus: int, response: kstring, state: State) =
state.loading = false
postFinished:
discard
# TODO: show success message
proc onCategoryChanged(oldCategory: Category, newCategory: Category) =
let uri = makeUri("/updateThread")
let formData = newFormData()
formData.append("threadId", $state.list.get().thread.id)
formData.append("category", $newCategory.id)
state.loading = true
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onCategoryPost(s, r, state))
proc onPostList(httpStatus: int, response: kstring, postId: Option[int]) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = to(parsed, PostList)
state.list = some(list)
dom.document.title = list.thread.topic & " - " & dom.document.title
state.categoryPicker.select(list.thread.category.id)
# The anchor should be jumped to once all the posts have been loaded.
if postId.isSome():
discard setTimeout(
() => (
# Would have used scrollIntoView but then the `:target` selector
# isn't activated.
getVNodeById($postId.get()).dom.scrollIntoView()
),
100
)
proc onMorePosts(httpStatus: int, response: kstring, start: int) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
var list = to(parsed, seq[Post])
var idsLoaded: seq[int] = @[]
for i in 0..<list.len:
state.list.get().posts.insert(list[i], i+start)
idsLoaded.add(list[i].id)
# Save a list of the IDs which have not yet been loaded into the top-most
# post.
let postIndex = start+list.len
# The following check is necessary because we reuse this proc to load
# a newly created post.
if postIndex < state.list.get().posts.len:
let post = state.list.get().posts[postIndex]
var newPostIds: seq[int] = @[]
for id in post.moreBefore:
if id notin idsLoaded:
newPostIds.add(id)
post.moreBefore = newPostIds
proc loadMore(start: int, ids: seq[int]) =
if state.loading: return
state.loading = true
let uri = makeUri(
"specific_posts.json",
[("ids", $(%ids))]
)
ajaxGet(
uri,
@[],
(s: int, r: kstring) => onMorePosts(s, r, start)
)
proc onReplyPosted(id: int) =
## Executed when a reply has been successfully posted.
loadMore(state.list.get().posts.len, @[id])
proc onEditCancelled() = state.editing = none[Post]()
proc onEditPosted(id: int, content: string, subject: Option[string]) =
## Executed when an edit has been successfully posted.
state.editing = none[Post]()
let list = state.list.get()
for i in 0 ..< list.posts.len:
if list.posts[i].id == id:
list.posts[i].history.add(PostInfo(
creation: getTime().toUnix(),
content: content
))
break
proc onReplyClick(e: Event, n: VNode, p: Option[Post]) =
state.replyingTo = p
state.replyBox.show()
proc onEditClick(e: Event, n: VNode, p: Post) =
state.editing = some(p)
# TODO: Ensure the edit box is as big as its content. Auto resize the
# text area.
proc onDeletePost(post: Post) =
state.list.get().posts.keepIf(
x => x.id != post.id
)
proc onDeleteThread(thread: Thread) =
window.location.href = makeUri("/")
proc onDeleteClick(e: Event, n: VNode, p: Post) =
let list = state.list.get()
if list.posts[0].id == p.id:
state.deleteModal.show(list.thread)
else:
state.deleteModal.show(p)
proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) =
loadMore(start, post.moreBefore) # TODO: Don't load all!
proc genLoadMore(post: Post, start: int): VNode =
result = buildHtml():
tdiv(class="information load-more-posts",
onClick=(e: Event, n: VNode) => onLoadMore(e, n, start, post)):
tdiv(class="information-icon"):
italic(class="fas fa-comment-dots")
tdiv(class="information-main"):
if state.loading:
tdiv(class="loading loading-lg")
else:
tdiv(class="information-title"):
text "Load more posts "
span(class="more-post-count"):
text "(" & $post.moreBefore.len & ")"
proc genCategories(thread: Thread, currentUser: Option[User]): VNode =
let loggedIn = currentUser.isSome()
let authoredByUser =
loggedIn and currentUser.get().name == thread.author.name
let canChangeCategory =
loggedIn and currentUser.get().rank in {Admin, Moderator}
result = buildHtml():
tdiv():
if authoredByUser or canChangeCategory:
render(state.categoryPicker, currentUser, compact=false)
else:
render(thread.category)
proc genPostButtons(post: Post, currentUser: Option[User]): Vnode =
let loggedIn = currentUser.isSome()
let authoredByUser =
loggedIn and currentUser.get().name == post.author.name
let currentAdmin =
currentUser.isSome() and currentUser.get().rank == Admin
# Don't show buttons if the post is being edited.
if state.editing.isSome() and state.editing.get() == post:
return buildHtml(tdiv())
result = buildHtml():
tdiv(class="post-buttons"):
if authoredByUser or currentAdmin:
tdiv(class="edit-button", onClick=(e: Event, n: VNode) =>
onEditClick(e, n, post)):
button(class="btn"):
italic(class="far fa-edit")
tdiv(class="delete-button",
onClick=(e: Event, n: VNode) => onDeleteClick(e, n, post)):
button(class="btn"):
italic(class="far fa-trash-alt")
render(state.likeButton, post, currentUser)
if loggedIn:
tdiv(class="flag-button"):
button(class="btn"):
italic(class="far fa-flag")
tdiv(class="reply-button"):
button(class="btn", onClick=(e: Event, n: VNode) =>
onReplyClick(e, n, some(post))):
italic(class="fas fa-reply")
text " Reply"
proc genPost(
post: Post, thread: Thread, currentUser: Option[User], highlight: bool
): VNode =
let postCopy = post # TODO: Another workaround here, closure capture :(
let originalPost = thread.author == post.author
result = buildHtml():
tdiv(class=class({"highlight": highlight, "original-post": originalPost}, "post"),
id = $post.id):
tdiv(class="post-icon"):
render(post.author, "post-avatar")
tdiv(class="post-main"):
tdiv(class="post-title"):
tdiv(class="post-username"):
text post.author.name
renderUserRank(post.author)
tdiv(class="post-metadata"):
if post.replyingTo.isSome():
let replyingTo = post.replyingTo.get()
tdiv(class="post-replyingTo"):
a(href=renderPostUrl(replyingTo)):
italic(class="fas fa-reply")
renderUserMention(replyingTo.author.get())
if post.history.len > 0:
let title = post.lastEdit.creation.fromUnix().local.
format("'Last modified' MMM d, yyyy HH:mm")
tdiv(class="post-history", title=title):
span(class="edit-count"):
text $post.history.len
italic(class="fas fa-pencil-alt")
let title = post.info.creation.fromUnix().local.
format("MMM d, yyyy HH:mm")
a(href=renderPostUrl(post, thread), title=title):
text renderActivity(post.info.creation)
tdiv(class="post-content"):
if state.editing.isSome() and state.editing.get() == post:
render(state.editBox, postCopy)
else:
let content =
if post.history.len > 0:
post.lastEdit.content
else:
post.info.content
verbatim(content)
genPostButtons(postCopy, currentUser)
proc genTimePassed(prevPost: Post, post: Option[Post], last: bool): VNode =
var latestTime =
if post.isSome: post.get().info.creation.fromUnix()
else: getTime()
# TODO: Use `between` once it's merged into stdlib.
let
tmpl =
if last: [
"A long time since last reply",
"$1 year since last reply",
"$1 years since last reply",
"$1 month since last reply",
"$1 months since last reply",
]
else: [
"Some time later",
"$1 year later", "$1 years later",
"$1 month later", "$1 months later"
]
var diffStr = tmpl[0]
let diff = latestTime - prevPost.info.creation.fromUnix()
if diff.inWeeks > 48:
let years = diff.inWeeks div 48
diffStr =
(if years == 1: tmpl[1] else: tmpl[2]) % $years
elif diff.inWeeks > 4:
let months = diff.inWeeks div 4
diffStr =
(if months == 1: tmpl[3] else: tmpl[4]) % $months
else:
return buildHtml(tdiv())
# PROTIP: Good thread ID to test this with is: 1267.
result = buildHtml():
tdiv(class="information time-passed"):
tdiv(class="information-icon"):
italic(class="fas fa-clock")
tdiv(class="information-main"):
tdiv(class="information-title"):
text diffStr
proc renderPostList*(threadId: int, postId: Option[int],
currentUser: Option[User]): VNode =
if state.list.isSome() and state.list.get().thread.id != threadId:
state.list = none[PostList]()
state.status = Http200
if state.status != Http200:
return renderError("Couldn't retrieve posts.", state.status)
if state.list.isNone:
var params = @[("id", $threadId)]
if postId.isSome():
params.add(("anchor", $postId.get()))
let uri = makeUri("posts.json", params)
if not state.loading:
state.loading = true
ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, postId))
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get()
result = buildHtml():
section(class="container grid-xl"):
tdiv(id="thread-title", class="title"):
if state.error.isSome():
span(class="text-error"):
text state.error.get().message
p(class="title-text"): text list.thread.topic
if list.thread.isLocked:
italic(class="fas fa-lock fa-xs",
title="Thread cannot be replied to")
text "Locked"
if list.thread.isModerated:
italic(class="fas fa-eye-slash fa-xs",
title="Thread is moderated")
text "Moderated"
if list.thread.isSolved:
italic(class="fas fa-check-square fa-xs",
title="Thread has a solution")
text "Solved"
genCategories(list.thread, currentUser)
tdiv(class="posts"):
var prevPost: Option[Post] = none[Post]()
for i, post in list.posts:
if not post.visibleTo(currentUser): continue
if prevPost.isSome:
genTimePassed(prevPost.get(), some(post), false)
if post.moreBefore.len > 0:
genLoadMore(post, i)
let highlight = postId.isSome() and postId.get() == post.id
genPost(post, list.thread, currentUser, highlight)
prevPost = some(post)
if prevPost.isSome:
genTimePassed(prevPost.get(), none[Post](), true)
tdiv(id="thread-buttons"):
button(class="btn btn-secondary",
onClick=(e: Event, n: VNode) =>
onReplyClick(e, n, none[Post]())):
italic(class="fas fa-reply")
text " Reply"
render(state.lockButton, list.thread, currentUser)
render(state.pinButton, list.thread, currentUser)
render(state.replyBox, list.thread, state.replyingTo, false)
render(state.deleteModal)
+150
View File
@@ -0,0 +1,150 @@
import options, httpcore, json, sugar, times, strutils
import threadlist, post, error, user
when defined(js):
from dom import document
include karax/prelude
import karax/[kajax, kdom]
import karaxutils, profilesettings
type
ProfileTab* = enum
Overview, Settings
ProfileState* = ref object
profile: Option[Profile]
settings: Option[ProfileSettings]
currentTab: ProfileTab
loading: bool
status: HttpCode
proc newProfileState*(): ProfileState =
ProfileState(
loading: false,
status: Http200,
currentTab: Overview
)
proc onProfile(httpStatus: int, response: kstring, state: ProfileState) =
# TODO: Try to abstract these.
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let profile = to(parsed, Profile)
state.profile = some(profile)
state.settings = some(newProfileSettings(profile))
dom.document.title = profile.user.name & " - " & dom.document.title
proc genPostLink(link: PostLink): VNode =
let url = renderPostUrl(link)
result = buildHtml():
tdiv(class="profile-post"):
tdiv(class="profile-post-main"):
tdiv(class="profile-post-title"):
a(href=url):
text link.topic
tdiv(class="profile-post-time"):
let title = link.creation.fromUnix().local.
format("MMM d, yyyy HH:mm")
p(title=title):
text renderActivity(link.creation)
proc render*(
state: ProfileState,
username: kstring,
currentUser: Option[User]
): VNode =
if state.profile.isSome() and state.profile.get().user.name != username:
state.profile = none[Profile]()
state.status = Http200
if state.status != Http200:
return renderError("Couldn't retrieve profile.", state.status)
if state.profile.isNone:
if not state.loading:
state.loading = true
let uri = makeUri("profile.json", ("username", $username))
ajaxGet(uri, @[], (s: int, r: kstring) => onProfile(s, r, state))
return buildHtml(tdiv(class="loading loading-lg"))
let profile = state.profile.get()
result = buildHtml():
section(class="container grid-xl"):
tdiv(class="profile"):
tdiv(class="profile-icon"):
render(profile.user, "profile-avatar")
tdiv(class="profile-content"):
h2(class="profile-title"):
text profile.user.name
tdiv(class="profile-stats"):
dl():
dt(text "Joined")
dd(text threadlist.renderActivity(profile.joinTime))
if profile.posts.len > 0:
dt(text "Last Post")
dd(text renderActivity(profile.posts[0].creation))
dt(text "Last Online")
dd(text renderActivity(profile.user.lastOnline))
dt(text "Posts")
dd():
if profile.postCount > 999:
text $(profile.postCount / 1000) & "k"
else:
text $profile.postCount
dt(text "Threads")
dd():
if profile.threadCount > 999:
text $(profile.threadCount / 1000) & "k"
else:
text $profile.threadCount
dt(text "Rank")
dd(text $profile.user.rank)
if currentUser.isSome():
let user = currentUser.get()
if user.name == profile.user.name or user.rank >= Moderator:
ul(class="tab profile-tabs"):
li(class=class(
{"active": state.currentTab == Overview},
"tab-item"
),
onClick=(e: Event, n: VNode) => (state.currentTab = Overview)
):
a(id="overview-tab", class="c-hand"):
text "Overview"
li(class=class(
{"active": state.currentTab == Settings},
"tab-item"
),
onClick=(e: Event, n: VNode) => (state.currentTab = Settings)
):
a(id="settings-tab", class="c-hand"):
italic(class="fas fa-cog")
text " Settings"
case state.currentTab
of Overview:
if profile.posts.len > 0 or profile.threads.len > 0:
tdiv(class="columns"):
tdiv(class="column col-6"):
h4(text "Latest Posts")
tdiv(class="posts"):
for post in profile.posts:
genPostLink(post)
tdiv(class="column col-6"):
h4(text "Latest Threads")
tdiv(class="posts"):
for thread in profile.threads:
genPostLink(thread)
of Settings:
if state.settings.isSome():
render(state.settings.get(), currentUser)
+206
View File
@@ -0,0 +1,206 @@
when defined(js):
import httpcore, options, sugar, json, strutils, strformat
import jsffi except `&`
include karax/prelude
import karax/[kajax, kdom]
import post, karaxutils, postbutton, error, delete, user
type
ProfileSettings* = ref object
loading: bool
status: HttpCode
error: Option[PostError]
email: kstring
rank: Rank
deleteModal: DeleteModal
resetPassword: PostButton
profile: Profile
proc onUserDelete(user: User) =
window.location.href = makeUri("/")
proc resetSettings(state: ProfileSettings) =
let profile = state.profile
if profile.email.isSome():
state.email = profile.email.get()
else:
state.email = ""
state.rank = profile.user.rank
state.error = none[PostError]()
proc newProfileSettings*(profile: Profile): ProfileSettings =
result = ProfileSettings(
status: Http200,
deleteModal: newDeleteModal(nil, nil, onUserDelete),
resetPassword: newResetPasswordButton(profile.user.name),
profile: profile
)
resetSettings(result)
proc onProfilePost(httpStatus: int, response: kstring,
state: ProfileSettings) =
postFinished:
state.profile.email = some($state.email)
state.profile.user.rank = state.rank
proc onEmailChange(event: Event, node: VNode, state: ProfileSettings) =
state.email = node.value
if state.profile.user.rank != Admin:
if state.email != state.profile.email.get():
state.rank = EmailUnconfirmed
else:
state.rank = state.profile.user.rank
proc onRankChange(event: Event, node: VNode, state: ProfileSettings) =
state.rank = parseEnum[Rank]($node.value)
proc save(state: ProfileSettings) =
if state.loading:
return
state.loading = true
state.error = none[PostError]()
let formData = newFormData()
formData.append("email", state.email)
formData.append("rank", $state.rank)
formData.append("username", $state.profile.user.name)
let uri = makeUri("/saveProfile")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onProfilePost(s, r, state))
proc needsSave(state: ProfileSettings): bool =
if state.profile.email.isSome():
result = state.email != state.profile.email.get()
result = result or state.rank != state.profile.user.rank
proc render*(state: ProfileSettings,
currentUser: Option[User]): VNode =
let canEditRank = currentUser.isSome() and
currentUser.get().rank > state.profile.user.rank
let canResetPassword = state.profile.user.rank > EmailUnconfirmed
let rankSelect = buildHtml(tdiv()):
if canEditRank:
select(id="rank-field",
class="form-select", value = $state.rank,
onchange=(e: Event, n: VNode) => onRankChange(e, n, state)):
for r in Rank:
option(text $r, id="rank-" & toLowerAscii($r))
p(class="form-input-hint text-warning"):
text "You can modify anyone's rank. Remember: with " &
"great power comes great responsibility."
else:
input(id="rank-field", class="form-input",
`type`="text", disabled="", value = $state.rank)
p(class="form-input-hint"):
text "Your rank determines the actions you can perform " &
"on the forum."
case state.rank:
of bannedRanks:
p(class="form-input-hint text-warning"):
text "Your account was banned."
of EmailUnconfirmed:
p(class="form-input-hint text-warning"):
text "You cannot post until you confirm your email."
of Moderated:
p(class="form-input-hint text-warning"):
text "Your account is under moderation. This is a spam prevention "&
"measure. You can write posts but only moderators and admins "&
"will see them until your account is verified by them."
else:
discard
result = buildHtml():
tdiv(class="columns"):
tdiv(class="column col-6"):
form(class="form-horizontal"):
tdiv(class="form-group"):
tdiv(class="col-3 col-sm-12"):
label(class="form-label"):
text "Username"
tdiv(class="col-9 col-sm-12"):
input(class="form-input",
`type`="text",
value=state.profile.user.name,
disabled="")
p(class="form-input-hint"):
text fmt("Users can refer to you by writing" &
" @{state.profile.user.name} in their posts.")
if state.profile.email.isSome():
tdiv(class="form-group"):
tdiv(class="col-3 col-sm-12"):
label(class="form-label"):
text "Email"
tdiv(class="col-9 col-sm-12"):
input(id="email-input", class="form-input",
`type`="text", value=state.email,
oninput=(e: Event, n: VNode) =>
onEmailChange(e, n, state)
)
p(class="form-input-hint"):
text "Your avatar is linked to this email and can be " &
"changed at "
a(href="https://gravatar.com/emails"):
text "gravatar.com"
text ". Note that any changes to your email will " &
"require email verification."
tdiv(class="form-group"):
tdiv(class="col-3 col-sm-12"):
label(class="form-label"):
text "Rank"
tdiv(class="col-9 col-sm-12"):
rankSelect
tdiv(class="form-group"):
tdiv(class="col-3 col-sm-12"):
label(class="form-label"):
text "Password"
tdiv(class="col-9 col-sm-12"):
render(state.resetPassword,
disabled=not canResetPassword)
tdiv(class="form-group"):
tdiv(class="col-3 col-sm-12"):
label(class="form-label"):
text "Account"
tdiv(class="col-9 col-sm-12"):
button(id="delete-account-btn",
class="btn btn-secondary", `type`="button",
onClick=(e: Event, n: VNode) =>
(state.deleteModal.show(state.profile.user))):
italic(class="fas fa-times")
text " Delete account"
tdiv(class="float-right"):
if state.error.isSome():
span(class="text-error"):
text state.error.get().message
button(id="cancel-btn",
class=class(
{"disabled": not needsSave(state)}, "btn btn-link"
),
onClick=(e: Event, n: VNode) => (resetSettings(state))):
text "Cancel"
button(id="save-btn",
class=class(
{"disabled": not needsSave(state)}, "btn btn-primary"
),
onClick=(e: Event, n: VNode) => save(state),
id="save-btn"):
italic(class="fas fa-save")
text " Save"
render(state.deleteModal)
# TODO: I really should just be able to set the `value` attr.
# TODO: This doesn't work when settings are reset for some reason.
let rankField = getVNodeById("rank-field")
if not rankField.isNil:
rankField.setInputText($state.rank)
let emailField = getVNodeById("email-field")
if not emailField.isNil:
emailField.setInputText($state.email)
+167
View File
@@ -0,0 +1,167 @@
when defined(js):
import strformat, options, httpcore, json, sugar
import jsffi except `&`
from dom import getElementById, scrollIntoView, setTimeout
include karax/prelude
import karax / [vstyles, kajax, kdom]
import karaxutils, threadlist, post, error, user
type
ReplyBox* = ref object
shown: bool
text: kstring
preview: bool
loading: bool
error: Option[PostError]
rendering: Option[kstring]
onPost: proc (id: int)
proc newReplyBox*(onPost: proc (id: int)): ReplyBox =
ReplyBox(
text: "",
onPost: onPost
)
proc performScroll() =
let replyBox = dom.document.getElementById("reply-box")
replyBox.scrollIntoView()
proc show*(state: ReplyBox) =
# Scroll to the reply box.
if not state.shown:
# TODO: It would be nice for Karax to give us an event when it renders
# things. That way we can remove this crappy hack.
discard dom.window.setTimeout(performScroll, 50)
else:
performScroll()
state.shown = true
proc getText*(state: ReplyBox): kstring = state.text
proc setText*(state: ReplyBox, text: kstring) = state.text = text
proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) =
postFinished:
echo response
state.rendering = some[kstring](response)
proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) =
state.preview = true
state.loading = true
state.error = none[PostError]()
state.rendering = none[kstring]()
let formData = newFormData()
formData.append("msg", state.text)
let uri = makeUri("/preview")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onPreviewPost(s, r, state))
proc onMessageClick(e: Event, n: VNode, state: ReplyBox) =
state.preview = false
state.error = none[PostError]()
proc onReplyPost(httpStatus: int, response: kstring, state: ReplyBox) =
postFinished:
state.text = ""
state.shown = false
state.onPost(parseJson($response).getInt())
proc onReplyClick(e: Event, n: VNode, state: ReplyBox,
thread: Thread, replyingTo: Option[Post]) =
state.loading = true
state.error = none[PostError]()
let formData = newFormData()
formData.append("msg", state.text)
formData.append("threadId", $thread.id)
if replyingTo.isSome:
formData.append("replyingTo", $replyingTo.get().id)
let uri = makeUri("/createPost")
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onReplyPost(s, r, state))
proc onCancelClick(e: Event, n: VNode, state: ReplyBox) =
# TODO: Double check reply box contents and ask user whether to discard.
state.shown = false
proc onChange(e: Event, n: VNode, state: ReplyBox) =
# TODO: Please document this better in Karax.
state.text = n.value
proc renderContent*(state: ReplyBox, thread: Option[Thread],
post: Option[Post]): VNode =
result = buildHtml():
tdiv(class="panel"):
tdiv(class="panel-nav"):
ul(class="tab tab-block"):
li(class=class({"active": not state.preview}, "tab-item"),
onClick=(e: Event, n: VNode) =>
onMessageClick(e, n, state)):
a(class="c-hand"):
text "Message"
li(class=class({"active": state.preview}, "tab-item"),
onClick=(e: Event, n: VNode) =>
onPreviewClick(e, n, state)):
a(class="c-hand"):
text "Preview"
tdiv(class="panel-body"):
if state.preview:
if state.loading:
tdiv(class="loading")
elif state.rendering.isSome():
verbatim(state.rendering.get())
else:
textarea(id="reply-textarea",
class="form-input post-text-area", rows="5",
onChange=(e: Event, n: VNode) =>
onChange(e, n, state),
value=state.text)
a(href=makeUri("/about/rst"), target="blank_"):
text "Styling with RST is supported"
if state.error.isSome():
span(class="text-error",
style=style(StyleAttr.marginTop, "0.4rem")):
text state.error.get().message
if thread.isSome:
tdiv(class="panel-footer"):
button(class=class(
{"loading": state.loading},
"btn btn-primary float-right"
),
onClick=(e: Event, n: VNode) =>
onReplyClick(e, n, state, thread.get(), post)):
text "Reply"
button(class="btn btn-link float-right",
onClick=(e: Event, n: VNode) =>
onCancelClick(e, n, state)):
text "Cancel"
proc render*(state: ReplyBox, thread: Thread, post: Option[Post],
hasMore: bool): VNode =
if not state.shown:
return buildHtml(tdiv(id="reply-box"))
result = buildHtml():
tdiv(class=class({"no-border": hasMore}, "information"), id="reply-box"):
tdiv(class="information-icon"):
italic(class="fas fa-reply")
tdiv(class="information-main", style=style(StyleAttr.width, "100%")):
tdiv(class="information-title"):
if post.isNone:
text fmt("Replying to \"{thread.topic}\"")
else:
text "Replying to "
renderUserMention(post.get().author)
tdiv(class="post-buttons",
style=style(StyleAttr.marginTop, "-0.3rem")):
a(href=renderPostUrl(post.get(), thread)):
button(class="btn"):
italic(class="fas fa-arrow-up")
tdiv(class="information-content"):
renderContent(state, some(thread), post)
+156
View File
@@ -0,0 +1,156 @@
when defined(js):
import sugar, httpcore, options, json, uri
import dom except Event, KeyboardEvent
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error
import karaxutils
type
ResetPassword* = ref object
loading: bool
status: HttpCode
error: Option[PostError]
newPassword: kstring
proc newResetPassword*(): ResetPassword =
ResetPassword(
status: Http200,
newPassword: ""
)
proc onPassChange(e: Event, n: VNode, state: ResetPassword) =
state.newPassword = n.value
proc onPost(httpStatus: int, response: kstring, state: ResetPassword) =
postFinished:
navigateTo(makeUri("/resetPassword/success"))
proc onSetClick(
ev: Event, n: VNode,
state: ResetPassword
) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("resetPassword", ("newPassword", encodeUrl($state.newPassword)))
ajaxPost(uri, @[], "",
(s: int, r: kstring) => onPost(s, r, state))
proc render*(state: ResetPassword): VNode =
if state.loading:
return buildHtml(tdiv(class="loading"))
result = buildHtml():
section(class="container grid-xl"):
tdiv(id="resetpassword"):
tdiv(class="title"):
p(): text "Reset Password"
tdiv(class="content"):
label(class="form-label", `for`="password"):
text "Password"
input(class="form-input", `type`="password", name="password",
placeholder="Type your new password here",
oninput=(e: Event, n: VNode) => onPassChange(e, n, state))
if state.error.isSome():
p(class="text-error"):
text state.error.get().message
tdiv(class="footer"):
button(class=class(
{"loading": state.loading},
"btn btn-primary"
),
onClick=(ev: Event, n: VNode) =>
(onSetClick(ev, n, state))):
text "Set password"
type
ResetPasswordModal* = ref object
shown: bool
loading: bool
error: Option[PostError]
sent: bool
proc onPost(httpStatus: int, response: kstring, state: ResetPasswordModal) =
postFinished:
state.sent = true
proc onClick(ev: Event, n: VNode, state: ResetPasswordModal) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("sendResetPassword")
let form = dom.document.getElementById("resetpassword-form")
# TODO: This is a hack, karax should support this.
let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onPost(s, r, state))
ev.preventDefault()
proc onClose(ev: Event, n: VNode, state: ResetPasswordModal) =
state.shown = false
ev.preventDefault()
proc newResetPasswordModal*(): ResetPasswordModal =
ResetPasswordModal(
shown: false
)
proc show*(state: ResetPasswordModal) =
state.shown = true
proc onKeyDown(e: Event, n: VNode, state: ResetPasswordModal) =
let event = cast[KeyboardEvent](e)
if event.key == "Enter":
onClick(e, n, state)
proc render*(state: ResetPasswordModal,
recaptchaSiteKey: Option[string]): VNode =
result = buildHtml():
tdiv(class=class({"active": state.shown}, "modal"),
id="resetpassword-modal"):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
a(href="", class="btn btn-clear float-right",
"aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-title h5"):
text "Reset your password"
tdiv(class="modal-body"):
tdiv(class="content"):
form(id="resetpassword-form",
onKeyDown=(ev: Event, n: VNode) => onKeyDown(ev, n, state)):
genFormField(
state.error,
"email",
"Enter your email or username and we will send you a " &
"password reset email.",
"text",
true,
placeholder="Username or email"
)
if recaptchaSiteKey.isSome:
tdiv(id="recaptcha"):
tdiv(class="g-recaptcha",
"data-sitekey"=recaptchaSiteKey.get())
script(src="https://www.google.com/recaptcha/api.js")
tdiv(class="modal-footer"):
if state.sent:
span(class="text-success"):
italic(class="fas fa-check-circle")
text " Sent"
else:
button(class=class(
{"loading": state.loading},
"btn btn-primary"
),
`type`="button",
onClick=(ev: Event, n: VNode) => onClick(ev, n, state)):
text "Reset password"
+102
View File
@@ -0,0 +1,102 @@
import user, options, httpcore, json, times
type
SearchResultKind* = enum
ThreadMatch, PostMatch
SearchResult* = object
kind*: SearchResultKind
threadId*: int
postId*: int
threadTitle*: string
postContent*: string
author*: User
creation*: int64
proc isModerated*(searchResult: SearchResult): bool =
return searchResult.author.rank <= Moderated
when defined(js):
from dom import nil
include karax/prelude
import karax / [kajax]
import karaxutils, error, threadlist, sugar
type
Search* = ref object
list: Option[seq[SearchResult]]
loading: bool
status: HttpCode
query: string
proc newSearch*(): Search =
Search(
list: none[seq[SearchResult]](),
loading: false,
status: Http200,
query: ""
)
proc onList(httpStatus: int, response: kstring, state: Search) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = to(parsed, seq[SearchResult])
state.list = some(list)
proc genSearchResult(searchResult: SearchResult): VNode =
let url = renderPostUrl(searchResult.threadId, searchResult.postId)
result = buildHtml():
tdiv(class="post", id = $searchResult.postId):
tdiv(class="post-icon"):
render(searchResult.author, "post-avatar")
tdiv(class="post-main"):
tdiv(class="post-title"):
tdiv(class="thread-title"):
a(href=url, onClick=anchorCB):
verbatim(searchResult.threadTitle)
tdiv(class="post-username"):
text searchResult.author.name
renderUserRank(searchResult.author)
tdiv(class="post-metadata"):
# TODO: History and replying to.
let title = searchResult.creation.fromUnix().local.
format("MMM d, yyyy HH:mm")
a(href=url, title=title, onClick=anchorCB):
text renderActivity(searchResult.creation)
tdiv(class="post-content"):
verbatim(searchResult.postContent)
proc render*(state: Search, query: string, currentUser: Option[User]): VNode =
if state.query != query:
state.list = none[seq[SearchResult]]()
state.status = Http200
state.query = query
if state.status != Http200:
return renderError("Couldn't retrieve search results.", state.status)
if state.list.isNone:
var params = @[("q", state.query)]
let uri = makeUri("search.json", params)
ajaxGet(uri, @[], (s: int, r: kstring) => onList(s, r, state))
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get()
result = buildHtml():
section(class="container grid-xl"):
tdiv(class="title"):
p(): text "Search results"
tdiv(class="searchresults"):
if list.len == 0:
renderMessage("No results found", "", "fa-exclamation")
else:
for searchResult in list:
if not searchResult.visibleTo(currentUser): continue
genSearchResult(searchResult)
+97
View File
@@ -0,0 +1,97 @@
when defined(js):
import sugar, httpcore, options, json
import dom except Event
import jsffi except `&`
include karax/prelude
import karax / [kajax, kdom]
import error
import karaxutils
type
SignupModal* = ref object
shown: bool
loading: bool
onSignUp, onLogIn: proc ()
error: Option[PostError]
proc onSignUpPost(httpStatus: int, response: kstring, state: SignupModal) =
postFinished:
state.shown = false
state.onSignUp()
proc onSignUpClick(ev: Event, n: VNode, state: SignupModal) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("signup")
let form = dom.document.getElementById("signup-form")
# TODO: This is a hack, karax should support this.
let formData = newFormData(form)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onSignUpPost(s, r, state))
proc onClose(ev: Event, n: VNode, state: SignupModal) =
state.shown = false
ev.preventDefault()
proc newSignupModal*(onSignUp, onLogIn: proc ()): SignupModal =
SignupModal(
shown: false,
onLogIn: onLogIn,
onSignUp: onSignUp
)
proc show*(state: SignupModal) =
state.shown = true
proc render*(state: SignupModal, recaptchaSiteKey: Option[string]): VNode =
setForeignNodeId("recaptcha")
result = buildHtml():
tdiv(class=class({"active": state.shown}, "modal"),
id="signup-modal"):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
a(href="", class="btn btn-clear float-right",
"aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-title h5"):
text "Create a new account"
tdiv(class="modal-body"):
tdiv(class="content"):
form(id="signup-form"):
genFormField(state.error, "email", "Email", "email", false)
genFormField(state.error, "username", "Username", "text", false)
genFormField(
state.error,
"password",
"Password",
"password",
true
)
if recaptchaSiteKey.isSome:
tdiv(id="recaptcha"):
tdiv(class="g-recaptcha",
"data-sitekey"=recaptchaSiteKey.get())
script(src="https://www.google.com/recaptcha/api.js")
tdiv(class="modal-footer"):
button(class=class({"loading": state.loading},
"btn btn-primary create-account-btn"),
onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)):
text "Create account"
button(class="btn login-btn",
onClick=(ev: Event, n: VNode) =>
(state.onLogIn(); state.shown = false)):
text "Log in"
p(class="license-text text-gray"):
text "By registering, you agree to the "
a(id="license", href=makeUri("/about/license"),
onClick=(ev: Event, n: VNode) =>
(state.shown = false; anchorCB(ev, n))):
text "content license"
text "."
+251
View File
@@ -0,0 +1,251 @@
import strformat, times, options, json, httpcore, sugar
import category, user
type
Thread* = object
id*: int
topic*: string
category*: Category
author*: User
users*: seq[User]
replies*: int
views*: int
activity*: int64 ## Unix timestamp
creation*: int64 ## Unix timestamp
isLocked*: bool
isSolved*: bool
isPinned*: bool
ThreadList* = ref object
threads*: seq[Thread]
moreCount*: int ## How many more threads are left
proc isModerated*(thread: Thread): bool =
## Determines whether the specified thread is under moderation.
## (i.e. whether the specified thread is invisible to ordinary users).
thread.author.rank <= Moderated
when defined(js):
import sugar
include karax/prelude
import karax / [vstyles, kajax, kdom]
import karaxutils, error, user, mainbuttons
type
State = ref object
list: Option[ThreadList]
refreshList: bool
loading: bool
status: HttpCode
mainButtons: MainButtons
var state: State
proc newState(): State =
State(
list: none[ThreadList](),
loading: false,
status: Http200,
mainButtons: newMainButtons(
onCategoryChange =
(oldCategory: Category, newCategory: Category) => (state.list = none[ThreadList]())
)
)
state = newState()
proc visibleTo*[T](thread: T, user: Option[User]): bool =
## Determines whether the specified thread (or post) should be
## shown to the user. This procedure is generic and works on any
## object with a `isModerated` proc.
##
## The rules for this are determined by the rank of the user, their
## settings (TODO), and whether the thread's creator is moderated or not.
##
## The ``user`` argument refers to the currently logged in user.
mixin isModerated
if user.isNone(): return not thread.isModerated
let rank = user.get().rank
if rank < Rank.Moderator and thread.isModerated:
return thread.author == user.get()
return true
proc genUserAvatars(users: seq[User]): VNode =
result = buildHtml(td(class="thread-users")):
for user in users:
render(user, "avatar avatar-sm", showStatus=true)
text " "
proc renderActivity*(activity: int64): string =
let currentTime = getTime()
let activityTime = fromUnix(activity)
let duration = currentTime - activityTime
if currentTime.local().year != activityTime.local().year:
return activityTime.local().format("MMM yyyy")
elif duration.inDays > 30 and duration.inDays < 300:
return activityTime.local().format("MMM dd")
elif duration.inDays != 0:
return $duration.inDays & "d"
elif duration.inHours != 0:
return $duration.inHours & "h"
elif duration.inMinutes != 0:
return $duration.inMinutes & "m"
else:
return $duration.inSeconds & "s"
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
let isBanned = thread.author.rank.isBanned()
result = buildHtml():
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})):
td(class="thread-title"):
if thread.isLocked:
italic(class="fas fa-lock fa-xs",
title="Thread cannot be replied to")
if thread.isPinned:
italic(class="fas fa-thumbtack fa-xs",
title="Pinned post")
if isBanned:
italic(class="fas fa-ban fa-xs",
title="Thread author is banned")
if thread.isModerated:
italic(class="fas fa-eye-slash fa-xs",
title="Thread is moderated")
if thread.isSolved:
italic(class="fas fa-check-square fa-xs",
title="Thread has a solution")
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB):
text thread.topic
tdiv(class="show-sm" & class({"d-none": not displayCategory})):
render(thread.category)
td(class="hide-sm" & class({"d-none": not displayCategory})):
render(thread.category)
genUserAvatars(thread.users)
td(class="thread-replies"): text $thread.replies
td(class="hide-sm" & class({
"views-text": thread.views < 999,
"popular-text": thread.views > 999 and thread.views < 5000,
"super-popular-text": thread.views > 5000
})):
if thread.views > 999:
text fmt"{thread.views/1000:.1f}k"
else:
text $thread.views
let friendlyCreation = thread.creation.fromUnix.local.format(
"'First post:' MMM d, yyyy HH:mm'\n'"
)
let friendlyActivity = thread.activity.fromUnix.local.format(
"'Last reply:' MMM d, yyyy HH:mm"
)
td(class=class({"is-new": isNew, "is-old": isOld}, "thread-time"),
title=friendlyCreation & friendlyActivity):
text renderActivity(thread.activity)
proc onThreadList(httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = to(parsed, ThreadList)
if state.list.isSome:
state.list.get().threads.add(list.threads)
state.list.get().moreCount = list.moreCount
else:
state.list = some(list)
proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) =
state.loading = true
let start = state.list.get().threads.len
if categoryId.isSome:
ajaxGet(makeUri("threads.json?start=" & $start & "&categoryId=" & $categoryId.get()), @[], onThreadList)
else:
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
proc getInfo(
list: seq[Thread], i: int, currentUser: Option[User]
): tuple[isLastUnseen, isNew: bool] =
## Determines two properties about a thread.
##
## * isLastUnseen - Whether this is the last thread that had new
## activity since the last time the user visited the forum.
## * isNew - Whether this thread was created during the time that the
# user was absent from the forum.
let previousVisitAt =
if currentUser.isSome(): currentUser.get().previousVisitAt
else: getTime().toUnix
assert previousVisitAt != 0
let thread = list[i]
let isUnseen = thread.activity > previousVisitAt
let isNextUnseen = i+1 < list.len and list[i+1].activity > previousVisitAt
return (
isLastUnseen: isUnseen and (not isNextUnseen),
isNew: thread.creation > previousVisitAt
)
proc genThreadList(currentUser: Option[User], categoryId: Option[int]): VNode =
if state.status != Http200:
return renderError("Couldn't retrieve threads.", state.status)
if state.list.isNone:
if not state.loading:
state.loading = true
if categoryId.isSome:
ajaxGet(makeUri("threads.json?categoryId=" & $categoryId.get()), @[], onThreadList)
else:
ajaxGet(makeUri("threads.json"), @[], onThreadList)
return buildHtml(tdiv(class="loading loading-lg"))
let displayCategory = categoryId.isNone
let list = state.list.get()
result = buildHtml():
section(class="thread-list"):
table(class="table", id="threads-list"):
thead():
tr:
th(text "Topic")
th(class="hide-sm" & class({"d-none": not displayCategory})): text "Category"
th(class="thread-users"): text "Users"
th(class="centered-header"): text "Replies"
th(class="hide-sm centered-header"): text "Views"
th(class="centered-header"): text "Activity"
tbody():
for i in 0 ..< list.threads.len:
let thread = list.threads[i]
if not visibleTo(thread, currentUser): continue
let isLastThread = i+1 == list.threads.len
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
genThread(i+1, thread, isNew,
noBorder=isLastUnseen or isLastThread,
displayCategory=displayCategory)
if isLastUnseen and (not isLastThread):
tr(class="last-visit-separator"):
td(colspan="6"):
span(text "last visit")
if list.moreCount > 0:
tr(class="load-more-separator"):
if state.loading:
td(colspan="6"):
tdiv(class="loading loading-lg")
else:
td(colspan="6",
onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))):
span(text "load more threads")
proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode =
result = buildHtml(tdiv):
state.mainButtons.render(currentUser, categoryId=categoryId)
genThreadList(currentUser, categoryId)
+82
View File
@@ -0,0 +1,82 @@
import times, options
type
# If you add more "Banned" states, be sure to modify forum's threadsQuery too.
Rank* {.pure.} = enum ## serialized as 'status'
AutoSpammer ## Account automatically marked as spammer
Spammer ## spammer: every post is invisible
Moderated ## new member: posts manually reviewed before everybody
## can see them
Troll ## troll: cannot write new posts
Banned ## A non-specific ban
EmailUnconfirmed ## member with unconfirmed email address. Their posts
## are visible, but cannot make new posts. This is so that
## when a user with existing posts changes their email,
## their posts don't disappear.
User ## Ordinary user
Moderator ## Moderator: can change a user's rank
Admin ## Admin: can do everything
User* = object
id*: string
name*: string
avatarUrl*: string
lastOnline*: int64
previousVisitAt*: int64 ## Tracks the "last visit" line position
rank*: Rank
isDeleted*: bool
const bannedRanks* = {AutoSpammer, Spammer, Troll, Banned}
proc isOnline*(user: User): bool =
return getTime().toUnix() - user.lastOnline < (60*5)
proc isAdmin*(user: Option[User]): bool =
return user.isSome and user.get().rank == Admin
proc `==`*(u1, u2: User): bool =
u1.name == u2.name
proc canPost*(rank: Rank): bool =
## Determines whether the specified rank can make new posts.
rank >= Rank.User or rank == Moderated
proc isBanned*(rank: Rank): bool =
rank in bannedRanks
when defined(js):
include karax/prelude
import karaxutils
proc render*(user: User, class: string, showStatus=false): VNode =
result = buildHtml():
a(href=renderProfileUrl(user.name), onClick=anchorCB):
figure(class=class):
img(src=user.avatarUrl, title=user.name)
if user.isOnline and showStatus:
italic(class="avatar-presence online")
proc renderUserMention*(user: User): VNode =
result = buildHtml():
a(class="user-mention",
href=makeUri("/profile/" & user.name),
onClick=anchorCB):
text "@" & user.name
proc renderUserRank*(user: User): VNode =
result = buildHtml():
case user.rank
of bannedRanks:
italic(class="fas fa-eye-ban",
title="User is banned")
of Rank.User, EmailUnconfirmed:
span()
of Moderated:
italic(class="fas fa-eye-slash",
title="User is moderated")
of Moderator:
italic(class="fas fa-shield-alt",
title="User is a moderator")
of Admin:
italic(class="fas fa-chess-knight",
title="User is an admin")
+66
View File
@@ -0,0 +1,66 @@
when defined(js):
import sugar
include karax/prelude
import karax/[vstyles]
import karaxutils
import user
type
UserMenu* = ref object
shown: bool
user: User
onLogout: proc ()
proc newUserMenu*(onLogout: proc ()): UserMenu =
UserMenu(
shown: false,
onLogout: onLogout
)
proc onClick(e: Event, n: VNode, state: UserMenu) =
state.shown = not state.shown
proc render*(state: UserMenu, user: User): VNode =
result = buildHtml():
tdiv(id="profile-btn"):
figure(class="avatar c-hand",
onClick=(e: Event, n: VNode) => onClick(e, n, state)):
img(src=user.avatarUrl, title=user.name)
if user.isOnline:
italic(class="avatar-presense online")
tdiv(style=style([
(StyleAttr.width, kstring"999999px"),
(StyleAttr.height, kstring"999999px"),
(StyleAttr.position, kstring"absolute"),
(StyleAttr.left, kstring"0"),
(StyleAttr.top, kstring"0"),
(
StyleAttr.display,
if state.shown: kstring"block" else: kstring"none"
)
]),
onClick=(e: Event, n: VNode) => (state.shown = false))
ul(class="menu menu-right", style=style(
StyleAttr.display, if state.shown: "inherit" else: "none"
)):
li(class="menu-item"):
tdiv(class="tile tile-centered"):
tdiv(class="tile-icon"):
img(class="avatar", src=user.avatarUrl,
title=user.name)
tdiv(id="profile-name", class="tile-content"):
text user.name
li(class="divider")
li(class="menu-item"):
a(id="myprofile-btn",
href=makeUri("/profile/" & user.name)):
text "My profile"
li(class="menu-item c-hand"):
a(id="logout-btn",
onClick = (e: Event, n: VNode) =>
(state.shown=false; state.onLogout())):
text "Logout"
+73
View File
@@ -0,0 +1,73 @@
-- selects just threads,
-- those where title doesn't coinside with some of its posts' titles
-- by now selects only the threads title (no post snippet)
SELECT
thread_id,
snippet(thread_fts, '<b>', '</b>', '<b>...</b>') AS thread,
post_id,
post_content,
cdate,
person.id,
person.name AS author,
person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline,
strftime('%s', person.previousVisitAt) AS previousVisitAt,
person.status AS status,
person.isDeleted as person_isDeleted,
0 AS what
FROM (
SELECT
thread_fts.id AS thread_id,
post.id AS post_id,
post.content AS post_content,
strftime('%s', post.creation) AS cdate,
MIN(post.creation) AS cdate,
post.author AS author_id
FROM thread_fts
JOIN post ON post.thread=thread_id
WHERE thread_fts MATCH ?
GROUP BY thread_id, post_id
HAVING thread_id NOT IN (
SELECT thread
FROM post_fts JOIN post USING(id)
WHERE post_fts MATCH ?
)
LIMIT ? OFFSET ?
)
JOIN thread_fts ON thread_fts.id=thread_id
JOIN person ON person.id=author_id
WHERE thread_fts MATCH ?
UNION
-- the main query, selects posts
SELECT
thread.id AS thread_id,
thread.name AS thread,
post.id AS post_id,
CASE what WHEN 1
THEN snippet(post_fts, '**', '**', '...', what, -45)
ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
cdate,
person.id,
person.name AS author,
person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline,
strftime('%s', person.previousVisitAt) AS previousVisitAt,
person.status AS status,
person.isDeleted as person_isDeleted,
what
FROM post_fts JOIN (
-- inner query, selects ids of matching posts, orders and limits them,
-- so snippets only for limited count of posts are created (in outer query)
SELECT id, strftime('%s', post.creation) AS cdate, thread, 1 AS what, post.author AS author
FROM post_fts JOIN post USING(id)
WHERE post_fts.content MATCH ?
ORDER BY what, cdate DESC
LIMIT ? OFFSET ?
) AS post USING(id)
JOIN thread ON thread.id=thread
JOIN person ON person.id=author
WHERE post_fts MATCH ?
ORDER BY what ASC, cdate DESC
LIMIT 300 -- hardcoded limit just in case
;
+70
View File
@@ -0,0 +1,70 @@
import strutils
when NimMajor > 1:
import db_connector/db_sqlite
else:
import std/db_sqlite
let origFile = "nimforum.db-21-05-18"
let targetFile = "nimforum-blank.db"
let orig = open(connection=origFile, user="", password="",
database="nimforum")
let target = open(connection=targetFile, user="", password="",
database="nimforum")
block:
let fields = "id, name, views, modified"
for thread in getAllRows(orig, sql("select $1 from thread;" % fields)):
target.exec(
sql("""
insert into thread($1)
values (?, ?, ?, ?)
""" % fields),
thread
)
block:
let fields = "id, name, password, email, creation, salt, status, lastOnline"
for person in getAllRows(orig, sql("select $1 from person;" % fields)):
target.exec(
sql("""
insert into person($1)
values (?, ?, ?, ?, ?, ?, ?, ?)
""" % fields),
person
)
block:
let fields = "id, author, ip, content, thread, creation"
for post in getAllRows(orig, sql("select $1 from post;" % fields)):
target.exec(
sql("""
insert into post($1)
values (?, ?, ?, ?, ?, ?)
""" % fields),
post
)
block:
let fields = "id, name"
for t in getAllRows(orig, sql("select $1 from thread_fts;" % fields)):
target.exec(
sql("""
insert into thread_fts($1)
values (?, ?)
""" % fields),
t
)
block:
let fields = "id, content"
for p in getAllRows(orig, sql("select $1 from post_fts;" % fields)):
target.exec(
sql("""
insert into post_fts($1)
values (?, ?)
""" % fields),
p
)
echo("Imported!")
+107
View File
@@ -0,0 +1,107 @@
#? stdtmpl | standard
#template `!`(idx: untyped): untyped =
# row[idx]
#end template
#proc genRSSHeaders(c: TForumData): string =
# result = ""
<link href="${c.req.makeUri("/threadActivity.xml")}" title="Thread activity"
type="application/atom+xml" rel="alternate">
<link href="${c.req.makeUri("/postActivity.xml")}" title="Post activity"
type="application/atom+xml" rel="alternate">
#end proc
#
#proc genThreadsRSS(c: TForumData): string =
# result = ""
# const query = sql"""SELECT A.id, A.name,
# strftime('%Y-%m-%dT%H:%M:%SZ', (A.modified)),
# COUNT(B.id), C.name, B.content, B.id
# FROM thread AS A, post AS B, person AS C
# WHERE A.id = b.thread AND B.author = C.id
# GROUP BY B.thread
# ORDER BY modified DESC LIMIT ?"""
# const threadId = 0
# const name = 1
# const threadDate = 2
# const postCount = 3
# const postAuthor = 4
# const postContent = 5
# const postId = 6
# let frontQuery = c.req.makeUri("/")
# let recent = getValue(db, sql"""SELECT
# strftime('%Y-%m-%dT%H:%M:%SZ', (modified)) FROM thread
# ORDER BY modified DESC LIMIT 1""")
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${config.name} thread activity</title>
<link href="${c.req.makeUri("/threadActivity.xml")}" rel="self" />
<link href="${frontQuery}" />
<id>${frontQuery}</id>
<updated>${recent}</updated>
# for row in rows(db, query, 10):
<entry>
<title>${xmlEncode(!name)}</title>
<id>urn:entry:${!threadid}</id>
# let url = c.genThreadUrl(threadid = !threadid) &
# "#" & !postId
<link rel="alternate" type="text/html"
href="${c.req.makeUri(url)}"/>
<published>${!threadDate}</published>
<updated>${!threadDate}</updated>
<author><name>${xmlEncode(!postAuthor)}</name></author>
<content type="html"
>Posts ${!postCount}, ${xmlEncode(!postAuthor)} said:
&lt;p&gt;
${xmlEncode(rstToHtml(!postContent))}</content>
</entry>
# end for
</feed>
#end proc
#
#proc genPostsRSS(c: TForumData): string =
# result = ""
# const query = sql"""SELECT A.id, B.name, A.content, A.thread, T.name,
# strftime('%Y-%m-%dT%H:%M:%SZ', A.creation),
# A.creation, COUNT(C.id)
# FROM post AS A, person AS B, post AS C, thread AS T
# WHERE A.author = B.id AND A.thread = C.thread AND C.id <= A.id
# AND T.id = A.thread
# GROUP BY A.id
# ORDER BY A.creation DESC LIMIT 10"""
# const postId = 0
# const postAuthor = 1
# const postContent = 2
# const postThread = 3
# const postHeader = 4
# const postRssDate = 5
# const postHumanDate = 6
# const postPosition = 7
# let frontQuery = c.req.makeUri("/")
# let recent = getValue(db, sql"""SELECT
# strftime('%Y-%m-%dT%H:%M:%SZ', creation) FROM post
# ORDER BY creation DESC LIMIT 1""")
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${config.name} post activity</title>
<link href="${c.req.makeUri("/postActivity.xml")}" rel="self" />
<link href="${frontQuery}" />
<id>${frontQuery}</id>
<updated>${recent}</updated>
# for row in rows(db, query):
<entry>
<title>${xmlEncode(!postHeader)}</title>
<id>urn:entry:${!postId}</id>
# let url = c.genThreadUrl(threadid = !postThread) &
# "#" & !postId
<link rel="alternate" type="text/html"
href="${c.req.makeUri(url)}"/>
<published>${!postRssDate}</published>
<updated>${!postRssDate}</updated>
<author><name>${xmlEncode(!postAuthor)}</name></author>
<content type="html"
>On ${xmlEncode(!postHumanDate)}, ${xmlEncode(!postAuthor)} said:
&lt;p&gt;
${xmlEncode(rstToHtml(!postContent))}</content>
</entry>
# end for
</feed>
#end proc
+13
View File
@@ -0,0 +1,13 @@
# we need the documentation generator of the compiler:
path="$lib/packages/docutils"
path="$nim"
-d:ssl
# Use asynchttpserver instead of httpbeast. httpbeast spawns OS threads that
# share the global `db` ref (containing async TCP state), causing GC/runtime
# corruption and segfaults at startup. asynchttpserver is single-threaded.
-d:useStdLib
# --threads:on
# --threadAnalysis:off
+374
View File
@@ -0,0 +1,374 @@
#
#
# The Nim Forum
# (c) Copyright 2018 Andreas Rumpf, Dominik Picheta
# Look at license.txt for more info.
# All rights reserved.
#
# Script to initialise the nimforum.
import strutils, os, times, json, options, terminal
import baradb_sqlite
import auth, frontend/user
proc backup(path: string, contents: Option[string]=none[string]()) =
if fileExists(path):
if contents.isSome() and readFile(path) == contents.get():
# Don't backup if the files are equivalent.
echo("Not backing up because new file is the same.")
return
let backupPath = path & "." & $getTime().toUnix()
echo(path, " already exists. Moving to ", backupPath)
moveFile(path, backupPath)
proc createUser(db: DbConn, user: tuple[username, password, email: string],
rank: Rank) =
assert user.username.len != 0
let salt = makeSalt()
let password = makePassword(user.password, salt)
let id = db.nextId("person")
echo "createUser: ", user.username, " id=", id
exec(db, sql"""
INSERT INTO person(id, name, password, email, salt, usrStatus, creation, lastOnline, previousVisitAt)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())
""", $id, user.username, password, user.email, salt, $rank)
echo "createUser done: ", user.username
proc initialiseDb(admin: tuple[username, password, email: string],
filename="nimforum.db") =
echo "initialiseDb starting..."
let
path = getCurrentDir() / filename
isTest = "-test" in filename
isDev = "-dev" in filename
if not isDev and not isTest:
backup(path)
echo "Removing file..."
removeFile(path)
echo "Opening DB..."
var db = open(connection="127.0.0.1:9472", user="", password="",
database="nimforum")
echo "DB opened"
const
userNameType = "varchar(20)"
passwordType = "varchar(300)"
emailType = "varchar(254)" # https://stackoverflow.com/a/574698/492186
# -- Category
echo "Creating category table..."
db.exec(sql"""
create table category(
id integer primary key,
name varchar(100) not null,
description varchar(500) not null,
color varchar(10) not null
);
""")
db.exec(sql"""
insert into category (id, name, description, color)
values (0, 'Unsorted', 'No category has been chosen yet.', '000000');
""")
# -- Thread
db.exec(sql"""
create table thread(
id integer primary key,
name varchar(100) not null,
views integer not null,
modified datetime not null default '1970-01-01 00:00:00',
category integer not null default 0,
isLocked boolean not null default 0,
solution integer,
isDeleted boolean not null default 0,
isPinned boolean not null default 0,
foreign key (category) references category(id),
foreign key (solution) references post(id)
);""", [])
db.exec(sql"""
create unique index ThreadNameIx on thread (name);
""", [])
# -- Person
db.exec(sql("""
create table person(
id integer primary key,
name $# not null,
password $# not null,
email $# not null,
creation datetime not null default '1970-01-01 00:00:00',
salt varbin(128) not null,
usrStatus varchar(30) not null,
lastOnline datetime not null default '1970-01-01 00:00:00',
previousVisitAt datetime not null default '1970-01-01 00:00:00',
isDeleted boolean not null default 0,
needsPasswordReset boolean not null default 0
);""" % [userNameType, passwordType, emailType]), [])
db.exec(sql"""
create unique index UserNameIx on person (name);
""", [])
db.exec sql"create index PersonStatusIdx on person(usrStatus);"
# Create default user.
echo "Creating admin..."
db.createUser(admin, Admin)
# Create some test data for development
if isTest or isDev:
echo "Creating dev users..."
for rank in AutoSpammer..Moderator:
let rankLower = toLowerAscii($rank)
let user = (username: $rankLower,
password: $rankLower,
email: $rankLower & "@localhost.local")
echo "Creating user: ", rankLower
db.createUser(user, rank)
echo "Inserting categories..."
db.exec(sql"""
insert into category (id, name, description, color)
values (1, 'Libraries', 'Libraries and library development', '0198E1'),
(2, 'Announcements', 'Announcements by Nim core devs', 'FFEB3B'),
(3, 'Fun', 'Posts that are just for fun', '00897B'),
(4, 'Potential Issues', 'Potential Nim compiler issues', 'E53935');
""")
# -- Post
db.exec(sql"""
create table post(
id integer primary key,
author integer not null,
ip string not null,
content varchar(1000) not null,
thread integer not null,
creation datetime not null default '1970-01-01 00:00:00',
isDeleted boolean not null default 0,
replyingTo integer,
foreign key (thread) references thread(id),
foreign key (author) references person(id),
foreign key (replyingTo) references post(id)
);""", [])
db.exec sql"create index PostByAuthorIdx on post(thread, author);"
db.exec(sql"""
create table postRevision(
id integer primary key,
creation datetime not null default '1970-01-01 00:00:00',
original integer not null,
content varchar(1000) not null,
foreign key (original) references post(id)
)
""")
# -- Session
db.exec(sql("""
create table session(
id integer primary key,
ip string not null,
sessionKey $# not null,
userid integer not null,
lastModified datetime not null default '1970-01-01 00:00:00',
foreign key (userid) references person(id)
);""" % [passwordType]), [])
# -- Likes
db.exec(sql("""
create table postLike(
id integer primary key,
author integer not null,
post integer not null,
creation datetime not null default '1970-01-01 00:00:00',
foreign key (author) references person(id),
foreign key (post) references post(id)
)
"""))
# -- Report
db.exec(sql("""
create table report(
id integer primary key,
author integer not null,
post integer not null,
kind varchar(30) not null,
content varchar(500) not null default '',
foreign key (author) references person(id),
foreign key (post) references post(id)
)
"""))
# -- FTS disabled for BaraDB demo
echo "FTS tables skipped (BaraDB does not support fts4)"
close(db)
proc initialiseConfig(
name, title, hostname: string,
recaptcha: tuple[siteKey, secretKey: string],
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
isDev: bool,
dbPath: string,
ga: string=""
) =
let path = getCurrentDir() / "forum.json"
var j = %{
"name": %name,
"title": %title,
"hostname": %hostname,
"recaptchaSiteKey": %recaptcha.siteKey,
"recaptchaSecretKey": %recaptcha.secretKey,
"smtpAddress": %smtp.address,
"smtpUser": %smtp.user,
"smtpPassword": %smtp.password,
"smtpFromAddr": %smtp.fromAddr,
"smtpTls": %smtp.tls,
"isDev": %isDev,
"dbPath": %dbPath
}
if ga.len > 0:
j["ga"] = %ga
backup(path, some(pretty(j)))
writeFile(path, pretty(j))
proc question(q: string): string =
while result.len == 0:
stdout.write(q)
result = stdin.readLine()
proc setup() =
echo("""
Welcome to the NimForum setup script. Please answer the following questions.
These can be changed later in the generated forum.json file.
""")
let name = question("Forum full name: ")
let title = question("Forum short name: ")
let hostname = question("Forum hostname: ")
let adminUser = question("Admin username: ")
let adminPass = readPasswordFromStdin("Admin password: ")
let adminEmail = question("Admin email: ")
echo("")
echo("The following question are related to recaptcha. \nYou must set up a " &
"recaptcha v2 for your forum before answering them. \nPlease do so now " &
"and then answer these questions: https://www.google.com/recaptcha/admin")
let recaptchaSiteKey = question("Recaptcha site key: ")
let recaptchaSecretKey = question("Recaptcha secret key: ")
echo("The following questions are related to smtp. You must set up a \n" &
"mailing server for your forum or use an external service.")
let smtpAddress = question("SMTP address (eg: mail.hostname.com): ")
let smtpUser = question("SMTP user: ")
let smtpPassword = readPasswordFromStdin("SMTP pass: ")
let smtpFromAddr = question("SMTP sending email address (eg: mail@mail.hostname.com): ")
let smtpTls = parseBool(question("Enable TLS for SMTP: "))
echo("The following is optional. You can specify your Google Analytics ID " &
"if you wish. Otherwise just leave it blank.")
stdout.write("Google Analytics (eg: UA-12345678-1): ")
let ga = stdin.readLine().strip()
let dbPath = "nimforum.db"
initialiseConfig(
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
dbPath, ga
)
initialiseDb(
admin=(adminUser, adminPass, adminEmail),
dbPath
)
echo("Setup complete!")
proc echoHelp() =
quit("""
Usage: setup_nimforum opts
Options:
--setup Performs first time setup for end users.
Development options:
--dev Creates a new development DB and config.
--test Creates a new test DB and config.
--blank Creates a new blank DB.
""")
when isMainModule:
if paramCount() > 0:
case paramStr(1)
of "--dev":
let dbPath = "nimforum-dev.db"
echo("Initialising nimforum for development...")
initialiseConfig(
"Development Forum",
"Development Forum",
"localhost",
recaptcha=("", ""),
smtp=("", "", "", "", false),
isDev=true,
dbPath
)
initialiseDb(
admin=("admin", "admin", "admin@localhost.local"),
dbPath
)
of "--test":
let dbPath = "nimforum-test.db"
echo("Initialising nimforum for testing...")
initialiseConfig(
"Test Forum",
"Test Forum",
"localhost",
recaptcha=("", ""),
smtp=("", "", "", "", false),
isDev=true,
dbPath
)
initialiseDb(
admin=("admin", "admin", "admin@localhost.local"),
dbPath
)
of "--blank":
let dbPath = "nimforum-blank.db"
echo("Initialising blank DB...")
initialiseDb(
admin=("", "", ""),
dbPath
)
of "--setup":
setup()
else:
echoHelp()
else:
echoHelp()
quit()
+374
View File
@@ -0,0 +1,374 @@
#
#
# The Nim Forum
# (c) Copyright 2018 Andreas Rumpf, Dominik Picheta
# Look at license.txt for more info.
# All rights reserved.
#
# Script to initialise the nimforum.
import strutils, os, times, json, options, terminal
import baradb_sqlite
import auth, frontend/user
proc backup(path: string, contents: Option[string]=none[string]()) =
if fileExists(path):
if contents.isSome() and readFile(path) == contents.get():
# Don't backup if the files are equivalent.
echo("Not backing up because new file is the same.")
return
let backupPath = path & "." & $getTime().toUnix()
echo(path, " already exists. Moving to ", backupPath)
moveFile(path, backupPath)
proc createUser(db: DbConn, user: tuple[username, password, email: string],
rank: Rank) =
assert user.username.len != 0
let salt = makeSalt()
let password = makePassword(user.password, salt)
let id = db.nextId("person")
echo "createUser: ", user.username, " id=", id
exec(db, sql"""
INSERT INTO person(id, name, password, email, salt, usrStatus, creation, lastOnline, previousVisitAt)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())
""", $id, user.username, password, user.email, salt, $rank)
echo "createUser done: ", user.username
proc initialiseDb(admin: tuple[username, password, email: string],
filename="nimforum.db") =
echo "initialiseDb starting..."
let
path = getCurrentDir() / filename
isTest = "-test" in filename
isDev = "-dev" in filename
if not isDev and not isTest:
backup(path)
echo "Removing file..."
removeFile(path)
echo "Opening DB..."
var db = open(connection="127.0.0.1:9472", user="", password="",
database="nimforum")
echo "DB opened"
const
userNameType = "varchar(20)"
passwordType = "varchar(300)"
emailType = "varchar(254)" # https://stackoverflow.com/a/574698/492186
# -- Category
echo "Creating category table..."
db.exec(sql"""
create table category(
id integer primary key,
name varchar(100) not null,
description varchar(500) not null,
color varchar(10) not null
);
""")
db.exec(sql"""
insert into category (id, name, description, color)
values (0, 'Unsorted', 'No category has been chosen yet.', '000000');
""")
# -- Thread
db.exec(sql"""
create table thread(
id integer primary key,
name varchar(100) not null,
views integer not null,
modified datetime not null default '1970-01-01 00:00:00',
category integer not null default 0,
isLocked boolean not null default 0,
solution integer,
isDeleted boolean not null default 0,
isPinned boolean not null default 0,
foreign key (category) references category(id),
foreign key (solution) references post(id)
);""", [])
db.exec(sql"""
create unique index ThreadNameIx on thread (name);
""", [])
# -- Person
db.exec(sql("""
create table person(
id integer primary key,
name $# not null,
password $# not null,
email $# not null,
creation datetime not null default '1970-01-01 00:00:00',
salt varbin(128) not null,
usrStatus varchar(30) not null,
lastOnline datetime not null default '1970-01-01 00:00:00',
previousVisitAt datetime not null default '1970-01-01 00:00:00',
isDeleted boolean not null default 0,
needsPasswordReset boolean not null default 0
);""" % [userNameType, passwordType, emailType]), [])
db.exec(sql"""
create unique index UserNameIx on person (name);
""", [])
db.exec sql"create index PersonStatusIdx on person(usrStatus);"
# Create default user.
echo "Creating admin..."
db.createUser(admin, Admin)
# Create some test data for development
if isTest or isDev:
echo "Creating dev users..."
for rank in AutoSpammer..Moderator:
let rankLower = toLowerAscii($rank)
let user = (username: $rankLower,
password: $rankLower,
email: $rankLower & "@localhost.local")
echo "Creating user: ", rankLower
db.createUser(user, rank)
echo "Inserting categories..."
db.exec(sql"""
insert into category (id, name, description, color)
values (1, 'Libraries', 'Libraries and library development', '0198E1'),
(2, 'Announcements', 'Announcements by Nim core devs', 'FFEB3B'),
(3, 'Fun', 'Posts that are just for fun', '00897B'),
(4, 'Potential Issues', 'Potential Nim compiler issues', 'E53935');
""")
# -- Post
db.exec(sql"""
create table post(
id integer primary key,
author integer not null,
ip string not null,
content varchar(1000) not null,
thread integer not null,
creation datetime not null default '1970-01-01 00:00:00',
isDeleted boolean not null default 0,
replyingTo integer,
foreign key (thread) references thread(id),
foreign key (author) references person(id),
foreign key (replyingTo) references post(id)
);""", [])
db.exec sql"create index PostByAuthorIdx on post(thread, author);"
db.exec(sql"""
create table postRevision(
id integer primary key,
creation datetime not null default '1970-01-01 00:00:00',
original integer not null,
content varchar(1000) not null,
foreign key (original) references post(id)
)
""")
# -- Session
db.exec(sql("""
create table session(
id integer primary key,
ip string not null,
sessionKey $# not null,
userid integer not null,
lastModified datetime not null default '1970-01-01 00:00:00',
foreign key (userid) references person(id)
);""" % [passwordType]), [])
# -- Likes
db.exec(sql("""
create table postLike(
id integer primary key,
author integer not null,
post integer not null,
creation datetime not null default '1970-01-01 00:00:00',
foreign key (author) references person(id),
foreign key (post) references post(id)
)
"""))
# -- Report
db.exec(sql("""
create table report(
id integer primary key,
author integer not null,
post integer not null,
kind varchar(30) not null,
content varchar(500) not null default '',
foreign key (author) references person(id),
foreign key (post) references post(id)
)
"""))
# -- FTS disabled for BaraDB demo
echo "FTS tables skipped (BaraDB does not support fts4)"
close(db)
proc initialiseConfig(
name, title, hostname: string,
recaptcha: tuple[siteKey, secretKey: string],
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
isDev: bool,
dbPath: string,
ga: string=""
) =
let path = getCurrentDir() / "forum.json"
var j = %{
"name": %name,
"title": %title,
"hostname": %hostname,
"recaptchaSiteKey": %recaptcha.siteKey,
"recaptchaSecretKey": %recaptcha.secretKey,
"smtpAddress": %smtp.address,
"smtpUser": %smtp.user,
"smtpPassword": %smtp.password,
"smtpFromAddr": %smtp.fromAddr,
"smtpTls": %smtp.tls,
"isDev": %isDev,
"dbPath": %dbPath
}
if ga.len > 0:
j["ga"] = %ga
backup(path, some(pretty(j)))
writeFile(path, pretty(j))
proc question(q: string): string =
while result.len == 0:
stdout.write(q)
result = stdin.readLine()
proc setup() =
echo("""
Welcome to the NimForum setup script. Please answer the following questions.
These can be changed later in the generated forum.json file.
""")
let name = question("Forum full name: ")
let title = question("Forum short name: ")
let hostname = question("Forum hostname: ")
let adminUser = question("Admin username: ")
let adminPass = readPasswordFromStdin("Admin password: ")
let adminEmail = question("Admin email: ")
echo("")
echo("The following question are related to recaptcha. \nYou must set up a " &
"recaptcha v2 for your forum before answering them. \nPlease do so now " &
"and then answer these questions: https://www.google.com/recaptcha/admin")
let recaptchaSiteKey = question("Recaptcha site key: ")
let recaptchaSecretKey = question("Recaptcha secret key: ")
echo("The following questions are related to smtp. You must set up a \n" &
"mailing server for your forum or use an external service.")
let smtpAddress = question("SMTP address (eg: mail.hostname.com): ")
let smtpUser = question("SMTP user: ")
let smtpPassword = readPasswordFromStdin("SMTP pass: ")
let smtpFromAddr = question("SMTP sending email address (eg: mail@mail.hostname.com): ")
let smtpTls = parseBool(question("Enable TLS for SMTP: "))
echo("The following is optional. You can specify your Google Analytics ID " &
"if you wish. Otherwise just leave it blank.")
stdout.write("Google Analytics (eg: UA-12345678-1): ")
let ga = stdin.readLine().strip()
let dbPath = "nimforum.db"
initialiseConfig(
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
dbPath, ga
)
initialiseDb(
admin=(adminUser, adminPass, adminEmail),
dbPath
)
echo("Setup complete!")
proc echoHelp() =
quit("""
Usage: setup_nimforum opts
Options:
--setup Performs first time setup for end users.
Development options:
--dev Creates a new development DB and config.
--test Creates a new test DB and config.
--blank Creates a new blank DB.
""")
when isMainModule:
if paramCount() > 0:
case paramStr(1)
of "--dev":
let dbPath = "nimforum-dev.db"
echo("Initialising nimforum for development...")
initialiseConfig(
"Development Forum",
"Development Forum",
"localhost",
recaptcha=("", ""),
smtp=("", "", "", "", false),
isDev=true,
dbPath
)
initialiseDb(
admin=("admin", "admin", "admin@localhost.local"),
dbPath
)
of "--test":
let dbPath = "nimforum-test.db"
echo("Initialising nimforum for testing...")
initialiseConfig(
"Test Forum",
"Test Forum",
"localhost",
recaptcha=("", ""),
smtp=("", "", "", "", false),
isDev=true,
dbPath
)
initialiseDb(
admin=("admin", "admin", "admin@localhost.local"),
dbPath
)
of "--blank":
let dbPath = "nimforum-blank.db"
echo("Initialising blank DB...")
initialiseDb(
admin=("", "", ""),
dbPath
)
of "--setup":
setup()
else:
echoHelp()
else:
echoHelp()
quit()
BIN
View File
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
import baradb_sqlite
var db = open("127.0.0.1:9472", "", "", "default")
db.exec(sql"CREATE TABLE test_adapter (id INT PRIMARY KEY, name STRING)")
db.exec(sql"INSERT INTO test_adapter (id, name) VALUES (1, 'hello')")
db.exec(sql"INSERT INTO test_adapter (id, name) VALUES (2, 'world')")
let rows = db.getAllRows(sql"SELECT * FROM test_adapter")
echo "All rows: ", rows
let row = db.getRow(sql"SELECT * FROM test_adapter WHERE id = 1")
echo "Row 1: ", row
let val = db.getValue(sql"SELECT name FROM test_adapter WHERE id = 2")
echo "Value: ", val
let cnt = db.getValue(sql"SELECT count(*) FROM test_adapter")
echo "Count: ", cnt
db.close()
+192
View File
@@ -0,0 +1,192 @@
import asyncdispatch, smtp, strutils, json, os, xmltree, strtabs,
htmlparser, streams, parseutils, options, logging
from times import getTime, utc, format
import packages/docutils/[rst, rstgen]
# Used to be:
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
let
UsernameIdent* = IdentChars + {'-'} # TODO: Double check that everyone follows this.
import frontend/[karaxutils, error]
export parseInt
type
Config* = object
smtpAddress*: string
smtpPort*: int
smtpUser*: string
smtpPassword*: string
smtpFromAddr*: string
smtpTls*: bool
smtpSsl*: bool
mlistAddress*: string
recaptchaSecretKey*: string
recaptchaSiteKey*: string
isDev*: bool
dbPath*: string
hostname*: string
name*, title*: string
ga*: string
port*: int
ForumError* = object of Exception
data*: PostError
proc newForumError*(message: string,
fields: seq[string] = @[]): ref ForumError =
new(result)
result.msg = message
result.data =
PostError(
errorFields: fields,
message: message
)
var docConfig: StringTableRef
docConfig = rstgen.defaultConfig()
docConfig["doc.listing_start"] = "<pre class='code' data-lang='$2'><code>"
docConfig["doc.listing_end"] = "</code><div class='code-buttons'><button class='btn btn-primary btn-sm'>Run</button></div></pre>"
proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
result = Config(smtpAddress: "", smtpPort: 25, smtpUser: "",
smtpPassword: "", mlistAddress: "")
let root = parseFile(filename)
result.smtpAddress = root{"smtpAddress"}.getStr("")
result.smtpPort = root{"smtpPort"}.getInt(25)
result.smtpUser = root{"smtpUser"}.getStr("")
result.smtpPassword = root{"smtpPassword"}.getStr("")
result.smtpFromAddr = root{"smtpFromAddr"}.getStr("")
result.smtpTls = root{"smtpTls"}.getBool(false)
result.smtpSsl = root{"smtpSsl"}.getBool(false)
result.mlistAddress = root{"mlistAddress"}.getStr("")
result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("")
result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("")
result.isDev = root{"isDev"}.getBool()
result.dbPath = root{"dbPath"}.getStr("nimforum.db")
result.hostname = root["hostname"].getStr()
result.name = root["name"].getStr()
result.title = root["title"].getStr()
result.ga = root{"ga"}.getStr()
result.port = root{"port"}.getInt(5000)
proc processGT(n: XmlNode, tag: string): (int, XmlNode, string) =
result = (0, newElement(tag), tag)
if n.kind == xnElement and len(n) == 1 and n[0].kind == xnElement:
return processGT(n[0], if n[0].kind == xnElement: n[0].tag else: tag)
var countGT = true
for c in items(n):
case c.kind
of xnText:
if c.text == ">" and countGT:
result[0].inc()
else:
countGT = false
result[1].add(newText(c.text))
else:
result[1].add(c)
proc blockquoteFinish(currentBlockquote, newNode: var XmlNode, n: XmlNode) =
if currentBlockquote.len > 0:
#echo(currentBlockquote.repr)
newNode.add(currentBlockquote)
currentBlockquote = newElement("blockquote")
newNode.add(n)
proc processQuotes(node: XmlNode): XmlNode =
# Bolt on quotes.
# TODO: Yes, this is ugly. I wrote it quickly. PRs welcome ;)
result = newElement("div")
var currentBlockquote = newElement("blockquote")
for n in items(node):
case n.kind
of xnElement:
case n.tag
of "p":
let (nesting, contentNode, _) = processGT(n, "p")
if nesting > 0:
var bq = currentBlockquote
for i in 1 ..< nesting:
var newBq = bq.child("blockquote")
if newBq.isNil:
newBq = newElement("blockquote")
bq.add(newBq)
bq = newBq
bq.add(contentNode)
else:
blockquoteFinish(currentBlockquote, result, n)
else:
blockquoteFinish(currentBlockquote, result, n)
of xnText:
if n.text[0] == '\10':
result.add(n)
else:
blockquoteFinish(currentBlockquote, result, n)
else:
blockquoteFinish(currentBlockquote, result, n)
proc replaceMentions(node: XmlNode): seq[XmlNode] =
assert node.kind == xnText
result = @[]
var current = ""
var i = 0
while i < len(node.text):
i += parseUntil(node.text, current, {'@'}, i)
if i >= len(node.text): break
if node.text[i] == '@':
i.inc # Skip @
var username = ""
i += parseWhile(node.text, username, UsernameIdent, i)
if username.len == 0:
result.add(newText(current & "@"))
else:
let el = <>span(
class="user-mention",
data-username=username,
newText("@" & username)
)
result.add(newText(current))
current = ""
result.add(el)
result.add(newText(current))
proc processMentions(node: XmlNode): XmlNode =
case node.kind
of xnText:
result = newElement("span")
for child in replaceMentions(node):
result.add(child)
of xnElement:
case node.tag
of "pre", "code", "tt", "a":
return node
else:
result = newElement(node.tag)
result.attrs = node.attrs
for n in items(node):
result.add(processMentions(n))
else:
return node
proc rstToHtml*(content: string): string {.gcsafe.}=
{.cast(gcsafe).}:
result = rstgen.rstToHtml(content, {roSupportMarkdown},
docConfig)
try:
var node = parseHtml(newStringStream(result))
# rst.nim parser did not support quotes until Nim 1.7:
when (NimMajor, NimMinor) < (1, 7):
if node.kind == xnElement:
node = processQuotes(node)
node = processMentions(node)
result = ""
add(result, node, indWidth=0, addNewLines=false)
except:
warn("Could not parse rst html.")
+78
View File
@@ -0,0 +1,78 @@
import options, osproc, streams, threadpool, os, strformat, httpclient
import webdriver
proc runProcess(cmd: string) =
let p = startProcess(
cmd,
options={
poStdErrToStdOut,
poEvalCommand
}
)
let o = p.outputStream
while p.running and (not o.atEnd):
echo cmd.substr(0, 10), ": ", o.readLine()
p.close()
const backend = "forum"
const port = 5000
const baseUrl = "http://localhost:" & $port & "/"
template withBackend(body: untyped): untyped =
## Starts a new backend instance.
spawn runProcess("nimble -y testbackend")
defer:
discard execCmd("killall " & backend)
echo("Waiting for server...")
var success = false
for i in 0..5:
sleep(5000)
try:
let client = newHttpClient()
doAssert client.getContent(baseUrl).len > 0
success = true
break
except:
echo("Failed to getContent")
doAssert success
body
import browsertests/[scenario1, threads, issue181, categories]
proc main() =
# Kill any already running instances
discard execCmd("killall geckodriver")
spawn runProcess("geckodriver -p 4444 --log config")
defer:
discard execCmd("killall geckodriver")
# Create a fresh DB for the tester.
doAssert(execCmd("nimble testdb") == QuitSuccess)
doAssert(execCmd("nimble -y frontend") == QuitSuccess)
echo("Waiting for geckodriver to startup...")
sleep(5000)
try:
let driver = newWebDriver()
let session = driver.createSession()
withBackend:
scenario1.test(session, baseUrl)
threads.test(session, baseUrl)
categories.test(session, baseUrl)
issue181.test(session, baseUrl)
session.close()
except:
sleep(10000) # See if we can grab any more output.
raise
when isMainModule:
main()
+2
View File
@@ -0,0 +1,2 @@
--threads:on
--path:"../src/frontend"
+214
View File
@@ -0,0 +1,214 @@
import unittest, common
import webdriver
import karaxutils
proc selectCategory(session: Session, name: string) =
with session:
click "#category-selection .dropdown-toggle"
click "#category-selection ." & name
proc createCategory(session: Session, baseUrl, name, color, description: string) =
with session:
navigate baseUrl
click "#categories-btn"
ensureExists "#add-category"
click "#add-category .plus-btn"
clear "#add-category input[name='name']"
clear "#add-category input[name='description']"
sendKeys "#add-category input[name='name']", name
setColor "#add-category input[name='color']", color
sendKeys "#add-category input[name='description']", description
click "#add-category #add-category-btn"
checkText "#category-" & name.slug(), name
proc categoriesUserTests(session: Session, baseUrl: string) =
let
title = "Category Test"
content = "Choosing category test"
suite "user tests":
with session:
navigate baseUrl
login "user", "user"
setup:
with session:
navigate baseUrl
test "no category add available":
with session:
click "#new-thread-btn"
checkIsNone "#add-category"
test "no category add available category page":
with session:
click "#categories-btn"
checkIsNone "#add-category"
test "can create category thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", title
selectCategory "fun"
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .category", "Fun"
navigate baseUrl
ensureExists title, LinkTextSelector
test "can create category thread and change category":
with session:
let newTitle = title & " Selection"
click "#new-thread-btn"
sendKeys "#thread-title", newTitle
selectCategory "fun"
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .category", "Fun"
selectCategory "announcements"
checkText "#thread-title .category", "Announcements"
# Make sure there is no error
checkIsNone "#thread-title .text-error"
navigate baseUrl
ensureExists newTitle, LinkTextSelector
test "can navigate to categories page":
with session:
click "#categories-btn"
ensureExists "#categories-list"
test "can view post under category":
with session:
# create a few threads
click "#new-thread-btn"
sendKeys "#thread-title", "Post 1"
selectCategory "fun"
sendKeys "#reply-textarea", "Post 1"
click "#create-thread-btn"
navigate baseUrl
click "#new-thread-btn"
sendKeys "#thread-title", "Post 2"
selectCategory "announcements"
sendKeys "#reply-textarea", "Post 2"
click "#create-thread-btn"
navigate baseUrl
click "#new-thread-btn"
sendKeys "#thread-title", "Post 3"
selectCategory "unsorted"
sendKeys "#reply-textarea", "Post 3"
click "#create-thread-btn"
navigate baseUrl
click "#categories-btn"
ensureExists "#categories-list"
click "#category-unsorted"
checkText "#threads-list .thread-title a", "Post 3"
for element in session.waitForElements("#threads-list .category-name"):
# Have to user "innerText" because elements are hidden on this page
assert element.getProperty("innerText") == "Unsorted"
selectCategory "announcements"
checkText "#threads-list .thread-title a", "Post 2"
for element in session.waitForElements("#threads-list .category-name"):
assert element.getProperty("innerText") == "Announcements"
selectCategory "fun"
checkText "#threads-list .thread-title a", "Post 1"
for element in session.waitForElements("#threads-list .category-name"):
assert element.getProperty("innerText") == "Fun"
session.logout()
proc categoriesAdminTests(session: Session, baseUrl: string) =
suite "admin tests":
with session:
navigate baseUrl
login "admin", "admin"
test "can create category via dropdown":
let
name = "Category Test"
color = "#720904"
description = "This is a description"
with session:
click "#new-thread-btn"
ensureExists "#add-category"
click "#add-category .plus-btn"
clear "#add-category input[name='name']"
clear "#add-category input[name='description']"
sendKeys "#add-category input[name='name']", name
setColor "#add-category input[name='color']", color
sendKeys "#add-category input[name='description']", description
click "#add-category #add-category-btn"
checkText "#category-selection .selected-category", name
test "can create category on category page":
let
name = "Category Test Page"
color = "#70B4D4"
description = "This is a description on category page"
with session:
createCategory baseUrl, name, color, description
test "category adding disabled on admin logout":
with session:
navigate(baseUrl & "c/0")
ensureExists "#add-category"
logout()
checkIsNone "#add-category"
navigate baseUrl
login "admin", "admin"
session.logout()
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
categoriesUserTests(session, baseUrl)
categoriesAdminTests(session, baseUrl)
session.navigate(baseUrl)
+189
View File
@@ -0,0 +1,189 @@
import os, options, unittest, strutils
import webdriver
import macros
const actionDelayMs {.intdefine.} = 0
## Inserts a delay in milliseconds between automated actions. Useful for debugging tests
macro with*(obj: typed, code: untyped): untyped =
## Execute a set of statements with an object
expectKind code, nnkStmtList
template checkCompiles(res, default) =
when compiles(res):
res
else:
default
result = code.copy
# Simply inject obj into call
for i in 0 ..< result.len:
if result[i].kind in {nnkCommand, nnkCall}:
result[i].insert(1, obj)
result = getAst(checkCompiles(result, code))
proc elementIsSome(element: Option[Element]): bool =
return element.isSome
proc elementIsNone(element: Option[Element]): bool =
return element.isNone
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50,
waitCondition: proc(element: Option[Element]): bool = elementIsSome): Option[Element]
proc click*(session: Session, element: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy)
el.get().click()
proc sendKeys*(session: Session, element, keys: string) =
let el = session.waitForElement(element)
el.get().sendKeys(keys)
proc clear*(session: Session, element: string) =
let el = session.waitForElement(element)
el.get().clear()
proc sendKeys*(session: Session, element: string, keys: varargs[Key]) =
let el = session.waitForElement(element)
# focus
el.get().click()
for key in keys:
session.press(key)
proc ensureExists*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy)
template check*(session: Session, element: string, function: untyped) =
let el = session.waitForElement(element)
check function(el)
template check*(session: Session, element: string,
strategy: LocationStrategy, function: untyped) =
let el = session.waitForElement(element, strategy)
check function(el)
proc setColor*(session: Session, element, color: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy)
discard session.execute("arguments[0].setAttribute('value', '" & color & "')", el.get())
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
template checkText*(session: Session, element, expectedValue: string) =
let el = session.waitForElement(element)
check el.get().getText() == expectedValue
proc waitForElement*(
session: Session, selector: string, strategy=CssSelector,
timeout=20000, pollTime=50,
waitCondition: proc(element: Option[Element]): bool = elementIsSome
): Option[Element] =
var waitTime = 0
when actionDelayMs > 0:
sleep(actionDelayMs)
while true:
try:
let loading = session.findElement(selector, strategy)
if waitCondition(loading):
return loading
finally:
discard
sleep(pollTime)
waitTime += pollTime
if waitTime > timeout:
doAssert false, "Wait for load time exceeded"
proc waitForElements*(
session: Session, selector: string, strategy=CssSelector,
timeout=20000, pollTime=50
): seq[Element] =
var waitTime = 0
when actionDelayMs > 0:
sleep(actionDelayMs)
while true:
let loading = session.findElements(selector, strategy)
if loading.len > 0:
return loading
sleep(pollTime)
waitTime += pollTime
if waitTime > timeout:
doAssert false, "Wait for load time exceeded"
proc setUserRank*(session: Session, baseUrl, user, rank: string) =
with session:
navigate(baseUrl & "profile/" & user)
click "#settings-tab"
click "#rank-field"
click("#rank-field option#rank-" & rank.toLowerAscii)
click "#save-btn"
proc logout*(session: Session) =
with session:
click "#profile-btn"
click "#profile-btn #logout-btn"
# Verify we have logged out by looking for the log in button.
ensureExists "#login-btn"
proc login*(session: Session, user, password: string) =
with session:
click "#login-btn"
clear "#login-form input[name='username']"
clear "#login-form input[name='password']"
sendKeys "#login-form input[name='username']", user
sendKeys "#login-form input[name='password']", password
sendKeys "#login-form input[name='password']", Key.Enter
# Verify that the user menu has been initialised properly.
click "#profile-btn"
checkText "#profile-btn #profile-name", user
click "#profile-btn"
proc register*(session: Session, user, password: string, verify = true) =
with session:
click "#signup-btn"
clear "#signup-form input[name='email']"
clear "#signup-form input[name='username']"
clear "#signup-form input[name='password']"
sendKeys "#signup-form input[name='email']", user & "@" & user & ".com"
sendKeys "#signup-form input[name='username']", user
sendKeys "#signup-form input[name='password']", password
click "#signup-modal .create-account-btn"
if verify:
with session:
# Verify that the user menu has been initialised properly.
click "#profile-btn"
checkText "#profile-btn #profile-name", user
# close menu
click "#profile-btn"
proc createThread*(session: Session, title, content: string) =
with session:
click "#new-thread-btn"
sendKeys "#thread-title", title
sendKeys "#reply-textarea", content
click "#create-thread-btn"
checkText "#thread-title .title-text", title
checkText ".original-post div.post-content", content
+36
View File
@@ -0,0 +1,36 @@
import unittest, common
import webdriver
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
test "can see banned posts":
with session:
register("issue181", "issue181")
logout()
# Change rank to `user` so they can post.
login("admin", "admin")
setUserRank(baseUrl, "issue181", "user")
logout()
login("issue181", "issue181")
navigate(baseUrl)
const title = "Testing issue 181."
createThread(title, "Test for issue #181")
logout()
login("admin", "admin")
# Ban our user.
setUserRank(baseUrl, "issue181", "banned")
# Make sure the banned user's thread is still visible.
navigate(baseUrl)
ensureExists("tr.banned")
checkText("tr.banned .thread-title > a", title)
logout()
checkText("tr.banned .thread-title > a", title)
+43
View File
@@ -0,0 +1,43 @@
import unittest, common
import webdriver
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
# Sanity checks
test "shows sign up":
session.checkText("#signup-btn", "Sign up")
test "shows log in":
session.checkText("#login-btn", "Log in")
test "is empty":
session.checkIsNone("tr > td.thread-title")
# Logging in
test "can login/logout":
with session:
login("admin", "admin")
# Check whether we can log out.
logout()
# Verify we have logged out by looking for the log in button.
ensureExists "#login-btn"
test "can register":
with session:
register("test", "test")
logout()
test "can't register same username with different case":
with session:
register "test1", "test1", verify = false
logout()
navigate baseUrl
register "TEst1", "test1", verify = false
ensureExists "#signup-form .has-error"
navigate baseUrl
+267
View File
@@ -0,0 +1,267 @@
import unittest, common
import webdriver
let
userTitleStr = "This is a user thread!"
userContentStr = "A user has filled this out"
adminTitleStr = "This is a thread title!"
adminContentStr = "This is content"
proc banUser(session: Session, baseUrl: string) =
with session:
login "admin", "admin"
setUserRank baseUrl, "user", "banned"
logout()
proc unBanUser(session: Session, baseUrl: string) =
with session:
login "admin", "admin"
setUserRank baseUrl, "user", "user"
logout()
proc userTests(session: Session, baseUrl: string) =
suite "user thread tests":
session.login("user", "user")
setup:
session.navigate(baseUrl)
test "can create thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", userTitleStr
sendKeys "#reply-textarea", userContentStr
click "#create-thread-btn"
checkText "#thread-title .title-text", userTitleStr
checkText ".original-post div.post-content", userContentStr
test "can delete thread":
with session:
# create thread to be deleted
click "#new-thread-btn"
sendKeys "#thread-title", "To be deleted"
sendKeys "#reply-textarea", "This thread is to be deleted"
click "#create-thread-btn"
click ".post-buttons .delete-button"
# click delete confirmation
click "#delete-modal .delete-btn"
# Make sure the forum post is gone
checkIsNone "To be deleted", LinkTextSelector
test "cannot (un)pin thread":
with session:
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Unpinnable"
sendKeys "#reply-textarea", "Cannot (un)pin as an user"
click "#create-thread-btn"
checkIsNone "#pin-btn"
test "cannot lock threads":
with session:
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Locking"
sendkeys "#reply-textarea", "Cannot lock as an user"
click "#create-thread-btn"
checkIsNone "#lock-btn"
session.logout()
proc anonymousTests(session: Session, baseUrl: string) =
suite "anonymous user tests":
with session:
navigate baseUrl
test "can view banned thread":
with session:
ensureExists userTitleStr, LinkTextSelector
with session:
navigate baseUrl
proc bannedTests(session: Session, baseUrl: string) =
suite "banned user thread tests":
with session:
navigate baseUrl
login "banned", "banned"
test "can't start thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "test"
sendKeys "#reply-textarea", "test"
click "#create-thread-btn"
ensureExists "#new-thread p.text-error"
session.logout()
proc adminTests(session: Session, baseUrl: string) =
suite "admin thread tests":
session.login("admin", "admin")
setup:
session.navigate(baseUrl)
test "can view banned thread":
with session:
ensureExists userTitleStr, LinkTextSelector
test "can create thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", adminTitleStr
sendKeys "#reply-textarea", adminContentStr
click "#create-thread-btn"
checkText "#thread-title .title-text", adminTitleStr
checkText ".original-post div.post-content", adminContentStr
test "try create duplicate thread":
with session:
click "#new-thread-btn"
ensureExists "#new-thread"
sendKeys "#thread-title", adminTitleStr
sendKeys "#reply-textarea", adminContentStr
click "#create-thread-btn"
ensureExists "#new-thread p.text-error"
test "can edit post":
let modificationText = " and I edited it!"
with session:
click adminTitleStr, LinkTextSelector
click ".post-buttons .edit-button"
sendKeys ".original-post #reply-textarea", modificationText
click ".edit-buttons .save-button"
checkText ".original-post div.post-content", adminContentStr & modificationText
test "can like thread":
# Try to like the user thread above
with session:
click userTitleStr, LinkTextSelector
click ".post-buttons .like-button"
checkText ".post-buttons .like-button .like-count", "1"
test "can delete thread":
with session:
click adminTitleStr, LinkTextSelector
click ".post-buttons .delete-button"
# click delete confirmation
click "#delete-modal .delete-btn"
# Make sure the forum post is gone
checkIsNone adminTitleStr, LinkTextSelector
test "can pin a thread":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "Pinned post"
sendKeys "#reply-textarea", "A pinned post"
click "#create-thread-btn"
navigate(baseUrl)
click "#new-thread-btn"
sendKeys "#thread-title", "Normal post"
sendKeys "#reply-textarea", "A normal post"
click "#create-thread-btn"
navigate(baseUrl)
click "Pinned post", LinkTextSelector
click "#pin-btn"
checkText "#pin-btn", "Unpin Thread"
navigate(baseUrl)
# Make sure pin exists
ensureExists "#threads-list .thread-1 .thread-title i"
checkText "#threads-list .thread-1 .thread-title a", "Pinned post"
checkText "#threads-list .thread-2 .thread-title a", "Normal post"
test "can unpin a thread":
with session:
click "Pinned post", LinkTextSelector
click "#pin-btn"
checkText "#pin-btn", "Pin Thread"
navigate(baseUrl)
checkIsNone "#threads-list .thread-2 .thread-title i"
checkText "#threads-list .thread-1 .thread-title a", "Normal post"
checkText "#threads-list .thread-2 .thread-title a", "Pinned post"
test "can lock a thread":
with session:
click "Locking", LinkTextSelector
click "#lock-btn"
ensureExists "#thread-title i.fas.fa-lock.fa-xs"
test "locked thread appears on frontpage":
with session:
click "#new-thread-btn"
sendKeys "#thread-title", "A new locked thread"
sendKeys "#reply-textarea", "This thread should appear locked on the frontpage"
click "#create-thread-btn"
click "#lock-btn"
navigate(baseUrl)
ensureExists "#threads-list .thread-1 .thread-title i.fas.fa-lock.fa-xs"
test "can unlock a thread":
with session:
click "Locking", LinkTextSelector
click "#lock-btn"
checkIsNone "#thread-title i.fas.fa-lock.fa-xs"
session.logout()
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
userTests(session, baseUrl)
banUser(session, baseUrl)
bannedTests(session, baseUrl)
anonymousTests(session, baseUrl)
adminTests(session, baseUrl)
unBanUser(session, baseUrl)
session.navigate(baseUrl)