diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..fde1b09 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,80 @@ +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the action will run. +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [ master ] + pull_request: + branches: [ master ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + test_stable: + runs-on: ubuntu-latest + strategy: + matrix: + firefox: [ '73.0' ] + include: + - nim-version: 'stable' + cache-key: 'stable' + steps: + - uses: actions/checkout@v2 + - name: Checkout submodules + run: git submodule update --init --recursive + + - name: Setup firefox + uses: browser-actions/setup-firefox@latest + with: + firefox-version: ${{ matrix.firefox }} + + - name: Get Date + id: get-date + run: echo "::set-output name=date::$(date "+%Y-%m-%d")" + shell: bash + + - name: Cache choosenim + uses: actions/cache@v2 + with: + path: ~/.choosenim + key: ${{ runner.os }}-choosenim-${{ matrix.cache-key }} + + - name: Cache nimble + uses: actions/cache@v2 + with: + path: ~/.nimble + key: ${{ runner.os }}-nimble-${{ hashFiles('*.nimble') }} + + - uses: jiro4989/setup-nim-action@v1 + with: + nim-version: "${{ matrix.nim-version }}" + + - name: Install geckodriver + run: | + sudo apt-get -qq update + sudo apt-get install autoconf libtool libsass-dev + + wget https://github.com/mozilla/geckodriver/releases/download/v0.29.1/geckodriver-v0.29.1-linux64.tar.gz + mkdir geckodriver + tar -xzf geckodriver-v0.29.1-linux64.tar.gz -C geckodriver + export PATH=$PATH:$PWD/geckodriver + + - name: Install choosenim + run: | + export CHOOSENIM_CHOOSE_VERSION="stable" + curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh + sh init.sh -y + export PATH=$HOME/.nimble/bin:$PATH + nimble refresh -y + + - name: Run tests + run: | + export MOZ_HEADLESS=1 + nimble -y install + nimble -y test + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b5c6c9b..0000000 --- a/.travis.yml +++ /dev/null @@ -1,45 +0,0 @@ -os: - - linux - -language: c - -cache: - directories: - - "$HOME/.nimble" - - "$HOME/.choosenim" - -addons: - firefox: "73.0" - -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.26.0/geckodriver-v0.26.0-linux64.tar.gz - - mkdir geckodriver - - tar -xzf geckodriver-v0.26.0-linux64.tar.gz -C geckodriver - - export PATH=$PATH:$PWD/geckodriver - -install: - - 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 - -script: - - export MOZ_HEADLESS=1 - - nimble -y install - - nimble -y test diff --git a/src/forum.nim b/src/forum.nim index c85ad2e..c2682eb 100644 --- a/src/forum.nim +++ b/src/forum.nim @@ -276,7 +276,7 @@ template createTFD() = new(c) init(c) c.req = request - if request.cookies.len > 0: + if cookies(request).len > 0: checkLoggedIn(c) #[ DB functions. TODO: Move to another module? ]# @@ -400,10 +400,10 @@ proc selectThread(threadRow: seq[string], author: User): Thread = id: threadRow[0].parseInt, topic: threadRow[1], category: Category( - id: threadRow[5].parseInt, - name: threadRow[6], - description: threadRow[7], - color: threadRow[8] + id: threadRow[6].parseInt, + name: threadRow[7], + description: threadRow[8], + color: threadRow[9] ), users: @[], replies: posts[0].parseInt-1, @@ -412,6 +412,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread = creation: posts[1].parseInt, isLocked: threadRow[4] == "1", isSolved: false, # TODO: Add a field to `post` to identify the solution. + isPinned: threadRow[5] == "1" ) # Gather the users list. @@ -709,6 +710,13 @@ 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""" @@ -833,7 +841,7 @@ routes: categoryArgs.insert($categoryId, 0) const threadsQuery = - """select t.id, t.name, views, strftime('%s', modified), isLocked, + """select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, c.id, c.name, c.description, c.color, u.id, u.name, u.email, strftime('%s', u.lastOnline), strftime('%s', u.previousVisitAt), u.status, u.isDeleted @@ -846,14 +854,14 @@ routes: order by p.author limit 1 ) - order by modified desc limit ?, ?;""" + order by isPinned desc, modified desc limit ?, ?;""" let thrCount = getValue(db, countQuery, countArgs).parseInt() let moreCount = max(0, thrCount - (start + count)) var list = ThreadList(threads: @[], moreCount: moreCount) for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs): - let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1])) + let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1])) list.threads.add(thread) resp $(%list), "application/json" @@ -868,12 +876,17 @@ routes: count = 10 const threadsQuery = - sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, + sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, c.id, c.name, c.description, c.color from thread t, category c where t.id = ? and isDeleted = 0 and category = c.id;""" let threadRow = getRow(db, threadsQuery, id) + if threadRow[0].len == 0: + let err = PostError( + message: "Specified thread does not exist" + ) + resp Http404, $(%err), "application/json" let thread = selectThread(threadRow, selectThreadAuthor(id)) let postsQuery = @@ -919,9 +932,14 @@ routes: get "/specific_posts.json": createTFD() - var + var ids: JsonNode + try: ids = parseJson(@"ids") - + except JsonParsingError: + let err = PostError( + message: "Invalid JSON in the `ids` parameter" + ) + resp Http400, $(%err), "application/json" cond ids.kind == JArray let intIDs = ids.elems.map(x => x.getInt()) let postsQuery = sql(""" @@ -1339,6 +1357,33 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" + post re"/(pin|unpin)": + createTFD() + if not c.loggedIn(): + let err = PostError( + errorFields: @[], + message: "Not logged in." + ) + resp Http401, $(%err), "application/json" + + let formData = request.formData + cond "id" in formData + + let threadId = getInt(formData["id"].body, -1) + cond threadId != -1 + + try: + case request.path + of "/pin": + executePinState(c, threadId, true) + of "/unpin": + executePinState(c, threadId, false) + else: + assert false + resp Http200, "{}", "application/json" + except ForumError as exc: + resp Http400, $(%exc.data), "application/json" + post re"/delete(Post|Thread)": createTFD() if not c.loggedIn(): diff --git a/src/frontend/header.nim b/src/frontend/header.nim index cde48de..7cfb133 100644 --- a/src/frontend/header.nim +++ b/src/frontend/header.nim @@ -96,8 +96,8 @@ when defined(js): section(class="navbar-section"): tdiv(class="input-group input-inline"): input(class="search-input input-sm", - `type`="text", placeholder="search", - id="search-box", + `type`="search", placeholder="Search", + id="search-box", required="required", onKeyDown=onKeyDown) if state.loading: tdiv(class="loading") diff --git a/src/frontend/post.nim b/src/frontend/post.nim index 0530814..dc12e47 100644 --- a/src/frontend/post.nim +++ b/src/frontend/post.nim @@ -64,4 +64,4 @@ when defined(js): renderPostUrl(thread.id, post.id) proc renderPostUrl*(link: PostLink): string = - renderPostUrl(link.threadId, link.postId) \ No newline at end of file + renderPostUrl(link.threadId, link.postId) diff --git a/src/frontend/postbutton.nim b/src/frontend/postbutton.nim index 8bd9c34..9fa4ab4 100644 --- a/src/frontend/postbutton.nim +++ b/src/frontend/postbutton.nim @@ -190,7 +190,7 @@ when defined(js): else: "" result = buildHtml(): - button(class="btn btn-secondary", + button(class="btn btn-secondary", id="lock-btn", onClick=(e: Event, n: VNode) => onLockClick(e, n, state, thread), "data-tooltip"=tooltip, @@ -201,4 +201,61 @@ when defined(js): text " Unlock Thread" else: italic(class="fas fa-lock") - text " Lock Thread" \ No newline at end of file + text " Lock Thread" + + type + PinButton* = ref object + error: Option[PostError] + loading: bool + + proc newPinButton*(): PinButton = + PinButton() + + proc onPost(httpStatus: int, response: kstring, state: PinButton, + thread: var Thread) = + postFinished: + thread.isPinned = not thread.isPinned + + proc onPinClick(ev: Event, n: VNode, state: PinButton, thread: var Thread) = + if state.loading: return + + state.loading = true + state.error = none[PostError]() + + # Same as LockButton so the following is still a hack and karax should support this. + var formData = newFormData() + formData.append("id", $thread.id) + let uri = + if thread.isPinned: + makeUri("/unpin") + else: + makeUri("/pin") + ajaxPost(uri, @[], formData.to(cstring), + (s: int, r: kstring) => onPost(s, r, state, thread)) + + ev.preventDefault() + + proc render*(state: PinButton, thread: var Thread, + currentUser: Option[User]): VNode = + if currentUser.isNone() or + currentUser.get().rank < Moderator: + return buildHtml(tdiv()) + + let tooltip = + if state.error.isSome(): state.error.get().message + else: "" + + result = buildHtml(): + button(class="btn btn-secondary", id="pin-btn", + onClick=(e: Event, n: VNode) => + onPinClick(e, n, state, thread), + "data-tooltip"=tooltip, + onmouseleave=(e: Event, n: VNode) => + (state.error = none[PostError]())): + if thread.isPinned: + italic(class="fas fa-thumbtack") + text " Unpin Thread" + else: + italic(class="fas fa-thumbtack") + text " Pin Thread" + diff --git a/src/frontend/postlist.nim b/src/frontend/postlist.nim index 7da57c1..66b3162 100644 --- a/src/frontend/postlist.nim +++ b/src/frontend/postlist.nim @@ -36,6 +36,7 @@ when defined(js): likeButton: LikeButton deleteModal: DeleteModal lockButton: LockButton + pinButton: PinButton categoryPicker: CategoryPicker proc onReplyPosted(id: int) @@ -56,6 +57,7 @@ when defined(js): likeButton: newLikeButton(), deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil), lockButton: newLockButton(), + pinButton: newPinButton(), categoryPicker: newCategoryPicker(onCategoryChanged) ) @@ -411,6 +413,7 @@ 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/threadlist.nim b/src/frontend/threadlist.nim index e0b2d36..ecec6da 100644 --- a/src/frontend/threadlist.nim +++ b/src/frontend/threadlist.nim @@ -15,6 +15,7 @@ type creation*: int64 ## Unix timestamp isLocked*: bool isSolved*: bool + isPinned*: bool ThreadList* = ref object threads*: seq[Thread] @@ -96,15 +97,18 @@ when defined(js): else: return $duration.inSeconds & "s" - proc genThread(thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode = + proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode = let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2 let isBanned = thread.author.rank.isBanned() result = buildHtml(): - tr(class=class({"no-border": noBorder, "banned": isBanned})): + tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})): td(class="thread-title"): if thread.isLocked: italic(class="fas fa-lock fa-xs", title="Thread cannot be replied to") + if thread.isPinned: + italic(class="fas fa-thumbtack fa-xs", + title="Pinned post") if isBanned: italic(class="fas fa-ban fa-xs", title="Thread author is banned") @@ -223,7 +227,7 @@ when defined(js): let isLastThread = i+1 == list.threads.len let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser) - genThread(thread, isNew, + genThread(i+1, thread, isNew, noBorder=isLastUnseen or isLastThread, displayCategory=displayCategory) if isLastUnseen and (not isLastThread): diff --git a/src/fts.sql b/src/fts.sql index ee6f1af..1590a05 100644 --- a/src/fts.sql +++ b/src/fts.sql @@ -7,6 +7,7 @@ SELECT post_id, post_content, cdate, + person.id, person.name AS author, person.email AS email, strftime('%s', person.lastOnline) AS lastOnline, diff --git a/src/setup_nimforum.nim b/src/setup_nimforum.nim index 34511fa..c80ad3b 100644 --- a/src/setup_nimforum.nim +++ b/src/setup_nimforum.nim @@ -81,6 +81,7 @@ proc initialiseDb(admin: tuple[username, password, email: string], isLocked boolean not null default 0, solution integer, isDeleted boolean not null default 0, + isPinned boolean not null default 0, foreign key (category) references category(id), foreign key (solution) references post(id) diff --git a/tests/browsertests/common.nim b/tests/browsertests/common.nim index d906675..e5924f3 100644 --- a/tests/browsertests/common.nim +++ b/tests/browsertests/common.nim @@ -30,7 +30,8 @@ proc elementIsSome(element: Option[Element]): bool = proc elementIsNone(element: Option[Element]): bool = return element.isNone -proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, waitCondition=elementIsSome): Option[Element] +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) @@ -71,14 +72,14 @@ proc setColor*(session: Session, element, color: string, strategy=CssSelector) = proc checkIsNone*(session: Session, element: string, strategy=CssSelector) = discard session.waitForElement(element, strategy, waitCondition=elementIsNone) -proc checkText*(session: Session, element, expectedValue: string) = +template checkText*(session: Session, element, expectedValue: string) = let el = session.waitForElement(element) check el.get().getText() == expectedValue proc waitForElement*( session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, - waitCondition=elementIsSome + waitCondition: proc(element: Option[Element]): bool = elementIsSome ): Option[Element] = var waitTime = 0 diff --git a/tests/browsertests/scenario1.nim b/tests/browsertests/scenario1.nim index 6e10e4c..dc78007 100644 --- a/tests/browsertests/scenario1.nim +++ b/tests/browsertests/scenario1.nim @@ -40,4 +40,4 @@ proc test*(session: Session, baseUrl: string) = register "TEst1", "test1", verify = false ensureExists "#signup-form .has-error" - navigate baseUrl \ No newline at end of file + navigate baseUrl diff --git a/tests/browsertests/threads.nim b/tests/browsertests/threads.nim index e80b9c0..32ce686 100644 --- a/tests/browsertests/threads.nim +++ b/tests/browsertests/threads.nim @@ -1,5 +1,4 @@ import unittest, common - import webdriver let @@ -58,10 +57,35 @@ proc userTests(session: Session, baseUrl: string) = # 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 @@ -161,6 +185,70 @@ proc adminTests(session: Session, baseUrl: string) = # 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) =