diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..fde1b09 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,80 @@ +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the action will run. +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [ master ] + pull_request: + branches: [ master ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + test_stable: + runs-on: ubuntu-latest + strategy: + matrix: + firefox: [ '73.0' ] + include: + - nim-version: 'stable' + cache-key: 'stable' + steps: + - uses: actions/checkout@v2 + - name: Checkout submodules + run: git submodule update --init --recursive + + - name: Setup firefox + uses: browser-actions/setup-firefox@latest + with: + firefox-version: ${{ matrix.firefox }} + + - name: Get Date + id: get-date + run: echo "::set-output name=date::$(date "+%Y-%m-%d")" + shell: bash + + - name: Cache choosenim + uses: actions/cache@v2 + with: + path: ~/.choosenim + key: ${{ runner.os }}-choosenim-${{ matrix.cache-key }} + + - name: Cache nimble + uses: actions/cache@v2 + with: + path: ~/.nimble + key: ${{ runner.os }}-nimble-${{ hashFiles('*.nimble') }} + + - uses: jiro4989/setup-nim-action@v1 + with: + nim-version: "${{ matrix.nim-version }}" + + - name: Install geckodriver + run: | + sudo apt-get -qq update + sudo apt-get install autoconf libtool libsass-dev + + wget https://github.com/mozilla/geckodriver/releases/download/v0.29.1/geckodriver-v0.29.1-linux64.tar.gz + mkdir geckodriver + tar -xzf geckodriver-v0.29.1-linux64.tar.gz -C geckodriver + export PATH=$PATH:$PWD/geckodriver + + - name: Install choosenim + run: | + export CHOOSENIM_CHOOSE_VERSION="stable" + 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 MOZ_HEADLESS=1 + nimble -y install + nimble -y test + diff --git a/.gitignore b/.gitignore index b209f11..fe26a8a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,12 @@ nimcache/ forum createdb editdb + +.vscode +forum.json* +browsertester +setup_nimforum +buildcss +nimforum.css + +/src/frontend/forum.js diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b80127e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,44 +0,0 @@ -os: - - linux - -language: c - -cache: - directories: - - "$HOME/.nimble" - - "$HOME/.choosenim" - -addons: - firefox: "60.0.1" - -before_install: - - sudo apt-get -qq update - - sudo apt-get install autoconf libtool - - git clone -b 3.5.4 https://github.com/sass/libsass.git - - cd libsass - - autoreconf --force --install - - | - ./configure \ - --disable-tests \ - --disable-static \ - --enable-shared \ - --prefix=/usr - - sudo make -j5 install - - cd .. - - - wget https://github.com/mozilla/geckodriver/releases/download/v0.20.1/geckodriver-v0.20.1-linux64.tar.gz - - mkdir geckodriver - - tar -xzf geckodriver-v0.20.1-linux64.tar.gz -C geckodriver - - export PATH=$PATH:$PWD/geckodriver - -install: - - export CHOOSENIM_CHOOSE_VERSION="#987bf13" - - | - curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh - sh init.sh -y - - export PATH=$HOME/.nimble/bin:$PATH - - nimble refresh -y - -script: - - export MOZ_HEADLESS=1 - - nimble -y test \ No newline at end of file diff --git a/README.md b/README.md index 9672657..d7dedb4 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,62 @@ test Runs tester fasttest Runs tester without recompiling backend ``` -Development typically involves running `nimble backend` which compiles -and runs the forum's backend, and `nimble frontend` separately to build -the frontend. When making changes to the frontend it should be enough to -simply run `nimble frontend` again to rebuild. This command will also -build the SASS ``nimforum.scss`` file in the `public/css` directory. +To get up and running: +```bash +git clone https://github.com/nim-lang/nimforum +cd nimforum +git submodule update --init --recursive + +# Setup the db with user: admin, pass: admin and some other users +nimble devdb + +# Run this again if frontend code changes +nimble frontend + +# Will start a server at localhost:5000 +nimble backend +``` + +Development typically involves running `nimble devdb` which sets up the +database for development and testing, then `nimble backend` +which compiles and runs the forum's backend, and `nimble frontend` +separately to build the frontend. When making changes to the frontend it +should be enough to simply run `nimble frontend` again to rebuild. This command +will also build the SASS ``nimforum.scss`` file in the `public/css` directory. + +### With docker + +You can easily launch site on localhost if you have `docker` and `docker-compose`. +You don't have to setup dependencies (libsass, sglite, pcre, etc...) on you host PC. + +To get up and running: + +```bash +cd docker +docker-compose build +docker-compose up +``` + +And you can access local NimForum site. +Open http://localhost:5000 . + +# Troubleshooting + +You might have to run `nimble install karax@#5f21dcd`, if setup fails +with: + +``` +andinus@circinus ~/projects/forks/nimforum> nimble --verbose devdb +[...] + Installing karax@#5f21dcd + Tip: 24 messages have been suppressed, use --verbose to show them. + Error: No binaries built, did you specify a valid binary name? +[...] + Error: Exception raised during nimble script execution +``` + +The hash needs to be replaced with the one specified in output. # Copyright diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..cb3191a --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,14 @@ +FROM nimlang/nim:1.2.6-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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..8657235 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.7" + +services: + forum: + build: + context: ../ + dockerfile: ./docker/Dockerfile + volumes: + - "../:/app" + ports: + - "5000:5000" + entrypoint: "/app/docker/entrypoint.sh" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..d8f5923 --- /dev/null +++ b/docker/entrypoint.sh @@ -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 diff --git a/nimforum.nimble b/nimforum.nimble index 64484b1..58a22f7 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -1,5 +1,5 @@ # Package -version = "2.0.0" +version = "2.1.0" author = "Dominik Picheta" description = "The Nim forum" license = "MIT" @@ -12,16 +12,16 @@ skipExt = @["nim"] # Dependencies -requires "nim >= 0.14.0" -requires "jester#64295c8" -requires "bcrypt#head" +requires "nim >= 1.0.6" +requires "jester#405be2e" +requires "bcrypt#440c5676ff6" requires "hmac#9c61ebe2fd134cf97" -requires "recaptcha 1.0.2" +requires "recaptcha#d06488e" requires "sass#649e0701fa5c" -requires "karax#d8df257dd" +requires "karax#5f21dcd" -requires "webdriver#a2be578" +requires "webdriver#429933a" # Tasks @@ -32,11 +32,14 @@ task backend, "Compiles and runs the forum backend": task runbackend, "Runs the forum backend": exec "./src/forum" +task testbackend, "Runs the forum backend in test mode": + exec "nimble c -r -d:skipRateLimitCheck src/forum.nim" + task frontend, "Builds the necessary JS frontend (with CSS)": exec "nimble c -r src/buildcss" exec "nimble js -d:release src/frontend/forum.nim" mkDir "public/js" - cpFile "src/frontend/nimcache/forum.js", "public/js/forum.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" @@ -55,7 +58,7 @@ task blankdb, "Creates a blank DB": task test, "Runs tester": exec "nimble c -y src/forum.nim" - exec "nimble c -y -r tests/browsertester" + exec "nimble c -y -r -d:actionDelayMs=0 tests/browsertester" task fasttest, "Runs tester without recompiling backend": - exec "nimble c -r tests/browsertester" + exec "nimble c -r -d:actionDelayMs=0 tests/browsertester" diff --git a/public/css/nimforum.scss b/public/css/nimforum.scss index e970e8e..2daecdb 100644 --- a/public/css/nimforum.scss +++ b/public/css/nimforum.scss @@ -22,6 +22,7 @@ table th { // Custom styles. // - Navigation bar. $navbar-height: 60px; +$default-category-color: #a3a3a3; $logo-height: $navbar-height - 20px; .navbar-button { @@ -50,6 +51,7 @@ $logo-height: $navbar-height - 20px; // Unfortunately we must colour the controls in the navbar manually. .search-input { @extend .form-input; + min-width: 120px; border-color: $navbar-border-color-dark; } @@ -107,6 +109,40 @@ $logo-height: $navbar-height - 20px; } } +#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; @@ -148,6 +184,33 @@ $logo-height: $navbar-height - 20px; } } +.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; @@ -196,14 +259,12 @@ $threads-meta-color: #545d70; } } -.triangle { - // TODO: Abstract this into a "category" class. +.category-color { width: 0; height: 0; - border-left: 0.3rem solid transparent; - border-right: 0.3rem solid transparent; - border-bottom: 0.6rem solid #98c766; + border: 0.25rem solid $default-category-color; display: inline-block; + margin-right: 5px; } .load-more-separator { @@ -240,6 +301,14 @@ $threads-meta-color: #545d70; } } +.thread-replies, .thread-time, .views-text, .popular-text, .centered-header { + text-align: center; +} + +.thread-users { + text-align: left; +} + .thread-time { color: $threads-meta-color; @@ -253,6 +322,13 @@ $threads-meta-color: #545d70; } +// 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; @@ -703,18 +779,3 @@ hr { margin-top: $control-padding-y*2; } } - -// - Hide features that have not been implemented yet. -#main-buttons > section.navbar-section:nth-child(1) { - display: none; -} - -#threads-list.table { - tr > th:nth-child(2), tr > td:nth-child(2) { - display: none; - } -} - -.category, div.flag-button { - display: none; -} \ No newline at end of file diff --git a/setup.md b/setup.md index 7619e10..d0f2d3e 100644 --- a/setup.md +++ b/setup.md @@ -17,14 +17,14 @@ Extract the downloaded tarball on your server. These steps can be done using the following commands: ``` -wget TODO -tar -xf TODO +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 TODO +cd nimforum_2.0.0_linux ``` ### Dependencies @@ -100,7 +100,7 @@ You should then create a symlink to this file inside ``/etc/nginx/sites-enabled/ ln -s /etc/nginx/sites-available/ /etc/nginx/sites-enabled/ ``` -Then restart nginx by running ``sudo systemctl restart nginx``. +Then reload nginx configuration by running ``sudo nginx -s reload``. ### Supervisor @@ -168,4 +168,4 @@ You should see something like this: ## 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. \ No newline at end of file +via your hostname, assuming that it points to your VPS' IP address. diff --git a/src/auth.nim b/src/auth.nim index 0b08bfe..381b666 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -71,13 +71,13 @@ when isMainModule: "test", "$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG", 1526908753, - "*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\um" + "*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um" ) let ident2 = makeIdentHash( "test", "$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG", 1526908753, - "*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\um" + "*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um" ) doAssert ident == ident2 @@ -85,6 +85,6 @@ when isMainModule: "test", "$2a$08$bY85AhoD1e9u0IsD9sM7Ee6kFSLeXRLxJ6rMgfb1wDnU9liaymoTG", 1526908754, - "*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\um" + "*B2a] IL\"~sh)q-GBd/i$^>.TL]PR~>1IX>Fp-:M3pCm^cFD\\um" ) - doAssert ident != invalid \ No newline at end of file + doAssert ident != invalid diff --git a/src/buildcss.nim b/src/buildcss.nim index 551956a..129bb64 100644 --- a/src/buildcss.nim +++ b/src/buildcss.nim @@ -1,4 +1,4 @@ -import os, strutils +import os import sass diff --git a/src/email.nim b/src/email.nim index 60b4527..580cc76 100644 --- a/src/email.nim +++ b/src/email.nim @@ -20,7 +20,7 @@ proc newMailer*(config: Config): Mailer = proc rateCheck(mailer: Mailer, address: string): bool = ## Returns true if we've emailed the address too much. let diff = getTime() - mailer.lastReset - if diff.hours >= 1: + if diff.inHours >= 1: mailer.lastReset = getTime() mailer.emailsSent.clear() @@ -30,7 +30,6 @@ proc rateCheck(mailer: Mailer, address: string): bool = proc sendMail( mailer: Mailer, subject, message, recipient: string, - fromAddr = "forum@nim-lang.org", otherHeaders:seq[(string, string)] = @[] ) {.async.} = # Ensure we aren't emailing this address too much. @@ -41,21 +40,37 @@ proc sendMail( 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)) - var client = newAsyncSmtp() - 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", fromAddr)) + 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(fromAddr, toList, $encoded) + await client.sendMail(mailer.config.smtpFromAddr, toList, $encoded) proc sendPassReset(mailer: Mailer, email, user, resetUrl: string) {.async.} = let message = """Hello $1, @@ -133,4 +148,4 @@ proc sendSecureEmail*( if emailSentFut.error of ForumError: raise emailSentFut.error else: - raise newForumError("Couldn't send email", @["email"]) \ No newline at end of file + raise newForumError("Couldn't send email", @["email"]) diff --git a/src/forum.nim b/src/forum.nim index e55aa06..c2682eb 100644 --- a/src/forum.nim +++ b/src/forum.nim @@ -8,7 +8,7 @@ import system except Thread import os, strutils, times, md5, strtabs, math, db_sqlite, - scgi, jester, asyncdispatch, asyncnet, sequtils, + jester, asyncdispatch, asyncnet, sequtils, parseutils, random, rst, recaptcha, json, re, sugar, strformat, logging import cgi except setCookie @@ -76,7 +76,6 @@ proc getGravatarUrl(email: string, size = 80): string = # ----------------------------------------------------------------------------- -template `||`(x: untyped): untyped = (if not isNil(x): x else: "") proc validateCaptcha(recaptchaResp, ip: string) {.async.} = # captcha validation: @@ -115,27 +114,27 @@ proc sendResetPassword( ) proc logout(c: TForumData) = - const query = sql"delete from session where ip = ? and key = ?" + const query = sql"delete from session where key = ?" c.username = "" c.userpass = "" - exec(db, query, c.req.ip, c.req.cookies["sid"]) + exec(db, query, c.req.cookies["sid"]) proc checkLoggedIn(c: TForumData) = if not c.req.cookies.hasKey("sid"): return let sid = c.req.cookies["sid"] if execAffectedRows(db, sql("update session set lastModified = DATETIME('now') " & - "where ip = ? and key = ?"), - c.req.ip, sid) > 0: + "where key = ?"), + sid) > 0: c.userid = getValue(db, - sql"select userid from session where ip = ? and key = ?", - c.req.ip, sid) + sql"select userid from session where key = ?", + sid) let row = getRow(db, sql"select name, email, status from person where id = ?", c.userid) - c.username = ||row[0] - c.email = ||row[1] - c.rank = parseEnum[Rank](||row[2]) + c.username = row[0] + c.email = row[1] + c.rank = parseEnum[Rank](row[2]) # In order to handle the "last visit" line appropriately, i.e. # it shouldn't disappear after a refresh, we need to manage a @@ -152,7 +151,7 @@ proc checkLoggedIn(c: TForumData) = ) c.previousVisitAt = personRow[1].parseInt let diff = getTime() - fromUnix(personRow[0].parseInt) - if diff.minutes > 30: + if diff.inMinutes > 30: c.previousVisitAt = personRow[0].parseInt db.exec( sql""" @@ -239,7 +238,7 @@ proc verifyIdentHash( let newIdent = makeIdentHash(name, row[0], epoch, row[1]) # Check that it hasn't expired. let diff = getTime() - epoch.fromUnix() - if diff.hours > 2: + if diff.inHours > 2: raise newForumError("Link expired") if newIdent != ident: raise newForumError("Invalid ident hash") @@ -277,25 +276,26 @@ template createTFD() = new(c) init(c) c.req = request - if request.cookies.len > 0: + if cookies(request).len > 0: checkLoggedIn(c) #[ DB functions. TODO: Move to another module? ]# proc selectUser(userRow: seq[string], avatarSize: int=80): User = result = User( - name: userRow[0], - avatarUrl: userRow[1].getGravatarUrl(avatarSize), - lastOnline: userRow[2].parseInt, - previousVisitAt: userRow[3].parseInt, - rank: parseEnum[Rank](userRow[4]), - isDeleted: userRow[5] == "1" + id: userRow[0], + name: userRow[1], + avatarUrl: userRow[2].getGravatarUrl(avatarSize), + lastOnline: userRow[3].parseInt, + previousVisitAt: userRow[4].parseInt, + rank: parseEnum[Rank](userRow[5]), + isDeleted: userRow[6] == "1" ) # Don't give data about a deleted user. if result.isDeleted: result.name = "DeletedUser" - result.avatarUrl = getGravatarUrl(result.name & userRow[1], avatarSize) + result.avatarUrl = getGravatarUrl(result.name & userRow[2], avatarSize) proc selectPost(postRow: seq[string], skippedPosts: seq[int], replyingTo: Option[PostLink], history: seq[PostInfo], @@ -303,7 +303,7 @@ proc selectPost(postRow: seq[string], skippedPosts: seq[int], return Post( id: postRow[0].parseInt, replyingTo: replyingTo, - author: selectUser(postRow[5..10]), + author: selectUser(postRow[5..11]), likes: likes, seen: false, # TODO: history: history, @@ -319,7 +319,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] = const replyingToQuery = sql""" select p.id, strftime('%s', p.creation), p.thread, - u.name, u.email, strftime('%s', u.lastOnline), + u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.status, u.isDeleted, t.name @@ -335,7 +335,7 @@ proc selectReplyingTo(replyingTo: string): Option[PostLink] = topic: row[^1], threadId: row[2].parseInt(), postId: row[0].parseInt(), - author: some(selectUser(row[3..8])) + author: some(selectUser(row[3..9])) )) proc selectHistory(postId: int): seq[PostInfo] = @@ -354,7 +354,7 @@ proc selectHistory(postId: int): seq[PostInfo] = proc selectLikes(postId: int): seq[User] = const likeQuery = sql""" - select u.name, u.email, strftime('%s', u.lastOnline), + select u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.status, u.isDeleted from like h, person u @@ -369,7 +369,7 @@ proc selectLikes(postId: int): seq[User] = proc selectThreadAuthor(threadId: int): User = const authorQuery = sql""" - select name, email, strftime('%s', lastOnline), + select id, name, email, strftime('%s', lastOnline), strftime('%s', previousVisitAt), status, isDeleted from person where id in ( select author from post @@ -387,7 +387,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread = where thread = ?;""" const usersListQuery = sql""" - select name, email, strftime('%s', lastOnline), + select u.id, name, email, strftime('%s', lastOnline), strftime('%s', previousVisitAt), status, u.isDeleted, count(*) from person u, post p where p.author = u.id and p.thread = ? @@ -400,10 +400,10 @@ proc selectThread(threadRow: seq[string], author: User): Thread = id: threadRow[0].parseInt, topic: threadRow[1], category: Category( - id: threadRow[5].parseInt, - name: threadRow[6], - description: threadRow[7], - color: threadRow[8] + id: threadRow[6].parseInt, + name: threadRow[7], + description: threadRow[8], + color: threadRow[9] ), users: @[], replies: posts[0].parseInt-1, @@ -412,6 +412,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread = creation: posts[1].parseInt, isLocked: threadRow[4] == "1", isSolved: false, # TODO: Add a field to `post` to identify the solution. + isPinned: threadRow[5] == "1" ) # Gather the users list. @@ -435,8 +436,9 @@ proc executeReply(c: TForumData, threadId: int, content: string, else: raise newForumError("You are not allowed to post") - if rateLimitCheck(c): - raise newForumError("You're posting too fast!") + when not defined(skipRateLimitCheck): + if rateLimitCheck(c): + raise newForumError("You're posting too fast!") if content.strip().len == 0: raise newForumError("Message cannot be empty") @@ -458,13 +460,21 @@ proc executeReply(c: TForumData, threadId: int, content: string, if isLocked == "1": raise newForumError("Cannot reply to a locked thread.") - let retID = insertID( - db, - crud(crCreate, "post", "author", "ip", "content", "thread", "replyingTo"), - c.userId, c.req.ip, content, $threadId, - if replyingTo.isSome(): $replyingTo.get() - else: nil - ) + var retID: int64 + + if replyingTo.isSome(): + retID = insertID( + db, + crud(crCreate, "post", "author", "ip", "content", "thread", "replyingTo"), + c.userId, c.req.ip, content, $threadId, $replyingTo.get() + ) + else: + retID = insertID( + db, + crud(crCreate, "post", "author", "ip", "content", "thread"), + c.userId, c.req.ip, content, $threadId + ) + discard tryExec( db, crud(crCreate, "post_fts", "id", "content"), @@ -490,10 +500,10 @@ proc updatePost(c: TForumData, postId: int, content: string, # Verify that the current user has permissions to edit the specified post. let creation = fromUnix(postRow[1].parseInt) - let isArchived = (getTime() - creation).weeks > 8 + let isArchived = (getTime() - creation).inHours >= 2 let canEdit = c.rank == Admin or c.userid == postRow[0] - if isArchived: - raise newForumError("This post is archived and can no longer be edited") + if isArchived and c.rank < Admin: + raise newForumError("This post is too old and can no longer be edited") if not canEdit: raise newForumError("You cannot edit this post") @@ -520,10 +530,20 @@ proc updatePost(c: TForumData, postId: int, content: string, if row[0] == $postId: exec(db, crud(crUpdate, "thread", "name"), subject.get(), threadId) -proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) = +proc updateThread(c: TForumData, threadId: string, queryKeys: seq[string], queryValues: seq[string]) = + let threadAuthor = selectThreadAuthor(threadId.parseInt) + + # Verify that the current user has permissions to edit the specified thread. + let canEdit = c.rank in {Admin, Moderator} or c.userid == threadAuthor.id + if not canEdit: + raise newForumError("You cannot edit this thread") + + exec(db, crud(crUpdate, "thread", queryKeys), queryValues) + +proc executeNewThread(c: TForumData, subject, msg, categoryID: string): (int64, int64) = const query = sql""" - insert into thread(name, views, modified) values (?, 0, DATETIME('now')) + insert into thread(name, views, modified, category) values (?, 0, DATETIME('now'), ?) """ assert c.loggedIn() @@ -543,13 +563,18 @@ proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) = if msg.len == 0: raise newForumError("Message is empty", @["msg"]) + let catID = getInt(categoryID, -1) + if catID == -1: + raise newForumError("CategoryID is invalid", @["categoryId"]) + if not validateRst(c, msg): raise newForumError("Message needs to be valid RST", @["msg"]) - if rateLimitCheck(c): - raise newForumError("You're posting too fast!") + when not defined(skipRateLimitCheck): + if rateLimitCheck(c): + raise newForumError("You're posting too fast!") - result[0] = tryInsertID(db, query, subject).int + result[0] = tryInsertID(db, query, subject, categoryID).int if result[0] < 0: raise newForumError("Subject already exists", @["subject"]) @@ -608,7 +633,7 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, raise newForumError("Invalid username", @["username"]) if getValue( db, - sql"select name from person where name = ? and isDeleted = 0", + sql"select name from person where name = ? collate nocase and isDeleted = 0", name ).len > 0: raise newForumError("Username already exists", @["username"]) @@ -651,6 +676,18 @@ proc executeLike(c: TForumData, postId: int) = # Save the like. exec(db, crud(crCreate, "like", "author", "post"), c.userid, postId) +proc executeNewCategory(c: TForumData, name, color, description: string): int64 = + + let canAdd = c.rank == Admin + + if not canAdd: + raise newForumError("You do not have permissions to add a category.") + + if name.len == 0: + raise newForumError("Category name must not be empty!", @["name"]) + + result = insertID(db, crud(crCreate, "category", "name", "color", "description"), name, color, description) + proc executeUnlike(c: TForumData, postId: int) = # Verify the post and like exists for the current user. const likeQuery = sql""" @@ -673,15 +710,25 @@ proc executeLockState(c: TForumData, threadId: int, locked: bool) = # Save the like. exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId) +proc executePinState(c: TForumData, threadId: int, pinned: bool) = + if c.rank < Moderator: + raise newForumError("You do not have permission to pin this thread.") + + # (Un)pin this thread + exec(db, crud(crUpdate, "thread", "isPinned"), pinned.int, threadId) + proc executeDeletePost(c: TForumData, postId: int) = # Verify that this post belongs to the user. const postQuery = sql""" - select p.id from post p + select p.author, p.id from post p where p.author = ? and p.id = ? """ - let id = getValue(db, postQuery, c.username, postId) + let + row = getRow(db, postQuery, c.username, postId) + author = row[0] + id = row[1] - if id.len == 0 and c.rank < Admin: + if id.len == 0 and not (c.rank == Admin or c.userid == author): raise newForumError("You cannot delete this post") # Set the `isDeleted` flag. @@ -736,7 +783,7 @@ proc updateProfile( raise newForumError("Rank needs a change when setting new email.") await sendSecureEmail( - mailer, ActivateEmail, c.req, row[0], row[1], row[2], row[3] + mailer, ActivateEmail, c.req, row[0], row[1], email, row[3] ) validateEmail(email, checkDuplicated=wasEmailChanged) @@ -756,34 +803,65 @@ settings: routes: + get "/categories.json": + # TODO: Limit this query in the case of many many categories + const categoriesQuery = + sql""" + select c.*, count(thread.category) + from category c + left join thread on c.id == thread.category + group by c.id; + """ + + var list = CategoryList(categories: @[]) + for data in getAllRows(db, categoriesQuery): + let category = Category( + id: data[0].getInt, name: data[1], description: data[2], color: data[3], numTopics: data[4].parseInt + ) + list.categories.add(category) + + resp $(%list), "application/json" + get "/threads.json": var start = getInt(@"start", 0) count = getInt(@"count", 30) + categoryId = getInt(@"categoryId", -1) + + var + categorySection = "" + categoryArgs: seq[string] = @[$start, $count] + countQuery = sql"select count(*) from thread;" + countArgs: seq[string] = @[] + + if categoryId != -1: + categorySection = "c.id == ? and " + countQuery = sql"select count(*) from thread t, category c where category == c.id and c.id == ?;" + countArgs.add($categoryId) + categoryArgs.insert($categoryId, 0) const threadsQuery = - sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, + """select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, c.id, c.name, c.description, c.color, - u.name, u.email, strftime('%s', u.lastOnline), + u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.status, u.isDeleted from thread t, category c, person u - where t.isDeleted = 0 and category = c.id and + where t.isDeleted = 0 and category = c.id and $# u.status <> 'Spammer' and u.status <> 'Troll' and - u.status <> 'Banned' and - u.id in ( - select u.id from post p, person u - where p.author = u.id and p.thread = t.id - order by u.id + u.id = ( + select p.author from post p + where p.thread = t.id + order by p.author limit 1 ) - order by modified desc limit ?, ?;""" + order by isPinned desc, modified desc limit ?, ?;""" - let thrCount = getValue(db, sql"select count(*) from thread;").parseInt() + let thrCount = getValue(db, countQuery, countArgs).parseInt() let moreCount = max(0, thrCount - (start + count)) var list = ThreadList(threads: @[], moreCount: moreCount) - for data in getAllRows(db, threadsQuery, start, count): - let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1])) + for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs): + let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1])) list.threads.add(thread) resp $(%list), "application/json" @@ -798,19 +876,24 @@ routes: count = 10 const threadsQuery = - sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, + sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, c.id, c.name, c.description, c.color from thread t, category c where t.id = ? and isDeleted = 0 and category = c.id;""" let threadRow = getRow(db, threadsQuery, id) + if threadRow[0].len == 0: + let err = PostError( + message: "Specified thread does not exist" + ) + resp Http404, $(%err), "application/json" let thread = selectThread(threadRow, selectThreadAuthor(id)) let postsQuery = sql( """select p.id, p.content, strftime('%s', p.creation), p.author, p.replyingTo, - u.name, u.email, strftime('%s', u.lastOnline), + u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.status, u.isDeleted from post p, person u @@ -849,15 +932,20 @@ routes: get "/specific_posts.json": createTFD() - var + var ids: JsonNode + try: ids = parseJson(@"ids") - + except JsonParsingError: + let err = PostError( + message: "Invalid JSON in the `ids` parameter" + ) + resp Http400, $(%err), "application/json" cond ids.kind == JArray let intIDs = ids.elems.map(x => x.getInt()) let postsQuery = sql(""" select p.id, p.content, strftime('%s', p.creation), p.author, p.replyingTo, - u.name, u.email, strftime('%s', u.lastOnline), + u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.status, u.isDeleted from post p, person u @@ -926,7 +1014,7 @@ routes: """ % postsFrom) let userQuery = sql(""" - select name, email, strftime('%s', lastOnline), + select id, name, email, strftime('%s', lastOnline), strftime('%s', previousVisitAt), status, isDeleted, strftime('%s', creation), id from person @@ -952,7 +1040,7 @@ routes: getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt() if c.rank >= Admin or c.username == username: - profile.email = some(userRow[1]) + profile.email = some(userRow[2]) for row in db.getAllRows(postsQuery, username): profile.posts.add( @@ -1023,8 +1111,22 @@ routes: let session = executeLogin(c, username, password) setCookie("sid", session) resp Http200, "{}", "application/json" - except ForumError: - let exc = (ref ForumError)(getCurrentException()) + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" + + post "/createCategory": + createTFD() + let formData = request.formData + + let name = formData["name"].body + let color = formData["color"].body.replace("#", "") + let description = formData["description"].body + + try: + let id = executeNewCategory(c, name, color, description) + let category = Category(id: id.int, name: name, color: color, description: description) + resp Http200, $(%category), "application/json" + except ForumError as exc: resp Http400, $(%exc.data), "application/json" get "/status.json": @@ -1073,7 +1175,8 @@ routes: except EParseError: let err = PostError( errorFields: @[], - message: getCurrentExceptionMsg() + message: "Message needs to be valid RST! Error: " & + getCurrentExceptionMsg() ) resp Http400, $(%err), "application/json" @@ -1137,6 +1240,45 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" + post "/updateThread": + # TODO: Add some way of keeping track of modifications for historical + # purposes + createTFD() + if not c.loggedIn(): + let err = PostError( + errorFields: @[], + message: "Not logged in." + ) + resp Http401, $(%err), "application/json" + + let formData = request.formData + + cond "threadId" in formData + + let threadId = formData["threadId"].body + + # TODO: might want to add more properties here under a tighter permissions + # model + let keys = ["name", "category", "solution"] + + # optional parameters + var + queryValues: seq[string] = @[] + queryKeys: seq[string] = @[] + + for key in keys: + if key in formData: + queryKeys.add(key) + queryValues.add(formData[key].body) + + if queryKeys.len() > 0: + queryValues.add(threadId) + try: + updateThread(c, threadId, queryKeys, queryValues) + resp Http200, "{}", "application/json" + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" + post "/newthread": createTFD() if not c.loggedIn(): @@ -1149,13 +1291,14 @@ routes: let formData = request.formData cond "msg" in formData cond "subject" in formData + cond "categoryId" in formData let msg = formData["msg"].body let subject = formData["subject"].body - # TODO: category + let categoryID = formData["categoryId"].body try: - let res = executeNewThread(c, subject, msg) + let res = executeNewThread(c, subject, msg, categoryID) resp Http200, $(%[res[0], res[1]]), "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" @@ -1214,6 +1357,33 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" + post re"/(pin|unpin)": + createTFD() + if not c.loggedIn(): + let err = PostError( + errorFields: @[], + message: "Not logged in." + ) + resp Http401, $(%err), "application/json" + + let formData = request.formData + cond "id" in formData + + let threadId = getInt(formData["id"].body, -1) + cond threadId != -1 + + try: + case request.path + of "/pin": + executePinState(c, threadId, true) + of "/unpin": + executePinState(c, threadId, false) + else: + assert false + resp Http200, "{}", "application/json" + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" + post re"/delete(Post|Thread)": createTFD() if not c.loggedIn(): @@ -1282,8 +1452,7 @@ routes: try: await updateProfile(c, username, email, rank) resp Http200, "{}", "application/json" - except ForumError: - let exc = (ref ForumError)(getCurrentException()) + except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/sendResetPassword": @@ -1311,8 +1480,7 @@ routes: c, formData["email"].body, recaptcha, request.host ) resp Http200, "{}", "application/json" - except ForumError: - let exc = (ref ForumError)(getCurrentException()) + except ForumError as exc: resp Http400, $(%exc.data), "application/json" post "/resetPassword": @@ -1350,7 +1518,7 @@ routes: ) resp Http200, "{}", "application/json" except ForumError as exc: - resp Http400, $(%exc.data),"application/json" + resp Http400, $(%exc.data), "application/json" post "/activateEmail": createTFD() @@ -1371,7 +1539,7 @@ routes: ) resp Http200, "{}", "application/json" except ForumError as exc: - resp Http400, $(%exc.data),"application/json" + resp Http400, $(%exc.data), "application/json" get "/t/@id": cond "id" in request.params @@ -1448,7 +1616,7 @@ routes: postId: rowFT[2].parseInt(), postContent: content, creation: rowFT[4].parseInt(), - author: selectUser(rowFT[5 .. 10]), + author: selectUser(rowFT[5 .. 11]), ) ) @@ -1456,4 +1624,4 @@ routes: get re"/(.*)": cond request.matches[0].splitFile.ext == "" - resp karaxHtml \ No newline at end of file + resp karaxHtml diff --git a/src/frontend/about.nim b/src/frontend/about.nim index a805a12..2ceb1d0 100644 --- a/src/frontend/about.nim +++ b/src/frontend/about.nim @@ -1,11 +1,11 @@ when defined(js): - import sugar, httpcore, options, json + import sugar, httpcore import dom except Event include karax/prelude - import karax / [kajax, kdom] + import karax / [kajax] - import error, replybox, threadlist, post + import error import karaxutils type diff --git a/src/frontend/activateemail.nim b/src/frontend/activateemail.nim index 4b049da..607bd4f 100644 --- a/src/frontend/activateemail.nim +++ b/src/frontend/activateemail.nim @@ -5,7 +5,7 @@ when defined(js): include karax/prelude import karax / [kajax, kdom] - import error, replybox, threadlist, post + import error import karaxutils type @@ -13,17 +13,12 @@ when defined(js): loading: bool status: HttpCode error: Option[PostError] - newPassword: kstring proc newActivateEmail*(): ActivateEmail = ActivateEmail( - status: Http200, - newPassword: "" + status: Http200 ) - proc onPassChange(e: Event, n: VNode, state: ActivateEmail) = - state.newPassword = n.value - proc onPost(httpStatus: int, response: kstring, state: ActivateEmail) = postFinished: navigateTo(makeUri("/activateEmail/success")) diff --git a/src/frontend/addcategorymodal.nim b/src/frontend/addcategorymodal.nim new file mode 100644 index 0000000..1232afb --- /dev/null +++ b/src/frontend/addcategorymodal.nim @@ -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" diff --git a/src/frontend/category.nim b/src/frontend/category.nim index 6c20f94..314720d 100644 --- a/src/frontend/category.nim +++ b/src/frontend/category.nim @@ -5,24 +5,44 @@ type 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, kajax, kdom] - + import karax / [vstyles] import karaxutils - proc render*(category: Category): VNode = - result = buildHtml(): - if category.name.len >= 0: + 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="triangle", + tdiv(class="category-color", style=style( - (StyleAttr.borderBottom, - kstring"0.6rem solid #" & category.color) + (StyleAttr.border, + kstring"0.25rem solid #" & category.color) )) - text category.name - else: - span() \ No newline at end of file + 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) \ No newline at end of file diff --git a/src/frontend/categorylist.nim b/src/frontend/categorylist.nim new file mode 100644 index 0000000..3c0e533 --- /dev/null +++ b/src/frontend/categorylist.nim @@ -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) diff --git a/src/frontend/categorypicker.nim b/src/frontend/categorypicker.nim new file mode 100644 index 0000000..f26f6c1 --- /dev/null +++ b/src/frontend/categorypicker.nim @@ -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) diff --git a/src/frontend/delete.nim b/src/frontend/delete.nim index 789aebc..d5415cf 100644 --- a/src/frontend/delete.nim +++ b/src/frontend/delete.nim @@ -1,6 +1,7 @@ when defined(js): import sugar, httpcore, options, json import dom except Event + import jsffi except `&` include karax/prelude import karax / [kajax, kdom] @@ -59,7 +60,7 @@ when defined(js): formData.append("id", $state.post.id) of DeleteThread: formData.append("id", $state.thread.id) - ajaxPost(uri, @[], cast[cstring](formData), + ajaxPost(uri, @[], formData.to(cstring), (s: int, r: kstring) => onDeletePost(s, r, state)) proc onClose(ev: Event, n: VNode, state: DeleteModal) = @@ -94,7 +95,7 @@ when defined(js): proc render*(state: DeleteModal): VNode = result = buildHtml(): tdiv(class=class({"active": state.shown}, "modal modal-sm"), - id="login-modal"): + id="delete-modal"): a(href="", class="modal-overlay", "aria-label"="close", onClick=(ev: Event, n: VNode) => onClose(ev, n, state)) tdiv(class="modal-container"): @@ -122,11 +123,11 @@ when defined(js): button(class=class( {"loading": state.loading}, - "btn btn-primary" + "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", + button(class="btn cancel-btn", onClick=(ev: Event, n: VNode) => (state.shown = false)): - text "Cancel" \ No newline at end of file + text "Cancel" diff --git a/src/frontend/editbox.nim b/src/frontend/editbox.nim index 3f8753e..61d2a1c 100644 --- a/src/frontend/editbox.nim +++ b/src/frontend/editbox.nim @@ -1,5 +1,6 @@ when defined(js): import httpcore, options, sugar, json + import jsffi except `&` include karax/prelude import karax/kajax @@ -54,7 +55,7 @@ when defined(js): formData.append("postId", $state.post.id) # TODO: Subject let uri = makeUri("/updatePost") - ajaxPost(uri, @[], cast[cstring](formData), + ajaxPost(uri, @[], formData.to(cstring), (s: int, r: kstring) => onEditPost(s, r, state)) proc render*(state: EditBox, post: Post): VNode = @@ -87,7 +88,7 @@ when defined(js): text state.error.get().message tdiv(class="edit-buttons"): - tdiv(class="reply-button"): + tdiv(class="cancel-button"): button(class="btn btn-link", onClick=(e: Event, n: VNode) => (state.onEditCancel())): text " Cancel" @@ -95,4 +96,4 @@ when defined(js): button(class=class({"loading": state.loading}, "btn btn-primary"), onClick=(e: Event, n: VNode) => state.save()): italic(class="fas fa-check") - text " Save" \ No newline at end of file + text " Save" diff --git a/src/frontend/error.nim b/src/frontend/error.nim index 4a23c44..0b67f5d 100644 --- a/src/frontend/error.nim +++ b/src/frontend/error.nim @@ -1,13 +1,12 @@ -import options, httpcore +import httpcore type PostError* = object errorFields*: seq[string] ## IDs of the fields with an error. message*: string when defined(js): - import json + import json, options include karax/prelude - import karax / [vstyles, kajax, kdom] import karaxutils @@ -86,8 +85,8 @@ when defined(js): state.error = some(error) except: - kout(getCurrentExceptionMsg().cstring) + echo getCurrentExceptionMsg() state.error = some(PostError( errorFields: @[], message: "Unknown error occurred." - )) \ No newline at end of file + )) diff --git a/src/frontend/forum.nim b/src/frontend/forum.nim index cfc4151..da74eab 100644 --- a/src/frontend/forum.nim +++ b/src/frontend/forum.nim @@ -1,10 +1,12 @@ -import strformat, times, options, json, tables, sugar, httpcore, uri +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 @@ -49,7 +51,7 @@ 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. - kout(kstring"New URL: ", window.location.href, " ", state.url.href) + 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. @@ -81,9 +83,17 @@ 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)) + (render(state.newThread, getLoggedInUser())) ), r("/profile/@username", (params: Params) => diff --git a/src/frontend/header.nim b/src/frontend/header.nim index fc16941..7cfb133 100644 --- a/src/frontend/header.nim +++ b/src/frontend/header.nim @@ -1,12 +1,13 @@ -import options, times, httpcore, json, sugar +import options, httpcore -import threadlist, user +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] @@ -31,7 +32,7 @@ when defined(js): var state = newState() - proc getStatus(logout: bool=false) + proc getStatus(logout=false) proc newState(): State = State( data: none[UserStatus](), @@ -60,10 +61,10 @@ when defined(js): state.lastUpdate = getTime() - proc getStatus(logout: bool=false) = + proc getStatus(logout=false) = if state.loading: return let diff = getTime() - state.lastUpdate - if diff.minutes < 5: + if diff.inMinutes < 5: return state.loading = true @@ -95,8 +96,8 @@ when defined(js): section(class="navbar-section"): tdiv(class="input-group input-inline"): input(class="search-input input-sm", - `type`="text", placeholder="search", - id="search-box", + `type`="search", placeholder="Search", + id="search-box", required="required", onKeyDown=onKeyDown) if state.loading: tdiv(class="loading") diff --git a/src/frontend/karaxutils.nim b/src/frontend/karaxutils.nim index 462d000..f70ec5d 100644 --- a/src/frontend/karaxutils.nim +++ b/src/frontend/karaxutils.nim @@ -1,4 +1,15 @@ -import strutils, options, strformat, parseutils, tables +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.. onLogInPost(s, r, state)) proc onClose(ev: Event, n: VNode, state: LoginModal) = @@ -93,4 +94,4 @@ when defined(js): (state.onSignUp(); state.shown = false)): text "Create account" - render(state.resetPasswordModal, recaptchaSiteKey) \ No newline at end of file + render(state.resetPasswordModal, recaptchaSiteKey) diff --git a/src/frontend/mainbuttons.nim b/src/frontend/mainbuttons.nim new file mode 100644 index 0000000..c91d354 --- /dev/null +++ b/src/frontend/mainbuttons.nim @@ -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" diff --git a/src/frontend/newthread.nim b/src/frontend/newthread.nim index 8e701a2..9189bc0 100644 --- a/src/frontend/newthread.nim +++ b/src/frontend/newthread.nim @@ -1,12 +1,13 @@ 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 - import karaxutils + import error, replybox, threadlist, post, user + import karaxutils, categorypicker type NewThread* = ref object @@ -14,11 +15,13 @@ when defined(js): error: Option[PostError] replyBox: ReplyBox subject: kstring + categoryPicker: CategoryPicker proc newNewThread*(): NewThread = NewThread( replyBox: newReplyBox(nil), - subject: "" + subject: "", + categoryPicker: newCategoryPicker() ) proc onSubjectChange(e: Event, n: VNode, state: NewThread) = @@ -37,31 +40,40 @@ when defined(js): 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()) - ajaxPost(uri, @[], cast[cstring](formData), + formData.append("categoryId", $categoryID) + + ajaxPost(uri, @[], formData.to(cstring), (s: int, r: kstring) => onCreatePost(s, r, state)) - proc render*(state: NewThread): VNode = + 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(class="form-input", `type`="text", name="subject", + 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(class=class( - {"loading": state.loading}, - "btn btn-primary" + 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" \ No newline at end of file + text "Create thread" diff --git a/src/frontend/post.nim b/src/frontend/post.nim index c32b490..dc12e47 100644 --- a/src/frontend/post.nim +++ b/src/frontend/post.nim @@ -1,6 +1,6 @@ -import strformat, options +import options -import user, threadlist +import user type PostInfo* = object @@ -32,7 +32,8 @@ proc lastEdit*(post: Post): PostInfo = post.history[^1] proc isModerated*(post: Post): bool = - ## Determines whether the specified thread is under moderation. + ## 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 = @@ -57,10 +58,10 @@ type email*: Option[string] when defined(js): - import karaxutils + 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) \ No newline at end of file + renderPostUrl(link.threadId, link.postId) diff --git a/src/frontend/postbutton.nim b/src/frontend/postbutton.nim index b76f6a0..9fa4ab4 100644 --- a/src/frontend/postbutton.nim +++ b/src/frontend/postbutton.nim @@ -7,6 +7,7 @@ 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 @@ -116,7 +117,7 @@ when defined(js): makeUri("/unlike") else: makeUri("/like") - ajaxPost(uri, @[], cast[cstring](formData), + ajaxPost(uri, @[], formData.to(cstring), (s: int, r: kstring) => onPost(s, r, state, post, currentUser.get())) @@ -172,7 +173,7 @@ when defined(js): makeUri("/unlock") else: makeUri("/lock") - ajaxPost(uri, @[], cast[cstring](formData), + ajaxPost(uri, @[], formData.to(cstring), (s: int, r: kstring) => onPost(s, r, state, thread)) @@ -189,7 +190,7 @@ when defined(js): else: "" result = buildHtml(): - button(class="btn btn-secondary", + button(class="btn btn-secondary", id="lock-btn", onClick=(e: Event, n: VNode) => onLockClick(e, n, state, thread), "data-tooltip"=tooltip, @@ -200,4 +201,61 @@ when defined(js): text " Unlock Thread" else: italic(class="fas fa-lock") - text " Lock Thread" \ No newline at end of file + 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" + diff --git a/src/frontend/postlist.nim b/src/frontend/postlist.nim index 006af52..66b3162 100644 --- a/src/frontend/postlist.nim +++ b/src/frontend/postlist.nim @@ -1,6 +1,6 @@ import system except Thread -import options, json, times, httpcore, strformat, sugar, math, strutils +import options, json, times, httpcore, sugar, strutils import sequtils import threadlist, category, post, user @@ -15,17 +15,20 @@ type when defined(js): from dom import document + import jsffi except `&` include karax/prelude - import karax / [vstyles, kajax, kdom] + 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. @@ -33,8 +36,11 @@ when defined(js): 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) @@ -44,17 +50,38 @@ when defined(js): 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() + 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 @@ -66,6 +93,7 @@ when defined(js): 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(): @@ -179,6 +207,20 @@ when defined(js): 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 = @@ -220,8 +262,11 @@ when defined(js): ): VNode = let postCopy = post # TODO: Another workaround here, closure capture :( + let originalPost = thread.author == post.author + result = buildHtml(): - tdiv(class=class({"highlight": highlight}, "post"), id = $post.id): + 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"): @@ -283,12 +328,12 @@ when defined(js): ] var diffStr = tmpl[0] let diff = latestTime - prevPost.info.creation.fromUnix() - if diff.weeks > 48: - let years = diff.weeks div 48 + if diff.inWeeks > 48: + let years = diff.inWeeks div 48 diffStr = (if years == 1: tmpl[1] else: tmpl[2]) % $years - elif diff.weeks > 4: - let months = diff.weeks div 4 + elif diff.inWeeks > 4: + let months = diff.inWeeks div 4 diffStr = (if months == 1: tmpl[3] else: tmpl[4]) % $months else: @@ -326,8 +371,11 @@ when defined(js): let list = state.list.get() result = buildHtml(): section(class="container grid-xl"): - tdiv(class="title"): - p(): text list.thread.topic + 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") @@ -340,7 +388,7 @@ when defined(js): italic(class="fas fa-check-square fa-xs", title="Thread has a solution") text "Solved" - render(list.thread.category) + genCategories(list.thread, currentUser) tdiv(class="posts"): var prevPost: Option[Post] = none[Post]() for i, post in list.posts: @@ -365,7 +413,8 @@ when defined(js): 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) \ No newline at end of file + render(state.deleteModal) diff --git a/src/frontend/profile.nim b/src/frontend/profile.nim index 48f519a..fbfea68 100644 --- a/src/frontend/profile.nim +++ b/src/frontend/profile.nim @@ -1,12 +1,12 @@ -import options, httpcore, json, sugar, times, strformat, strutils +import options, httpcore, json, sugar, times, strutils -import threadlist, post, category, error, user +import threadlist, post, error, user when defined(js): from dom import document include karax/prelude import karax/[kajax, kdom] - import karaxutils, postbutton, delete, profilesettings + import karaxutils, profilesettings type ProfileTab* = enum @@ -119,7 +119,7 @@ when defined(js): ), onClick=(e: Event, n: VNode) => (state.currentTab = Overview) ): - a(class="c-hand"): + a(id="overview-tab", class="c-hand"): text "Overview" li(class=class( {"active": state.currentTab == Settings}, @@ -127,7 +127,7 @@ when defined(js): ), onClick=(e: Event, n: VNode) => (state.currentTab = Settings) ): - a(class="c-hand"): + a(id="settings-tab", class="c-hand"): italic(class="fas fa-cog") text " Settings" @@ -147,4 +147,4 @@ when defined(js): genPostLink(thread) of Settings: if state.settings.isSome(): - render(state.settings.get(), currentUser) \ No newline at end of file + render(state.settings.get(), currentUser) diff --git a/src/frontend/profilesettings.nim b/src/frontend/profilesettings.nim index 07862b7..8d7fda3 100644 --- a/src/frontend/profilesettings.nim +++ b/src/frontend/profilesettings.nim @@ -1,10 +1,11 @@ when defined(js): import httpcore, options, sugar, json, strutils, strformat + import jsffi except `&` include karax/prelude import karax/[kajax, kdom] - import replybox, post, karaxutils, postbutton, error, delete, user + import post, karaxutils, postbutton, error, delete, user type ProfileSettings* = ref object @@ -68,7 +69,7 @@ when defined(js): formData.append("rank", $state.rank) formData.append("username", $state.profile.user.name) let uri = makeUri("/saveProfile") - ajaxPost(uri, @[], cast[cstring](formData), + ajaxPost(uri, @[], formData.to(cstring), (s: int, r: kstring) => onProfilePost(s, r, state)) proc needsSave(state: ProfileSettings): bool = @@ -88,7 +89,7 @@ when defined(js): class="form-select", value = $state.rank, onchange=(e: Event, n: VNode) => onRankChange(e, n, state)): for r in Rank: - option(text $r) + 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." @@ -165,7 +166,8 @@ when defined(js): label(class="form-label"): text "Account" tdiv(class="col-9 col-sm-12"): - button(class="btn btn-secondary", `type`="button", + 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") @@ -176,16 +178,19 @@ when defined(js): span(class="text-error"): text state.error.get().message - button(class=class( + button(id="cancel-btn", + class=class( {"disabled": not needsSave(state)}, "btn btn-link" ), onClick=(e: Event, n: VNode) => (resetSettings(state))): text "Cancel" - button(class=class( + button(id="save-btn", + class=class( {"disabled": not needsSave(state)}, "btn btn-primary" ), - onClick=(e: Event, n: VNode) => save(state)): + onClick=(e: Event, n: VNode) => save(state), + id="save-btn"): italic(class="fas fa-save") text " Save" @@ -198,4 +203,4 @@ when defined(js): rankField.setInputText($state.rank) let emailField = getVNodeById("email-field") if not emailField.isNil: - emailField.setInputText($state.email) \ No newline at end of file + emailField.setInputText($state.email) diff --git a/src/frontend/replybox.nim b/src/frontend/replybox.nim index 8d56e1d..64e5864 100644 --- a/src/frontend/replybox.nim +++ b/src/frontend/replybox.nim @@ -1,5 +1,6 @@ when defined(js): import strformat, options, httpcore, json, sugar + import jsffi except `&` from dom import getElementById, scrollIntoView, setTimeout @@ -26,7 +27,7 @@ when defined(js): proc performScroll() = let replyBox = dom.document.getElementById("reply-box") - replyBox.scrollIntoView(false) + replyBox.scrollIntoView() proc show*(state: ReplyBox) = # Scroll to the reply box. @@ -44,7 +45,7 @@ when defined(js): proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) = postFinished: - kout(response) + echo response state.rendering = some[kstring](response) proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) = @@ -56,7 +57,7 @@ when defined(js): let formData = newFormData() formData.append("msg", state.text) let uri = makeUri("/preview") - ajaxPost(uri, @[], cast[cstring](formData), + ajaxPost(uri, @[], formData.to(cstring), (s: int, r: kstring) => onPreviewPost(s, r, state)) proc onMessageClick(e: Event, n: VNode, state: ReplyBox) = @@ -80,7 +81,7 @@ when defined(js): if replyingTo.isSome: formData.append("replyingTo", $replyingTo.get().id) let uri = makeUri("/createPost") - ajaxPost(uri, @[], cast[cstring](formData), + ajaxPost(uri, @[], formData.to(cstring), (s: int, r: kstring) => onReplyPost(s, r, state)) proc onCancelClick(e: Event, n: VNode, state: ReplyBox) = @@ -114,7 +115,8 @@ when defined(js): elif state.rendering.isSome(): verbatim(state.rendering.get()) else: - textarea(class="form-input post-text-area", rows="5", + textarea(id="reply-textarea", + class="form-input post-text-area", rows="5", onChange=(e: Event, n: VNode) => onChange(e, n, state), value=state.text) @@ -162,4 +164,4 @@ when defined(js): button(class="btn"): italic(class="fas fa-arrow-up") tdiv(class="information-content"): - renderContent(state, some(thread), post) \ No newline at end of file + renderContent(state, some(thread), post) diff --git a/src/frontend/resetpassword.nim b/src/frontend/resetpassword.nim index c2f01a1..497af98 100644 --- a/src/frontend/resetpassword.nim +++ b/src/frontend/resetpassword.nim @@ -1,11 +1,12 @@ when defined(js): import sugar, httpcore, options, json - import dom except Event + import dom except Event, KeyboardEvent + import jsffi except `&` include karax/prelude import karax / [kajax, kdom] - import error, replybox, threadlist, post + import error import karaxutils type @@ -86,7 +87,7 @@ when defined(js): let form = dom.document.getElementById("resetpassword-form") # TODO: This is a hack, karax should support this. let formData = newFormData(form) - ajaxPost(uri, @[], cast[cstring](formData), + ajaxPost(uri, @[], formData.to(cstring), (s: int, r: kstring) => onPost(s, r, state)) ev.preventDefault() @@ -152,4 +153,4 @@ when defined(js): ), `type`="button", onClick=(ev: Event, n: VNode) => onClick(ev, n, state)): - text "Reset password" \ No newline at end of file + text "Reset password" diff --git a/src/frontend/search.nim b/src/frontend/search.nim index 2edb90e..a1ef3fa 100644 --- a/src/frontend/search.nim +++ b/src/frontend/search.nim @@ -20,7 +20,7 @@ when defined(js): from dom import nil include karax/prelude - import karax / [vstyles, kajax, kdom] + import karax / [kajax] import karaxutils, error, threadlist, sugar diff --git a/src/frontend/signup.nim b/src/frontend/signup.nim index 20a14ae..98e330d 100644 --- a/src/frontend/signup.nim +++ b/src/frontend/signup.nim @@ -1,6 +1,7 @@ when defined(js): import sugar, httpcore, options, json import dom except Event + import jsffi except `&` include karax/prelude import karax / [kajax, kdom] @@ -28,7 +29,7 @@ when defined(js): let form = dom.document.getElementById("signup-form") # TODO: This is a hack, karax should support this. let formData = newFormData(form) - ajaxPost(uri, @[], cast[cstring](formData), + ajaxPost(uri, @[], formData.to(cstring), (s: int, r: kstring) => onSignUpPost(s, r, state)) proc onClose(ev: Event, n: VNode, state: SignupModal) = @@ -78,18 +79,19 @@ when defined(js): "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"), - onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)): + 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", + 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(href=makeUri("/about/license"), + a(id="license", href=makeUri("/about/license"), onClick=(ev: Event, n: VNode) => (state.shown = false; anchorCB(ev, n))): text "content license" - text "." \ No newline at end of file + text "." diff --git a/src/frontend/threadlist.nim b/src/frontend/threadlist.nim index 710c2c5..ecec6da 100644 --- a/src/frontend/threadlist.nim +++ b/src/frontend/threadlist.nim @@ -15,6 +15,7 @@ type creation*: int64 ## Unix timestamp isLocked*: bool isSolved*: bool + isPinned*: bool ThreadList* = ref object threads*: seq[Thread] @@ -22,29 +23,38 @@ type 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 + 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 + status: Http200, + mainButtons: newMainButtons( + onCategoryChange = + (oldCategory: Category, newCategory: Category) => (state.list = none[ThreadList]()) + ) ) - var - state = newState() + state = newState() proc visibleTo*[T](thread: T, user: Option[User]): bool = ## Determines whether the specified thread (or post) should be @@ -53,38 +63,19 @@ when defined(js): ## ## 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 < Moderator and thread.isModerated: + if rank < Rank.Moderator and thread.isModerated: return thread.author == user.get() return true - proc genTopButtons(currentUser: Option[User]): 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" - button(class="btn btn-primary"): text "Latest" - button(class="btn btn-link"): text "Most Active" - button(class="btn btn-link"): text "Categories" - section(class="navbar-section"): - if currentUser.isSome(): - a(href=makeUri("/newthread"), onClick=anchorCB): - button(class="btn btn-secondary"): - italic(class="fas fa-plus") - text " New Thread" - proc genUserAvatars(users: seq[User]): VNode = - result = buildHtml(td): + result = buildHtml(td(class="thread-users")): for user in users: render(user, "avatar avatar-sm", showStatus=true) text " " @@ -95,44 +86,48 @@ when defined(js): let duration = currentTime - activityTime if currentTime.local().year != activityTime.local().year: return activityTime.local().format("MMM yyyy") - elif duration.days > 30 and duration.days < 300: + elif duration.inDays > 30 and duration.inDays < 300: return activityTime.local().format("MMM dd") - elif duration.days != 0: - return $duration.days & "d" - elif duration.hours != 0: - return $duration.hours & "h" - elif duration.minutes != 0: - return $duration.minutes & "m" + 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.seconds & "s" + return $duration.inSeconds & "s" - proc genThread(thread: Thread, isNew: bool, noBorder: bool): VNode = - let isOld = (getTime() - thread.creation.fromUnix).weeks > 2 - let isBanned = thread.author.rank < Moderated + 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})): + 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: - if isBanned: - italic(class="fas fa-ban fa-xs", - title="Thread author is banned") - else: - italic(class="fas fa-eye-slash fa-xs", - title="Thread is moderated") + 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): + a(href=makeUri("/t/" & $thread.id), onClick=anchorCB): text thread.topic - td(): + 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(): text $thread.replies - td(class=class({ + 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 @@ -166,10 +161,13 @@ when defined(js): else: state.list = some(list) - proc onLoadMore(ev: Event, n: VNode) = + proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) = state.loading = true let start = state.list.get().threads.len - ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList) + 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] @@ -194,29 +192,34 @@ when defined(js): isNew: thread.creation > previousVisitAt ) - proc genThreadList(currentUser: Option[User]): VNode = + 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 - ajaxGet(makeUri("threads.json"), @[], onThreadList) + 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="container grid-xl"): # TODO: Rename to `.thread-list`. + section(class="thread-list"): table(class="table", id="threads-list"): thead(): tr: th(text "Topic") - th(text "Category") - th(style=style((StyleAttr.width, kstring"8rem"))): text "Users" - th(text "Replies") - th(text "Views") - th(text "Activity") + 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] @@ -224,8 +227,9 @@ when defined(js): let isLastThread = i+1 == list.threads.len let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser) - genThread(thread, isNew, - noBorder=isLastUnseen or isLastThread) + genThread(i+1, thread, isNew, + noBorder=isLastUnseen or isLastThread, + displayCategory=displayCategory) if isLastUnseen and (not isLastThread): tr(class="last-visit-separator"): td(colspan="6"): @@ -237,10 +241,11 @@ when defined(js): td(colspan="6"): tdiv(class="loading loading-lg") else: - td(colspan="6", onClick=onLoadMore): + td(colspan="6", + onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))): span(text "load more threads") - proc renderThreadList*(currentUser: Option[User]): VNode = + proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode = result = buildHtml(tdiv): - genTopButtons(currentUser) - genThreadList(currentUser) \ No newline at end of file + state.mainButtons.render(currentUser, categoryId=categoryId) + genThreadList(currentUser, categoryId) diff --git a/src/frontend/user.nim b/src/frontend/user.nim index bbf5782..db874c3 100644 --- a/src/frontend/user.nim +++ b/src/frontend/user.nim @@ -1,13 +1,13 @@ -import times +import times, options type # If you add more "Banned" states, be sure to modify forum's threadsQuery too. Rank* {.pure.} = enum ## serialized as 'status' Spammer ## spammer: every post is invisible - Troll ## troll: cannot write new posts - Banned ## A non-specific ban 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, @@ -17,6 +17,7 @@ type Admin ## Admin: can do everything User* = object + id*: string name*: string avatarUrl*: string lastOnline*: int64 @@ -27,6 +28,9 @@ type 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 @@ -34,6 +38,9 @@ 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 {Spammer, Troll, Banned} + when defined(js): include karax/prelude import karaxutils @@ -69,4 +76,4 @@ when defined(js): title="User is a moderator") of Admin: italic(class="fas fa-chess-knight", - title="User is an admin") \ No newline at end of file + title="User is an admin") diff --git a/src/frontend/usermenu.nim b/src/frontend/usermenu.nim index 65ea45c..89954a9 100644 --- a/src/frontend/usermenu.nim +++ b/src/frontend/usermenu.nim @@ -24,7 +24,7 @@ when defined(js): proc render*(state: UserMenu, user: User): VNode = result = buildHtml(): - tdiv(): + 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) @@ -52,13 +52,15 @@ when defined(js): tdiv(class="tile-icon"): img(class="avatar", src=user.avatarUrl, title=user.name) - tdiv(class="tile-content"): + tdiv(id="profile-name", class="tile-content"): text user.name li(class="divider") li(class="menu-item"): - a(href=makeUri("/profile/" & user.name)): + a(id="myprofile-btn", + href=makeUri("/profile/" & user.name)): text "My profile" li(class="menu-item c-hand"): - a(onClick = (e: Event, n: VNode) => + a(id="logout-btn", + onClick = (e: Event, n: VNode) => (state.shown=false; state.onLogout())): - text "Logout" \ No newline at end of file + text "Logout" diff --git a/src/fts.sql b/src/fts.sql index e5490f2..1590a05 100644 --- a/src/fts.sql +++ b/src/fts.sql @@ -7,6 +7,7 @@ SELECT post_id, post_content, cdate, + person.id, person.name AS author, person.email AS email, strftime('%s', person.lastOnline) AS lastOnline, @@ -46,6 +47,7 @@ SELECT 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, diff --git a/src/setup_nimforum.nim b/src/setup_nimforum.nim index a015f2d..c80ad3b 100644 --- a/src/setup_nimforum.nim +++ b/src/setup_nimforum.nim @@ -22,10 +22,25 @@ proc backup(path: string, contents: Option[string]=none[string]()) = 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) + + exec(db, sql""" + INSERT INTO person(name, password, email, salt, status, lastOnline) + VALUES (?, ?, ?, ?, ?, DATETIME('now')) + """, user.username, password, user.email, salt, $rank) + proc initialiseDb(admin: tuple[username, password, email: string], filename="nimforum.db") = - let path = getCurrentDir() / filename - if "-dev" notin filename and "-test" notin filename: + let + path = getCurrentDir() / filename + isTest = "-test" in filename + isDev = "-dev" in filename + + if not isDev and not isTest: backup(path) removeFile(path) @@ -51,7 +66,7 @@ proc initialiseDb(admin: tuple[username, password, email: string], db.exec(sql""" insert into category (id, name, description, color) - values (0, 'Default', '', ''); + values (0, 'Unsorted', 'No category has been chosen yet.', ''); """) # -- Thread @@ -66,6 +81,7 @@ proc initialiseDb(admin: tuple[username, password, email: string], 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) @@ -98,13 +114,24 @@ proc initialiseDb(admin: tuple[username, password, email: string], db.exec sql"create index PersonStatusIdx on person(status);" # Create default user. - if admin.username.len != 0: - let salt = makeSalt() - let password = makePassword(admin.password, salt) + db.createUser(admin, Admin) + + # Create some test data for development + if isTest or isDev: + for rank in Spammer..Moderator: + let rankLower = toLowerAscii($rank) + let user = (username: $rankLower, + password: $rankLower, + email: $rankLower & "@localhost.local") + db.createUser(user, rank) + db.exec(sql""" - insert into person (id, name, password, email, salt, status) - values (1, ?, ?, ?, ?, ?); - """, admin.username, password, admin.email, salt, $Admin) + insert into category (name, description, color) + values ('Libraries', 'Libraries and library development', '0198E1'), + ('Announcements', 'Announcements by Nim core devs', 'FFEB3B'), + ('Fun', 'Posts that are just for fun', '00897B'), + ('Potential Issues', 'Potential Nim compiler issues', 'E53935'); + """) # -- Post @@ -208,7 +235,7 @@ proc initialiseDb(admin: tuple[username, password, email: string], proc initialiseConfig( name, title, hostname: string, recaptcha: tuple[siteKey, secretKey: string], - smtp: tuple[address, user, password: string], + smtp: tuple[address, user, password, fromAddr: string, tls: bool], isDev: bool, dbPath: string, ga: string="" @@ -224,6 +251,8 @@ proc initialiseConfig( "smtpAddress": %smtp.address, "smtpUser": %smtp.user, "smtpPassword": %smtp.password, + "smtpFromAddr": %smtp.fromAddr, + "smtpTls": %smtp.tls, "isDev": %isDev, "dbPath": %dbPath } @@ -255,7 +284,7 @@ These can be changed later in the generated forum.json file. echo("") echo("The following question are related to recaptcha. \nYou must set up a " & - "recaptcha for your forum before answering them. \nPlease do so now " & + "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: ") @@ -266,6 +295,8 @@ These can be changed later in the generated forum.json file. 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.") @@ -275,7 +306,7 @@ These can be changed later in the generated forum.json file. let dbPath = "nimforum.db" initialiseConfig( name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey), - (smtpAddress, smtpUser, smtpPassword), isDev=false, + (smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false, dbPath, ga ) @@ -310,7 +341,7 @@ when isMainModule: "Development Forum", "localhost", recaptcha=("", ""), - smtp=("", "", ""), + smtp=("", "", "", "", false), isDev=true, dbPath ) @@ -327,7 +358,7 @@ when isMainModule: "Test Forum", "localhost", recaptcha=("", ""), - smtp=("", "", ""), + smtp=("", "", "", "", false), isDev=true, dbPath ) diff --git a/src/utils.nim b/src/utils.nim index 82b1e51..4b1d339 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -1,26 +1,24 @@ import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs, htmlparser, streams, parseutils, options, logging -from times import getTime, getGMTime, format +from times import getTime, utc, format # Used to be: # {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'} let - UsernameIdent* = IdentChars # TODO: Double check that everyone follows this. + UsernameIdent* = IdentChars + {'-'} # TODO: Double check that everyone follows this. import frontend/[karaxutils, error] export parseInt -proc `%`*[T](opt: Option[T]): JsonNode = - ## Generic constructor for JSON data. Creates a new ``JNull JsonNode`` - ## if ``opt`` is empty, otherwise it delegates to the underlying value. - if opt.isSome: %opt.get else: newJNull() - type Config* = object smtpAddress*: string smtpPort*: int smtpUser*: string smtpPassword*: string + smtpFromAddr*: string + smtpTls*: bool + smtpSsl*: bool mlistAddress*: string recaptchaSecretKey*: string recaptchaSiteKey*: string @@ -55,9 +53,12 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config = smtpPassword: "", mlistAddress: "") let root = parseFile(filename) result.smtpAddress = root{"smtpAddress"}.getStr("") - result.smtpPort = root{"smtpPort"}.getNum(25).int + 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("") @@ -67,7 +68,7 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config = result.name = root["name"].getStr() result.title = root["title"].getStr() result.ga = root{"ga"}.getStr() - result.port = root{"port"}.getNum(5000).int + result.port = root{"port"}.getInt(5000) proc processGT(n: XmlNode, tag: string): (int, XmlNode, string) = result = (0, newElement(tag), tag) diff --git a/tests/browsertester.nim b/tests/browsertester.nim index 77d4ada..8b6c046 100644 --- a/tests/browsertester.nim +++ b/tests/browsertester.nim @@ -23,7 +23,7 @@ const baseUrl = "http://localhost:" & $port & "/" template withBackend(body: untyped): untyped = ## Starts a new backend instance. - spawn runProcess("nimble -y runbackend") + spawn runProcess("nimble -y testbackend") defer: discard execCmd("killall " & backend) @@ -43,9 +43,11 @@ template withBackend(body: untyped): untyped = body -import browsertests/scenario1 +import browsertests/[scenario1, threads, issue181, categories] -when isMainModule: +proc main() = + # Kill any already running instances + discard execCmd("killall geckodriver") spawn runProcess("geckodriver -p 4444 --log config") defer: discard execCmd("killall geckodriver") @@ -63,8 +65,14 @@ when isMainModule: 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 \ No newline at end of file + raise + +when isMainModule: + main() diff --git a/tests/browsertester.nims b/tests/browsertester.nims index 9d57ecf..3cd49f0 100644 --- a/tests/browsertester.nims +++ b/tests/browsertester.nims @@ -1 +1,2 @@ ---threads:on \ No newline at end of file +--threads:on +--path:"../src/frontend" \ No newline at end of file diff --git a/tests/browsertests/categories.nim b/tests/browsertests/categories.nim new file mode 100644 index 0000000..8c27a87 --- /dev/null +++ b/tests/browsertests/categories.nim @@ -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) diff --git a/tests/browsertests/common.nim b/tests/browsertests/common.nim new file mode 100644 index 0000000..e5924f3 --- /dev/null +++ b/tests/browsertests/common.nim @@ -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 diff --git a/tests/browsertests/issue181.nim b/tests/browsertests/issue181.nim new file mode 100644 index 0000000..031cd92 --- /dev/null +++ b/tests/browsertests/issue181.nim @@ -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) diff --git a/tests/browsertests/scenario1.nim b/tests/browsertests/scenario1.nim index e190e03..dc78007 100644 --- a/tests/browsertests/scenario1.nim +++ b/tests/browsertests/scenario1.nim @@ -1,115 +1,43 @@ -import unittest, options, os +import unittest, common import webdriver -proc waitForLoad(session: Session, timeout=20000) = - var waitTime = 0 - sleep(2000) - - while true: - let loading = session.findElement(".loading") - if loading.isNone: return - sleep(1000) - waitTime += 1000 - - if waitTime > timeout: - doAssert false, "Wait for load time exceeded" - proc test*(session: Session, baseUrl: string) = session.navigate(baseUrl) - waitForLoad(session) - # Sanity checks test "shows sign up": - let signUp = session.findElement("#signup-btn") - check signUp.get().getText() == "Sign up" + session.checkText("#signup-btn", "Sign up") test "shows log in": - let logIn = session.findElement("#login-btn") - check logIn.get().getText() == "Log in" + session.checkText("#login-btn", "Log in") test "is empty": - let thread = session.findElement("tr > td.thread-title") - check thread.isNone() + session.checkIsNone("tr > td.thread-title") # Logging in test "can login/logout": - let logIn = session.findElement("#login-btn").get() - logIn.click() + with session: + login("admin", "admin") - let usernameField = session.findElement( - "#login-form input[name='username']" - ) - check usernameField.isSome() - let passwordField = session.findElement( - "#login-form input[name='password']" - ) - check passwordField.isSome() - - usernameField.get().sendKeys("admin") - passwordField.get().sendKeys("admin") - passwordField.get().click() # Focus field. - session.press(Key.Enter) - - waitForLoad(session, 5000) - - # Verify that the user menu has been initialised properly. - let profileButton = session.findElement( - "#main-navbar figure.avatar" - ).get() - profileButton.click() - - let profileName = session.findElement( - "#main-navbar .menu-right div.tile-content" - ).get() - - check profileName.getText() == "admin" - - # Check whether we can log out. - let logoutLink = session.findElement( - "Logout", - LinkTextSelector - ).get() - logoutLink.click() - - # Verify we have logged out by looking for the log in button. - check session.findElement("#login-btn").isSome() + # 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": - let signup = session.findElement("#signup-btn").get() - signup.click() + with session: + register("test", "test") + logout() - let emailField = session.findElement( - "#signup-form input[name='email']" - ).get() - let usernameField = session.findElement( - "#signup-form input[name='username']" - ).get() - let passwordField = session.findElement( - "#signup-form input[name='password']" - ).get() + test "can't register same username with different case": + with session: + register "test1", "test1", verify = false + logout() - emailField.sendKeys("test@test.com") - usernameField.sendKeys("test") - passwordField.sendKeys("test") + navigate baseUrl - let createAccount = session.findElement( - "#signup-modal .modal-footer .btn-primary" - ).get() + register "TEst1", "test1", verify = false - createAccount.click() - - waitForLoad(session, 5000) - - # Verify that the user menu has been initialised properly. - let profileButton = session.findElement( - "#main-navbar figure.avatar" - ).get() - profileButton.click() - - let profileName = session.findElement( - "#main-navbar .menu-right div.tile-content" - ).get() - - check profileName.getText() == "test" \ No newline at end of file + ensureExists "#signup-form .has-error" + navigate baseUrl diff --git a/tests/browsertests/threads.nim b/tests/browsertests/threads.nim new file mode 100644 index 0000000..32ce686 --- /dev/null +++ b/tests/browsertests/threads.nim @@ -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)