contrib: intro of tnitter
authorAlexis Laferrière <alexis.laf@xymus.net>
Thu, 24 Jul 2014 00:40:19 +0000 (20:40 -0400)
committerAlexis Laferrière <alexis.laf@xymus.net>
Thu, 31 Jul 2014 18:55:40 +0000 (14:55 -0400)
Signed-off-by: Alexis Laferrière <alexis.laf@xymus.net>

contrib/tnitter/Makefile [new file with mode: 0644]
contrib/tnitter/README.md [new file with mode: 0644]
contrib/tnitter/src/action.nit [new file with mode: 0644]
contrib/tnitter/src/model.nit [new file with mode: 0644]
contrib/tnitter/src/tnitter.nit [new file with mode: 0644]

diff --git a/contrib/tnitter/Makefile b/contrib/tnitter/Makefile
new file mode 100644 (file)
index 0000000..c2a1d4a
--- /dev/null
@@ -0,0 +1,3 @@
+all:
+       mkdir -p bin/
+       ../../bin/nitg --dir bin src/tnitter.nit
diff --git a/contrib/tnitter/README.md b/contrib/tnitter/README.md
new file mode 100644 (file)
index 0000000..0eca29d
--- /dev/null
@@ -0,0 +1,23 @@
+Tnitter is a Twitter-like micro-blogging platform
+
+# Compile and execute
+
+Make sure all the required packages are installed with: `apt-get install libevent-dev libsqlite3-dev`
+
+To compile, run: `make`
+
+To execute, run: `bin/tnitter`
+
+The Web interface will be accessible at http://localhost:8080/
+
+# Main server
+
+The Tnitter application is deployed with other `nitcorn` projects at http://tnitter.xymus.net/
+
+# Notable implementation details
+
+* Implemented in Nit using the `nitcorn` framework.
+* On the server side, besides `nitcorn` it uses the Nit modules `sqlite3`, `md5` and `privileges`
+* The client-side UI is implemented with bootstrap 3.0 and jquery 1.11
+* Passwords are salted and hashed, but sent in clear text to the server
+* Launches on localhost on port 80 if running as root, on 8080 otherwise
diff --git a/contrib/tnitter/src/action.nit b/contrib/tnitter/src/action.nit
new file mode 100644 (file)
index 0000000..db3847f
--- /dev/null
@@ -0,0 +1,232 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Copyright 2014 Alexis Laferrière <alexis.laf@xymus.net>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# View and controller of Tnitter
+module action
+
+import nitcorn
+
+import model
+
+redef class Session
+       # User logged in
+       var user: nullable String = null
+end
+
+# Main Tnitter Action
+class Tnitter
+       super Action
+
+       var db_path = "tnitter.db"
+       var db = new DB.open(db_path)
+
+       # Header on pages served by this `Action`
+       #
+       # Keywords to `Text::replace`:
+       # * `%app_path%` is the main URL to reach this `Action`
+       # * `%nav_right%` is the pulled right part of the header, used for login form
+       var header = """
+<nav class="navbar navbar-default" role="navigation">
+  <div class="container-fluid">
+    <div class="navbar-header">
+      <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
+        <span class="sr-only">Toggle navigation</span>
+               <span class="icon-bar"></span>
+               <span class="icon-bar"></span>
+               <span class="icon-bar"></span>
+      </button>
+      <a class="navbar-brand" href="%app_path%">Tnitter</a>
+    </div>
+
+    <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
+      <ul class="nav navbar-nav">
+        <li><a href="https://github.com/privat/nit/">Nit repository</a></li>
+      </ul>
+
+      <ul class="nav navbar-nav pull-right">
+%header_right%
+      </ul>
+    </div>
+  </div>
+</nav>"""
+
+       # Template of the pages served by this `Action`
+       #
+       # Keywords to `Text::replace`:
+       # * The `%header%`, first thing in the `<body>`
+       # * The main page `%content%` within a `<div class="container">`
+       var template = """
+<!DOCTYPE html>
+<html>
+<head>
+       <title>Tnitter</title>
+       <meta charset="utf-8">
+       <meta http-equiv="X-UA-Compatible" content="IE=edge">
+       <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+
+       <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
+       <script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js"></script>
+       <script src="//netdna.bootstrapcdn.com/bootstrap/3.0.0/js/bootstrap.min.js"></script>
+</head>
+<body>
+
+%header%
+
+<div class="container">
+       %content%
+</div>
+</body>
+</html>"""
+
+       redef fun answer(request, turi)
+       do
+               # Get existing session
+               var session = request.session
+
+               # Error to display on page as a dismissable panel
+               var error = null
+
+               # Login/logout
+               if turi == "/login" and request.post_args.keys.has("user") and
+                  request.post_args.keys.has("pass") then
+
+                       var user = request.post_args["user"].trim
+                       var pass = request.post_args["pass"]
+
+                       var original_user = db.check_login(user, pass)
+                       if original_user != null then
+                               # Log in successful
+                               if session == null then session = new Session
+                               session.user = original_user
+                       else
+                               # Check for basic requirements
+                               if user.is_empty then
+                                       error = "Username must have at least 1 character"
+                               else if user.chars.has(' ') or user.chars.has('\n') then
+                                       error = "Username cannot contain white spaces"
+                               else if db.sign_up(user, pass) then
+                                       # Sign up successful
+                                       if session == null then session = new Session
+                                       session.user = user
+                               else
+                                       # Invalid user/pass
+                                       error = "Invalid combination of username and password"
+                                       session = null
+                               end
+                       end
+               else if turi == "/logout" then
+                       # Logging out
+                       session = null
+               else if turi == "/post" and request.post_args.keys.has("text") and session != null then
+                       var user = session.user
+                       if user != null then
+                               # Post a Tnit!
+                               var text = request.post_args["text"]
+                               db.post(user, text)
+                       end
+               end
+
+               var login_or_out
+               var content
+               if session == null or session.user == null then
+                       # Log in form in the navbar
+                       login_or_out = """
+<li>
+<form class="navbar-form" role="form" action="login" method="POST">
+  <div class="form-group">
+    <input type="text" placeholder="Username" class="form-control" name="user">
+  </div>
+  <div class="form-group">
+    <input type="password" placeholder="Password" class="form-control" name="pass">
+  </div>
+  <button type="submit" class="btn btn-default">Log in (or sign up)</button>
+</form>
+</li>
+                       """
+
+                       # Cannot post when not logged in
+                       content = ""
+               else
+                       # Log out form in the navbar
+                       login_or_out = """
+<li><p class="navbar-text">Signed in as @{{{session.user.html_escape}}}</p></li>
+<li>
+<form class="navbar-form" role="form" action="logout" method="POST">
+  <button type="submit" class="btn btn-default">Log out</button>
+</form>
+</li>
+                       """
+
+                       # Post form
+                       content = """
+<form class="form" role="form" action="post" method="POST">
+  <div class="form-group">
+       <div class="input-group">
+          <div class="input-group-addon">Share your thoughts</div>
+          <input class="form-control" type="text" placeholder="..." name="text">
+          <span class="input-group-btn">
+                 <button class="btn btn-default" type="submit">Tnit!</button>
+          </span>
+       </div><!-- /input-group -->
+  </div>
+</form>
+                       """
+               end
+
+               # Show error if any
+               var error_html
+               if error != null then
+                       error_html = """
+<div class="alert alert-danger alert-dismissible" role="alert">
+  <button type="button" class="close" data-dismiss="alert"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
+  {{{error}}}
+</div>
+               """
+               else error_html = ""
+
+               # Load the last 16 Tnits
+               var posts = db.latest_posts(16)
+
+               var html_posts = new Array[String]
+               for post in posts do
+                       html_posts.add "<tr><td>@{post.user.html_escape}</td><td>{post.text.html_escape}</td></tr>"
+               end
+
+               content += """
+<div class="panel panel-default">
+  <div class="panel-heading">Latest Tnits</div>
+    <table class="table table-striped">
+      {{{html_posts.join("\n")}}}
+    </table>
+  </div>
+</div>
+               """
+
+               # Get page from template, we replace the header first so we can replace
+               # everything on the same body afterwards
+               var body = template.
+                       replace("%header%", header).
+                       replace("%app_path%", request.uri.strip_extension(turi) + "/").
+                       replace("%header_right%", login_or_out).
+                       replace("%content%", error_html + content)
+
+               # Build response
+               var response = new HttpResponse(200)
+               response.body = body
+               response.session = session
+               return response
+       end
+end
diff --git a/contrib/tnitter/src/model.nit b/contrib/tnitter/src/model.nit
new file mode 100644 (file)
index 0000000..bb46193
--- /dev/null
@@ -0,0 +1,113 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Copyright 2014 Alexis Laferrière <alexis.laf@xymus.net>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Data and DB model of Tnitter
+module model
+
+import sqlite3
+import md5
+
+# The Tnitter database
+class DB
+       super Sqlite3DB
+
+       redef init open(path)
+       do
+               super
+               create_tables
+       end
+
+       # Create the needed tables
+       fun create_tables
+       do
+               assert create_table("IF NOT EXISTS users (user TEXT PRIMARY KEY, pass TEXT)") else
+                       print error or else "?"
+               end
+
+               assert create_table("IF NOT EXISTS posts (user TEXT, text TEXT, posted DATETIME DEFAULT CURRENT_TIMESTAMP)") else
+                       print error or else "?"
+               end
+       end
+
+       # Check if the login credentials are valid
+       #
+       # If valid, returns the username at database creation time. Otherwise returns `null`.
+       fun check_login(user, pass: String): nullable String
+       do
+               var stmt = select("user FROM users WHERE lower({user.to_sql_string}) = lower(user) " +
+                                 "AND {pass.tnitter_hash.to_sql_string} = pass")
+               assert stmt != null else print error or else "?"
+
+               for row in stmt do return row[0].to_s
+               return null
+       end
+
+       # Try to sign up a new user, return `true` on success
+       fun sign_up(user, pass: String): Bool
+       do
+               # Check if already in user
+               var stmt = select("user FROM users WHERE lower({user.to_sql_string}) = lower(user)")
+               assert stmt != null else print error or else "?"
+
+               if not stmt.iterator.to_a.is_empty then return false
+
+               # Insert intro BD
+               assert insert("INTO users(user, pass) VALUES ({user.to_sql_string}, {pass.tnitter_hash.to_sql_string})") else
+                       print error or else "?"
+               end
+
+               return true
+       end
+
+       # Tnit something
+       fun post(user, text: String)
+       do
+               assert insert("INTO posts(user, text) VALUES ({user.to_sql_string}, {text.to_sql_string})") else
+                       print error or else "?"
+               end
+       end
+
+       # Get the latest tnits
+       fun latest_posts(count: Int): Array[Post]
+       do
+               var stmt = select("user, text FROM posts ORDER BY datetime(posted) DESC LIMIT {count}")
+               assert stmt != null else print error or else "?"
+
+               var posts = new Array[Post]
+               for row in stmt do posts.add new Post(row[0].to_s, row[1].to_s)
+
+               return posts
+       end
+end
+
+# A single post (or Tnit)
+class Post
+       # The author
+       var user: String
+
+       # Tnit content
+       var text: String
+end
+
+redef class String
+       # Hash passwords for Tnitter
+       fun tnitter_hash: String do return (self+salt).md5
+end
+
+# Salt used on passwords
+#
+# Can be redefed by user modules
+fun salt: String do return "tnitter is cool"
diff --git a/contrib/tnitter/src/tnitter.nit b/contrib/tnitter/src/tnitter.nit
new file mode 100644 (file)
index 0000000..490fc07
--- /dev/null
@@ -0,0 +1,65 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Copyright 2014 Alexis Laferrière <alexis.laf@xymus.net>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Web server part of the Tnitter project
+module tnitter
+
+import privileges
+
+import model
+import action
+
+redef class OptionContext
+       var drop = new OptionUserAndGroup.for_dropping_privileges
+       var help = new OptionBool("Print this message", "--help", "-h")
+
+       init do add_option(drop, help)
+end
+
+# Avoid executing when running tests
+if "NIT_TESTING".environ == "true" then exit 0
+
+# Prepare options
+var opts = new OptionContext
+opts.parse(args)
+if not opts.errors.is_empty or opts.help.value then
+       print opts.errors
+       print "Usage: tnitter [Options]"
+       opts.usage
+       exit 1
+end
+
+# If we can, we use port 80
+var interfac
+if sys.uid == 0 then # Are we root?
+       interfac = "localhost:80"
+else interfac = "localhost:8080"
+
+# Setup server
+var vh = new VirtualHost(interfac)
+var factory = new HttpFactory.and_libevent
+factory.config.virtual_hosts.add vh
+
+# Drop to a low-privileged user
+var user_group = opts.drop.value
+if user_group != null then user_group.drop_privileges
+
+# Complete server config
+vh.routes.add new Route(null, new Tnitter)
+
+# Run
+print "Launching server on http://{interfac} ..."
+factory.run