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..83421ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Wildcard patterns. *.swp nimcache/ -*.db* +*.db # Specific paths /createdb @@ -12,12 +12,3 @@ nimcache/ forum createdb editdb - -.vscode -forum.json* -browsertester -setup_nimforum -buildcss -nimforum.css - -/src/frontend/forum.js diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 6ea9ea9..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "frontend/spectre"] - path = frontend/spectre - url = https://github.com/picturepan2/spectre -[submodule "public/css/spectre"] - path = public/css/spectre - url = https://github.com/picturepan2/spectre diff --git a/README.md b/README.md index d7dedb4..1df440e 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,83 @@ # nimforum -NimForum is a light-weight forum implementation -with many similarities to Discourse. It is implemented in -the [Nim](https://nim-lang.org) programming -language and uses SQLite for its database. +This is Nim's forum. Available at http://forum.nim-lang.org. -## Examples in the wild +## Building -[![forum.nim-lang.org](https://i.imgur.com/hdIF5Az.png)](https://forum.nim-lang.org) +You can use ``nimble`` (available [here](https://github.com/nim-lang/nimble) +to get all the necessary +[dependencies](https://github.com/nim-lang/nimforum/blob/master/nimforum.nimble#L11). -

forum.nim-lang.org

+Clone this repo and execute ``nimble build`` in this repositories directory. -## Features - -* Efficient, type safe and clean **single-page application** developed using the - [Karax](https://github.com/pragmagic/karax) and - [Jester](https://github.com/dom96/jester) frameworks. -* **Utilizes SQLite** making set up much easier. -* Endlessly **customizable** using SASS. -* Spam blocking via new user sandboxing with great tools for moderators. -* reStructuredText enriched by Markdown to make formatting your posts a breeze. -* Search powered by SQLite's full-text search. -* Context-aware replies. -* Last visit tracking. -* Gravatar support. -* And much more! - -## Setup - -[See this document.](https://github.com/nim-lang/nimforum/blob/master/setup.md) +_See also: Running the forum for how to create the database_ ## Dependencies -The following lists the dependencies which you may need to install manually -in order to get NimForum running, compiled*, or tested†. +The code depends on the RST parser of the Nim +compiler and on Jester. The code generating captchas for registration uses the +[cairo module](https://github.com/nim-lang/cairo), which requires you to have +the [cairo library](http://cairographics.org) installed when you run the forum, +or you will be greeted by a cryptic error message similar to: -* libsass -* SQLite -* pcre -* Nim (and the Nimble package manager)* -* [geckodriver](https://github.com/mozilla/geckodriver)† - * Firefox† + $ ./forum could not load: libcairo.so(1.2) -[*] Build time dependencies +### Mac OS X -[†] Test time dependencies +#### cairo +If you are using macosx and have installed the ``cairo`` library through +[MacPorts](https://www.macports.org) you still need to add the library path to +your ``LD_LIBRARY_PATH`` environment variable. Example: -## Development + $ LD_LIBRARY_PATH=/opt/local/lib/ ./forum -Check out the tasks defined by this project's ``nimforum.nimble`` file by -running ``nimble tasks``, as of writing they are: +Replace ``/opt/local/lib`` with the correct path on your system. + +#### bcrypt + +On macosx you also need to make sure to use the bcrypt >= 0.2.1 module if that +is not yet updated you can install it with: ``` -backend Compiles and runs the forum backend -runbackend Runs the forum backend -frontend Builds the necessary JS frontend (with CSS) -minify Minifies the JS using Google's closure compiler -testdb Creates a test DB (with admin account!) -devdb Creates a test DB (with admin account!) -blankdb Creates a blank DB -test Runs tester -fasttest Runs tester without recompiling backend +nimble install https://github.com/oderwat/bcryptnim.git@#fix-osx ``` -To get up and running: - -```bash -git clone https://github.com/nim-lang/nimforum -cd nimforum -git submodule update --init --recursive - -# Setup the db with user: admin, pass: admin and some other users -nimble devdb - -# Run this again if frontend code changes -nimble frontend - -# Will start a server at localhost:5000 -nimble backend -``` - -Development typically involves running `nimble devdb` which sets up the -database for development and testing, then `nimble backend` -which compiles and runs the forum's backend, and `nimble frontend` -separately to build the frontend. When making changes to the frontend it -should be enough to simply run `nimble frontend` again to rebuild. This command -will also build the SASS ``nimforum.scss`` file in the `public/css` directory. - -### With docker - -You can easily launch site on localhost if you have `docker` and `docker-compose`. -You don't have to setup dependencies (libsass, sglite, pcre, etc...) on you host PC. - -To get up and running: - -```bash -cd docker -docker-compose build -docker-compose up -``` - -And you can access local NimForum site. -Open http://localhost:5000 . - -# Troubleshooting - -You might have to run `nimble install karax@#5f21dcd`, if setup fails -with: +You may also need to change `nimforum.nimble` such that it uses 0.2.1 by +changing the dependencies slightly. ``` -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 +[Deps] +Requires: "nimrod >= 0.10.3, cairo#head, jester#head, bcrypt >= 0.2.1" ``` -The hash needs to be replaced with the one specified in output. +# Running the forum + +**Important: You need to compile and run `createdb` to generate the initial database +before you can run `forum` the first time**! + +**Note: If you do not have a mail server set up locally, you can specify +``-d:dev`` during compilation to prevent nimforum from attempting to send +emails and to automatically activate user accounts** + +This is as simple as: + +``` +nim c -r createdb +``` + +After that you can just run `forum` and if everything is ok you will get the info which URL you need to open in your browser (http://localhost:5000) to access it. + +_There is an update helper `editdb` which you can safely ignore for now._ + +_The files `captchas.nim`, `cache.nim` are included by `forum.nim` and do +not need to be compiled by you._ # Copyright -Copyright (c) 2012-2018 Andreas Rumpf, Dominik Picheta. +Copyright (c) 2012-2015 Andreas Rumpf, Dominik Picheta. All rights reserved. # License -NimForum is licensed under the MIT license. +Nimforum is licensed under the MIT license. diff --git a/cache.nim b/cache.nim new file mode 100644 index 0000000..6023393 --- /dev/null +++ b/cache.nim @@ -0,0 +1,32 @@ +import tables, uri +type + CacheInfo = object + valid: bool + value: string + + CacheHolder = ref object + caches: Table[string, CacheInfo] + +proc normalizePath(x: string): string = + let u = parseUri(x) + result = u.path & (if u.query != "": '?' & u.query else: "") + +proc newCacheHolder*(): CacheHolder = + new result + result.caches = initTable[string, CacheInfo]() + +proc invalidate*(cache: CacheHolder, name: string) = + cache.caches[name.normalizePath()].valid = false + +proc invalidateAll*(cache: CacheHolder) = + for key, val in mpairs(cache.caches): + val.valid = false + +template get*(cache: CacheHolder, name: string, grabValue: untyped): untyped = + ## Check to see if the cache contains value for ``name``. If it does and the + ## cache is valid then doesn't recalculate it but returns the cached version. + mixin normalizePath + let nName = name.normalizePath() + if not (cache.caches.hasKey(nName) and cache.caches[nName].valid): + cache.caches[nName] = CacheInfo(valid: true, value: grabValue) + cache.caches[nName].value diff --git a/captchas.nim b/captchas.nim new file mode 100644 index 0000000..f569f04 --- /dev/null +++ b/captchas.nim @@ -0,0 +1,37 @@ +# +# +# The Nim Forum +# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta +# Look at license.txt for more info. +# All rights reserved. +# + +import cairo, os, strutils, jester + +proc getCaptchaFilename*(i: int): string {.inline.} = + result = "public/captchas/capture_" & $i & ".png" + +proc getCaptchaUrl*(req: Request, i: int): string = + result = req.makeUri("/captchas/capture_" & $i & ".png", absolute = false) + +proc createCaptcha*(file, text: string) = + var surface = imageSurfaceCreate(FORMAT_ARGB32, int32(10*text.len), int32(10)) + var cr = create(surface) + + selectFontFace(cr, "serif", FONT_SLANT_NORMAL, FONT_WEIGHT_BOLD) + setFontSize(cr, 12.0) + + setSourceRgb(cr, 1.0, 0.5, 0.0) + moveTo(cr, 0.0, 10.0) + showText(cr, repeat('O', text.len)) + + setSourceRgb(cr, 0.0, 0.0, 1.0) + moveTo(cr, 0.0, 10.0) + showText(cr, text) + + destroy(cr) + discard writeToPng(surface, file) + destroy(surface) + +when isMainModule: + createCaptcha("test.png", "1+33") diff --git a/createdb.nim b/createdb.nim new file mode 100644 index 0000000..4162c36 --- /dev/null +++ b/createdb.nim @@ -0,0 +1,124 @@ +# +# +# The Nim Forum +# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta +# Look at license.txt for more info. +# All rights reserved. +# + +import strutils, db_sqlite + +var db = open(connection="nimforum.db", user="postgres", password="", + database="nimforum") + +const + TUserName = "varchar(20)" + TPassword = "varchar(32)" + TEmail = "varchar(30)" + +db.exec(sql""" +create table if not exists thread( + id integer primary key, + name varchar(100) not null, + views integer not null, + modified timestamp not null default (DATETIME('now')) +);""", []) + +db.exec(sql""" +create unique index if not exists ThreadNameIx on thread (name); +""", []) + +db.exec(sql(""" +create table if not exists person( + id integer primary key, + name $# not null, + password $# not null, + email $# not null, + creation timestamp not null default (DATETIME('now')), + salt varbin(128) not null, + status varchar(30) not null, + lastOnline timestamp not null default (DATETIME('now')) +);""" % [TUserName, TPassword, TEmail]), []) +# echo "person table already exists" + +db.exec(sql(""" +alter table person +add ban varchar(128) not null default '' +""")) + +db.exec(sql""" +create unique index if not exists UserNameIx on person (name); +""", []) + +# ----------------------- Forum ------------------------------------------------ + + +if not db.tryExec(sql""" +create table if not exists post( + id integer primary key, + author integer not null, + ip inet not null, + header varchar(100) not null, + content varchar(1000) not null, + thread integer not null, + creation timestamp not null default (DATETIME('now')), + + foreign key (thread) references thread(id), + foreign key (author) references person(id) +);""", []): + echo "post table already exists" + +# -------------------- Session ------------------------------------------------- + +if not db.tryExec(sql(""" +create table if not exists session( + id integer primary key, + ip inet not null, + password $# not null, + userid integer not null, + lastModified timestamp not null default (DATETIME('now')), + foreign key (userid) references person(id) +);""" % [TPassword]), []): + echo "session table already exists" + +if not db.tryExec(sql""" +create table if not exists antibot( + id integer primary key, + ip inet not null, + answer varchar(30) not null, + created timestamp not null default (DATETIME('now')) +);""", []): + echo "antibot table already exists" + +# -------------------- Search -------------------------------------------------- + +if not db.tryExec(sql""" + CREATE VIRTUAL TABLE thread_fts USING fts4 ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL + );""", []): + echo "thread_fts table already exists or fts4 not supported" +else: + db.exec(sql""" + INSERT INTO thread_fts + SELECT id, name FROM thread; + """, []) +if not db.tryExec(sql""" + CREATE VIRTUAL TABLE post_fts USING fts4 ( + id INTEGER PRIMARY KEY, + header VARCHAR(100) NOT NULL, + content VARCHAR(1000) NOT NULL + );""", []): + echo "post_fts table already exists or fts4 not supported" +else: + db.exec(sql""" + INSERT INTO post_fts + SELECT id, header, content FROM post; + """, []) + + +# ------------------------------------------------------------------------------ + +#discard stdin.readline() + +close(db) 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/editdb.nim b/editdb.nim new file mode 100644 index 0000000..e12f679 --- /dev/null +++ b/editdb.nim @@ -0,0 +1,13 @@ + +import strutils, db_sqlite, ranks + +var db = open(connection="nimforum.db", user="postgres", password="", + database="nimforum") + +db.exec(sql("update person set status = ?"), $User) +db.exec(sql("update person set status = ? where ban <> ''"), $Troll) +db.exec(sql("update person set status = ? where ban like '%spam%'"), $Spammer) +db.exec(sql("update person set status = ? where ban = 'DEACTIVATED' or ban = 'EMAILCONFIRMATION'"), $EmailUnconfirmed) +db.exec(sql("update person set status = ? where admin = 'true'"), $Admin) + +close(db) diff --git a/forms.tmpl b/forms.tmpl new file mode 100644 index 0000000..9d62d95 --- /dev/null +++ b/forms.tmpl @@ -0,0 +1,514 @@ +#? stdtmpl | standard +# +#template `%`(idx: untyped): untyped = +# row[idx] +#end template +# +# +#proc genThreadsList(c: var TForumData, count: var int): string = +# const queryModAdmin = sql"""select id, name, views, modified from thread +# where id in (select thread from post where author in +# (select id from person where status not in ('Spammer') or id = ?)) +# order by modified desc limit ?, ?""" +# const query = sql"""select id, name, views, modified from thread +# where id in (select thread from post where author in +# (select id from person where status not in ('Moderated', 'Spammer') or id = ?)) +# order by modified desc limit ?, ?""" +# const threadId = 0 +# const name = 1 +# const views = 2 +# +# result = "" +# count = 0 +
+
+
+ Topic + + + +
+
+
Users
+
Details
+
+
+ Activity + + + +
+
+
+
+# for row in rows(db, if c.rank >= Moderator: queryModAdmin else: query, +# c.userId, $((c.pageNum-1) * ThreadsPerPage), $ThreadsPerPage): +# inc(count) +
+
+
+ ${xmlEncode(%name)} + ${genPagenumLocalNav(c, (%threadid).parseInt)} +
+
+ + #let users = getAllRows(db, + # sql("select distinct name, email from person where id in " & + # "(select author from post where thread = ?)"), %threadId) +
+
+ #for i in 0 .. min(6, users.len-1): + + #end for +
+
+ + #let latestReplyAuthor = getValue(db, sql("select name from person where id = " & + # "(select author from post where id = " & + # "(select max(id) from post where thread = ?))"), %threadId) + + #let replyProfileUrl = c.req.makeUri("profile/", false) & + # xmlEncode(latestReplyAuthor) + +# let posts = getValue(db, sql"select count(*) from post where thread = ?", %threadId) +
+
${xmlEncode(%views)}
+
$posts
+
+ + #let latestReplyDate = getValue(db, sql("SELECT strftime('%s', " & + # "(select creation from post where id = (select max(id) from post where thread = ?)))"), %threadId) + #let timeStr = formatTimestamp(latestReplyDate.parseInt()) +
+
+ $latestReplyAuthor replied $timeStr +
+
+
+# end for +
+ +#end proc +# +# +#proc genPostPreview(c: var TForumData, +# title, content, author, date: string): string = +# result = "" + +
+
+
+
+ #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(author) + ${xmlEncode(author)} +
+
+
+
+ #try: + ${content.rstToHtml} + #except EParseError: + # c.errorMsg = getCurrentExceptionMsg() + #end + ${xmlEncode(date)} +
+
+
+
+#end proc +# +# +#proc genPostsList(c: var TForumData, threadId: string, count: var int): string = +# let query = sql("""select p.id, u.name, p.header, p.content, p.creation, p.author, u.email from post p, +# person u +# where u.id = p.author and p.thread = ? and $# +# and (u.status <> 'Spammer' or p.author = ?) +# order by p.id limit ?, ?""" % +# (if c.rank >= Moderator: "(1 or u.id = ?)" else: "(u.status <> 'Moderated' or p.author = ?)")) +# const postId = 0 +# const userName = 1 +# const postHeader = 2 +# const postContent = 3 +# const postCreation = 4 +# const postAuthor = 5 +# const userEmail = 6 +# result = "" +# count = 0 +# let posts = getAllRows(db, query, threadId, c.userId, c.userId, $((c.pageNum-1) * PostsPerPage), $PostsPerPage) +# if posts.len < 1: return "" +# end if +
+
+
+ forum index > + ${posts[0][postHeader]} +
+
+
+
+# for row in posts: +# inc(count) + +
+
+
+ #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(%userName) +
${genGravatar(%userEmail)}
+ ${xmlEncode(%userName)} + #if c.userId == %postAuthor and c.currentPost.subject.len == 0: +
Edit post + #elif c.rank >= Moderator and c.currentPost.subject.len == 0: +
Edit post + #end if +
+
+
+
+ #try: + ${(%postContent).rstToHtml} + #except EParseError: + # c.errorMsg = getCurrentExceptionMsg() + #end + ${xmlEncode(%postCreation)} +
+
+
+# end for +
+#end proc +# +#proc genMarkHelp(): string +#end proc +#proc genFormPost(c: var TForumData, action: string, +# topText, title, content: string, isEdit: bool): string = +# result = "" +
+ +
+
+
+
+ forum index > + $topText +
+
+
+
+ #if action == "doreply": + ${hiddenField(c, "subject", title)} + #else: + ${fieldValid(c, "subject", "Subject:")} + ${textWidget(c, "subject", title, maxlength=100)} +
+ #end if + ${fieldValid(c, "content", "Content:")}
+ ${textAreaWidget(c, "content", content)}
+ ${formSession(c, action)} + + # if isEdit: + Delete Post
+ # end if + #if c.errorMsg != "": +
+ $c.errorMsg +
+ #end if +
+ + + + + ${genMarkHelp()} +
+
+#end proc +# +# +#proc genFormRegister(c: var TForumData): string = +# result = "" +
+
+
+ forum index > + Register +
+
+
+
+ + + + + + + + + + + + + + + + + +
${fieldValid(c, "name", "Username:")}${textWidget(c, "name", reuseText, maxlength=20)}
${fieldValid(c, "new_password", "Password:")}
${fieldValid(c, "email", "E-Mail:")}${textWidget(c, "email", reuseText, maxlength=300)}
${fieldValid(c, "antibot", "What is " & antibot(c) & "?")}${textWidget(c, "antibot", "", maxlength=4)}
+ #if c.errorMsg != "": +
+ $c.errorMsg +
+ #end if + +
+#end proc +# +#proc genFormSetRank(c: var TForumData; ui: TUserInfo): string = +# result = "" +
+ + + + + + + + + +
Reason${textWidget(c, "reason", ui.ban, maxlength=100)}
Rank
+ +
+#end proc +# +#proc genFormLogin(c: var TForumData): string = +# result = "" +# if not c.loggedIn: +
+ + + +
Username: +
Password: +
+ +
+ $c.loginErrorMsg +# else: + You're already logged in! +# end if +#end proc +# +# +#proc genListOnline(c: var TForumData, stats: TForumStats): string = +# result = "" +# var active: seq[string] = @[] +# for i in stats.activeUsers: +# active.add(i.nick) +# end for +# let profileUrl = c.req.makeUri("profile/", false) & +# xmlEncode(stats.newestMember.nick) + + ${stats.activeUsers.len} of ${stats.totalUsers} users online  |  + ${stats.totalThreads} threads  |  ${stats.totalPosts} posts  |  + newest member: ${stats.newestMember.nick} +#end proc +# +# +# +# +#proc genSearchResults(c: var TForumData, +# results: iterator: db_sqlite.Row {.closure, tags: [ReadDbEffect].}, +# count: var int): string = +# const threadId = 0 +# const threadName = 1 +# const postId = 2 +# const postHeader = 3 +# const postContent = 4 +# const userName = 5 +# const postCreation = 6 +# const postAuthor = 7 +# const userEmail = 8 +# const what = 9 +# result = "" +# count = 0 +# var whCount: array[bool, int] +
+
+
+ Search results for: ${xmlEncode(c.search.replace(""","\""))}. +
+
+
+
+# for row in results(): +# inc(count) +# let isThread = %what == "0" +# inc(whCount[isThread]) +# let postUrl = c.genThreadUrl(%postId,"",%threadId,"") +# let threadUrl = c.genThreadUrl("","",%threadId) +# var headersDiffer = false +
+
+
+ #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(%userName) +
${genGravatar(%userEmail, 40)}
+
${xmlEncode(%userName)}
+ #if c.userId == %postAuthor and c.currentPost.subject.len == 0: +
Edit post + #elif c.rank >= Moderator and c.currentPost.subject.len == 0: +
Edit post + #end if +
+
+
+
+ #if %postHeader != "": + + #end if + #if not isThread: + #try: + ${(%postContent).rstToHtml} + #except EParseError: + # c.errorMsg = getCurrentExceptionMsg() + ${xmlEncode(%postContent)} + #end + #end if + ${xmlEncode(%postCreation)} +
+
+
+# end for +
+# if c.pageNum > 1: +
+ + +
+# end if +# if whCount[true] == ThreadsPerPage or whCount[false] == ThreadsPerPage: +
+ + +
+# end if +#end proc +# +# +#proc genFormResetPassword(c: var TForumData): string = +# result = "" +
+
+
+ forum index > + Reset Password +
+
+
+
+ + + + + + + + + +
${fieldValid(c, "nick", "Your nickname:")}
${fieldValid(c, "antibot", "What is " & antibot(c) & "?")}${textWidget(c, "antibot", "", maxlength=4)}
+ #if c.errorMsg != "": +
+ $c.errorMsg +
+ #end if + +
+#end proc +#proc genMarkHelp(): string = +#result = "" +
+

nimforum uses a slightly-customized version of + reStructuredText for formatting. See below for some basics, or check + this link for a more detailed help reference.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
you type: + you see: +
*italics*italics +
**bold**bold +
`nim! <http://nim-lang.org>`_nim! +
* item 1 +
* item 2 +
* item 3
+
    +
  • item 1
  • +
  • item 2
  • +
  • item 3
  • +
+
> quoted text +
quoted text
+
The forum supports the Github Markdown syntax +
for code listings: +
+
```nim +
if 1 * 2 < 3: +
  echo "hello, world!" +
``` +
+
The forum supports the Github Markdown syntax +
for code listings: +
+
+if 1*2 < 3:
+  echo "hello, world!"
+                    
+
A horizontal rule can be created
----
but it needs text after it
A horizontal rule can be created
but it needs text after it
+
+#end proc diff --git a/forum.nim b/forum.nim new file mode 100644 index 0000000..1b29cdf --- /dev/null +++ b/forum.nim @@ -0,0 +1,1353 @@ +# +# +# The Nim Forum +# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta +# Look at license.txt for more info. +# All rights reserved. +# + +import + os, strutils, times, md5, strtabs, cgi, math, db_sqlite, + captchas, scgi, jester, asyncdispatch, asyncnet, cache, sequtils, + parseutils, utils, random, rst, ranks + +when not defined(windows): + import bcrypt # TODO + +from htmlgen import tr, th, td, span, input + +const + unselectedThread = -1 + transientThread = 0 + + ThreadsPerPage = 15 + PostsPerPage = 10 + MaxPagesFromCurrent = 8 + noPageNums = ["/login", "/register", "/dologin", "/doregister", "/profile"] + noHomeBtn = ["/", "/login", "/register", "/dologin", "/doregister", "/profile"] + +type + TCrud = enum crCreate, crRead, crUpdate, crDelete + + TSession = object of RootObj + threadid: int + postid: int + userName, userPass, email: string + rank: Rank + + TPost = tuple[subject, content: string] + + TForumData = object of TSession + req: Request + userid: string + actionContent: string + errorMsg, loginErrorMsg: string + invalidField: string + currentPost: TPost ## Only used for reply previews + startTime: float + isThreadsList: bool + pageNum: int + totalPosts: int + search: string + noPagenumumNav: bool + config: Config + + TStyledButton = tuple[text: string, link: string] + + TForumStats = object + totalUsers: int + totalPosts: int + totalThreads: int + newestMember: tuple[nick: string, id: int] + activeUsers: seq[tuple[nick: string, id: int]] + + TUserInfo = object + nick: string + posts: int + threads: int + lastOnline: int + email: string + ban: string + rank: Rank + + ForumError = object of Exception + +var + db: DbConn + isFTSAvailable: bool + config: Config + +proc init(c: var TForumData) = + c.userPass = "" + c.userName = "" + c.threadId = unselectedThread + c.postId = -1 + + c.userid = "" + c.actionContent = "" + c.errorMsg = "" + c.loginErrorMsg = "" + c.invalidField = "" + c.currentPost = (subject: "", content: "") + + c.search = "" + +proc loggedIn(c: TForumData): bool = + result = c.userName.len > 0 + +# --------------- HTML widgets ------------------------------------------------ + +# for widgets "" means the empty string as usual; should the old value be +# used again, pass `reuseText` instead: +const + reuseText = "\1" + +proc textWidget(c: TForumData, name, defaultText: string, + maxlength = 30, size = -1): string = + let x = if defaultText != reuseText: defaultText + else: xmlEncode(c.req.params.getOrDefault(name)) + return """""" % [ + name, $maxlength, x, if size != -1: "size=\"" & $size & "\"" else: ""] + +proc hiddenField(c: TForumData, name, defaultText: string): string = + let x = xmlencode( + if defaultText != reuseText: defaultText + else: c.req.params.getOrDefault(name) + ) + return """""" % [name, x] + +proc textAreaWidget(c: TForumData, name, defaultText: string): string = + let x = if defaultText != reuseText: defaultText + else: xmlEncode(c.req.params.getOrDefault(name)) + return """""" % [ + name, x] + +proc fieldValid(c: TForumData, name, text: string): string = + if name == c.invalidField: + result = """$1""" % text + else: + result = text + +proc genThreadUrl(c: TForumData, postId = "", action = "", threadid = "", pageNum = ""): string = + result = "/t/" & (if threadid == "": $c.threadId else: threadid) + if pageNum != "": + result.add("/" & pageNum) + if action != "": + result.add("?action=" & action) + if postId != "": + result.add("&postid=" & postid) + elif postId != "": + result.add("#" & postId) + result = c.req.makeUri(result, absolute = false) + +proc formSession(c: var TForumData, nextAction: string): string = + return """ + """ % [ + $c.threadId, $c.postid] + +proc urlButton(c: var TForumData, text, url: string): string = + return ("""$2""") % [ + url, text] + +proc genButtons(c: var TForumData, btns: seq[TStyledButton]): string = + if btns.len == 1: + var anchor = "" + + result = ("""$2""") % [ + btns[0].link, btns[0].text, anchor] + else: + result = "" + for i, btn in pairs(btns): + var anchor = "" + + var class = "" + if i == 0: class = "left " + elif i == btns.len()-1: class = "right " + else: class = "middle " + result.add(("""$2""") % [ + btns[i].link, btns[i].text, class, anchor]) + +proc toInterval(diff: int64): TimeInterval = + var remaining = diff + let years = remaining div 31536000 + remaining -= years * 31536000 + let months = remaining div 2592000 + remaining -= months * 2592000 + let days = remaining div 86400 + remaining -= days * 86400 + let hours = remaining div 3600 + remaining -= hours * 3600 + let minutes = remaining div 60 + remaining -= minutes * 60 + result = initInterval(0, remaining.int, minutes.int, hours.int, days.int, + months.int, years.int) + +proc formatTimestamp(t: int): string = + let t2 = Time(t) + let now = getTime() + let diff = (now - t2).toInterval() + if diff.years > 0: + return getGMTime(t2).format("MMMM d',' yyyy") + elif diff.months > 0: + return $diff.months & (if diff.months > 1: " months ago" else: " month ago") + elif diff.days > 0: + return $diff.days & (if diff.days > 1: " days ago" else: " day ago") + elif diff.hours > 0: + return $diff.hours & (if diff.hours > 1: " hours ago" else: " hour ago") + elif diff.minutes > 0: + return $diff.minutes & + (if diff.minutes > 1: " minutes ago" else: " minute ago") + else: + return "just now" + +proc getGravatarUrl(email: string, size = 80): string = + let emailMD5 = email.toLowerAscii.toMD5 + return ("http://www.gravatar.com/avatar/" & $emailMD5 & "?s=" & $size & + "&d=identicon") + +proc genGravatar(email: string, size: int = 80): string = + result = "" % + [$size, $size, getGravatarUrl(email, size)] + +proc randomSalt(): string = + result = "" + for i in 0..127: + var r = random(225) + if r >= 32 and r <= 126: + result.add(chr(random(225))) + +proc devRandomSalt(): string = + when defined(posix): + result = "" + var f = open("/dev/urandom") + var randomBytes: array[0..127, char] + discard f.readBuffer(addr(randomBytes), 128) + for i in 0..127: + if ord(randomBytes[i]) >= 32 and ord(randomBytes[i]) <= 126: + result.add(randomBytes[i]) + f.close() + else: + result = randomSalt() + +proc makeSalt(): string = + ## Creates a salt using a cryptographically secure random number generator. + ## + ## Ensures that the resulting salt contains no ``\0``. + try: + result = devRandomSalt() + except IOError: + result = randomSalt() + + var newResult = "" + for i in 0 .. 0 + +proc antibot(c: var TForumData): string = + let a = random(10)+1 + let b = random(1000)+1 + let answer = $(a+b) + + exec(db, sql"delete from antibot where ip = ?", c.req.ip) + let captchaId = tryInsertID(db, + sql"insert into antibot(ip, answer) values (?, ?)", c.req.ip, + answer).int mod 10_000 + let captchaFile = getCaptchaFilename(captchaId) + createCaptcha(captchaFile, $a & "+" & $b) + result = """""" % c.req.getCaptchaUrl(captchaId) + +const + SecureChars = {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'} + +proc setError(c: var TForumData, field, msg: string): bool {.inline.} = + c.invalidField = field + c.errorMsg = "Error: " & msg + return false + +proc isCaptchaCorrect(c: var TForumData, antibot: string): bool = + ## Determines whether the user typed in the captcha correctly. + let correctRes = getValue(db, + sql"select answer from antibot where ip = ?", c.req.ip) + return antibot == correctRes + +proc register(c: var TForumData, name, pass, antibot, + email: string): bool = + # Username validation: + if name.len == 0 or not allCharsInSet(name, SecureChars): + return setError(c, "name", "Invalid username!") + if getValue(db, sql"select name from person where name = ?", name).len > 0: + return setError(c, "name", "Username already exists!") + + # Password validation: + if pass.len < 4: + return setError(c, "new_password", "Invalid password!") + + # captcha validation: + if not isCaptchaCorrect(c, antibot): + return setError(c, "antibot", "Answer to captcha incorrect!") + + # email validation + if not ('@' in email and '.' in email): + return setError(c, "email", "Invalid email address") + + # perform registration: + var salt = makeSalt() + let password = makePassword(pass, salt) + + # Send activation email. + let epoch = $int(epochTime()) + let activateUrl = c.req.makeUri("/activateEmail?nick=$1&epoch=$2&ident=$3" % + [encodeUrl(name), encodeUrl(epoch), + encodeUrl(makeIdentHash(name, password, epoch, salt))]) + + let emailSentFut = sendEmailActivation(c.config, email, name, activateUrl) + # Block until we send the email. + # TODO: This is a workaround for 'var T' not being usable in async procs. + while not emailSentFut.finished: + poll() + when not defined(dev): + if emailSentFut.failed: + echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg) + return setError(c, "email", "Couldn't send activation email") + + # add account to person table + exec(db, + sql("INSERT INTO person(name, password, email, salt, status, lastOnline) " & + "VALUES (?, ?, ?, ?, ?, DATETIME('now'))"), name, + password, email, salt, + when defined(dev): $Moderated else: $EmailUnconfirmed) + + return true + +proc resetPassword(c: var TForumData, nick, antibot: string): bool = + # Validate captcha + if not isCaptchaCorrect(c, antibot): + return setError(c, "antibot", "Answer to captcha incorrect!") + # Gather some extra information to determine ident hash. + let epoch = $int(epochTime()) + let row = db.getRow( + sql"select password, salt, email from person where name = ?", nick) + if row[0] == "": + return setError(c, "nick", "Nickname not found") + # Generate URL for the email. + # TODO: Get rid of the stupid `%` in main.tmpl as it screws up strutils.% + let resetUrl = c.req.makeUri( + strutils.`%`("/emailResetPassword?nick=$1&epoch=$2&ident=$3", + [encodeUrl(nick), encodeUrl(epoch), + encodeUrl(makeIdentHash(nick, row[0], epoch, row[1]))])) + echo "User's reset URL is: ", resetUrl + # Send the email. + let emailSentFut = sendPassReset(c.config, row[2], nick, resetUrl) + # TODO: This is a workaround for 'var T' not being usable in async procs. + while not emailSentFut.finished: + poll() + if emailSentFut.failed: + echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg) + return setError(c, "email", "Couldn't send activation email") + + return true + +proc logout(c: var TForumData) = + const query = sql"delete from session where ip = ? and password = ?" + c.username = "" + c.userpass = "" + exec(db, query, c.req.ip, c.req.cookies["sid"]) + +proc getBanErrorMsg(banValue: string; rank: Rank): string = + if banValue.len > 0: + return "You have been banned: " & banValue + case rank + of Spammer: return "You are a spammer." + of Troll: return "You have been banned." + of EmailUnconfirmed: + return "You need to confirm your email first." + of Moderated, User, Moderator, Admin: + return "" + +proc checkLoggedIn(c: var TForumData) = + if not c.req.cookies.hasKey("sid"): return + let pass = c.req.cookies["sid"] + if execAffectedRows(db, + sql("update session set lastModified = DATETIME('now') " & + "where ip = ? and password = ?"), + c.req.ip, pass) > 0: + c.userpass = pass + c.userid = getValue(db, + sql"select userid from session where ip = ? and password = ?", + c.req.ip, pass) + + let row = getRow(db, + sql"select name, email, status, ban from person where id = ?", c.userid) + c.username = ||row[0] + c.email = ||row[1] + c.rank = parseEnum[Rank](||row[2]) + let ban = getBanErrorMsg(||row[3], c.rank) + if ban.len > 0: + discard c.setError("name", ban) + logout(c) + return + + # Update lastOnline + db.exec(sql"update person set lastOnline = DATETIME('now') where id = ?", + c.userid) + + else: + echo("SID not found in sessions. Assuming logged out.") + +proc incrementViews(c: var TForumData) = + const query = sql"update thread set views = views + 1 where id = ?" + exec(db, query, $c.threadId) + +proc isPreview(c: TForumData): bool = + result = c.req.params.hasKey("previewBtn") + +proc isDelete(c: TForumData): bool = + result = c.req.params.hasKey("delete") + +proc validateRst(c: var TForumData, content: string): bool = + result = true + try: + discard rstToHtml(content) + except EParseError: + result = setError(c, "", getCurrentExceptionMsg()) + +proc crud(c: TCrud, table: string, data: varargs[string]): SqlQuery = + case c + of crCreate: + var fields = "insert into " & table & "(" + var vals = "" + for i, d in data: + if i > 0: + fields.add(", ") + vals.add(", ") + fields.add(d) + vals.add('?') + result = sql(fields & ") values (" & vals & ")") + of crRead: + var res = "select " + for i, d in data: + if i > 0: res.add(", ") + res.add(d) + result = sql(res & " from " & table) + of crUpdate: + var res = "update " & table & " set " + for i, d in data: + if i > 0: res.add(", ") + res.add(d) + res.add(" = ?") + result = sql(res & " where id = ?") + of crDelete: + result = sql("delete from " & table & " where id = ?") + +template retrSubject(c: untyped) = + if not c.req.params.hasKey("subject"): + raise newException(ForumError, "Subject empty") + let subject {.inject.} = c.req.params["subject"] + if subject.strip.len < 3: + return setError(c, "subject", "Subject not long enough") + +template retrContent(c: untyped) = + if not c.req.params.hasKey("content"): + raise newException(ForumError, "Content empty") + let content {.inject.} = c.req.params["content"] + if content.strip.len < 2: + return setError(c, "content", "Content not long enough") + + if not validateRst(c, content): return false + +template retrPost(c: untyped) = + retrSubject(c) + retrContent(c) + +template checkLogin(c: untyped) = + if not loggedIn(c): return setError(c, "", "User is not logged in") + +template checkOwnership(c, postId: untyped) = + if c.rank < Moderator: + let x = getValue(db, sql"select author from post where id = ?", + postId) + if x != c.userId: + return setError(c, "", "You are not the owner of this post") + +template setPreviewData(c: untyped) {.dirty.} = + c.currentPost.subject = subject + c.currentPost.content = content + +template writeToDb(c, cr, setPostId: untyped) = + # insert a comment in the DB + let retID = insertID(db, crud(cr, "post", "author", "ip", "header", "content", "thread"), + c.userId, c.req.ip, subject, content, $c.threadId, "") + discard tryExec(db, crud(cr, "post_fts", "id", "header", "content"), + retID.int, subject, content) + if setPostId: + c.postId = retID.int + +proc updateThreads(c: var TForumData): int = + ## Removes threads if they have no posts, or changes their modified field + ## if they still contain posts. + const query = + sql"delete from thread where id not in (select thread from post)" + result = execAffectedRows(db, query).int + if result > 0: + discard tryExec(db, sql"delete from thread_fts where id not in (select thread from post)") + else: + # Update corresponding thread's modified field. + let getModifiedSql = "(select creation from post where post.thread = ?" & + " order by creation desc limit 1)" + let updateSql = sql("update thread set modified=" & getModifiedSql & + " where id = ?") + if not tryExec(db, updateSql, $c.threadId, $c.threadId): + result = -1 + discard setError(c, "", "database error") + +proc edit(c: var TForumData, postId: int): bool = + checkLogin(c) + if c.isPreview: + retrPost(c) + setPreviewData(c) + elif c.isDelete: + checkOwnership(c, $postId) + if not tryExec(db, crud(crDelete, "post"), $postId): + return setError(c, "", "database error") + discard tryExec(db, crud(crDelete, "post_fts"), $postId) + result = true + # delete corresponding thread: + let updateResult = updateThreads(c) + if updateResult > 0: + # whole thread has been deleted, so: + c.threadId = unselectedThread + elif updateResult < 0: + # error occurred + return false + else: + checkOwnership(c, $postId) + retrPost(c) + exec(db, crud(crUpdate, "post", "header", "content"), + subject, content, $postId) + exec(db, crud(crUpdate, "post_fts", "header", "content"), + subject, content, $postId) + # Check if post is the first post of the thread. + let rows = db.getAllRows(sql("select id, thread, creation from post " & + "where thread = ? order by creation asc"), $c.threadId) + if rows[0][0] == $postId: + exec(db, crud(crUpdate, "thread", "name"), subject, $c.threadId) + result = true + +proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool +proc spamCheck(c: var TForumData, subject, content: string): bool = + # Check current user's info + var ui: TUserInfo + if gatherUserInfo(c, c.userName, ui): + if ui.posts > 1: return + + # Strip all punctuation + var subjAlphabet = "" + for i in subject: + if i in Letters: + subjAlphabet.add(i) + case i + of '!': + subjAlphabet.add("i") + else: discard + var contentAlphabet = "" + for i in content: + if i in Letters: + contentAlphabet.add(i) + case i + of '!': + subjAlphabet.add("i") + else: discard + + for word in ["appliance", "kitchen", "cheap", "sale", "relocating", + "packers", "lenders", "fifa", "coins"]: + if word in subjAlphabet.toLowerAscii() or + word in contentAlphabet.toLowerAscii(): + return true + +proc rateLimitCheck(c: var TForumData): bool = + const query40 = + sql("SELECT count(*) FROM post where author = ? and " & + "(strftime('%s', 'now') - strftime('%s', creation)) < 40") + const query90 = + sql("SELECT count(*) FROM post where author = ? and " & + "(strftime('%s', 'now') - strftime('%s', creation)) < 90") + const query300 = + sql("SELECT count(*) FROM post where author = ? and " & + "(strftime('%s', 'now') - strftime('%s', creation)) < 300") + # TODO Why can't I pass the secs as a param? + let last40s = getValue(db, query40, c.userId).parseInt + let last90s = getValue(db, query90, c.userId).parseInt + let last300s = getValue(db, query300, c.userId).parseInt + if last40s > 1: return true + if last90s > 2: return true + if last300s > 6: return true + return false + +proc makeThreadURL(c: var TForumData): string = + c.req.makeUri("/t/" & $c.threadId) + +template postChecks() {.dirty.} = + if spamCheck(c, subject, content): + echo("[WARNING] Found spam: ", subject) + return true + if rateLimitCheck(c): + return setError(c, "subject", "You're posting too fast.") + +proc reply(c: var TForumData): bool = + # reply to an existing thread + checkLogin(c) + retrPost(c) + if c.isPreview: + setPreviewData(c) + else: + postChecks() + writeToDb(c, crCreate, true) + + exec(db, sql"update thread set modified = DATETIME('now') where id = ?", + $c.threadId) + if c.rank >= User: + asyncCheck sendMailToMailingList(c.config, c.username, c.email, + subject, content, threadId=c.threadId, postId=c.postID, is_reply=true, + threadUrl=c.makeThreadURL()) + result = true + +proc newThread(c: var TForumData): bool = + # create new conversation thread (permanent or transient) + const query = sql"insert into thread(name, views, modified) values (?, 0, DATETIME('now'))" + checkLogin(c) + retrPost(c) + if c.isPreview: + setPreviewData(c) + c.threadID = transientThread + else: + postChecks() + c.threadID = tryInsertID(db, query, c.req.params["subject"]).int + if c.threadID < 0: return setError(c, "subject", "Subject already exists") + discard tryExec(db, crud(crCreate, "thread_fts", "id", "name"), + c.threadID, c.req.params["subject"]) + writeToDb(c, crCreate, false) + discard tryExec(db, sql"insert into post_fts(post_fts) values('optimize')") + discard tryExec(db, sql"insert into post_fts(thread_fts) values('optimize')") + if c.rank >= User: + asyncCheck sendMailToMailingList(c.config, c.username, c.email, + subject, content, threadId=c.threadID, postId=c.postID, is_reply=false, + threadUrl=c.makeThreadURL()) + result = true + +proc login(c: var TForumData, name, pass: string): bool = + # get form data: + const query = + sql"select id, name, password, email, salt, status, ban from person where name = ?" + if name.len == 0: + return c.setError("name", "Username cannot be nil.") + var success = false + for row in fastRows(db, query, name): + if row[2] == makePassword(pass, row[4], row[2]): + c.rank = parseEnum[Rank](row[5]) + let ban = getBanErrorMsg(row[6], c.rank) + if ban.len > 0: + return c.setError("name", ban) + c.userid = row[0] + c.username = row[1] + c.userpass = row[2] + c.email = row[3] + success = true + break + if success: + # create session: + exec(db, + sql"insert into session (ip, password, userid) values (?, ?, ?)", + c.req.ip, c.userpass, c.userid) + return true + else: + return c.setError("password", "Login failed!") + +proc verifyIdentHash(c: var TForumData, name, epoch, ident: string): bool = + const query = + sql"select password, salt, strftime('%s', lastOnline) from person where name = ?" + var row = getRow(db, query, name) + if row[0] == "": return false + let newIdent = makeIdentHash(name, row[0], epoch, row[1], ident) + # Check that the user has not been logged in since this ident hash has been + # created. Give the timestamp a certain range to prevent false negatives. + if row[2].parseInt > (epoch.parseInt + 60): return false + result = newIdent == ident + +proc deleteAll(c: var TForumData, nick: string): bool = + const query = + sql("delete from post where author = (select id from person where name = ?)") + result = tryExec(db, query, nick) + result = result and updateThreads(c) >= 0 + +proc setStatus(c: var TForumData, nick: string, status: Rank; + reason: string): bool = + const query = + sql("update person set status = ?, ban = ? where name = ?") + result = tryExec(db, query, $status, reason, nick) + when false: + # for now we filter Spammers in forms.tmpl, so that a moderator + # cannot accidentically delete all of a user's posts. We go even + # further than that and show spammers their own spam postings. + if status == Spammer and result: + result = deleteAll(c, nick) + +proc setPassword(c: var TForumData, nick, pass: string): bool = + const query = + sql("update person set password = ?, salt = ? where name = ?") + var salt = makeSalt() + result = tryExec(db, query, makePassword(pass, salt), salt, nick) + +proc hasReplyBtn(c: var TForumData): bool = + result = c.req.pathInfo != "/donewthread" and c.req.pathInfo != "/doreply" + result = result and c.req.params.getOrDefault("action") notin ["reply", "edit"] + # If the user is not logged in and there are no page numbers then we shouldn't + # generate the div. + let pages = ceil(c.totalPosts / PostsPerPage).int + result = result and (pages > 1 or c.loggedIn) + return c.threadId >= 0 and result + +proc getStats(c: var TForumData, simple: bool): TForumStats = + const totalUsersQuery = + sql"select count(*) from person" + result.totalUsers = getValue(db, totalUsersQuery).parseInt + const totalPostsQuery = + sql"select count(*) from post" + result.totalPosts = getValue(db, totalPostsQuery).parseInt + const totalThreadsQuery = + sql"select count(*) from thread" + result.totalThreads = getValue(db, totalThreadsQuery).parseInt + if not simple: + var newestMemberCreation = 0 + result.activeUsers = @[] + result.newestMember = ("", -1) + const getUsersQuery = + sql"select id, name, strftime('%s', lastOnline), strftime('%s', creation) from person" + for row in fastRows(db, getUsersQuery): + let secs = if row[3] == "": 0 else: row[3].parseint + let lastOnlineSeconds = getTime() - Time(secs) + if lastOnlineSeconds < (60 * 5): # 5 minutes + result.activeUsers.add((row[1], row[0].parseInt)) + if row[3].parseInt > newestMemberCreation: + result.newestMember = (row[1], row[0].parseInt) + newestMemberCreation = row[3].parseInt + +proc genPagenumNav(c: var TForumData, stats: TForumStats): string = + result = "" + var + firstUrl = "" + prevUrl = "" + totalPages = 0 + lastUrl = "" + nextUrl = "" + + if c.isThreadsList: + firstUrl = c.req.makeUri("/") + prevUrl = c.req.makeUri(if c.pageNum == 1: "/" else: "/page/" & $(c.pageNum-1)) + totalPages = ceil(stats.totalThreads / ThreadsPerPage).int + lastUrl = c.req.makeUri("/page/" & $(totalPages)) + nextUrl = c.req.makeUri("/page/" & $(c.pageNum+1)) + else: + firstUrl = c.makeThreadURL() + if c.pageNum == 1: + prevUrl = firstUrl + else: + prevUrl = c.req.makeUri(firstUrl & "/" & $(c.pageNum-1)) + totalPages = ceil(c.totalPosts / PostsPerPage).int + lastUrl = c.req.makeUri(firstUrl & "/" & $(totalPages)) + nextUrl = c.req.makeUri(firstUrl & "/" & $(c.pageNum+1)) + + if totalPages <= 1: + return "" + + var firstTag = "" + var prevTag = "" + if c.pageNum == 1: + firstTag = span("<<") + prevTag = span("<••") + else: + firstTag = htmlgen.a(href=firstUrl, "<<") + prevTag = htmlgen.a(href=prevUrl, "<••") + prevTag.add(htmlgen.link(rel="previous", href=prevUrl)) + result.add(firstTag) + result.add(prevTag) + + # Numbers + var pages = "" # Tags + # cutting numbers to the left and to the right tp MaxPagesFromCurrent + let firstToShow = max(1, c.pageNum - MaxPagesFromCurrent) + let lastToShow = min(totalPages, c.pageNum + MaxPagesFromCurrent) + if firstToShow > 1: pages.add(span("...")) + for i in firstToShow .. lastToShow: + if i == c.pageNum: + pages.add(span($(i))) + else: + var pageUrl = "" + if c.isThreadsList: + pageUrl = c.req.makeUri("/page/" & $(i)) + else: + pageUrl = c.req.makeUri(firstUrl & "/" & $(i)) + + pages.add(htmlgen.a(href = pageUrl, $(i))) + if lastToShow < totalPages: pages.add(span("...")) + result.add(pages) + + # Right + var lastTag = "" + var nextTag = "" + if c.pageNum == totalPages: + lastTag = span(">>") + nextTag = span("••>") + else: + lastTag = htmlgen.a(href=lastUrl, ">>") + nextTag = htmlgen.a(href=nextUrl, "••>") + nextTag.add(htmlgen.link(rel="next",href=nextUrl)) + result.add(nextTag) + result.add(lastTag) + +proc gatherTotalPostsByID(c: var TForumData, thrid: int): int = + ## Gets the total post count of a thread. + result = getValue(db, sql"select count(*) from post where thread = ?", $thrid).parseInt + +proc gatherTotalPosts(c: var TForumData) = + if c.totalPosts > 0: return + # Gather some data. + const totalPostsQuery = + sql"select count(*) from post p, person u where u.id = p.author and p.thread = ?" + c.totalPosts = getValue(db, totalPostsQuery, $c.threadId).parseInt + +proc getPagesInThread(c: var TForumData): int = + c.gatherTotalPosts() # Get total post count + result = ceil(c.totalPosts / PostsPerPage).int-1 + +proc getPagesInThreadByID(c: var TForumData, thrid: int): int = + result = ceil(c.gatherTotalPostsByID(thrid) / PostsPerPage).int + +proc getThreadTitle(thrid: int, pageNum: int): string = + result = getValue(db, sql"select name from thread where id = ?", $thrid) + if pageNum notin {0,1}: + result.add(" - Page " & $pageNum) + +proc genPagenumLocalNav(c: var TForumData, thrid: int): string = + result = "" + const maxPostPages = 6 # Maximum links to pages shown. + const hmpp = maxPostPages div 2 + # 1 2 3 ... 10 11 12 + var currentThrURL = "/t/" & $thrid & "/" + let totalPagesInThread = c.getPagesInThreadByID(thrid) + if totalPagesInThread <= 1: return + var i = 1 + while i <= totalPagesInThread: + result.add(htmlgen.a(href=c.req.makeUri(currentThrURL & $i), $i)) + if i == hmpp and totalPagesInThread-i > hmpp: + result.add(span("...")) + # skip to the last 3 + i = totalPagesInThread-(hmpp-1) + else: + inc(i) + + result = htmlgen.span(class = "pages", result) + +proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool = + ui.nick = nick + const getUIDQuery = sql"select id from person where name = ?" + var uid = getValue(db, getUIDQuery, nick) + if uid == "": return false + result = true + const totalPostsQuery = + sql"select count(*) from post where author = ?" + ui.posts = getValue(db, totalPostsQuery, uid).parseInt + const totalThreadsQuery = + sql("select count(*) from thread where id in (select thread from post where" & + " author = ? and post.id in (select min(id) from post group by thread))") + + ui.threads = getValue(db, totalThreadsQuery, uid).parseInt + const lastOnlineQuery = + sql"""select strftime('%s', lastOnline), email, ban, status + from person where id = ?""" + let row = db.getRow(lastOnlineQuery, $uid) + ui.lastOnline = if row[0].len > 0: row[0].parseInt else: -1 + ui.email = row[1] + ui.ban = row[2] + ui.rank = parseEnum[Rank](row[3]) + +include "forms.tmpl" +include "main.tmpl" + +proc genProfile(c: var TForumData, ui: TUserInfo): string = + result = "" + + result.add(htmlgen.`div`(id = "talk-head", + htmlgen.`div`(class="info-post", + htmlgen.`div`( + htmlgen.a(href = c.req.makeUri("/"), + span(style = "font-weight: bold;", "forum index") + ), + " > " & ui.nick & "'s profile" + ) + ) + ) + ) + result.add(htmlgen.`div`(id = "avatar", genGravatar(ui.email, 250))) + let t2 = if ui.lastOnline != -1: getGMTime(Time(ui.lastOnline)) + else: getGMTime(getTime()) + + result.add(htmlgen.`div`(id = "info", + htmlgen.table( + tr( + th("Nickname"), + td(ui.nick) + ), + tr( + th("Threads"), + td($ui.threads) + ), + tr( + th("Posts"), + td($ui.posts) + ), + tr( + th("Last Online"), + td(if ui.lastOnline != -1: t2.format("dd/MM/yy HH':'mm 'UTC'") + else: "Never") + ), + tr( + th("Status"), + td($ui.rank) + ), + tr( + th(""), + td(if c.rank >= Moderator and c.rank > ui.rank: + c.genFormSetRank(ui) + else: "") + ), + tr( + th(""), + td(if c.rank >= Moderator: + htmlgen.a(href=c.req.makeUri("/deleteAll?nick=$1" % ui.nick), + "Delete all user's posts and threads") + else: "") + ), + ) + )) + + result = htmlgen.`div`(id = "profile", + htmlgen.`div`(id = "left", result)) + +proc prependRe(s: string): string = + result = if s.len == 0: + "" + elif s.startswith("Re:"): s + else: "Re: " & s + +template createTFD() = + var c {.inject.}: TForumData + init(c) + c.req = request + c.startTime = epochTime() + c.isThreadsList = false + c.pageNum = 1 + c.config = config + if request.cookies.len > 0: + checkLoggedIn(c) + +routes: + get "/": + createTFD() + c.isThreadsList = true + var count = 0 + let threadList = genThreadsList(c, count) + let data = genMain(c, threadList, + additionalHeaders = genRSSHeaders(c), showRssLinks = true) + resp data + + get "/threadActivity.xml": + createTFD() + c.isThreadsList = true + resp genThreadsRSS(c), "application/atom+xml" + + get "/postActivity.xml": + createTFD() + resp genPostsRSS(c), "application/atom+xml" + + get "/t/@threadid/?@page?/?@postid?/?": + createTFD() + parseInt(@"threadid", c.threadId, -1..1000_000) + + if c.threadId == unselectedThread: + # Thread has just been deleted. + redirect(uri("/")) + + if @"page".len > 0: + parseInt(@"page", c.pageNum, 0..1000_000) + if @"postid".len > 0: + parseInt(@"postid", c.postId, 0..1000_000) + cond(c.pageNum > 0) + var count = 0 + var pSubject = getThreadTitle(c.threadid, c.pageNum) + cond validThreadId(c) + gatherTotalPosts(c) + if (@"action").len > 0: + var title = "" + case @"action" + of "reply": + let subject = getValue(db, + sql"select header from post where id = (select max(id) from post where thread = ?)", + $c.threadId).prependRe + body = genPostsList(c, $c.threadId, count) + cond count != 0 + body.add genFormPost(c, "doreply", "Reply", subject, "", false) + title = "Replying to thread: " & pSubject + of "edit": + cond c.postId != -1 + const query = sql"select header, content from post where id = ?" + let row = getRow(db, query, $c.postId) + let header = ||row[0] + let content = ||row[1] + body = genFormPost(c, "doedit", "Edit", header, content, true) + title = "Editing post" + else: discard + resp c.genMain(body, title & " - Nim Forum") + else: + incrementViews(c) + let posts = genPostsList(c, $c.threadId, count) + cond count != 0 + resp genMain(c, posts, pSubject & " - Nim Forum") + + get "/page/?@page?/?": + createTFD() + c.isThreadsList = true + cond(@"page" != "") + parseInt(@"page", c.pageNum, 0..1000_000) + cond(c.pageNum > 0) + var count = 0 + let list = genThreadsList(c, count) + if count == 0: + pass() + resp genMain(c, list, "Page " & $c.pageNum & " - Nim Forum", + genRSSHeaders(c), showRssLinks = true) + + get "/profile/@nick/?": + createTFD() + cond(@"nick" != "") + var userinfo: TUserInfo + if gatherUserInfo(c, @"nick", userinfo): + resp genMain(c, c.genProfile(userinfo), + @"nick" & "'s profile - Nim Forum") + else: + halt() + + get "/login/?": + createTFD() + resp genMain(c, genFormLogin(c), "Log in - Nim Forum") + + get "/logout/?": + createTFD() + logout(c) + redirect(uri("/")) + + get "/register/?": + createTFD() + resp genMain(c, genFormRegister(c), "Register - Nim Forum") + + template readIDs() = + # Retrieve the threadid, postid and pagenum + if (@"threadid").len > 0: + parseInt(@"threadid", c.threadId, -1..1000_000) + if (@"postid").len > 0: + parseInt(@"postid", c.postId, -1..1000_000) + + template finishLogin() = + setCookie("sid", c.userpass, daysForward(7)) + redirect(uri("/")) + + template handleError(action: string, topText: string, isEdit: bool) = + if c.isPreview: + body.add genPostPreview(c, @"subject", @"content", + c.userName, $getGMTime(getTime())) + body.add genFormPost(c, action, topText, reuseText, reuseText, isEdit) + resp genMain(c, body(), "Nim Forum - " & + (if c.isPreview: "Preview" else: "Error")) + + post "/dologin": + createTFD() + if login(c, @"name", @"password"): + finishLogin() + else: + c.isThreadsList = true + var count = 0 + let threadList = genThreadsList(c, count) + let data = genMain(c, threadList, + additionalHeaders = genRSSHeaders(c), showRssLinks = true) + resp data + + post "/doregister": + createTFD() + if c.register(@"name", @"new_password", @"antibot", @"email"): + resp genMain(c, "You are now registered. You must now confirm your" & + " email address by clicking the link sent to " & @"email", + "Registration successful - Nim Forum") + else: + resp c.genMain(genFormRegister(c)) + + post "/donewthread": + createTFD() + if newThread(c): + redirect(uri("/")) + else: + body = "" + handleError("donewthread", "New thread", false) + + post "/doreply": + createTFD() + readIDs() + if reply(c): + redirect(c.genThreadUrl(pageNum = $(c.getPagesInThread+1)) & "#" & $c.postId) + else: + var count = 0 + if c.isPreview: + c.pageNum = c.getPagesInThread+1 + body = genPostsList(c, $c.threadId, count) + handleError("doreply", "Reply", false) + + post "/doedit": + createTFD() + readIDs() + if edit(c, c.postId): + redirect(c.genThreadUrl(postId = $c.postId, + pageNum = $(c.getPagesInThread+1))) + else: + body = "" + handleError("doedit", "Edit", true) + + get "/newthread/?": + createTFD() + resp genMain(c, genFormPost(c, "donewthread", "New thread", "", "", false), + "New Thread - Nim Forum") + + get "/deleteAll/?": + createTFD() + cond(@"nick" != "") + var formBody = "" + var del = false + var content = "" + formBody.add "" + content = htmlgen.p("Are you sure you wish to delete all " & + "the posts and threads created by ", htmlgen.b(@"nick"), "?") + content = content & htmlgen.form(action = c.req.makeUri("/dodeleteall"), + `method` = "POST", formBody) + resp genMain(c, content, "Delete all user's posts & threads - Nim Forum") + + post "/dodeleteall/?": + createTFD() + cond(@"nick" != "") + if c.rank < Moderator: + resp genMain(c, "You cannot delete this user's data.", "Error - Nim Forum") + let result = deleteAll(c, @"nick") + if result: + redirect(c.req.makeUri("/profile/" & @"nick")) + else: + resp genMain(c, "Failed to delete all user's posts and threads.", + "Error - NimForum") + + post "/dosetrank/?@nick?/?": + createTFD() + cond(@"nick" != "") + + if c.rank < Moderator: + resp genMain(c, "You cannot change this user's rank.", "Error - Nim Forum") + + var ui: TUserInfo + if not gatherUserInfo(c, @"nick", ui): + resp genMain(c, "User " & @"nick" & " does not exist.", "Error - Nim Forum") + let newRank = parseEnum[Rank](@"rank") + if newRank > c.rank: + resp genMain(c, "You cannot change this user's rank to this value.", "Error - Nim Forum") + + if setStatus(c, @"nick", newRank, @"reason"): + redirect(c.req.makeUri("/profile/" & @"nick")) + else: + resp genMain(c, "Failed to change the ban status of user.", + "Error - Nim Forum") + + get "/setpassword/?": + createTFD() + cond(@"nick" != "") + cond(@"pass" != "") + if c.rank < Moderator: + resp genMain(c, "You cannot change this user's pass.", "Error - Nim Forum") + let res = setPassword(c, @"nick", @"pass") + if res: + resp genMain(c, "Success", "Nim Forum") + else: + resp genMain(c, "Failure", "Nim Forum") + + get "/activateEmail/?": + createTFD() + cond(@"nick" != "") + cond(@"epoch" != "") + cond(@"ident" != "") + var epoch: BiggestInt = 0 + cond(parseBiggestInt(@"epoch", epoch) > 0) + var success = false + if verifyIdentHash(c, @"nick", $epoch, @"ident"): + let ban = parseEnum[Rank](db.getValue(sql"select status from person where name = ?", @"nick")) + if ban == EmailUnconfirmed: + success = setStatus(c, @"nick", Moderated, "") + + if success: + resp genMain(c, "Account activated", "Nim Forum") + else: + resp genMain(c, "Account activation failed", "Nim Forum") + + get "/emailResetPassword/?": + createTFD() + cond(@"nick" != "") + cond(@"epoch" != "") + cond(@"ident" != "") + var epoch: BiggestInt = 0 + cond(parseBiggestInt(@"epoch", epoch) > 0) + if verifyIdentHash(c, @"nick", $epoch, @"ident"): + let formBody = input(`type`="hidden", name="nick", value = @"nick") & + input(`type`="hidden", name="epoch", value = @"epoch") & + input(`type`="hidden", name="ident", value = @"ident") & + input(`type`="password", name="password") & + "
" & + input(`type`="submit", name="submitBtn", + value="Change my password") + let message = htmlgen.p("Please enter a new password for ", + htmlgen.b(@"nick"), ':') + let content = htmlgen.form(action=c.req.makeUri("/doemailresetpassword"), + `method`="POST", message & formBody) + + resp genMain(c, content, "Reset password - Nim Forum") + else: + resp genMain(c, "Invalid ident hash", "Error - Nim Forum") + + post "/doemailresetpassword": + createTFD() + cond(@"nick" != "") + cond(@"epoch" != "") + cond(@"ident" != "") + cond(@"password" != "") + var epoch: BiggestInt = 0 + cond(parseBiggestInt(@"epoch", epoch) > 0) + if verifyIdentHash(c, @"nick", $epoch, @"ident"): + let res = setPassword(c, @"nick", @"password") + if res: + resp genMain(c, "Password reset successfully!", "Nim Forum") + else: + resp genMain(c, "Password reset failure", "Nim Forum") + else: + resp genMain(c, "Invalid ident hash", "Nim Forum") + + get "/resetPassword/?": + createTFD() + + resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum") + + post "/doresetpassword": + createTFD() + echo(request.params) + cond(@"nick" != "") + + if resetPassword(c, @"nick", @"antibot"): + resp genMain(c, "Email sent!", "Reset Password - Nim Forum") + else: + resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum") + + const licenseRst = slurp("static/license.rst") + get "/license": + createTFD() + resp genMain(c, rstToHtml(licenseRst), "Content license - Nim Forum") + + post "/search/?@page?": + cond isFTSAvailable + createTFD() + c.isThreadsList = true + c.noPagenumumNav = true + var count = 0 + var q = @"q" + for i in 0 .. q.len-1: + if q[i].int < 32: q[i] = ' ' + elif q[i] == '\'': q[i] = '"' + c.search = q.replace("\"",""") + if @"page".len > 0: + parseInt(@"page", c.pageNum, 0..1000_000) + cond(c.pageNum > 0) + iterator searchResults(): db_sqlite.Row {.closure, tags: [ReadDbEffect].} = + const queryFT = "fts.sql".slurp.sql + for rowFT in fastRows(db, queryFT, + [q,q,$ThreadsPerPage,$c.pageNum,$ThreadsPerPage,q, + q,q,$ThreadsPerPage,$c.pageNum,$ThreadsPerPage,q]): + yield rowFT + resp genMain(c, genSearchResults(c, searchResults, count), + additionalHeaders = genRSSHeaders(c), showRssLinks = true) + + # tries first to read html, then to read rst, convert ot html, cache and return + template textPage(path: string) = + createTFD() + #c.isThreadsList = true + var page = "" + if existsFile(path): + page = readFile(path) + else: + let basePath = + if path[path.high] == '/': path & "index" + elif path.endsWith(".html"): path[^5 .. ^1] + else: path + if existsFile(basePath & ".html"): + page = readFile(basePath & ".html") + elif existsFile(basePath & ".rst"): + page = readFile(basePath & ".rst").rstToHtml + writeFile(basePath & ".html", page) + resp genMain(c, page) + get "/search-help": + textPage "static/search-help" + +when isMainModule: + randomize() + db = open(connection="nimforum.db", user="postgres", password="", + database="nimforum") + isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & + "type='table' AND name='post_fts'")).len == 1 + config = loadConfig() + var http = true + if paramCount() > 0: + if paramStr(1) == "scgi": + http = false + + #run("", port = TPort(9000), http = http) + + runForever() + db.close() diff --git a/src/nim.cfg b/forum.nim.cfg similarity index 68% rename from src/nim.cfg rename to forum.nim.cfg index d169997..429dc5b 100644 --- a/src/nim.cfg +++ b/forum.nim.cfg @@ -2,8 +2,3 @@ # we need the documentation generator of the compiler: path="$lib/packages/docutils" path="$nim" - --d:ssl - -# --threads:on -# --threadAnalysis:off \ No newline at end of file diff --git a/src/fts.sql b/fts.sql similarity index 71% rename from src/fts.sql rename to fts.sql index 1590a05..777eb99 100644 --- a/src/fts.sql +++ b/fts.sql @@ -4,23 +4,19 @@ SELECT thread_id, snippet(thread_fts, '', '', '...') AS thread, - post_id, - post_content, - cdate, - person.id, + 0 AS post_id, + '' AS header, + '' AS content, person.name AS author, + cdate, + author_id, person.email AS email, - strftime('%s', person.lastOnline) AS lastOnline, - strftime('%s', person.previousVisitAt) AS previousVisitAt, - person.status AS status, - person.isDeleted as person_isDeleted, 0 AS what FROM ( SELECT thread_fts.id AS thread_id, post.id AS post_id, - post.content AS post_content, - strftime('%s', post.creation) AS cdate, + post.creation AS cdate, MIN(post.creation) AS cdate, post.author AS author_id FROM thread_fts @@ -32,7 +28,7 @@ SELECT FROM post_fts JOIN post USING(id) WHERE post_fts MATCH ? ) - LIMIT ? OFFSET ? + LIMIT ? OFFSET (? - 1) * ? ) JOIN thread_fts ON thread_fts.id=thread_id JOIN person ON person.id=author_id @@ -44,25 +40,30 @@ SELECT thread.name AS thread, post.id AS post_id, CASE what WHEN 1 + THEN snippet(post_fts, '', '', '...', what) + ELSE post_fts.header END AS header, + CASE what WHEN 2 THEN snippet(post_fts, '**', '**', '...', what, -45) ELSE SUBSTR(post_fts.content, 1, 200) END AS content, - cdate, - person.id, person.name AS author, + cdate, + post.author AS author_id, person.email AS email, - strftime('%s', person.lastOnline) AS lastOnline, - strftime('%s', person.previousVisitAt) AS previousVisitAt, - person.status AS status, - person.isDeleted as person_isDeleted, what FROM post_fts JOIN ( -- inner query, selects ids of matching posts, orders and limits them, -- so snippets only for limited count of posts are created (in outer query) - SELECT id, strftime('%s', post.creation) AS cdate, thread, 1 AS what, post.author AS author + SELECT id, post.creation AS cdate, thread, 1 AS what, post.author AS author + FROM post_fts JOIN post USING(id) + WHERE post_fts.header MATCH ? + GROUP BY post.header + HAVING SUBSTR(post.header,1,3)<>'Re:' + UNION + SELECT id, post.creation AS cdate, thread, 2 AS what, post.author AS author FROM post_fts JOIN post USING(id) WHERE post_fts.content MATCH ? ORDER BY what, cdate DESC - LIMIT ? OFFSET ? + LIMIT ? OFFSET (? - 1) * ? ) AS post USING(id) JOIN thread ON thread.id=thread JOIN person ON person.id=author diff --git a/license.txt b/license.txt index 3eb168b..cf4ac4a 100644 --- a/license.txt +++ b/license.txt @@ -1,4 +1,4 @@ -Copyright (C) 2018 Andreas Rumpf, Dominik Picheta +Copyright (C) 2015 Andreas Rumpf, Dominik Picheta Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in @@ -7,7 +7,7 @@ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR diff --git a/localhost.local/public/css/custom-style.scss b/localhost.local/public/css/custom-style.scss deleted file mode 100644 index 6c9e50c..0000000 --- a/localhost.local/public/css/custom-style.scss +++ /dev/null @@ -1,16 +0,0 @@ -// Use this to customise the styles of your forum. -$primary-color: #6577ac; -$body-font-color: #292929; -$dark-color: #505050; -$label-color: #7cd2ff; -$secondary-btn-color: #f1f1f1; - -// Define nav bar colours. -$body-bg: #ffffff; -$navbar-color: $body-bg; -$navbar-border-color-dark: $body-bg; -$navbar-primary-color: #e80080; - -#main-navbar input#search-box { - border: 1px solid #e6e6e6; -} \ No newline at end of file diff --git a/localhost.local/public/images/logo.png b/localhost.local/public/images/logo.png deleted file mode 100644 index 4ac52b7..0000000 Binary files a/localhost.local/public/images/logo.png and /dev/null differ diff --git a/main.tmpl b/main.tmpl new file mode 100644 index 0000000..3590b8d --- /dev/null +++ b/main.tmpl @@ -0,0 +1,276 @@ +#? stdtmpl | standard +#proc genMain(c: var TForumData, content: string, title = "Nim Forum", +# additional_headers = "", showRssLinks = false): string = +# result = "" +# var stats: TForumStats +# if c.isThreadsList: stats = c.getStats(false) +# else: +# stats = c.getStats(true) +# end if + + + + ${xmlEncode(title)} + + ${additional_headers} + + + + + #let frontQuery = c.req.makeUri("/") + + + +
+
+
+
+
+
+
+
+
+ ${content} + #if c.isThreadsList: +
+
+
+ ${c.genListOnline(stats)} +
+
+
+ #if not c.noPagenumumNav: +
+ ${genPagenumNav(c, stats)} +
+ #end if + #elif hasReplyBtn(c): +
+
+
+ ${genPagenumNav(c, stats)} +
+
+
+ #if c.loggedIn(): + #let replyUri = c.req.makeUri(c.req.path & "?action=reply#reply") + +
+ Reply +
+
+ #end if +
+
+ #end if + +
+ +
+
+ + + + + + + +#end proc +# +#proc genRSSHeaders(c: var TForumData): string = +# result = "" + + +#end proc +# +#proc genThreadsRSS(c: var TForumData): string = +# result = "" +# const query = sql"""SELECT A.id, A.name, +# strftime('%Y-%m-%dT%H:%M:%SZ', (A.modified)), +# COUNT(B.id), C.name, B.content, B.id +# FROM thread AS A, post AS B, person AS C +# WHERE A.id = b.thread AND B.author = C.id +# GROUP BY B.thread +# ORDER BY modified DESC LIMIT ?""" +# const threadId = 0 +# const name = 1 +# const threadDate = 2 +# const postCount = 3 +# const postAuthor = 4 +# const postContent = 5 +# const postId = 6 +# let frontQuery = c.req.makeUri("/") +# let recent = getValue(db, sql"""SELECT +# strftime('%Y-%m-%dT%H:%M:%SZ', (modified)) FROM thread +# ORDER BY modified DESC LIMIT 1""") + + + Nim forum thread activity + + + ${frontQuery} + ${recent} +# for row in rows(db, query, 10): + + ${xmlEncode(%name)} + urn:entry:${%threadid} + # let url = c.genThreadUrl(threadid = %threadid, + # pageNum = $(ceil(parseInt(%postCount) / PostsPerPage).int)) & + # "#" & %postId + + ${%threadDate} + ${%threadDate} + ${xmlEncode(%postAuthor)} + Posts ${%postCount}, ${xmlEncode(%postAuthor)} said: +<p> +${xmlEncode(rstToHtml(%postContent))} + +# end for + +#end proc +# +#proc genPostsRSS(c: var TForumData): string = +# result = "" +# const query = sql"""SELECT A.id, B.name, A.content, A.thread, +# A.header, strftime('%Y-%m-%dT%H:%M:%SZ', A.creation), +# A.creation, COUNT(C.id) +# FROM post AS A, person AS B, post AS C +# WHERE A.author = B.id AND A.thread = C.thread AND C.id <= A.id +# GROUP BY A.id +# ORDER BY A.creation DESC LIMIT ?""" +# const postId = 0 +# const postAuthor = 1 +# const postContent = 2 +# const postThread = 3 +# const postHeader = 4 +# const postRssDate = 5 +# const postHumanDate = 6 +# const postPosition = 7 +# let frontQuery = c.req.makeUri("/") +# let recent = getValue(db, sql"""SELECT +# strftime('%Y-%m-%dT%H:%M:%SZ', creation) FROM post +# ORDER BY creation DESC LIMIT 1""") + + + Nim forum post activity + + + ${frontQuery} + ${recent} +# for row in rows(db, query, 10): + + ${xmlEncode(%postHeader)} + urn:entry:${%postId} + # let url = c.genThreadUrl(threadid = %postThread, + # pageNum = $(ceil(parseInt(%postPosition) / PostsPerPage).int)) & + # "#" & %postId + + ${%postRssDate} + ${%postRssDate} + ${xmlEncode(%postAuthor)} + On ${xmlEncode(%postHumanDate)}, ${xmlEncode(%postAuthor)} said: +<p> +${xmlEncode(rstToHtml(%postContent))} + +# end for + +#end proc diff --git a/mockup/index.html b/mockup/index.html deleted file mode 100644 index b836674..0000000 --- a/mockup/index.html +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - - - The Nim programming language forum - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TopicCategoryUsersRepliesViewsActivity
Few mixed up questions
help
-
- -
-
- -
-
-
-
-
-
-
-
554745m
Lexers and parsers in Nim
community
-
-
-
01444m
I need help 2
help
-
- -
-
- -
-
-
-
41d
- - last visit - -
Nim v1.0 is here!
announcement
-
- -
-
- -
-
44d
- - load more threads - -
-
- - - - \ No newline at end of file diff --git a/mockup/thread.html b/mockup/thread.html deleted file mode 100644 index 298bb80..0000000 --- a/mockup/thread.html +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - The Nim programming language forum - - - - - - - - - -
-
-

Lexers and parsers in nim

-
community -
-
-
-
-
- Avatar -
-
-
-
-
- ErikCampobadal -
-
Jan 2015
-
-
-

Hey! I'm willing to create a programming language using nim.

- -

It's an educational project. Been reading about compilers for weeks now and I started using tools like flex and bison for lexer and parser. I know nim have a parsing library but nowhere near that level.

- -

There is an old post (2014) with a similar question so I'm bringing that back a few years later. Is there anything anyone know that could speed up the process of developing a programing language using nim? (I can have c code if needed ofc)

-
-
- -
- -
-
- -
-
-
-
- -
-
-
- Avatar -
-
-
-
-
- twetzel59 -
-
Jan 2015
-
-
-

Wow, I was just reading about the compilation pipeline today!

- -

I suppose you could use at least the lexing part from a generator like flex, not so sure about using AST generators easily (it's possible).

- -

Is your language complicated enough to warrant a parser generator or could you just use a custom parser?

-
-
- -
- -
-
- -
-
-
-
- -
-
- -
-
-
- 3 years later -
-
-
- -
-
-
- Avatar -
-
-
-
-
- dom96 -
-
32m
-
-
-

Let us test this new design a bit, shall we?

-
proc hello(x: int) =
-  echo("Hello ", x)
-
-42.hello()
Output
Hello 42
- -

The greatest function ever written is hello.

-
-

Designing websites is often a pain.

-
Multi-level baby!
-

True that.

-

I also want to be able to support more detailed quoting:

-
-
-
- Avatar -
- Araq: - -
- Unix is a cancer. -
-

We also want to be able to highlight user mentions:

-

Please let - - @Araq - - know that this forum is awesome.

-
-
- -
- -
-
- -
-
-
-
- - - -
-
- -
-
-
- Replying to "Lexers and parsers in nim" -
-
- -
-
-
- -
-
- - - - \ No newline at end of file diff --git a/nimforum.nimble b/nimforum.nimble index 58a22f7..3591767 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -1,64 +1,11 @@ -# Package -version = "2.1.0" +[Package] +name = "nimforum" +version = "0.1.0" author = "Dominik Picheta" -description = "The Nim forum" +description = "Nim forum" license = "MIT" -srcDir = "src" +bin = "forum" -bin = @["forum"] - -skipExt = @["nim"] - -# Dependencies - -requires "nim >= 1.0.6" -requires "jester#405be2e" -requires "bcrypt#440c5676ff6" -requires "hmac#9c61ebe2fd134cf97" -requires "recaptcha#d06488e" -requires "sass#649e0701fa5c" - -requires "karax#5f21dcd" - -requires "webdriver#429933a" - -# Tasks - -task backend, "Compiles and runs the forum backend": - exec "nimble c src/forum.nim" - exec "./src/forum" - -task runbackend, "Runs the forum backend": - exec "./src/forum" - -task testbackend, "Runs the forum backend in test mode": - exec "nimble c -r -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" - -task minify, "Minifies the JS using Google's closure compiler": - exec "closure-compiler public/js/forum.js --js_output_file public/js/forum.js.opt" - -task testdb, "Creates a test DB (with admin account!)": - exec "nimble c src/setup_nimforum" - exec "./src/setup_nimforum --test" - -task devdb, "Creates a test DB (with admin account!)": - exec "nimble c src/setup_nimforum" - exec "./src/setup_nimforum --dev" - -task blankdb, "Creates a blank DB": - exec "nimble c src/setup_nimforum" - exec "./src/setup_nimforum --blank" - -task test, "Runs tester": - exec "nimble c -y src/forum.nim" - exec "nimble c -y -r -d:actionDelayMs=0 tests/browsertester" - -task fasttest, "Runs tester without recompiling backend": - exec "nimble c -r -d:actionDelayMs=0 tests/browsertester" +[Deps] +Requires: "nim >= 0.14.0, cairo#head, jester#head, bcrypt#head" diff --git a/public/captchas/.gitignore b/public/captchas/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/public/captchas/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/public/css/nimforum.scss b/public/css/nimforum.scss deleted file mode 100644 index 2daecdb..0000000 --- a/public/css/nimforum.scss +++ /dev/null @@ -1,781 +0,0 @@ -@import "custom-style"; - -// Import full Spectre source code -@import "spectre/src/spectre"; - -// Global styles. -// - TODO: Make these non-global. -.btn, .form-input { - margin-right: $control-padding-x; -} - -table th { - font-size: 0.65rem; -} - -// Spectre fixes. -// - Weird avatar outline. -.avatar { - background: transparent; -} - -// Custom styles. -// - Navigation bar. -$navbar-height: 60px; -$default-category-color: #a3a3a3; -$logo-height: $navbar-height - 20px; - -.navbar-button { - border-color: $navbar-border-color-dark; - background-color: $navbar-primary-color; - color: $navbar-color; - - &:focus { - box-shadow: none; - } - - &:hover { - background-color: darken($navbar-primary-color, 20%); - color: $navbar-color; - border-color: $navbar-border-color-dark; - } -} - -#main-navbar { - background-color: $navbar-color; - - .navbar { - height: $navbar-height; - } - - // Unfortunately we must colour the controls in the navbar manually. - .search-input { - @extend .form-input; - min-width: 120px; - border-color: $navbar-border-color-dark; - } - - .search-input:focus { - box-shadow: none; - border-color: $navbar-border-color-dark; - } - - .btn-primary { - @extend .navbar-button; - } - -} - -#img-logo { - vertical-align: middle; - height: $logo-height; -} - -.menu-right { - // To make sure the user menu doesn't move off the screen. - @media (max-width: 1600px) { - left: auto; - right: 0; - } - position: absolute; -} - -// - Main buttons -.btn-secondary { - background: $secondary-btn-color; - border-color: darken($secondary-btn-color, 5%); - color: invert($secondary-btn-color); - - margin-right: $control-padding-x*2; - - &:hover, &:focus { - background: darken($secondary-btn-color, 5%); - border-color: darken($secondary-btn-color, 10%); - - color: invert($secondary-btn-color); - } - - &:focus { - @include control-shadow(darken($secondary-btn-color, 40%)); - } -} - -#main-buttons { - margin-top: $control-padding-y*2; - margin-bottom: $control-padding-y*2; - - .dropdown > .btn { - @extend .btn-secondary; - } -} - -#category-selection { - .dropdown { - .btn { - margin-right: 0px; - } - } - .plus-btn { - margin-right: 0px; - i { - margin-right: 0px; - } - } -} - -.category-description { - opacity: 0.6; - font-size: small; -} - -.category-status { - font-size: small; - font-weight: bold; - - .topic-count { - margin-left: 5px; - opacity: 0.7; - font-size: small; - } -} - -.category { - white-space: nowrap; -} - -#new-thread { - .modal-container .modal-body { - max-height: none; - } - - .panel-body { - padding-top: $control-padding-y*2; - padding-bottom: $control-padding-y*2; - } - - .form-input[name='subject'] { - margin-bottom: $control-padding-y*2; - } - - textarea.form-input, .panel-body > div { - min-height: 40vh; - } - - .footer { - float: right; - margin-top: $control-padding-y*2; - } -} - -// - Thread table -.thread-title { - a, a:hover { - color: $body-font-color; - text-decoration: none; - } - - a.visited, a:visited { - color: lighten($body-font-color, 40%); - } - - i { - // Icon - margin-right: $control-padding-x-sm; - } -} - -.thread-list { - @extend .container; - @extend .grid-xl; -} - -.category-list { - @extend .thread-list; - - - .category-title { - @extend .thread-title; - a, a:hover { - color: lighten($body-font-color, 10%); - text-decoration: none; - } - } - - .category-description { - opacity: 0.6; - } -} - -#categories-list .category { - border-left: 6px solid; - border-left-color: $default-category-color; -} - -$super-popular-color: #f86713; -$popular-color: darken($super-popular-color, 25%); -$threads-meta-color: #545d70; - -.super-popular-text { - color: $super-popular-color; -} - -.popular-text { - color: $popular-color; -} - -.views-text { - color: $threads-meta-color; -} - -.label-custom { - color: white; - background-color: $label-color; - - font-size: 0.6rem; - padding-left: 0.3rem; - padding-right: 0.3rem; - border-radius: 5rem; -} - -.last-visit-separator { - td { - border-bottom: 1px solid $super-popular-color; - line-height: 0.1rem; - padding: 0; - text-align: center; - } - - span { - color: $super-popular-color; - padding: 0 8px; - font-size: 0.7rem; - background-color: $body-bg; - } -} - -.no-border { - td { - border: none; - } -} - -.category-color { - width: 0; - height: 0; - border: 0.25rem solid $default-category-color; - display: inline-block; - margin-right: 5px; -} - -.load-more-separator { - text-align: center; - color: darken($label-color, 35%); - background-color: lighten($label-color, 15%); - text-transform: uppercase; - font-weight: bold; - font-size: 80%; - cursor: pointer; - - td { - border: none; - padding: $control-padding-x $control-padding-y/2; - } -} - -// - Thread view -.title { - margin-top: $control-padding-y*2; - margin-bottom: $control-padding-y*2; - - p { - font-size: 1.4rem; - font-weight: bold; - - color: darken($dark-color, 20%); - - margin: 0; - } - - i.fas { - margin-right: $control-padding-x-sm; - } -} - -.thread-replies, .thread-time, .views-text, .popular-text, .centered-header { - text-align: center; -} - -.thread-users { - text-align: left; -} - -.thread-time { - color: $threads-meta-color; - - &.is-new { - @extend .text-success; - } - - &.is-old { - @extend .text-gray; - } - -} - -// Hide all the avatars but the first on small screens. -@media screen and (max-width: 600px) { - #threads-list a:not(:first-child) > .avatar { - display: none; - } -} - -.posts, .about { - @extend .grid-md; - @extend .container; - margin: 0; - padding: 0; - - margin-bottom: 10rem; // Just some empty space at the bottom. -} - -.post { - @extend .tile; - border-top: 1px solid $border-color; - padding-top: $control-padding-y-lg; - - &:target .post-main, &.highlight .post-main { - animation: highlight 2000ms ease-out; - } -} - -@keyframes highlight { - 0% { - background-color: lighten($primary-color, 20%); - } - 100% { - background-color: inherit; - } -} - -.post-icon { - @extend .tile-icon; -} - -.post-avatar { - @extend .avatar; - font-size: 1.6rem; - height: 2.5rem; - width: 2.5rem; -} - -.post-main { - @extend .tile-content; - - margin-bottom: $control-padding-y-lg*2; - // https://stackoverflow.com/a/41675912/492186 - flex: 1; - min-width: 0; -} - -.post-title { - margin-bottom: $control-padding-y*2; - - &, a, a:visited, a:hover { - color: lighten($body-font-color, 20%); - text-decoration: none; - } - - - .thread-title { - width: 100%; - - a > div { - display: inline-block; - } - } - - .post-username { - font-weight: bold; - display: inline-block; - - i { - margin-left: $control-padding-x; - } - } - - .post-metadata { - float: right; - - .post-replyingTo { - display: inline-block; - margin-right: $control-padding-x; - - i.fa-reply { - transform: rotate(180deg); - } - } - - .post-history { - display: inline-block; - margin-right: $control-padding-x; - - i { - font-size: 90%; - } - - .edit-count { - margin-right: $control-padding-x-sm/2; - } - } - } -} - -.post-content, .about { - img { - max-width: 100%; - } -} - -.post-buttons { - float: right; - - > div { - display: inline-block; - } - - .btn { - background: transparent; - border-color: transparent; - color: darken($secondary-btn-color, 40%); - - margin: 0; - margin-left: $control-padding-y-sm; - } - - .btn:hover { - background: $secondary-btn-color; - border-color: darken($secondary-btn-color, 5%); - color: invert($secondary-btn-color); - } - - .btn:focus { - @include control-shadow(darken($secondary-btn-color, 50%)); - } - - .btn:active { - box-shadow: inset 0 0 .4rem .01rem darken($secondary-btn-color, 80%); - } - - .like-button i:hover, .like-button i.fas { - color: #f783ac; - } - - .like-count { - margin-right: $control-padding-x-sm; - } -} - -#thread-buttons { - border-top: 1px solid $border-color; - width: 100%; - padding-top: $control-padding-y; - padding-bottom: $control-padding-y; - @extend .clearfix; - - .btn { - float: right; - margin-right: 0; - margin-left: $control-padding-x; - } -} - -blockquote { - border-left: 0.2rem solid darken($bg-color, 10%); - background-color: $bg-color; - - .detail { - margin-bottom: $control-padding-y; - color: lighten($body-font-color, 20%); - } -} - -.quote-avatar { - @extend .avatar; - @extend .avatar-sm; -} - -.quote-link { - float: right; -} - -.user-mention { - @extend .chip; - vertical-align: initial; - font-weight: bold; - display: inline-block; - font-size: 85%; - height: inherit; - padding: 0.08rem 0.4rem; - background-color: darken($bg-color-dark, 5%); - - img { - @extend .avatar; - @extend .avatar-sm; - } -} - -.code-buttons { - position: absolute; - bottom: 0; - right: 0; - - .btn-primary { - margin-bottom: $control-padding-y; - } -} - -.execution-result { - @extend .toast; - - h6 { - font-family: $base-font-family; - } -} - -.execution-success { - @extend .toast-success; -} - -.code { - // Don't show the "none". - &[data-lang="none"]::before { - content: ""; - } - - // &:not([data-lang="Nim"]) > .code-buttons { - // display: none; - // } -} -.code-buttons { - display: none; -} - -.post-content { - pre:not(.code) { - overflow: scroll; - } -} - -.information { - @extend .tile; - border-top: 1px solid $border-color; - padding-top: $control-padding-y-lg*2; - padding-bottom: $control-padding-y-lg*2; - color: lighten($body-font-color, 20%); - .information-title { - font-weight: bold; - } - - &.no-border { - border: none; - } -} - -.information-icon { - @extend .tile-icon; - - i { - width: $unit-16; - text-align: center; - font-size: 1rem; - - } -} - -.time-passed { - text-transform: uppercase; -} - -.load-more-posts { - text-align: center; - color: darken($label-color, 35%); - background-color: lighten($label-color, 15%); - border: none; - text-transform: uppercase; - font-weight: bold; - cursor: pointer; - - .information-main { - width: 100%; - text-align: left; - } - - .more-post-count { - color: rgba(darken($label-color, 35%), 0.5); - margin-right: $control-padding-x*2; - float: right; - } -} - -.form-input.post-text-area { - margin-top: $control-padding-y*2; - resize: vertical; -} - -#reply-box { - .panel { - margin-top: $control-padding-y*2; - } -} - -code { - color: $body-font-color; - background-color: $bg-color; -} - -tt { - @extend code; -} - -hr { - background: $border-color; - height: $border-width; - margin: $unit-2 0; - border: 0; -} - -.edit-box { - .edit-buttons { - margin-top: $control-padding-y*2; - - float: right; - - > div { - display: inline-block; - } - } - - .text-error { - margin-top: $control-padding-y*3; - display: inline-block; - } - - .form-input.post-text-area { - margin-bottom: $control-padding-y*2; - } -} - -@import "syntax.scss"; - -// - Profile view - -.profile { - @extend .tile; - margin-top: $control-padding-y*5; -} - -.profile-icon { - @extend .tile-icon; - margin-right: $control-padding-x; -} - -.profile-avatar { - @extend .avatar; - @extend .avatar-xl; - - height: 6.2rem; - width: 6.2rem; -} - -.profile-content { - @extend .tile-content; - padding: $control-padding-x $control-padding-y; -} - -.profile-title { - @extend .tile-title; -} - -.profile-stats { - dl { - border-top: 1px solid $border-color; - border-bottom: 1px solid $border-color; - padding: $control-padding-x $control-padding-y; - } - - dt { - font-weight: normal; - color: lighten($dark-color, 15%); - } - - dt, dd { - display: inline-block; - margin: 0; - margin-right: $control-padding-x; - } - - dd { - margin-right: $control-padding-x-lg; - } -} - -.profile-tabs { - margin-bottom: $control-padding-y-lg*2; -} - -.profile-post { - @extend .post; - - .profile-post-main { - flex: 1; - } - - .profile-post-time { - float: right; - } -} - -.spoiler { - text-shadow: gray 0px 0px 15px; - color: transparent; - -moz-user-select: none; - user-select: none; - cursor: normal; - - &:hover, &:focus { - text-shadow: $body-font-color 0px 0px 0px; - } -} - -.profile-post-title { - @extend .thread-title; -} - -// - Sign up modal - -#signup-modal { - .modal-container .modal-body { - max-height: 60vh; - } -} - -.license-text { - text-align: left; - font-size: 80%; -} - -// - Reset password -#resetpassword { - @extend .grid-sm; - @extend .container; - - .form-input { - display: inline-block; - width: 15rem; - margin-bottom: $control-padding-y*2; - } - - .footer { - margin-top: $control-padding-y*2; - } -} diff --git a/public/css/spectre b/public/css/spectre deleted file mode 160000 index 7a6af53..0000000 --- a/public/css/spectre +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7a6af53bca72b549b2cbf1948763348bd5b30dcd diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..fa38840 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,713 @@ + +a, a * { cursor:pointer; } + +html { margin:0; overflow-x:auto; } +body { + overflow-x:hidden; + min-width:1030px; + margin:0; + font: 13pt Helvetica,Arial,sans-serif; + background:#152534 url("/images/bg.png") no-repeat fixed center top; } + +pre { color: #F5F5F5;} +pre, pre * { cursor:text; } +pre .Comment { color:#6D6D6D; font-style:italic; } +pre .Keyword { color:#43A8CF; font-weight:bold; } +pre .Type { color:#128B7D; font-weight:bold; } +pre .Operator { font-weight: bold; } +pre .atr { color:#128B7D; font-weight:bold; font-style:italic; } +pre .def { color:#CAD6E4; font-weight:bold; font-style:italic; } +pre .StringLit { color:#854D6A; font-weight:bold; } +pre .DecNumber, pre .FloatNumber { color:#8AB647; } +pre .tab { border-left:1px dotted rgba(67,168,207,0.4); } +pre .end { background:url("/images/tabEnd.png") no-repeat left bottom; } +pre .EscapeSequence +{ + color: #C08D12; +} + +.tall { height:100%; } +.pre { padding:0 5px; font: 11pt "DejaVu Sans Mono",monospace; background:rgba(255,255,255,.30); border-radius:3px; } + +.page-layout { margin:0 auto; width:1000px; } +.docs-layout { margin:0 40px; } +.talk-layout { margin:0 40px; } +.wide-layout { margin:0 auto; } + +#head { + height:100px; + margin-bottom: 40px; + background:url("/images/head.png") repeat-x bottom; } +#head.docs { margin-left:280px; background:rgba(0,0,0,.25) url("/images/head-fade.png") no-repeat right top; } +#head > div { position:relative } + + #head-logo { + position:absolute; + left:-390px; + top:0; + width:917px; + height:268px; + pointer-events:none; + background:url("/images/logo.png") no-repeat; } + #head.docs #head-logo { left:-381px; position:fixed; } + #head.forum #head-logo { left:-370px; } + + #head-logo-link { + position:absolute; + display:block; + top:10px; + left:10px; + width:236px; + height:85px; } + #head.docs #head-logo-link { left:-260px; } + #head.forum #head-logo-link { left:30px; } + + #head-links { position:absolute; right:0; bottom:13px; } + #head.docs #head-links, + #head.forum #head-links { right:20px; } + #head-links > a { + display:block; + float:left; + padding:10px 25px 25px 25px; + color:rgba(255,255,255,.5); + font-size:14pt; + text-decoration:none; + letter-spacing:1px; + background:url("/images/head-link.png") no-repeat center bottom; + transition: + color 0.3s ease-in-out, + text-shadow 0.4s ease-in-out; } + #head-links > a:hover, + #head-links > a.active { + position: relative; + color:#1cb3ec; + text-shadow:0 0 4px rgba(28,179,236,.8); + background-image:url("/images/head-link_hover.png"); } + + #head-links > a.active:after { + display: block; + content: ""; + width: 771px; + background: url("/images/glow-arrow.png") no-repeat left; + height: 41px; + position: absolute; + left: 50%; + bottom: -49px; + transform: translateX(-618px); } + + #head-banner { width:200px; height:100px; background:#000; } + + #glow-line-vert { + position:fixed; + top:100px; + left:280px; + width:3px; + height:844px; + background:url("/images/glow-line-vert.png") no-repeat; } + + +#body { z-index:1; position:relative; background:rgba(220,231,248,.6); } +#body.docs { margin:0 40px 20px 320px; } +#body.forum { margin:0 40px 20px 40px; min-height: 700px; } + + #body-border { + position:absolute; + top:-25px; + left:0; + right:0; + height:35px; + background:rgba(0,0,0,.25); } + + #body-border-left { + position:absolute; + left:-25px; + top:-25px; + bottom:-25px; + width:35px; + background:rgba(0,0,0,.25); } + + #body-border-right { + position:absolute; + right:-25px; + top:-25px; + bottom:-25px; + width:35px; + background:rgba(0,0,0,.25); } + + #body-border-bottom { + position:absolute; + left:10px; + right:10px; + bottom:-25px; + height:35px; + background:rgba(0,0,0,.25); } + + #body.docs #body-border, + #body.forum #body-border { left:10px; right:10px; } + + #glow-line { + position:absolute; + top:-27px; + left:100px; + right:-25px; + height:3px; + background:url("/images/glow-line.png") no-repeat left; } + #glow-line-bottom { + position:absolute; + bottom:-27px; + left:-25px; + right:100px; + height:3px; + background:url("/images/glow-line2.png") no-repeat right; } + + #content { padding:40px 0; } + #content.page { width:680px; min-height:800px; padding-left:20px; } + #content h1 { font-size:20pt; letter-spacing:1px; color:rgba(0,0,0,.75); } + #content h2 { font-size:16pt; letter-spacing:1px; color:rgba(0,0,0,.7); margin-top:40px; } + #content p { color: #1D1D1D; margin: 5pt 0pt; } + #content a { color:#CEDAE9; text-decoration:none; } + #content a:hover { color:#fff; } + #content ul { padding-left:20px; } + #content li { margin-bottom:10px; text-align:justify; } + + #talk-heads { overflow:auto; margin:0 8px 0 8px; } + #talk-heads > div { float:left; font-size:120%; font-weight:bold; } + #talk-heads > .topic { width:45%; } + #talk-heads > .detail { width:15%; } + #talk-heads > .activity { width:25%; } + #talk-heads > .users { width:15%; } + #talk-heads > div > div { margin:0 10px 10px 10px; padding:0 10px 10px 10px; border-bottom:1px dashed rgba(0,0,0,0.4); } + #talk-heads > .topic > div { margin-left:0; } + #talk-heads > .activity > div { margin-right:0; } + + #talk-thread > div { + background-color: rgba(255, 255, 255, 0.5); + } + #talk-thread > div, + #talk-threads > div { + position:relative; + margin:5px 0; + overflow:auto; + border-radius:3px; + border:8px solid rgba(0,0,0,.8); + border-top:none; + border-bottom:none; + } + #talk-threads > div + { + line-height: 150%; + background:rgba(0,0,0,0.1); + } + #talk-threads > div:nth-child(odd) { background:rgba(0,0,0,0.2); } + #talk-thread > div > div, + #talk-threads > div > div + { + float:left; + text-overflow: ellipsis; + overflow: hidden; + font-size: 13pt; + } + #talk-threads > div > div > div { margin: 5px 10px; } + #talk-thread > div > div > div { margin: 15px 10px; } + #talk-thread > div > .topic + { + margin-top: 15pt; + white-space: normal; + } + #talk-thread > div > .topic > div + { + margin-left: 15px; + } + #talk-thread > div > .topic > div > span.date + { + position: absolute; + top: 5px; + right: 10pt; + border-bottom: 1px dashed; + color: #3D3D3D; + } + #talk-threads > div > .topic { width:45%; } + #talk-threads > div > .users { width:15%; overflow:hidden; height: 30px; } + #talk-threads > div > .users > div > img + { + margin-bottom: -4pt; + cursor: help; + width: 20px; + } + #talk-threads > div > .detail { width:16%; overflow:hidden; } + #talk-thread > div > .author, + #talk-threads > div > .activity { + overflow:hidden; + background:rgba(0,0,0,0.8); + color: white; + + } + #talk-thread > div > .author { + width: 15%; + } + #talk-threads > div > .activity { + width:24%; + font-size: 9pt; + } + #talk-threads > div > .activity a + { + color: #1CB3EC; + } + #talk-threads > div > .activity a:hover + { + color: #ffffff; + } + #talk-thread > div > .author { + height: 100%; + position: absolute; + } + #talk-thread > div > .author a, + #talk-threads > div > .author a { color:#1cb3ec !important; } + #talk-thread > div > .author a:hover, + #talk-threads > div > .author a:hover { color:#fff !important; } + #talk-threads > div > .topic .pages { float:right; } + #talk-threads > div > .topic .pages > a + { + margin-right: 5pt; + } + #talk-threads > div > .topic > div > a + { + font-weight:bold; + white-space: nowrap; + } + #talk-threads > div > .topic > div > a:visited { color: #1a1a1a; } + #talk-threads > div > .detail > div { float:left; margin:0; } + #talk-threads > div > .detail > div > div { margin-left:15px; padding: 5px 5px 5px 22px; } + #talk-threads > div > .detail > div { width:50%; } + #talk-threads > div > .detail > div:first-child > div { background:url("/images/forum-views.png") no-repeat left; cursor: help; } + #talk-threads > div > .detail > div:last-child > div { background:url("/images/forum-posts.png") no-repeat left; cursor: help; } + + #talk-thread > div { margin:20px 0; min-height:160px; padding-bottom: 10pt; } + #talk-thread > div > .author > div > .avatar { margin-top:20px; } + #talk-thread > div > .author > div > .name { } + #talk-thread > div > .author > div > .date { font-size: 8pt; color: white; } + #talk-thread > div > .topic { width:85%; padding-bottom:10px; margin-left: 15%; } + #talk-thread > div > .topic pre, #markhelp pre.listing { + overflow:auto; + margin:0; + padding:15px 10px; + font-size:10pt; + font-style:normal; + line-height:14pt; + background:rgba(0,0,0,.75); + border-left:8px solid rgba(0,0,0,.3); + margin-bottom: 10pt; + font-family: "DejaVu Sans Mono", monospace; + } + #talk-thread > div > .topic a, #talk-thread > div > .topic a:visited, + #markhelp a, #markhelp a:visited + { + color: #3680C9; + text-decoration: none; + } + #talk-thread > div > .topic a:hover + { + text-decoration: underline; + } + #talk-head, + #talk-info { + overflow:auto; + border-radius:3px; + border:8px solid rgba(0,0,0,.2); + border-top:none; + border-bottom:none; + background:rgba(0,0,0,0.1); } + #talk-head { margin-bottom:20px; } + #talk-info { margin-top:20px; } + #talk-head > div, + #talk-info > div { float:left; } + #talk-head > .info, + #talk-info > .info { width:80%; } + #talk-head > .info-post, + #talk-info > .info-post { width: 85%; } + #talk-head > .user, + #talk-info > .user { width:20%; background:rgba(0,0,0,.2); } + #talk-head > .user-post, + #talk-info > .user-post { width: 15%; background:rgba(0,0,0,.2); } + #talk-info > .user-post .reply { font-weight:bold; padding-left:22px; background:url("/images/forum-reply.png") no-repeat left; } + #talk-info > .user-post a span + { + color: #CEDAE9 !important; + } + #talk-info > .user-post > a > div:hover > span + { + color: #fff !important; + } + #talk-head > div > div, + #talk-info > div > div, + #talk-info > div > a > div { padding:5px 20px; color: #1a1a1a; } + #talk-head > div > div { color: #353535; } + #talk-head > .detail > div { float:left; margin:0; } + #talk-head > .detail > div > div { padding-left:22px; } + #talk-head > .detail > div:first-child > div { background:url("/images/forum-views.png") no-repeat left; } + #talk-head > .detail > div:last-child > div { background:url("/images/forum-posts.png") no-repeat left; } + + #talk-nav { margin:20px 8px 0 8px; padding-top:10px; border-top:1px dashed rgba(0,0,0,0.4); text-align:center; } + #talk-nav > a.active { text-decoration:underline !important; } + #talk-nav > a, #talk-nav > span, #talk-info > .info-post > div > a, + #talk-info > .info-post > div > span { margin-left: 5pt; } + + .standout { + padding:5px 30px; + margin-bottom:20px; + border:8px solid rgba(0,0,0,.8); + border-right-width:16px; + border-top-width:0; + border-bottom-width:0; + border-radius:3px; + background:rgba(0,0,0,0.1); + box-shadow:1px 3px 12px rgba(0,0,0,.4); } + .standout h3 { margin-bottom:10px; padding-bottom:10px; border-bottom:1px dashed rgba(0,0,0,.8); } + .standout li { margin:0 !important; padding-top:10px; border-top:1px dashed rgba(0,0,0,.2); } + .standout ul { padding-bottom:5px; } + .standout ul.tools { list-style:url("/images/docs-tools.png"); } + .standout ul.library { list-style:url("/images/docs-library.png"); } + .standout ul.internal { list-style:url("/images/docs-internal.png"); } + .standout ul.tutorial { list-style:url("/images/docs-tutorial.png"); } + .standout ul.example { list-style:url("/images/docs-example.png"); } + .standout li:first-child { padding-top:0; border-top:none; } + .standout li p { margin:0 0 10px 0 !important; line-height:130%; } + .standout li > a { font-weight:bold; } + + .forum-user-info, + .forum-user-info * { cursor:help } + +#foot { height:150px; position:relative; top:-10px; letter-spacing:1px; } +#foot.home { background:url("/images/foot.png") repeat-x top; height:200px; } +#foot.docs { margin-left:320px; margin-right:40px; } +#foot.forum { margin-left:40px; margin-right:40px; } +#foot > div { position:relative; } +#foot.home > div { width:960px; } +#foot h4 { font-size:11pt; color:rgba(255,255,255,.4); margin:40px 0 6px 0; } +#foot a:hover { color:#fff; } + + #foot-links { float:left; } + #foot-links > div { float:left; padding:0 40px 0 0; line-height:120%; } + #foot-links a { display:block; font-size:10pt; color:rgba(255,255,255,.3); text-decoration:none; } + #foot-legal { float:right; font-size:10pt; color:rgba(255,255,255,.3); line-height:150%; text-align:right; } + #foot-legal a { color:inherit; text-decoration:none; } + #foot-legal > h4 > a { color:inherit; } + + #mascot { + z-index:2; + position:absolute; + top:-340px; + right:25px; + width:202px; + height:319px; + background:url("/images/mascot.png") no-repeat; } + +article#content +{ + width: 80%; + display: inline-block; +} + +div#sidebar +{ + background-color: rgba(255, 255, 255, 0.1); + + border-left: 8px solid rgba(0, 0, 0, 0.8); + border-right: 8px solid rgba(0, 0, 0, 0.8); + border-bottom: 8px solid rgba(0, 0, 0, 0.8); + border-radius: 3px; + + width: 15%; + margin-top: 40px; + + display: inline-block; + float: right; + + color: #FFF; +} + +div#sidebar .title +{ + background-color: rgba(0, 0, 0, 0.8); + color: #FFF; + text-align: center; + padding: 10pt; +} + +div#sidebar .content +{ + padding: 12pt; + overflow: auto; + +} + +div#sidebar .content .button +{ + background-color: rgba(0,0,0,0.2); + text-decoration: none; + color: #FFF; + padding: 4pt; + float: right; + border-bottom: 2px solid rgba(0,0,0,0.24); + font-size: 11pt; + margin-top: 5pt; +} + +div#sidebar .content .button:hover +{ + border-bottom: 2px solid rgba(0,0,0,0.5); +} + +div#sidebar .content input +{ + width: 99%; + margin-bottom: 10pt; + margin-top: 2pt; + + border: 1px solid #6D6D6D; + font-size: 12pt; +} + +div#sidebar .content a.avatar img +{ + float: left; + margin-top: 5pt; +} + +div#sidebar .content a.user +{ + background-color: rgba(0, 0, 0, 0.8); + color: #1cb3ec; + padding: 5pt; + width: 93%; + display: block; + text-align: center; + text-decoration: none; +} + +div#sidebar .content a.user:hover +{ + color: #FFF; +} + +div#sidebar .user .button +{ + float: left; + margin-top: 5pt; + width: 52.5%; +} + +div#sidebar .user .logout +{ + clear: left; + width: 52pt; + text-align: center; + margin-left: 0pt; +} + +div#sidebar .user .avatar > img +{ + margin-right: 5pt; +} + +div#sidebar .content .search +{ + text-align: center; + margin: auto; + display: block; + width: 95%; +} + +div#sidebar .content a#passreset { + color: #CEDAE9; + font-size: 9pt; + display: block; + text-decoration: none; + margin-top: -4pt; +} + +div#sidebar .content a#passreset:hover { + color: #fff; +} + + + +span.error +{ + float: left; + width: 100%; + color: #FF4848; + text-align: center; + font-size: 10pt; + background-color: rgba(0,0,0,0.8); + padding: 5pt 0pt; + font-weight: bold; +} + +section#body #content span.error +{ + width: 25%; + margin-top: 5px; + margin-bottom: 5px; +} + +article#content form +{ + border-right: 8px solid rgba(0, 0, 0, 0.2); + background-color: rgba(255, 255, 255, 0.1); + padding: 10pt 20pt; +} + +article#content form > input, article#content form > textarea +{ + border: 1px solid #6D6D6D; +} + +article#content form > input[type=text] +{ + width: 70%; + min-width: 500px; +} + +article#content form > textarea +{ + width: 100%; + height: 200px; +} + +article#content form > input:focus, article#content form > textarea:focus +{ + border: 1px solid #1cb3ec; +} + +hr +{ + border: 1px solid #3D3D3D; +} + +.activity .isoDate +{ + display: none; +} + + +/* highlighting current post */ + +div:target { + background: rgba(139, 218, 255, 0.25) !important; +} + +/* full-text search */ + +.searchResults h4 b, +.searchResults h5 b { + border-bottom: 1px dotted #ffffff; +} +.titleHeader { + margin-right: 1em; + color: #121212; + font-weight: bold; +} + +.postTitle b { + border-bottom: 1px solid #D7300C; +} + +.postTitle a:hover { + text-decoration: none !important; + border-bottom: 1px solid #D7300C; +} + +.searchForm { + margin-top: 0px; + margin-right: 1em; + margin-bottom: 0px; + margin-left: 1em; +} + +.searchHelp { + color: #000000 !important; + float: right; + font-size: 11px; + left: -17px; + top: 3px; + position: relative; + text-decoration: none; + text-shadow: #FFFF00 1px 1px 2px; + cursor: help; +} + +#talk-thread.searchResults > div > div > div { + margin: 15px 8px; +} + +form.searchNav { + display: inline; + border: none !important; + background: transparent !important; +} + +.searchNav input { + background: #858C97; + color: #000000; + border: 1px solid #333; +} + +.clear { + clear: both; + height: 1px; +} + +img.smiley { + width: 20px; + height: 20px; + vertical-align: middle; + margin: 0; +} + +img.rssfeed { + width: 16px; + float: right; + margin-top: 10px; +} + + +#markhelp { + width: 80%; + background-color: #cbcfd6; + padding: 2pt 10pt; + margin-top: 10pt; +} + +#markhelp .markheading { + background-color: #6fa1ff; + text-align: center; +} + +#markhelp table.rst { + width: 100%; + margin: 10px 0px; + font-size: 12pt; + border-collapse: collapse; +} + +#markhelp table tr, #markhelp table td { + width: 50%; + border: 1px solid #7d7d7d; +} + +#markhelp table td { + padding:4px 9px; +} + +blockquote { + padding: 0px 8px; + margin: 10px 0px; + border-left: 2px solid rgb(61, 61, 61); + color: rgb(109, 109, 109); +} + +blockquote p { + color: rgb(109, 109, 109) !important; + +} \ No newline at end of file diff --git a/public/css/syntax.scss b/public/css/syntax.scss deleted file mode 100644 index 14dfa49..0000000 --- a/public/css/syntax.scss +++ /dev/null @@ -1,13 +0,0 @@ -pre .Comment { color:#618f0b; font-style:italic; } -pre .Keyword { color:rgb(39, 141, 182); font-weight:bold; } -pre .Type { color:#128B7D; font-weight:bold; } -pre .Operator { font-weight: bold; } -pre .atr { color:#128B7D; font-weight:bold; font-style:italic; } -pre .def { color:#CAD6E4; font-weight:bold; font-style:italic; } -pre .StringLit { color:rgb(190, 15, 15); font-weight:bold; } -pre .DecNumber, pre .FloatNumber { color:#8AB647; } -pre .tab { border-left:1px dotted rgba(67,168,207,0.4); } -pre .EscapeSequence -{ - color: #C08D12; -} \ No newline at end of file diff --git a/public/images/Feed-icon.svg b/public/images/Feed-icon.svg new file mode 100644 index 0000000..b325149 --- /dev/null +++ b/public/images/Feed-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/images/bg.png b/public/images/bg.png new file mode 100644 index 0000000..91f3359 Binary files /dev/null and b/public/images/bg.png differ diff --git a/public/images/forum-posts.png b/public/images/forum-posts.png new file mode 100644 index 0000000..4c4c63b Binary files /dev/null and b/public/images/forum-posts.png differ diff --git a/public/images/forum-reply.png b/public/images/forum-reply.png new file mode 100644 index 0000000..12e9c60 Binary files /dev/null and b/public/images/forum-reply.png differ diff --git a/public/images/forum-views.png b/public/images/forum-views.png new file mode 100644 index 0000000..febbdf6 Binary files /dev/null and b/public/images/forum-views.png differ diff --git a/public/images/glow-arrow.png b/public/images/glow-arrow.png new file mode 100644 index 0000000..6a91c23 Binary files /dev/null and b/public/images/glow-arrow.png differ diff --git a/public/images/glow-line.png b/public/images/glow-line.png new file mode 100644 index 0000000..6607bde Binary files /dev/null and b/public/images/glow-line.png differ diff --git a/public/images/glow-line2.png b/public/images/glow-line2.png new file mode 100644 index 0000000..7c4c5f5 Binary files /dev/null and b/public/images/glow-line2.png differ diff --git a/public/images/head-link.png b/public/images/head-link.png new file mode 100644 index 0000000..8e55b94 Binary files /dev/null and b/public/images/head-link.png differ diff --git a/public/images/head-link_hover.png b/public/images/head-link_hover.png new file mode 100644 index 0000000..4180fbc Binary files /dev/null and b/public/images/head-link_hover.png differ diff --git a/public/images/head.png b/public/images/head.png new file mode 100644 index 0000000..9d7bcd2 Binary files /dev/null and b/public/images/head.png differ diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..2594249 Binary files /dev/null and b/public/images/logo.png differ diff --git a/public/images/smilieys/icon_cool.png b/public/images/smilieys/icon_cool.png new file mode 100644 index 0000000..37e55e3 Binary files /dev/null and b/public/images/smilieys/icon_cool.png differ diff --git a/public/images/smilieys/icon_e_biggrin.png b/public/images/smilieys/icon_e_biggrin.png new file mode 100644 index 0000000..d54cc5d Binary files /dev/null and b/public/images/smilieys/icon_e_biggrin.png differ diff --git a/public/images/smilieys/icon_e_confused.png b/public/images/smilieys/icon_e_confused.png new file mode 100644 index 0000000..17dd8ea Binary files /dev/null and b/public/images/smilieys/icon_e_confused.png differ diff --git a/public/images/smilieys/icon_e_sad.png b/public/images/smilieys/icon_e_sad.png new file mode 100644 index 0000000..1ef0291 Binary files /dev/null and b/public/images/smilieys/icon_e_sad.png differ diff --git a/public/images/smilieys/icon_e_smile.png b/public/images/smilieys/icon_e_smile.png new file mode 100644 index 0000000..f7ee8aa Binary files /dev/null and b/public/images/smilieys/icon_e_smile.png differ diff --git a/public/images/smilieys/icon_e_surprised.png b/public/images/smilieys/icon_e_surprised.png new file mode 100644 index 0000000..60a82d4 Binary files /dev/null and b/public/images/smilieys/icon_e_surprised.png differ diff --git a/public/images/smilieys/icon_e_wink.png b/public/images/smilieys/icon_e_wink.png new file mode 100644 index 0000000..e32171d Binary files /dev/null and b/public/images/smilieys/icon_e_wink.png differ diff --git a/public/images/smilieys/icon_exclaim.png b/public/images/smilieys/icon_exclaim.png new file mode 100644 index 0000000..9391f2b Binary files /dev/null and b/public/images/smilieys/icon_exclaim.png differ diff --git a/public/images/smilieys/icon_mad.png b/public/images/smilieys/icon_mad.png new file mode 100644 index 0000000..3029c96 Binary files /dev/null and b/public/images/smilieys/icon_mad.png differ diff --git a/public/images/smilieys/icon_neutral.png b/public/images/smilieys/icon_neutral.png new file mode 100644 index 0000000..099cf9d Binary files /dev/null and b/public/images/smilieys/icon_neutral.png differ diff --git a/public/images/smilieys/icon_razz.png b/public/images/smilieys/icon_razz.png new file mode 100644 index 0000000..73401ea Binary files /dev/null and b/public/images/smilieys/icon_razz.png differ diff --git a/public/karax.html b/public/karax.html deleted file mode 100644 index 3f7f41b..0000000 --- a/public/karax.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - $title - - - - - - - - - - - -
- - - - diff --git a/public/license.rst b/public/license.rst deleted file mode 100644 index beebae3..0000000 --- a/public/license.rst +++ /dev/null @@ -1,38 +0,0 @@ -Content license -=============== - -All the content contributed to $hostname is `cc-wiki (aka cc-by-sa) -`_ licensed, intended to be -**shared and remixed**. - -The cc-wiki licensing, while intentionally permissive, does require -attribution: - -**Attribution** — You must attribute the work in the manner specified by -the author or licensor (but not in any way that suggests that they endorse -you or your use of the work). - -This means that if you republish this content, you are -required to: - -* **Visually indicate that the content is from the $name**. It doesn’t - have to be obnoxious; a discreet text blurb is fine. -* **Hyperlink directly to the original post** (e.g., - https://$hostname/t/186/1#908) -* **Show the author names** for every post. -* **Hyperlink each author name** directly back to their user profile page - (e.g., http://$hostname/profile/Araq) - -To be more specific, each hyperlink must -point directly to the $hostname domain in -standard HTML visible even with JavaScript disabled, and not use a tinyurl or -any other form of obfuscation or redirection. Furthermore, the links must not -be `nofollowed -`_. - -This is about the spirit of fair **attribution**. Attribution to the website, -and more importantly, to the individuals who so generously contributed their -time to create that content in the first place! - -Feel free to remix and reuse to your heart’s content, as long as a good faith -effort is made to attribute the content! diff --git a/ranks.nim b/ranks.nim new file mode 100644 index 0000000..3b518f4 --- /dev/null +++ b/ranks.nim @@ -0,0 +1,11 @@ + +type + Rank* = enum ## serialized as 'status' + Spammer ## spammer: every post is invisible + Troll ## troll: cannot write new posts + EmailUnconfirmed ## member with unconfirmed email address + Moderated ## new member: posts manually reviewed before everybody + ## can see them + User ## Ordinary user + Moderator ## Moderator: can ban/moderate users + Admin ## Admin: can do everything diff --git a/public/rst.rst b/rst.txt similarity index 62% rename from public/rst.rst rename to rst.txt index b5db812..70bbd63 100644 --- a/public/rst.rst +++ b/rst.txt @@ -1,8 +1,9 @@ -Markdown and RST supported by this forum -======================================== +=========================================================================== + reStructuredText cheat sheet +=========================================================================== This is a cheat sheet for the *reStructuredText* dialect as implemented by -Nim's documentation generator which has been reused for this forum. +Nimrod's documentation generator which has been reused for this forum. :-) See also the `official RST cheat sheet `_ @@ -11,8 +12,9 @@ for further information. Elements of **markdown** are also supported. + Inline elements ---------------- +=============== Ordinary text may contain *inline elements*: @@ -27,71 +29,68 @@ Plain text Result ``\\escape`` \\escape =============================== ============================================ -Quoting other users can be done by prefixing their message with ``>``:: - - > Hello World - - Hi! - -Which will result in: - -> Hello World - -Hi! - Links ------ +===== -Links are either direct URLs like ``https://nim-lang.org`` or written like +Links are either direct URLs like ``http://nimrod-lang.org`` or written like this:: - `Nim `_ + `Nimrod `_ Or like this:: - ``_ + ``_ Code blocks ------------ +=========== -The code blocks can be written in the same style as most common Markdown -flavours:: +are done this way:: - ```nim - if x == "abc": - echo "xyz" - ``` - -or using RST syntax:: - - .. code-block:: nim + .. code-block:: nimrod if x == "abc": echo "xyz" -Both are rendered as: -.. code-block:: nim +Is rendered as: + +.. code-block:: nimrod + + if x == "abc": + echo "xyz" + + +Except Nimrod, the programming languages C, C++, Java and C# have highlighting +support. + +An alternative github-like syntax is also supported. This has the advantage +that no excessive indentation is needed:: + + ```nimrod + if x == "abc": + echo "xyz"``` + +Is rendered as: + +.. code-block:: nimrod if x == "abc": echo "xyz" -Apart from Nim, the programming languages C, C++, Java and C# also -have highlighting support. Literal blocks --------------- +============== -These are introduced by '::' and a newline. The block is indicated by indentation: +Are introduced by '::' and a newline. The block is indicated by indentation: :: :: if x == "abc": echo "xyz" -The above is rendered as:: +Is rendered as:: if x == "abc": echo "xyz" @@ -99,9 +98,9 @@ The above is rendered as:: Bullet lists ------------- +============ -Bullet lists look like this:: +look like this:: * Item 1 * Item 2 that @@ -112,7 +111,7 @@ Bullet lists look like this:: - item 3b - valid bullet characters are ``+``, ``*`` and ``-`` -The above rendered as: +Is rendered as: * Item 1 * Item 2 that spans over multiple lines @@ -124,9 +123,9 @@ The above rendered as: Enumerated lists ----------------- +================ -Enumerated lists are written like this:: +are written like this:: 1. This is the first item 2. This is the second item @@ -134,17 +133,64 @@ Enumerated lists are written like this:: single letters, or roman numerals #. This item is auto-enumerated -They are rendered as: +Is rendered as: 1. This is the first item 2. This is the second item 3. Enumerators are arabic numbers, single letters, or roman numerals -#. This item is auto-enumerated +#. This item is auto-enumerated + + +Quoting someone +=============== + +quotes are just:: + + **Someone said**: Indented paragraphs, + + and they may nest. + +Is rendered as: + + **Someone said**: Indented paragraphs, + + and they may nest. + + + +Definition lists +================ + +are written like this:: + + what + Definition lists associate a term with + a definition. + + how + The term is a one-line phrase, and the + definition is one or more paragraphs or + body elements, indented relative to the + term. Blank lines are not allowed + between term and definition. + +and look like: + +what + Definition lists associate a term with + a definition. + +how + The term is a one-line phrase, and the + definition is one or more paragraphs or + body elements, indented relative to the + term. Blank lines are not allowed + between term and definition. Tables ------- +====== Only *simple tables* are supported. They are of the form:: @@ -172,39 +218,3 @@ Cell 4 Cell 5; any Cell 6 multiple lines Cell 7 Cell 8 Cell 9 ================== =============== =================== - -Images ------- - -Image embedding is supported. This includes GIFs as well as mp4 (for which a -