Compare commits

..

No commits in common. "master" and "fix-cannot-edit-category" have entirely different histories.

15 changed files with 75 additions and 310 deletions

View file

@ -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

45
.travis.yml Normal file
View file

@ -0,0 +1,45 @@
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

View file

@ -301,7 +301,7 @@ $threads-meta-color: #545d70;
} }
} }
.thread-replies, .thread-time, .views-text, .popular-text, .centered-header { .thread-replies, .thread-time, .views-text, .centered-header {
text-align: center; text-align: center;
} }

View file

@ -276,7 +276,7 @@ template createTFD() =
new(c) new(c)
init(c) init(c)
c.req = request c.req = request
if cookies(request).len > 0: if request.cookies.len > 0:
checkLoggedIn(c) checkLoggedIn(c)
#[ DB functions. TODO: Move to another module? ]# #[ DB functions. TODO: Move to another module? ]#
@ -400,10 +400,10 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
id: threadRow[0].parseInt, id: threadRow[0].parseInt,
topic: threadRow[1], topic: threadRow[1],
category: Category( category: Category(
id: threadRow[6].parseInt, id: threadRow[5].parseInt,
name: threadRow[7], name: threadRow[6],
description: threadRow[8], description: threadRow[7],
color: threadRow[9] color: threadRow[8]
), ),
users: @[], users: @[],
replies: posts[0].parseInt-1, replies: posts[0].parseInt-1,
@ -412,7 +412,6 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
creation: posts[1].parseInt, creation: posts[1].parseInt,
isLocked: threadRow[4] == "1", isLocked: threadRow[4] == "1",
isSolved: false, # TODO: Add a field to `post` to identify the solution. isSolved: false, # TODO: Add a field to `post` to identify the solution.
isPinned: threadRow[5] == "1"
) )
# Gather the users list. # Gather the users list.
@ -500,10 +499,10 @@ proc updatePost(c: TForumData, postId: int, content: string,
# Verify that the current user has permissions to edit the specified post. # Verify that the current user has permissions to edit the specified post.
let creation = fromUnix(postRow[1].parseInt) let creation = fromUnix(postRow[1].parseInt)
let isArchived = (getTime() - creation).inHours >= 2 let isArchived = (getTime() - creation).inWeeks > 8
let canEdit = c.rank == Admin or c.userid == postRow[0] let canEdit = c.rank == Admin or c.userid == postRow[0]
if isArchived and c.rank < Admin: if isArchived:
raise newForumError("This post is too old and can no longer be edited") raise newForumError("This post is archived and can no longer be edited")
if not canEdit: if not canEdit:
raise newForumError("You cannot edit this post") raise newForumError("You cannot edit this post")
@ -710,13 +709,6 @@ proc executeLockState(c: TForumData, threadId: int, locked: bool) =
# Save the like. # Save the like.
exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId) 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) = proc executeDeletePost(c: TForumData, postId: int) =
# Verify that this post belongs to the user. # Verify that this post belongs to the user.
const postQuery = sql""" const postQuery = sql"""
@ -841,7 +833,7 @@ routes:
categoryArgs.insert($categoryId, 0) categoryArgs.insert($categoryId, 0)
const threadsQuery = const threadsQuery =
"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned, """select t.id, t.name, views, strftime('%s', modified), isLocked,
c.id, c.name, c.description, c.color, c.id, c.name, c.description, c.color,
u.id, u.name, u.email, strftime('%s', u.lastOnline), u.id, u.name, u.email, strftime('%s', u.lastOnline),
strftime('%s', u.previousVisitAt), u.status, u.isDeleted strftime('%s', u.previousVisitAt), u.status, u.isDeleted
@ -854,14 +846,14 @@ routes:
order by p.author order by p.author
limit 1 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, countQuery, countArgs).parseInt()
let moreCount = max(0, thrCount - (start + count)) let moreCount = max(0, thrCount - (start + count))
var list = ThreadList(threads: @[], moreCount: moreCount) var list = ThreadList(threads: @[], moreCount: moreCount)
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs): for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs):
let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1])) let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1]))
list.threads.add(thread) list.threads.add(thread)
resp $(%list), "application/json" resp $(%list), "application/json"
@ -876,17 +868,12 @@ routes:
count = 10 count = 10
const threadsQuery = 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 c.id, c.name, c.description, c.color
from thread t, category c from thread t, category c
where t.id = ? and isDeleted = 0 and category = c.id;""" where t.id = ? and isDeleted = 0 and category = c.id;"""
let threadRow = getRow(db, threadsQuery, 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 thread = selectThread(threadRow, selectThreadAuthor(id))
let postsQuery = let postsQuery =
@ -932,14 +919,9 @@ routes:
get "/specific_posts.json": get "/specific_posts.json":
createTFD() createTFD()
var ids: JsonNode var
try:
ids = parseJson(@"ids") ids = parseJson(@"ids")
except JsonParsingError:
let err = PostError(
message: "Invalid JSON in the `ids` parameter"
)
resp Http400, $(%err), "application/json"
cond ids.kind == JArray cond ids.kind == JArray
let intIDs = ids.elems.map(x => x.getInt()) let intIDs = ids.elems.map(x => x.getInt())
let postsQuery = sql(""" let postsQuery = sql("""
@ -1357,33 +1339,6 @@ routes:
except ForumError as exc: except ForumError as exc:
resp Http400, $(%exc.data), "application/json" 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)": post re"/delete(Post|Thread)":
createTFD() createTFD()
if not c.loggedIn(): if not c.loggedIn():

View file

@ -96,8 +96,8 @@ when defined(js):
section(class="navbar-section"): section(class="navbar-section"):
tdiv(class="input-group input-inline"): tdiv(class="input-group input-inline"):
input(class="search-input input-sm", input(class="search-input input-sm",
`type`="search", placeholder="Search", `type`="text", placeholder="search",
id="search-box", required="required", id="search-box",
onKeyDown=onKeyDown) onKeyDown=onKeyDown)
if state.loading: if state.loading:
tdiv(class="loading") tdiv(class="loading")

View file

@ -64,4 +64,4 @@ when defined(js):
renderPostUrl(thread.id, post.id) renderPostUrl(thread.id, post.id)
proc renderPostUrl*(link: PostLink): string = proc renderPostUrl*(link: PostLink): string =
renderPostUrl(link.threadId, link.postId) renderPostUrl(link.threadId, link.postId)

View file

@ -190,7 +190,7 @@ when defined(js):
else: "" else: ""
result = buildHtml(): result = buildHtml():
button(class="btn btn-secondary", id="lock-btn", button(class="btn btn-secondary",
onClick=(e: Event, n: VNode) => onClick=(e: Event, n: VNode) =>
onLockClick(e, n, state, thread), onLockClick(e, n, state, thread),
"data-tooltip"=tooltip, "data-tooltip"=tooltip,
@ -201,61 +201,4 @@ when defined(js):
text " Unlock Thread" text " Unlock Thread"
else: else:
italic(class="fas fa-lock") italic(class="fas fa-lock")
text " Lock Thread" 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"

View file

@ -36,7 +36,6 @@ when defined(js):
likeButton: LikeButton likeButton: LikeButton
deleteModal: DeleteModal deleteModal: DeleteModal
lockButton: LockButton lockButton: LockButton
pinButton: PinButton
categoryPicker: CategoryPicker categoryPicker: CategoryPicker
proc onReplyPosted(id: int) proc onReplyPosted(id: int)
@ -57,7 +56,6 @@ when defined(js):
likeButton: newLikeButton(), likeButton: newLikeButton(),
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil), deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
lockButton: newLockButton(), lockButton: newLockButton(),
pinButton: newPinButton(),
categoryPicker: newCategoryPicker(onCategoryChanged) categoryPicker: newCategoryPicker(onCategoryChanged)
) )
@ -413,7 +411,6 @@ when defined(js):
text " Reply" text " Reply"
render(state.lockButton, list.thread, currentUser) render(state.lockButton, list.thread, currentUser)
render(state.pinButton, list.thread, currentUser)
render(state.replyBox, list.thread, state.replyingTo, false) render(state.replyBox, list.thread, state.replyingTo, false)

View file

@ -15,7 +15,6 @@ type
creation*: int64 ## Unix timestamp creation*: int64 ## Unix timestamp
isLocked*: bool isLocked*: bool
isSolved*: bool isSolved*: bool
isPinned*: bool
ThreadList* = ref object ThreadList* = ref object
threads*: seq[Thread] threads*: seq[Thread]
@ -97,18 +96,15 @@ when defined(js):
else: else:
return $duration.inSeconds & "s" return $duration.inSeconds & "s"
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode = proc genThread(thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2 let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
let isBanned = thread.author.rank.isBanned() let isBanned = thread.author.rank.isBanned()
result = buildHtml(): 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"): td(class="thread-title"):
if thread.isLocked: if thread.isLocked:
italic(class="fas fa-lock fa-xs", italic(class="fas fa-lock fa-xs",
title="Thread cannot be replied to") title="Thread cannot be replied to")
if thread.isPinned:
italic(class="fas fa-thumbtack fa-xs",
title="Pinned post")
if isBanned: if isBanned:
italic(class="fas fa-ban fa-xs", italic(class="fas fa-ban fa-xs",
title="Thread author is banned") title="Thread author is banned")
@ -227,7 +223,7 @@ when defined(js):
let isLastThread = i+1 == list.threads.len let isLastThread = i+1 == list.threads.len
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser) let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
genThread(i+1, thread, isNew, genThread(thread, isNew,
noBorder=isLastUnseen or isLastThread, noBorder=isLastUnseen or isLastThread,
displayCategory=displayCategory) displayCategory=displayCategory)
if isLastUnseen and (not isLastThread): if isLastUnseen and (not isLastThread):

View file

@ -7,7 +7,6 @@ SELECT
post_id, post_id,
post_content, post_content,
cdate, cdate,
person.id,
person.name AS author, person.name AS author,
person.email AS email, person.email AS email,
strftime('%s', person.lastOnline) AS lastOnline, strftime('%s', person.lastOnline) AS lastOnline,

View file

@ -81,7 +81,6 @@ proc initialiseDb(admin: tuple[username, password, email: string],
isLocked boolean not null default 0, isLocked boolean not null default 0,
solution integer, solution integer,
isDeleted boolean not null default 0, isDeleted boolean not null default 0,
isPinned boolean not null default 0,
foreign key (category) references category(id), foreign key (category) references category(id),
foreign key (solution) references post(id) foreign key (solution) references post(id)

View file

@ -5,7 +5,7 @@ from times import getTime, utc, format
# Used to be: # Used to be:
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'} # {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
let let
UsernameIdent* = IdentChars + {'-'} # TODO: Double check that everyone follows this. UsernameIdent* = IdentChars # TODO: Double check that everyone follows this.
import frontend/[karaxutils, error] import frontend/[karaxutils, error]
export parseInt export parseInt

View file

@ -30,8 +30,7 @@ proc elementIsSome(element: Option[Element]): bool =
proc elementIsNone(element: Option[Element]): bool = proc elementIsNone(element: Option[Element]): bool =
return element.isNone return element.isNone
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, waitCondition=elementIsSome): Option[Element]
waitCondition: proc(element: Option[Element]): bool = elementIsSome): Option[Element]
proc click*(session: Session, element: string, strategy=CssSelector) = proc click*(session: Session, element: string, strategy=CssSelector) =
let el = session.waitForElement(element, strategy) let el = session.waitForElement(element, strategy)
@ -72,14 +71,14 @@ proc setColor*(session: Session, element, color: string, strategy=CssSelector) =
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) = proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
discard session.waitForElement(element, strategy, waitCondition=elementIsNone) discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
template checkText*(session: Session, element, expectedValue: string) = proc checkText*(session: Session, element, expectedValue: string) =
let el = session.waitForElement(element) let el = session.waitForElement(element)
check el.get().getText() == expectedValue check el.get().getText() == expectedValue
proc waitForElement*( proc waitForElement*(
session: Session, selector: string, strategy=CssSelector, session: Session, selector: string, strategy=CssSelector,
timeout=20000, pollTime=50, timeout=20000, pollTime=50,
waitCondition: proc(element: Option[Element]): bool = elementIsSome waitCondition=elementIsSome
): Option[Element] = ): Option[Element] =
var waitTime = 0 var waitTime = 0

View file

@ -40,4 +40,4 @@ proc test*(session: Session, baseUrl: string) =
register "TEst1", "test1", verify = false register "TEst1", "test1", verify = false
ensureExists "#signup-form .has-error" ensureExists "#signup-form .has-error"
navigate baseUrl navigate baseUrl

View file

@ -1,4 +1,5 @@
import unittest, common import unittest, common
import webdriver import webdriver
let let
@ -57,35 +58,10 @@ proc userTests(session: Session, baseUrl: string) =
# Make sure the forum post is gone # Make sure the forum post is gone
checkIsNone "To be deleted", LinkTextSelector 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() session.logout()
proc anonymousTests(session: Session, baseUrl: string) = proc anonymousTests(session: Session, baseUrl: string) =
suite "anonymous user tests": suite "anonymous user tests":
with session: with session:
navigate baseUrl navigate baseUrl
@ -185,70 +161,6 @@ proc adminTests(session: Session, baseUrl: string) =
# Make sure the forum post is gone # Make sure the forum post is gone
checkIsNone adminTitleStr, LinkTextSelector 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() session.logout()
proc test*(session: Session, baseUrl: string) = proc test*(session: Session, baseUrl: string) =