diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index fde1b09..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,80 +0,0 @@ -# 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 fe26a8a..b209f11 100644 --- a/.gitignore +++ b/.gitignore @@ -12,12 +12,3 @@ 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 new file mode 100644 index 0000000..b6177c4 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,44 @@ +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="#f92d61b1f4e193bd" + - | + 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 d7dedb4..c8057cd 100644 --- a/README.md +++ b/README.md @@ -63,23 +63,6 @@ test Runs tester fasttest Runs tester without recompiling backend ``` -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` @@ -87,38 +70,6 @@ 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 deleted file mode 100644 index cb3191a..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 8657235..0000000 --- a/docker/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100755 index d8f5923..0000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/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 58a22f7..1ea21bd 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -1,5 +1,5 @@ # Package -version = "2.1.0" +version = "2.0.1" author = "Dominik Picheta" description = "The Nim forum" license = "MIT" @@ -12,16 +12,16 @@ skipExt = @["nim"] # Dependencies -requires "nim >= 1.0.6" -requires "jester#405be2e" -requires "bcrypt#440c5676ff6" +requires "nim >= 0.18.1" +requires "jester 0.4.0" +requires "bcrypt#head" requires "hmac#9c61ebe2fd134cf97" -requires "recaptcha#d06488e" +requires "recaptcha 1.0.2" requires "sass#649e0701fa5c" -requires "karax#5f21dcd" +requires "karax#d8df257dd" -requires "webdriver#429933a" +requires "webdriver#20f3c1b" # Tasks @@ -32,14 +32,11 @@ 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/forum.js", "public/js/forum.js" + cpFile "src/frontend/nimcache/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" @@ -58,7 +55,7 @@ task blankdb, "Creates a blank DB": task test, "Runs tester": exec "nimble c -y src/forum.nim" - exec "nimble c -y -r -d:actionDelayMs=0 tests/browsertester" + exec "nimble c -y -r tests/browsertester" task fasttest, "Runs tester without recompiling backend": - exec "nimble c -r -d:actionDelayMs=0 tests/browsertester" + exec "nimble c -r tests/browsertester" diff --git a/public/css/nimforum.scss b/public/css/nimforum.scss index 2daecdb..e970e8e 100644 --- a/public/css/nimforum.scss +++ b/public/css/nimforum.scss @@ -22,7 +22,6 @@ table th { // Custom styles. // - Navigation bar. $navbar-height: 60px; -$default-category-color: #a3a3a3; $logo-height: $navbar-height - 20px; .navbar-button { @@ -51,7 +50,6 @@ $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; } @@ -109,40 +107,6 @@ $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; @@ -184,33 +148,6 @@ $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; @@ -259,12 +196,14 @@ $threads-meta-color: #545d70; } } -.category-color { +.triangle { + // TODO: Abstract this into a "category" class. width: 0; height: 0; - border: 0.25rem solid $default-category-color; + border-left: 0.3rem solid transparent; + border-right: 0.3rem solid transparent; + border-bottom: 0.6rem solid #98c766; display: inline-block; - margin-right: 5px; } .load-more-separator { @@ -301,14 +240,6 @@ $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; @@ -322,13 +253,6 @@ $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; @@ -779,3 +703,18 @@ 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 d0f2d3e..ea3dc1b 100644 --- a/setup.md +++ b/setup.md @@ -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 reload nginx configuration by running ``sudo nginx -s reload``. +Then restart nginx by running ``sudo systemctl restart nginx``. ### 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. +via your hostname, assuming that it points to your VPS' IP address. \ No newline at end of file diff --git a/src/auth.nim b/src/auth.nim index 381b666..0b08bfe 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 + doAssert ident != invalid \ No newline at end of file diff --git a/src/buildcss.nim b/src/buildcss.nim index 129bb64..551956a 100644 --- a/src/buildcss.nim +++ b/src/buildcss.nim @@ -1,4 +1,4 @@ -import os +import os, strutils import sass diff --git a/src/email.nim b/src/email.nim index 580cc76..60b4527 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.inHours >= 1: + if diff.hours >= 1: mailer.lastReset = getTime() mailer.emailsSent.clear() @@ -30,6 +30,7 @@ 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. @@ -40,37 +41,21 @@ 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", mailer.config.smtpFromAddr)) - - let dateHeader = now().utc().format("ddd, dd MMM yyyy hh:mm:ss") & " +0000" - headers.add(("Date", dateHeader)) + headers.add(("From", fromAddr)) let encoded = createMessage(subject, message, toList, @[], headers) - await client.sendMail(mailer.config.smtpFromAddr, toList, $encoded) + await client.sendMail(fromAddr, toList, $encoded) proc sendPassReset(mailer: Mailer, email, user, resetUrl: string) {.async.} = let message = """Hello $1, @@ -148,4 +133,4 @@ proc sendSecureEmail*( if emailSentFut.error of ForumError: raise emailSentFut.error else: - raise newForumError("Couldn't send email", @["email"]) + raise newForumError("Couldn't send email", @["email"]) \ No newline at end of file diff --git a/src/forum.nim b/src/forum.nim index c2682eb..399a64e 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, - jester, asyncdispatch, asyncnet, sequtils, + scgi, jester, asyncdispatch, asyncnet, sequtils, parseutils, random, rst, recaptcha, json, re, sugar, strformat, logging import cgi except setCookie @@ -76,6 +76,7 @@ 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: @@ -132,9 +133,9 @@ proc checkLoggedIn(c: TForumData) = 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 @@ -151,7 +152,7 @@ proc checkLoggedIn(c: TForumData) = ) c.previousVisitAt = personRow[1].parseInt let diff = getTime() - fromUnix(personRow[0].parseInt) - if diff.inMinutes > 30: + if diff.minutes > 30: c.previousVisitAt = personRow[0].parseInt db.exec( sql""" @@ -238,7 +239,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.inHours > 2: + if diff.hours > 2: raise newForumError("Link expired") if newIdent != ident: raise newForumError("Invalid ident hash") @@ -276,26 +277,25 @@ template createTFD() = new(c) init(c) c.req = request - if cookies(request).len > 0: + if request.cookies.len > 0: checkLoggedIn(c) #[ DB functions. TODO: Move to another module? ]# proc selectUser(userRow: seq[string], avatarSize: int=80): User = result = User( - 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" + 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" ) # Don't give data about a deleted user. if result.isDeleted: result.name = "DeletedUser" - result.avatarUrl = getGravatarUrl(result.name & userRow[2], avatarSize) + result.avatarUrl = getGravatarUrl(result.name & userRow[1], 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..11]), + author: selectUser(postRow[5..10]), 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.id, u.name, u.email, strftime('%s', u.lastOnline), + 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..9])) + author: some(selectUser(row[3..8])) )) 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.id, u.name, u.email, strftime('%s', u.lastOnline), + select 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 id, name, email, strftime('%s', lastOnline), + select 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 u.id, name, email, strftime('%s', lastOnline), + select 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[6].parseInt, - name: threadRow[7], - description: threadRow[8], - color: threadRow[9] + id: threadRow[5].parseInt, + name: threadRow[6], + description: threadRow[7], + color: threadRow[8] ), users: @[], replies: posts[0].parseInt-1, @@ -412,7 +412,6 @@ 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. @@ -436,9 +435,8 @@ proc executeReply(c: TForumData, threadId: int, content: string, else: raise newForumError("You are not allowed to post") - when not defined(skipRateLimitCheck): - if rateLimitCheck(c): - raise newForumError("You're posting too fast!") + if rateLimitCheck(c): + raise newForumError("You're posting too fast!") if content.strip().len == 0: raise newForumError("Message cannot be empty") @@ -460,21 +458,13 @@ proc executeReply(c: TForumData, threadId: int, content: string, if isLocked == "1": raise newForumError("Cannot reply to a locked thread.") - 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 - ) - + 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 + ) discard tryExec( db, crud(crCreate, "post_fts", "id", "content"), @@ -500,10 +490,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).inHours >= 2 + let isArchived = (getTime() - creation).weeks > 8 let canEdit = c.rank == Admin or c.userid == postRow[0] - if isArchived and c.rank < Admin: - raise newForumError("This post is too old and can no longer be edited") + if isArchived: + raise newForumError("This post is archived and can no longer be edited") if not canEdit: raise newForumError("You cannot edit this post") @@ -530,20 +520,10 @@ proc updatePost(c: TForumData, postId: int, content: string, if row[0] == $postId: exec(db, crud(crUpdate, "thread", "name"), subject.get(), threadId) -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) = +proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) = const query = sql""" - insert into thread(name, views, modified, category) values (?, 0, DATETIME('now'), ?) + insert into thread(name, views, modified) values (?, 0, DATETIME('now')) """ assert c.loggedIn() @@ -563,18 +543,13 @@ proc executeNewThread(c: TForumData, subject, msg, categoryID: string): (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"]) - when not defined(skipRateLimitCheck): - if rateLimitCheck(c): - raise newForumError("You're posting too fast!") + if rateLimitCheck(c): + raise newForumError("You're posting too fast!") - result[0] = tryInsertID(db, query, subject, categoryID).int + result[0] = tryInsertID(db, query, subject).int if result[0] < 0: raise newForumError("Subject already exists", @["subject"]) @@ -633,7 +608,7 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, raise newForumError("Invalid username", @["username"]) if getValue( db, - sql"select name from person where name = ? collate nocase and isDeleted = 0", + sql"select name from person where name = ? and isDeleted = 0", name ).len > 0: raise newForumError("Username already exists", @["username"]) @@ -676,18 +651,6 @@ 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""" @@ -710,25 +673,15 @@ 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.author, p.id from post p + select p.id from post p where p.author = ? and p.id = ? """ - let - row = getRow(db, postQuery, c.username, postId) - author = row[0] - id = row[1] + let id = getValue(db, postQuery, c.username, postId) - if id.len == 0 and not (c.rank == Admin or c.userid == author): + if id.len == 0 and c.rank < Admin: raise newForumError("You cannot delete this post") # Set the `isDeleted` flag. @@ -783,7 +736,7 @@ proc updateProfile( raise newForumError("Rank needs a change when setting new email.") await sendSecureEmail( - mailer, ActivateEmail, c.req, row[0], row[1], email, row[3] + mailer, ActivateEmail, c.req, row[0], row[1], row[2], row[3] ) validateEmail(email, checkDuplicated=wasEmailChanged) @@ -803,65 +756,33 @@ 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 = - """select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, + sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, c.id, c.name, c.description, c.color, - u.id, u.name, u.email, strftime('%s', u.lastOnline), + 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.id = ( - select p.author from post p - where p.thread = t.id - order by p.author + 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 limit 1 ) - order by isPinned desc, modified desc limit ?, ?;""" + order by modified desc limit ?, ?;""" - let thrCount = getValue(db, countQuery, countArgs).parseInt() + let thrCount = getValue(db, sql"select count(*) from thread;").parseInt() let moreCount = max(0, thrCount - (start + count)) var list = ThreadList(threads: @[], moreCount: moreCount) - for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs): - let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1])) + for data in getAllRows(db, threadsQuery, start, count): + let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1])) list.threads.add(thread) resp $(%list), "application/json" @@ -876,24 +797,19 @@ routes: count = 10 const threadsQuery = - sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, + sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, 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.id, u.name, u.email, strftime('%s', u.lastOnline), + u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.status, u.isDeleted from post p, person u @@ -932,20 +848,15 @@ routes: get "/specific_posts.json": createTFD() - var ids: JsonNode - try: + var 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.id, u.name, u.email, strftime('%s', u.lastOnline), + u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.status, u.isDeleted from post p, person u @@ -1014,7 +925,7 @@ routes: """ % postsFrom) let userQuery = sql(""" - select id, name, email, strftime('%s', lastOnline), + select name, email, strftime('%s', lastOnline), strftime('%s', previousVisitAt), status, isDeleted, strftime('%s', creation), id from person @@ -1040,7 +951,7 @@ routes: getValue(db, sql("select count(*) " & threadsFrom), userID).parseInt() if c.rank >= Admin or c.username == username: - profile.email = some(userRow[2]) + profile.email = some(userRow[1]) for row in db.getAllRows(postsQuery, username): profile.posts.add( @@ -1114,21 +1025,6 @@ routes: 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": createTFD() @@ -1175,8 +1071,7 @@ routes: except EParseError: let err = PostError( errorFields: @[], - message: "Message needs to be valid RST! Error: " & - getCurrentExceptionMsg() + message: getCurrentExceptionMsg() ) resp Http400, $(%err), "application/json" @@ -1240,45 +1135,6 @@ 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(): @@ -1291,14 +1147,13 @@ 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 - let categoryID = formData["categoryId"].body + # TODO: category try: - let res = executeNewThread(c, subject, msg, categoryID) + let res = executeNewThread(c, subject, msg) resp Http200, $(%[res[0], res[1]]), "application/json" except ForumError as exc: resp Http400, $(%exc.data), "application/json" @@ -1357,33 +1212,6 @@ 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(): @@ -1616,7 +1444,7 @@ routes: postId: rowFT[2].parseInt(), postContent: content, creation: rowFT[4].parseInt(), - author: selectUser(rowFT[5 .. 11]), + author: selectUser(rowFT[5 .. 10]), ) ) diff --git a/src/frontend/about.nim b/src/frontend/about.nim index 2ceb1d0..a805a12 100644 --- a/src/frontend/about.nim +++ b/src/frontend/about.nim @@ -1,11 +1,11 @@ when defined(js): - import sugar, httpcore + import sugar, httpcore, options, json import dom except Event include karax/prelude - import karax / [kajax] + import karax / [kajax, kdom] - import error + import error, replybox, threadlist, post import karaxutils type diff --git a/src/frontend/activateemail.nim b/src/frontend/activateemail.nim index 607bd4f..4b049da 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 + import error, replybox, threadlist, post import karaxutils type @@ -13,12 +13,17 @@ when defined(js): loading: bool status: HttpCode error: Option[PostError] + newPassword: kstring proc newActivateEmail*(): ActivateEmail = ActivateEmail( - status: Http200 + status: Http200, + newPassword: "" ) + 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 deleted file mode 100644 index 1232afb..0000000 --- a/src/frontend/addcategorymodal.nim +++ /dev/null @@ -1,87 +0,0 @@ -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 314720d..6c20f94 100644 --- a/src/frontend/category.nim +++ b/src/frontend/category.nim @@ -5,44 +5,24 @@ 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] + import karax / [vstyles, kajax, kdom] + import karaxutils - proc render*(category: Category, compact=true): VNode = - if category.name.len == 0: - return buildHtml(): - span() - - result = buildhtml(tdiv): - tdiv(class="category-status"): + proc render*(category: Category): VNode = + result = buildHtml(): + if category.name.len >= 0: tdiv(class="category", - title=category.description, "data-color"="#" & category.color): - tdiv(class="category-color", + tdiv(class="triangle", style=style( - (StyleAttr.border, - kstring"0.25rem solid #" & category.color) + (StyleAttr.borderBottom, + kstring"0.6rem solid #" & category.color) )) - span(class="category-name"): - text category.name - if not compact: - span(class="topic-count"): - text "× " & $category.numTopics - if not compact: - tdiv(class="category-description"): - text category.description.limit(categoryDescriptionCharLimit) \ No newline at end of file + text category.name + else: + span() \ No newline at end of file diff --git a/src/frontend/categorylist.nim b/src/frontend/categorylist.nim deleted file mode 100644 index 3c0e533..0000000 --- a/src/frontend/categorylist.nim +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index f26f6c1..0000000 --- a/src/frontend/categorypicker.nim +++ /dev/null @@ -1,135 +0,0 @@ -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 d5415cf..37446e5 100644 --- a/src/frontend/delete.nim +++ b/src/frontend/delete.nim @@ -1,7 +1,6 @@ when defined(js): import sugar, httpcore, options, json import dom except Event - import jsffi except `&` include karax/prelude import karax / [kajax, kdom] @@ -60,7 +59,7 @@ when defined(js): formData.append("id", $state.post.id) of DeleteThread: formData.append("id", $state.thread.id) - ajaxPost(uri, @[], formData.to(cstring), + ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onDeletePost(s, r, state)) proc onClose(ev: Event, n: VNode, state: DeleteModal) = diff --git a/src/frontend/editbox.nim b/src/frontend/editbox.nim index 61d2a1c..d461f90 100644 --- a/src/frontend/editbox.nim +++ b/src/frontend/editbox.nim @@ -1,6 +1,5 @@ when defined(js): import httpcore, options, sugar, json - import jsffi except `&` include karax/prelude import karax/kajax @@ -55,7 +54,7 @@ when defined(js): formData.append("postId", $state.post.id) # TODO: Subject let uri = makeUri("/updatePost") - ajaxPost(uri, @[], formData.to(cstring), + ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onEditPost(s, r, state)) proc render*(state: EditBox, post: Post): VNode = diff --git a/src/frontend/error.nim b/src/frontend/error.nim index 0b67f5d..4a23c44 100644 --- a/src/frontend/error.nim +++ b/src/frontend/error.nim @@ -1,12 +1,13 @@ -import httpcore +import options, httpcore type PostError* = object errorFields*: seq[string] ## IDs of the fields with an error. message*: string when defined(js): - import json, options + import json include karax/prelude + import karax / [vstyles, kajax, kdom] import karaxutils @@ -85,8 +86,8 @@ when defined(js): state.error = some(error) except: - echo getCurrentExceptionMsg() + kout(getCurrentExceptionMsg().cstring) 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 da74eab..4dea047 100644 --- a/src/frontend/forum.nim +++ b/src/frontend/forum.nim @@ -1,12 +1,10 @@ -import options, tables, sugar, httpcore +import strformat, times, options, json, tables, sugar, httpcore, uri 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 @@ -51,7 +49,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. - echo "New URL: ", window.location.href, " ", state.url.href + kout(kstring"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. @@ -83,17 +81,9 @@ proc render(): VNode = result = buildHtml(tdiv()): renderHeader() route([ - r("/categories", - (params: Params) => - (renderCategoryList(getLoggedInUser())) - ), - r("/c/@id", - (params: Params) => - (renderThreadList(getLoggedInUser(), some(params["id"].parseInt))) - ), r("/newthread", (params: Params) => - (render(state.newThread, getLoggedInUser())) + (render(state.newThread)) ), r("/profile/@username", (params: Params) => @@ -159,4 +149,4 @@ proc render(): VNode = ]) window.onPopState = onPopState -setRenderer render \ No newline at end of file +setRenderer render diff --git a/src/frontend/header.nim b/src/frontend/header.nim index 7cfb133..fc16941 100644 --- a/src/frontend/header.nim +++ b/src/frontend/header.nim @@ -1,13 +1,12 @@ -import options, httpcore +import options, times, httpcore, json, sugar -import user +import threadlist, user type UserStatus* = object user*: Option[User] recaptchaSiteKey*: Option[string] when defined(js): - import times, json, sugar include karax/prelude import karax / [kajax, kdom] @@ -32,7 +31,7 @@ when defined(js): var state = newState() - proc getStatus(logout=false) + proc getStatus(logout: bool=false) proc newState(): State = State( data: none[UserStatus](), @@ -61,10 +60,10 @@ when defined(js): state.lastUpdate = getTime() - proc getStatus(logout=false) = + proc getStatus(logout: bool=false) = if state.loading: return let diff = getTime() - state.lastUpdate - if diff.inMinutes < 5: + if diff.minutes < 5: return state.loading = true @@ -96,8 +95,8 @@ when defined(js): section(class="navbar-section"): tdiv(class="input-group input-inline"): input(class="search-input input-sm", - `type`="search", placeholder="Search", - id="search-box", required="required", + `type`="text", placeholder="search", + id="search-box", onKeyDown=onKeyDown) if state.loading: tdiv(class="loading") diff --git a/src/frontend/karaxutils.nim b/src/frontend/karaxutils.nim index f70ec5d..462d000 100644 --- a/src/frontend/karaxutils.nim +++ b/src/frontend/karaxutils.nim @@ -1,15 +1,4 @@ -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) = @@ -94,4 +93,4 @@ when defined(js): (state.onSignUp(); state.shown = false)): text "Create account" - render(state.resetPasswordModal, recaptchaSiteKey) + render(state.resetPasswordModal, recaptchaSiteKey) \ No newline at end of file diff --git a/src/frontend/mainbuttons.nim b/src/frontend/mainbuttons.nim deleted file mode 100644 index c91d354..0000000 --- a/src/frontend/mainbuttons.nim +++ /dev/null @@ -1,58 +0,0 @@ -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 9189bc0..e75b6bb 100644 --- a/src/frontend/newthread.nim +++ b/src/frontend/newthread.nim @@ -1,13 +1,12 @@ when defined(js): import sugar, httpcore, options, json import dom except Event - import jsffi except `&` include karax/prelude import karax / [kajax, kdom] - import error, replybox, threadlist, post, user - import karaxutils, categorypicker + import error, replybox, threadlist, post + import karaxutils type NewThread* = ref object @@ -15,13 +14,11 @@ when defined(js): error: Option[PostError] replyBox: ReplyBox subject: kstring - categoryPicker: CategoryPicker proc newNewThread*(): NewThread = NewThread( replyBox: newReplyBox(nil), - subject: "", - categoryPicker: newCategoryPicker() + subject: "" ) proc onSubjectChange(e: Event, n: VNode, state: NewThread) = @@ -40,16 +37,12 @@ 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()) - formData.append("categoryId", $categoryID) - - ajaxPost(uri, @[], formData.to(cstring), + ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onCreatePost(s, r, state)) - proc render*(state: NewThread, currentUser: Option[User]): VNode = + proc render*(state: NewThread): VNode = result = buildHtml(): section(class="container grid-xl"): tdiv(id="new-thread"): @@ -62,10 +55,6 @@ when defined(js): 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"): diff --git a/src/frontend/post.nim b/src/frontend/post.nim index dc12e47..5ca0d5f 100644 --- a/src/frontend/post.nim +++ b/src/frontend/post.nim @@ -1,6 +1,6 @@ -import options +import strformat, options -import user +import user, threadlist type PostInfo* = object @@ -58,10 +58,10 @@ type email*: Option[string] when defined(js): - import karaxutils, threadlist + import karaxutils proc renderPostUrl*(post: Post, thread: Thread): string = renderPostUrl(thread.id, post.id) proc renderPostUrl*(link: PostLink): string = - renderPostUrl(link.threadId, link.postId) + renderPostUrl(link.threadId, link.postId) \ No newline at end of file diff --git a/src/frontend/postbutton.nim b/src/frontend/postbutton.nim index 9fa4ab4..b76f6a0 100644 --- a/src/frontend/postbutton.nim +++ b/src/frontend/postbutton.nim @@ -7,7 +7,6 @@ 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 @@ -117,7 +116,7 @@ when defined(js): makeUri("/unlike") else: makeUri("/like") - ajaxPost(uri, @[], formData.to(cstring), + ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onPost(s, r, state, post, currentUser.get())) @@ -173,7 +172,7 @@ when defined(js): makeUri("/unlock") else: makeUri("/lock") - ajaxPost(uri, @[], formData.to(cstring), + ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onPost(s, r, state, thread)) @@ -190,7 +189,7 @@ when defined(js): else: "" result = buildHtml(): - button(class="btn btn-secondary", id="lock-btn", + button(class="btn btn-secondary", onClick=(e: Event, n: VNode) => onLockClick(e, n, state, thread), "data-tooltip"=tooltip, @@ -201,61 +200,4 @@ when defined(js): text " Unlock Thread" else: italic(class="fas fa-lock") - text " Lock Thread" - - type - PinButton* = ref object - error: Option[PostError] - loading: bool - - proc newPinButton*(): PinButton = - PinButton() - - proc onPost(httpStatus: int, response: kstring, state: PinButton, - thread: var Thread) = - postFinished: - thread.isPinned = not thread.isPinned - - proc onPinClick(ev: Event, n: VNode, state: PinButton, thread: var Thread) = - if state.loading: return - - state.loading = true - state.error = none[PostError]() - - # Same as LockButton so the following is still a hack and karax should support this. - var formData = newFormData() - formData.append("id", $thread.id) - let uri = - if thread.isPinned: - makeUri("/unpin") - else: - makeUri("/pin") - ajaxPost(uri, @[], formData.to(cstring), - (s: int, r: kstring) => onPost(s, r, state, thread)) - - ev.preventDefault() - - proc render*(state: PinButton, thread: var Thread, - currentUser: Option[User]): VNode = - if currentUser.isNone() or - currentUser.get().rank < Moderator: - return buildHtml(tdiv()) - - let tooltip = - if state.error.isSome(): state.error.get().message - else: "" - - result = buildHtml(): - button(class="btn btn-secondary", id="pin-btn", - onClick=(e: Event, n: VNode) => - onPinClick(e, n, state, thread), - "data-tooltip"=tooltip, - onmouseleave=(e: Event, n: VNode) => - (state.error = none[PostError]())): - if thread.isPinned: - italic(class="fas fa-thumbtack") - text " Unpin Thread" - else: - italic(class="fas fa-thumbtack") - text " Pin Thread" - + text " Lock Thread" \ No newline at end of file diff --git a/src/frontend/postlist.nim b/src/frontend/postlist.nim index 66b3162..6a27df3 100644 --- a/src/frontend/postlist.nim +++ b/src/frontend/postlist.nim @@ -1,6 +1,6 @@ import system except Thread -import options, json, times, httpcore, sugar, strutils +import options, json, times, httpcore, strformat, sugar, math, strutils import sequtils import threadlist, category, post, user @@ -15,20 +15,17 @@ type when defined(js): from dom import document - import jsffi except `&` include karax/prelude - import karax / [kajax, kdom] + import karax / [vstyles, 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. @@ -36,11 +33,8 @@ 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) @@ -50,38 +44,17 @@ 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(), - pinButton: newPinButton(), - categoryPicker: newCategoryPicker(onCategoryChanged) + lockButton: newLockButton() ) 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 @@ -93,7 +66,6 @@ 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(): @@ -207,20 +179,6 @@ 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 = @@ -328,12 +286,12 @@ when defined(js): ] var diffStr = tmpl[0] let diff = latestTime - prevPost.info.creation.fromUnix() - if diff.inWeeks > 48: - let years = diff.inWeeks div 48 + if diff.weeks > 48: + let years = diff.weeks div 48 diffStr = (if years == 1: tmpl[1] else: tmpl[2]) % $years - elif diff.inWeeks > 4: - let months = diff.inWeeks div 4 + elif diff.weeks > 4: + let months = diff.weeks div 4 diffStr = (if months == 1: tmpl[3] else: tmpl[4]) % $months else: @@ -372,10 +330,7 @@ when defined(js): result = buildHtml(): section(class="container grid-xl"): tdiv(id="thread-title", class="title"): - if state.error.isSome(): - span(class="text-error"): - text state.error.get().message - p(class="title-text"): text list.thread.topic + p(): text list.thread.topic if list.thread.isLocked: italic(class="fas fa-lock fa-xs", title="Thread cannot be replied to") @@ -388,7 +343,7 @@ when defined(js): italic(class="fas fa-check-square fa-xs", title="Thread has a solution") text "Solved" - genCategories(list.thread, currentUser) + render(list.thread.category) tdiv(class="posts"): var prevPost: Option[Post] = none[Post]() for i, post in list.posts: @@ -413,7 +368,6 @@ 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) diff --git a/src/frontend/profile.nim b/src/frontend/profile.nim index fbfea68..60f9daf 100644 --- a/src/frontend/profile.nim +++ b/src/frontend/profile.nim @@ -1,12 +1,12 @@ -import options, httpcore, json, sugar, times, strutils +import options, httpcore, json, sugar, times, strformat, strutils -import threadlist, post, error, user +import threadlist, post, category, error, user when defined(js): from dom import document include karax/prelude import karax/[kajax, kdom] - import karaxutils, profilesettings + import karaxutils, postbutton, delete, profilesettings type ProfileTab* = enum diff --git a/src/frontend/profilesettings.nim b/src/frontend/profilesettings.nim index 8d7fda3..56ea7b2 100644 --- a/src/frontend/profilesettings.nim +++ b/src/frontend/profilesettings.nim @@ -1,11 +1,10 @@ when defined(js): import httpcore, options, sugar, json, strutils, strformat - import jsffi except `&` include karax/prelude import karax/[kajax, kdom] - import post, karaxutils, postbutton, error, delete, user + import replybox, post, karaxutils, postbutton, error, delete, user type ProfileSettings* = ref object @@ -69,7 +68,7 @@ when defined(js): formData.append("rank", $state.rank) formData.append("username", $state.profile.user.name) let uri = makeUri("/saveProfile") - ajaxPost(uri, @[], formData.to(cstring), + ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onProfilePost(s, r, state)) proc needsSave(state: ProfileSettings): bool = diff --git a/src/frontend/replybox.nim b/src/frontend/replybox.nim index 64e5864..9d815f6 100644 --- a/src/frontend/replybox.nim +++ b/src/frontend/replybox.nim @@ -1,6 +1,5 @@ when defined(js): import strformat, options, httpcore, json, sugar - import jsffi except `&` from dom import getElementById, scrollIntoView, setTimeout @@ -27,7 +26,7 @@ when defined(js): proc performScroll() = let replyBox = dom.document.getElementById("reply-box") - replyBox.scrollIntoView() + replyBox.scrollIntoView(false) proc show*(state: ReplyBox) = # Scroll to the reply box. @@ -45,7 +44,7 @@ when defined(js): proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) = postFinished: - echo response + kout(response) state.rendering = some[kstring](response) proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) = @@ -57,7 +56,7 @@ when defined(js): let formData = newFormData() formData.append("msg", state.text) let uri = makeUri("/preview") - ajaxPost(uri, @[], formData.to(cstring), + ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onPreviewPost(s, r, state)) proc onMessageClick(e: Event, n: VNode, state: ReplyBox) = @@ -81,7 +80,7 @@ when defined(js): if replyingTo.isSome: formData.append("replyingTo", $replyingTo.get().id) let uri = makeUri("/createPost") - ajaxPost(uri, @[], formData.to(cstring), + ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onReplyPost(s, r, state)) proc onCancelClick(e: Event, n: VNode, state: ReplyBox) = diff --git a/src/frontend/resetpassword.nim b/src/frontend/resetpassword.nim index 497af98..c2f01a1 100644 --- a/src/frontend/resetpassword.nim +++ b/src/frontend/resetpassword.nim @@ -1,12 +1,11 @@ when defined(js): import sugar, httpcore, options, json - import dom except Event, KeyboardEvent - import jsffi except `&` + import dom except Event include karax/prelude import karax / [kajax, kdom] - import error + import error, replybox, threadlist, post import karaxutils type @@ -87,7 +86,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, @[], formData.to(cstring), + ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onPost(s, r, state)) ev.preventDefault() @@ -153,4 +152,4 @@ when defined(js): ), `type`="button", onClick=(ev: Event, n: VNode) => onClick(ev, n, state)): - text "Reset password" + text "Reset password" \ No newline at end of file diff --git a/src/frontend/search.nim b/src/frontend/search.nim index a1ef3fa..2edb90e 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 / [kajax] + import karax / [vstyles, kajax, kdom] import karaxutils, error, threadlist, sugar diff --git a/src/frontend/signup.nim b/src/frontend/signup.nim index 98e330d..6a422d6 100644 --- a/src/frontend/signup.nim +++ b/src/frontend/signup.nim @@ -1,7 +1,6 @@ when defined(js): import sugar, httpcore, options, json import dom except Event - import jsffi except `&` include karax/prelude import karax / [kajax, kdom] @@ -29,7 +28,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, @[], formData.to(cstring), + ajaxPost(uri, @[], cast[cstring](formData), (s: int, r: kstring) => onSignUpPost(s, r, state)) proc onClose(ev: Event, n: VNode, state: SignupModal) = diff --git a/src/frontend/threadlist.nim b/src/frontend/threadlist.nim index ecec6da..0516d74 100644 --- a/src/frontend/threadlist.nim +++ b/src/frontend/threadlist.nim @@ -15,7 +15,6 @@ type creation*: int64 ## Unix timestamp isLocked*: bool isSolved*: bool - isPinned*: bool ThreadList* = ref object threads*: seq[Thread] @@ -27,34 +26,26 @@ proc isModerated*(thread: Thread): bool = thread.author.rank <= Moderated when defined(js): - import sugar include karax/prelude import karax / [vstyles, kajax, kdom] - import karaxutils, error, user, mainbuttons + import karaxutils, error, user type State = ref object list: Option[ThreadList] - refreshList: bool loading: bool status: HttpCode - mainButtons: MainButtons - - var state: State proc newState(): State = State( list: none[ThreadList](), loading: false, - status: Http200, - mainButtons: newMainButtons( - onCategoryChange = - (oldCategory: Category, newCategory: Category) => (state.list = none[ThreadList]()) - ) + status: Http200 ) - state = newState() + var + state = newState() proc visibleTo*[T](thread: T, user: Option[User]): bool = ## Determines whether the specified thread (or post) should be @@ -69,13 +60,34 @@ when defined(js): if user.isNone(): return not thread.isModerated let rank = user.get().rank - if rank < Rank.Moderator and thread.isModerated: + if 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(id="new-thread-btn", 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(class="thread-users")): + result = buildHtml(td): for user in users: render(user, "avatar avatar-sm", showStatus=true) text " " @@ -86,29 +98,26 @@ when defined(js): let duration = currentTime - activityTime if currentTime.local().year != activityTime.local().year: return activityTime.local().format("MMM yyyy") - elif duration.inDays > 30 and duration.inDays < 300: + elif duration.days > 30 and duration.days < 300: return activityTime.local().format("MMM dd") - elif duration.inDays != 0: - return $duration.inDays & "d" - elif duration.inHours != 0: - return $duration.inHours & "h" - elif duration.inMinutes != 0: - return $duration.inMinutes & "m" + elif duration.days != 0: + return $duration.days & "d" + elif duration.hours != 0: + return $duration.hours & "h" + elif duration.minutes != 0: + return $duration.minutes & "m" else: - return $duration.inSeconds & "s" + return $duration.seconds & "s" - proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode = - let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2 + proc genThread(thread: Thread, isNew: bool, noBorder: bool): VNode = + let isOld = (getTime() - thread.creation.fromUnix).weeks > 2 let isBanned = thread.author.rank.isBanned() result = buildHtml(): - tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})): + tr(class=class({"no-border": noBorder, "banned": isBanned})): 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") @@ -118,16 +127,14 @@ when defined(js): 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 - tdiv(class="show-sm" & class({"d-none": not displayCategory})): - render(thread.category) - - td(class="hide-sm" & class({"d-none": not displayCategory})): + td(): render(thread.category) genUserAvatars(thread.users) - td(class="thread-replies"): text $thread.replies - td(class="hide-sm" & class({ + td(): text $thread.replies + td(class=class({ "views-text": thread.views < 999, "popular-text": thread.views > 999 and thread.views < 5000, "super-popular-text": thread.views > 5000 @@ -161,13 +168,10 @@ when defined(js): else: state.list = some(list) - proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) = + proc onLoadMore(ev: Event, n: VNode) = state.loading = true let start = state.list.get().threads.len - if categoryId.isSome: - ajaxGet(makeUri("threads.json?start=" & $start & "&categoryId=" & $categoryId.get()), @[], onThreadList) - else: - ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList) + ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList) proc getInfo( list: seq[Thread], i: int, currentUser: Option[User] @@ -192,34 +196,29 @@ when defined(js): isNew: thread.creation > previousVisitAt ) - proc genThreadList(currentUser: Option[User], categoryId: Option[int]): VNode = + proc genThreadList(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 - if categoryId.isSome: - ajaxGet(makeUri("threads.json?categoryId=" & $categoryId.get()), @[], onThreadList) - else: - ajaxGet(makeUri("threads.json"), @[], onThreadList) + ajaxGet(makeUri("threads.json"), @[], onThreadList) return buildHtml(tdiv(class="loading loading-lg")) - let displayCategory = categoryId.isNone - let list = state.list.get() result = buildHtml(): - section(class="thread-list"): + section(class="container grid-xl"): # TODO: Rename to `.thread-list`. table(class="table", id="threads-list"): thead(): tr: th(text "Topic") - th(class="hide-sm" & class({"d-none": not displayCategory})): text "Category" - th(class="thread-users"): text "Users" - th(class="centered-header"): text "Replies" - th(class="hide-sm centered-header"): text "Views" - th(class="centered-header"): text "Activity" + th(text "Category") + th(style=style((StyleAttr.width, kstring"8rem"))): text "Users" + th(text "Replies") + th(text "Views") + th(text "Activity") tbody(): for i in 0 ..< list.threads.len: let thread = list.threads[i] @@ -227,9 +226,8 @@ when defined(js): let isLastThread = i+1 == list.threads.len let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser) - genThread(i+1, thread, isNew, - noBorder=isLastUnseen or isLastThread, - displayCategory=displayCategory) + genThread(thread, isNew, + noBorder=isLastUnseen or isLastThread) if isLastUnseen and (not isLastThread): tr(class="last-visit-separator"): td(colspan="6"): @@ -241,11 +239,10 @@ when defined(js): td(colspan="6"): tdiv(class="loading loading-lg") else: - td(colspan="6", - onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))): + td(colspan="6", onClick=onLoadMore): span(text "load more threads") - proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode = + proc renderThreadList*(currentUser: Option[User]): VNode = result = buildHtml(tdiv): - state.mainButtons.render(currentUser, categoryId=categoryId) - genThreadList(currentUser, categoryId) + genTopButtons(currentUser) + genThreadList(currentUser) diff --git a/src/frontend/user.nim b/src/frontend/user.nim index db874c3..ea0624b 100644 --- a/src/frontend/user.nim +++ b/src/frontend/user.nim @@ -1,4 +1,4 @@ -import times, options +import times type # If you add more "Banned" states, be sure to modify forum's threadsQuery too. @@ -17,7 +17,6 @@ type Admin ## Admin: can do everything User* = object - id*: string name*: string avatarUrl*: string lastOnline*: int64 @@ -28,9 +27,6 @@ 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 @@ -76,4 +72,4 @@ when defined(js): title="User is a moderator") of Admin: italic(class="fas fa-chess-knight", - title="User is an admin") + title="User is an admin") \ No newline at end of file diff --git a/src/fts.sql b/src/fts.sql index 1590a05..e5490f2 100644 --- a/src/fts.sql +++ b/src/fts.sql @@ -7,7 +7,6 @@ SELECT post_id, post_content, cdate, - person.id, person.name AS author, person.email AS email, strftime('%s', person.lastOnline) AS lastOnline, @@ -47,7 +46,6 @@ 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 c80ad3b..3c5cf5b 100644 --- a/src/setup_nimforum.nim +++ b/src/setup_nimforum.nim @@ -66,7 +66,7 @@ proc initialiseDb(admin: tuple[username, password, email: string], db.exec(sql""" insert into category (id, name, description, color) - values (0, 'Unsorted', 'No category has been chosen yet.', ''); + values (0, 'Default', '', ''); """) # -- Thread @@ -81,7 +81,6 @@ 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) @@ -116,7 +115,7 @@ proc initialiseDb(admin: tuple[username, password, email: string], # Create default user. db.createUser(admin, Admin) - # Create some test data for development + # Create test users if test or development if isTest or isDev: for rank in Spammer..Moderator: let rankLower = toLowerAscii($rank) @@ -125,14 +124,6 @@ proc initialiseDb(admin: tuple[username, password, email: string], email: $rankLower & "@localhost.local") db.createUser(user, rank) - db.exec(sql""" - 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 db.exec(sql""" @@ -235,7 +226,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, fromAddr: string, tls: bool], + smtp: tuple[address, user, password: string], isDev: bool, dbPath: string, ga: string="" @@ -251,8 +242,6 @@ proc initialiseConfig( "smtpAddress": %smtp.address, "smtpUser": %smtp.user, "smtpPassword": %smtp.password, - "smtpFromAddr": %smtp.fromAddr, - "smtpTls": %smtp.tls, "isDev": %isDev, "dbPath": %dbPath } @@ -284,7 +273,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 v2 for your forum before answering them. \nPlease do so now " & + "recaptcha 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: ") @@ -295,8 +284,6 @@ 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.") @@ -306,7 +293,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, smtpFromAddr, smtpTls), isDev=false, + (smtpAddress, smtpUser, smtpPassword), isDev=false, dbPath, ga ) @@ -341,7 +328,7 @@ when isMainModule: "Development Forum", "localhost", recaptcha=("", ""), - smtp=("", "", "", "", false), + smtp=("", "", ""), isDev=true, dbPath ) @@ -358,7 +345,7 @@ when isMainModule: "Test Forum", "localhost", recaptcha=("", ""), - smtp=("", "", "", "", false), + smtp=("", "", ""), isDev=true, dbPath ) diff --git a/src/utils.nim b/src/utils.nim index 4b1d339..82b1e51 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -1,24 +1,26 @@ import asyncdispatch, smtp, strutils, json, os, rst, rstgen, xmltree, strtabs, htmlparser, streams, parseutils, options, logging -from times import getTime, utc, format +from times import getTime, getGMTime, 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 @@ -53,12 +55,9 @@ proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config = smtpPassword: "", mlistAddress: "") let root = parseFile(filename) result.smtpAddress = root{"smtpAddress"}.getStr("") - result.smtpPort = root{"smtpPort"}.getInt(25) + result.smtpPort = root{"smtpPort"}.getNum(25).int 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("") @@ -68,7 +67,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"}.getInt(5000) + result.port = root{"port"}.getNum(5000).int 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 8b6c046..0f4efe9 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 testbackend") + spawn runProcess("nimble -y runbackend") defer: discard execCmd("killall " & backend) @@ -43,11 +43,9 @@ template withBackend(body: untyped): untyped = body -import browsertests/[scenario1, threads, issue181, categories] +import browsertests/[scenario1, threads, issue181] -proc main() = - # Kill any already running instances - discard execCmd("killall geckodriver") +when isMainModule: spawn runProcess("geckodriver -p 4444 --log config") defer: discard execCmd("killall geckodriver") @@ -66,13 +64,9 @@ proc main() = withBackend: scenario1.test(session, baseUrl) threads.test(session, baseUrl) - categories.test(session, baseUrl) issue181.test(session, baseUrl) session.close() except: sleep(10000) # See if we can grab any more output. raise - -when isMainModule: - main() diff --git a/tests/browsertester.nims b/tests/browsertester.nims index 3cd49f0..9d57ecf 100644 --- a/tests/browsertester.nims +++ b/tests/browsertester.nims @@ -1,2 +1 @@ ---threads:on ---path:"../src/frontend" \ No newline at end of file +--threads:on \ No newline at end of file diff --git a/tests/browsertests/categories.nim b/tests/browsertests/categories.nim deleted file mode 100644 index 8c27a87..0000000 --- a/tests/browsertests/categories.nim +++ /dev/null @@ -1,214 +0,0 @@ -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 index e5924f3..2c60840 100644 --- a/tests/browsertests/common.nim +++ b/tests/browsertests/common.nim @@ -2,125 +2,82 @@ 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 + result = code # 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) +template click*(session: Session, element: string, strategy=CssSelector) = + let el = session.findElement(element, strategy) + check el.isSome() el.get().click() -proc sendKeys*(session: Session, element, keys: string) = - let el = session.waitForElement(element) +template sendKeys*(session: Session, element, keys: string) = + let el = session.findElement(element) + check el.isSome() el.get().sendKeys(keys) -proc clear*(session: Session, element: string) = - let el = session.waitForElement(element) +template clear*(session: Session, element: string) = + let el = session.findElement(element) + check el.isSome() el.get().clear() -proc sendKeys*(session: Session, element: string, keys: varargs[Key]) = - let el = session.waitForElement(element) +template sendKeys*(session: Session, element: string, keys: varargs[Key]) = + let el = session.findElement(element) + check el.isSome() # 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 ensureExists*(session: Session, element: string, strategy=CssSelector) = + let el = session.findElement(element, strategy) + check el.isSome() template check*(session: Session, element: string, function: untyped) = - let el = session.waitForElement(element) + let el = session.findElement(element) check function(el) template check*(session: Session, element: string, strategy: LocationStrategy, function: untyped) = - let el = session.waitForElement(element, strategy) + let el = session.findElement(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 checkIsNone*(session: Session, element: string, strategy=CssSelector) = + let el = session.findElement(element, strategy) + check el.isNone() template checkText*(session: Session, element, expectedValue: string) = - let el = session.waitForElement(element) + let el = session.findElement(element) + check el.isSome() 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] = +proc waitForLoad*(session: Session, timeout=20000) = var waitTime = 0 - - when actionDelayMs > 0: - sleep(actionDelayMs) + sleep(2000) while true: - try: - let loading = session.findElement(selector, strategy) - if waitCondition(loading): - return loading - finally: - discard - sleep(pollTime) - waitTime += pollTime + let loading = session.findElement(".loading") + if loading.isNone: return + sleep(1000) + waitTime += 1000 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 wait*(session: Session, msTimeout: int = 5000) = + session.waitForLoad(msTimeout) proc setUserRank*(session: Session, baseUrl, user, rank: string) = with session: navigate(baseUrl & "profile/" & user) + wait() click "#settings-tab" @@ -128,11 +85,13 @@ proc setUserRank*(session: Session, baseUrl, user, rank: string) = click("#rank-field option#rank-" & rank.toLowerAscii) click "#save-btn" + wait() proc logout*(session: Session) = with session: click "#profile-btn" click "#profile-btn #logout-btn" + wait() # Verify we have logged out by looking for the log in button. ensureExists "#login-btn" @@ -149,12 +108,14 @@ proc login*(session: Session, user, password: string) = sendKeys "#login-form input[name='password']", Key.Enter + wait() + # 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) = +proc register*(session: Session, user, password: string) = with session: click "#signup-btn" @@ -167,23 +128,24 @@ proc register*(session: Session, user, password: string, verify = true) = sendKeys "#signup-form input[name='password']", password click "#signup-modal .create-account-btn" + wait() - 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" + # 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" + wait() sendKeys "#thread-title", title sendKeys "#reply-textarea", content click "#create-thread-btn" + wait() - checkText "#thread-title .title-text", title + checkText "#thread-title", title checkText ".original-post div.post-content", content diff --git a/tests/browsertests/issue181.nim b/tests/browsertests/issue181.nim index 031cd92..7a646c2 100644 --- a/tests/browsertests/issue181.nim +++ b/tests/browsertests/issue181.nim @@ -1,10 +1,12 @@ -import unittest, common +import unittest, options, os, common import webdriver proc test*(session: Session, baseUrl: string) = session.navigate(baseUrl) + waitForLoad(session) + test "can see banned posts": with session: register("issue181", "issue181") @@ -17,6 +19,7 @@ proc test*(session: Session, baseUrl: string) = login("issue181", "issue181") navigate(baseUrl) + wait() const title = "Testing issue 181." createThread(title, "Test for issue #181") @@ -30,6 +33,7 @@ proc test*(session: Session, baseUrl: string) = # Make sure the banned user's thread is still visible. navigate(baseUrl) + wait() ensureExists("tr.banned") checkText("tr.banned .thread-title > a", title) logout() diff --git a/tests/browsertests/scenario1.nim b/tests/browsertests/scenario1.nim index dc78007..34132e5 100644 --- a/tests/browsertests/scenario1.nim +++ b/tests/browsertests/scenario1.nim @@ -1,10 +1,12 @@ -import unittest, common +import unittest, options, os, common import webdriver proc test*(session: Session, baseUrl: string) = session.navigate(baseUrl) + waitForLoad(session) + # Sanity checks test "shows sign up": session.checkText("#signup-btn", "Sign up") @@ -28,16 +30,5 @@ proc test*(session: Session, baseUrl: string) = test "can register": with session: register("test", "test") - logout() - test "can't register same username with different case": - with session: - register "test1", "test1", verify = false - logout() - - navigate baseUrl - - register "TEst1", "test1", verify = false - - ensureExists "#signup-form .has-error" - navigate baseUrl + session.logout() diff --git a/tests/browsertests/threads.nim b/tests/browsertests/threads.nim index 32ce686..f0b1b32 100644 --- a/tests/browsertests/threads.nim +++ b/tests/browsertests/threads.nim @@ -1,4 +1,5 @@ -import unittest, common +import unittest, options, os, common + import webdriver let @@ -14,81 +15,36 @@ proc banUser(session: Session, baseUrl: string) = 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) + session.wait() test "can create thread": with session: click "#new-thread-btn" + wait() sendKeys "#thread-title", userTitleStr sendKeys "#reply-textarea", userContentStr click "#create-thread-btn" + wait() - checkText "#thread-title .title-text", userTitleStr + checkText "#thread-title", 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 + wait() test "can view banned thread": with session: @@ -96,21 +52,25 @@ proc anonymousTests(session: Session, baseUrl: string) = with session: navigate baseUrl + wait() proc bannedTests(session: Session, baseUrl: string) = suite "banned user thread tests": with session: navigate baseUrl + wait() login "banned", "banned" test "can't start thread": with session: click "#new-thread-btn" + wait() sendKeys "#thread-title", "test" sendKeys "#reply-textarea", "test" click "#create-thread-btn" + wait() ensureExists "#new-thread p.text-error" @@ -122,6 +82,7 @@ proc adminTests(session: Session, baseUrl: string) = setup: session.navigate(baseUrl) + session.wait() test "can view banned thread": with session: @@ -130,18 +91,21 @@ proc adminTests(session: Session, baseUrl: string) = test "can create thread": with session: click "#new-thread-btn" + wait() sendKeys "#thread-title", adminTitleStr sendKeys "#reply-textarea", adminContentStr click "#create-thread-btn" + wait() - checkText "#thread-title .title-text", adminTitleStr + checkText "#thread-title", adminTitleStr checkText ".original-post div.post-content", adminContentStr test "try create duplicate thread": with session: click "#new-thread-btn" + wait() ensureExists "#new-thread" sendKeys "#thread-title", adminTitleStr @@ -149,17 +113,22 @@ proc adminTests(session: Session, baseUrl: string) = click "#create-thread-btn" + wait() + ensureExists "#new-thread p.text-error" test "can edit post": let modificationText = " and I edited it!" with session: click adminTitleStr, LinkTextSelector + wait() click ".post-buttons .edit-button" + wait() sendKeys ".original-post #reply-textarea", modificationText click ".edit-buttons .save-button" + wait() checkText ".original-post div.post-content", adminContentStr & modificationText @@ -168,6 +137,7 @@ proc adminTests(session: Session, baseUrl: string) = with session: click userTitleStr, LinkTextSelector + wait() click ".post-buttons .like-button" @@ -176,83 +146,23 @@ proc adminTests(session: Session, baseUrl: string) = test "can delete thread": with session: click adminTitleStr, LinkTextSelector + wait() click ".post-buttons .delete-button" + wait() # click delete confirmation click "#delete-modal .delete-btn" + wait() # 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) + session.wait() userTests(session, baseUrl) @@ -262,6 +172,5 @@ proc test*(session: Session, baseUrl: string) = anonymousTests(session, baseUrl) adminTests(session, baseUrl) - unBanUser(session, baseUrl) - session.navigate(baseUrl) + session.wait()