PirateBoxScripts_Webserver/piratebox/piratebox/src/forest.py

553 lines
18 KiB
Python
Executable File

#!/usr/bin/python
"""
Script: Forest, a simple Python forum script.
Author: Andrew Nelis (andrew.nelis@gmail.com)
OnTheWeb: http://www.triv.org.uk/~nelis/forest
Date: Jun 2010
Version: 1.0.3
A Python CGI script for a basic flat-file based forum.
Getting Started:
* Set up your web server/place forest.py so that it is executed as a CGI script.
You'll probably have to change the python path at the top of this script and
chmod this script as appropriate if you're not on Windows.
* Put the stylesheet forest.css somewhere where it will be served by the
webserver.
* Edit some of the variables below to taste. Most important of all:
o DATA_PATH - Should point to a writable folder where the posts will be
stored.
o CSS_PATH - Specify where the stylesheet forest.css is.
(There are other settings within this file, mostly self explanatory.)
* Go to the appropriate URL and post away!
LICENCE:
Copyright (c) 2010 Andrew Nelis
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 the Software without
restriction, including without limitation the rights to 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 copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
"""
import md5
import os
import time
# Show any errors on the page. You might want to take this out on a live server
# and look in the servers error log instead.
# Removed for compatibility issues
#import cgitb
#cgitb.enable()
# ============================================================================
# Configuration
# ============================================================================
# Where the threads are stored. This folder must exist.
DATA_PATH = '/opt/piratebox/share/forumspace/'
#Where the forest CGI is located (as a URL).
CGI_URL='/cgi-bin/forest.py'
# Where the main stylesheet is kept (as a URL).
CSS_PATH = '/content/css/forest.css'
# What is the title of the board?
BOARD_TITLE = 'PirateBox Board'
# Simple Description of the board, appears at the top of each page
BOARD_DESCRIPTION = """PirateBox Board. Put media reviews or questions here.<br>
<A HREF="http://piratebox.lan">Click here to go back to the main site</a> """
# How dates are stored (see python time module for details)
DATE_FORMAT = '%d %b %Y %H:%M:%S'
# If no author name is given, then this is the default.
ANON_AUTHOR = 'Anonymous Coward'
# How many entries to show on the index?
INDEX_PAGE_SIZE = 20
# How many entries to show on the thread page?
THREAD_PAGE_SIZE = 20
# Maximum lengths for names, subjects and message bodies.
# (currently we chop them off without warning)
MAX_AUTHOR_LEN = 20
MAX_SUBJECT_LEN = 100
MAX_BODY_LEN = 10000
# ============================================================================
# HTML Elements.
# ============================================================================
HTML_TOP = '''
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>%s</title>
<link rel="stylesheet" href="%s" type="text/css" />
<script type="text/javascript">
function show(elem_id) {
elem = document.getElementById(elem_id);
if (elem) elem.style.display = 'block';
}
</script>
</head>
<body>
<h3>%s</h3>
<p class="board_description">%s</p>
''' % (BOARD_TITLE, CSS_PATH, BOARD_TITLE, BOARD_DESCRIPTION)
HTML_BOTTOM = '''
<p class="smallprint" >Powered by the <a href="http://www.triv.org.uk/~nelis/forest">Forest Python Board</a></font>
</body></html>'''
HTML_THREADS_TOP = '''<table width="95%" class="threads_table">
<tr class="threads_header">
<th width="60%">Subject</th><th>Author</th><th>Date</th><th>Replies</th><th>Last Reply</th>
</tr>
'''
HTML_THREADS_ROW = '''
<tr class="%s">
<td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td>
</tr>
'''
HTML_NEW_THREAD = '''
<p><a href="javascript:show('hidden_form');">Start a new thread</a></p>
<div id="hidden_form">
<form method="post" action="?new=thread">
<p>Name: <input name="author" maxlength="%s"/></p>
<p>Subject: <input name="subject" maxlength="%s" size="80"/></p>
<p><textarea name="body" rows="10" cols="80"></textarea></p>
<p><input class="submit_button" type="submit" value="New Thread!"/></p>
</form>
</div>
''' % (MAX_AUTHOR_LEN, MAX_SUBJECT_LEN)
HTML_NEW_REPLY = '''
<p><a href="javascript:show('hidden_form')">Reply to this thread</a></p>
<div id="hidden_form">
<form method="post" action="?new=reply&amp;thread=%%s">
<p>Name: <input name="author" maxlength="%s"/></p>
<p><textarea name="body" rows="10" cols="80"></textarea></p>
<p><input class="submit_button" type="submit" value="Reply!"/></p>
</form>
</div>
''' % (MAX_AUTHOR_LEN,)
HTML_THREADS_BOTTOM = '</table>'
HTML_THREAD_TOP = '''
<table width="95%%" class="threads_table">
<col width="15%%" />
<col width="85%%" />
<tr><td colspan="2"><a href="?">&lt;&lt; Main</a></td></tr>
<tr class="thread_header"><td colspan="2">%s</td></tr>
'''
HTML_THREAD_ROW = '''
<tr class="%s">
<td valign="top"><b>%s</b><br/><small>%s</small></td>
<td>%s</td>
</tr>
'''
HTML_THREAD_BOTTOM = '''
</table>
'''
# ============================================================================
# Error messages
# ============================================================================
ERR_INVALID_THREAD = '<h3 class="error">Invalid Thread Specified</h3>'
ERR_NO_SUBJECT = '<h3 class="error">No Subject Given</h3>'
ERR_NO_BODY = '<h3 class="error">No body text!</h3>'
# ============================================================================
# Misc. globals
# ============================================================================
# No need to fiddle with these though.
ROW_STYLES = {0: 'thread_row', 1: 'thread_row_alt'}
INDEX_FILE = os.path.join(DATA_PATH, 'index.txt')
THREAD_PATH = DATA_PATH
# ============================================================================
# Function definitions
# ============================================================================
html_escape_table = {
"&": "&amp;", '"': "&quot;", "'": "&apos;", ">": "&gt;",
"<": "&lt;", ';': "&#59;", "/": "&#47;", '=': "&#61;",
":": "&#58;", '?': "&#63;", '!': "&#33;", '(': "&#40;",
"{": "&#121;", "[": "&#91", "-": "&#45",
}
def strip_html( text ):
"""Remove HTML chars from the given text and replace them with HTML
entities. """
return "".join(html_escape_table.get(c,c) for c in text )
def process_body(body):
"""Process the message body e.g. for escaping smilies, HTML etc.
ready for storing. We should then just be able to print the body out"""
import re
# Maximum body length.
new_body = strip_html( body[:MAX_BODY_LEN] )
new_body = new_body.replace('\n', '<br/>\n')
# Turn (obvious) URLs into links.
# new_body = url_re.sub(r'<a href="\1">\1</a>', new_body)
# url_re = re.compile('(http://[\S\.]+)')
return new_body.encode('string_escape')
def process_author(author):
"""Clean the author tag"""
# Remove tabs and ensure a maximum length.
new_author = strip_html( author[:MAX_AUTHOR_LEN] )
return new_author.replace('\t', ' ')
def process_subject(subject):
"""Clean the subject line"""
if ( subject is not None):
return subject[:MAX_SUBJECT_LEN]
else:
return "No Subject"
def get_query_params():
"""Return the URL parameters as a dictionary.
Writing our own simple version means we don't have to import the cgi module
for every page (which noticeably slows down page viewing).
"""
param_string = os.getenv('QUERY_STRING', '')
params = param_string.split('&')
param_dict = {}
for param in params:
if '=' in param:
key, value = param.split('=', 1)
param_dict[key] = value
else:
param_dict[param] = None
return param_dict
def is_valid_hash(hash_string):
"""Ensure that <hash_string> is a proper hash representing an existing
thread"""
# Should be a string comprising of hex digits
if not hash_string.isalnum():
return False
if not os.path.exists(os.path.join(THREAD_PATH, hash_string)):
return False
return True
def get_offset(args):
"""Get the page offset, validating or returning 0 if None or invalid."""
offset = args.get('offset', '0')
if offset.isdigit():
return int(offset)
else:
return 0
def update_thread(author, subject=None, key=None):
"""Update the thread, creating a new thread if key is None. Returns the
key (hash).
author - String, the name of the author.
subject - String, the title of the thread.
key - String, the key to an existing thread to update.
If <subject> is given, then it's assumed that we're starting a new thread
and if <key> is given, then we should be updating an existing thread.
"""
now = time.strftime(DATE_FORMAT)
author = process_author(author)
if key:
row_hash = key
else:
row_hash = md5.new('%s%s%s' % (now, author, subject)).hexdigest()
# Read the index of threads in.
try:
threads = file(INDEX_FILE, 'r').readlines()
except IOError:
# The file gets (re)created later on so there's no problem.
threads = []
new_threads = []
# Index format:
# hash, date, num_replies, last_reply, author, subject
if not key:
# A new thread, put at the top.
new_threads.append('\t'.join(
(row_hash, now, '0', '-', author, subject)))
for thread in threads:
if thread.startswith(row_hash):
# insert the updated thread at the beginning.
# (_ ignore last reply - we're setting it to now)
_, date, num_replies, _, author, subject = \
thread.strip().split('\t')
num_replies = str(int(num_replies) + 1)
new_threads.insert(0, '\t'.join(
(row_hash, date, num_replies, now, author, subject)))
else:
new_threads.append(thread.strip())
# Overwrite the existing index with the updated index.
threads = file(INDEX_FILE, 'w')
threads.write('\n'.join(new_threads))
threads.close()
return row_hash
def new_subject(field_storage):
"""Add a new subject to the list of threads.
field_storage - cgi.FieldStorage instance.
On success:
returns <new subject hash string>
On error:
raises ValueError with error as message.
"""
author = field_storage.getfirst( 'author', ANON_AUTHOR )
subject = field_storage.getfirst( 'subject' )
body = field_storage.getfirst( 'body' )
if not subject:
raise ValueError( ERR_NO_SUBJECT )
elif not body:
raise ValueError( ERR_NO_BODY )
subject = strip_html(subject.replace('\t', ' '))
row_hash = update_thread( author, subject )
new_post( author, subject, body, row_hash )
return row_hash
def new_post(author, subject, body, key):
"""Create a new post, either by creating or appending to a post file.
author, subject, body, key - Strings
"""
author = process_author(author)
subject = process_subject(subject)
body = process_body(body)
date = time.strftime(DATE_FORMAT)
post_filename = os.path.join(THREAD_PATH, key)
if not os.path.exists(post_filename):
post_file = file(post_filename, 'w')
print >> post_file, '%s\t%s' % (key, subject)
else:
post_file = file(post_filename, 'a')
print >> post_file, '%s\t%s\t%s' % (date, author, body)
def reply(field_storage, key):
"""Reply to an existing post.
field_storage - A cgi.FieldStorage containing post data for the post
key - String, the id of the thread we're replying to.
On success:
return <thread key string>
On failure:
raise ValueError with error message as error value.
"""
# Check that the thread id is valid.
if not (key and is_valid_hash(key)):
raise ValueError( ERR_INVALID_THREAD )
author = field_storage.getfirst( 'author', ANON_AUTHOR )
body = field_storage.getfirst( 'body' )
if not body:
raise ValueError( ERR_NO_BODY )
author = author.replace('\t', ' ')
update_thread(author, key=key)
new_post(author, None, body, key)
return key
def display_paging_links( current_offset, num_items, page_length, thread=None ):
"""Display a list of links to go to a given page number"""
pages = num_items / page_length
# Any left over pages?
if (num_items % page_length):
pages += 1
if pages < 2:
# Only one page. Don't bother showing links.
return
links = []
if thread:
url = '?thread=%s&offset=%%d' % thread
else:
url = '?offset=%d'
for page_number in range(pages):
offset = page_number * page_length
if offset != current_offset:
links.append( '<a href="%s">%s</a>' % \
(url % offset, page_number + 1) )
else:
links.append( str( page_number + 1 ) )
print ' | '.join(links)
def list_threads(offset=0):
"""List the existing threads."""
if os.path.exists(INDEX_FILE):
thread_file = file(INDEX_FILE, 'r')
threads = thread_file.read().strip().split('\n')
thread_file.close()
else:
threads = []
num_threads = len(threads)
display_paging_links(offset, num_threads, INDEX_PAGE_SIZE)
print HTML_THREADS_TOP
thread_index = -1
for thread in threads[offset:offset + INDEX_PAGE_SIZE]:
thread_index += 1
thread_items = thread.split('\t')
if len(thread_items) != 6:
continue
thread_hash, date, num_replies, last_reply, author, subject = \
thread_items
link = '<a href="?thread=%s">%s</a>' % (thread_hash, subject)
# Date Author Subject Replies Last Reply
print HTML_THREADS_ROW % (ROW_STYLES[thread_index % 2], link, author,
date, num_replies, last_reply)
print HTML_THREADS_BOTTOM
print HTML_NEW_THREAD
def list_single_thread(thread_hash, offset=0):
"""Output the HTMl for a given thread id"""
if not is_valid_hash(thread_hash):
print ERR_INVALID_THREAD
return
thread_file = file(os.path.join(THREAD_PATH, thread_hash), 'r')
threads = thread_file.read().split('\n')
thread_file.close()
# The first item in the file is actually the hash and the subject. But we
# don't need it really.
_, subject = threads.pop(0).split('\t')
num_posts = len(threads)
display_paging_links(offset, num_posts, THREAD_PAGE_SIZE, thread_hash)
print HTML_THREAD_TOP % subject.strip()
row_index = -1
for line in threads[offset : offset + THREAD_PAGE_SIZE]:
row_index += 1
split_line = line.split('\t')
if len(split_line) != 3:
continue
date, author, body = split_line
print HTML_THREAD_ROW % (ROW_STYLES[row_index % 2], author, date,
body.decode('string_escape'))
print HTML_THREAD_BOTTOM
print HTML_NEW_REPLY % thread_hash
def redirect( threadid, offset=None ):
"""Redirect the browser"""
#new_location = os.environ.get('REQUEST_URI', '')
new_location = CGI_URL
new_location += '?thread=%s' % threadid
if offset:
new_location += '&offset=%s' % offset
## can't use standard redirect on CGIHTTPServer
# print 'Status: 303 See Other'
# print 'Location: %s' % new_location
# print
# print 'Nothing to see here, move along!'
print 'Content-Type: text/html; charset=utf-8'
print
print '<html><head><meta http-equiv="refresh" content="0;url=%s">' % new_location
print "</head></html>"
def handle():
"""Main entry point for our code. Handles the web request."""
query_params = get_query_params()
post_error = None
if query_params.has_key('new'):
# We only want the whole cgi module when we need to parse POST data.
import cgi
form_data = cgi.FieldStorage()
what = query_params['new']
if what == 'thread':
try:
thread_hash = new_subject(form_data)
redirect( thread_hash )
return
except ValueError, error:
post_error = str( error )
elif what == 'reply':
try:
thread_hash = reply(form_data, query_params.get('thread'))
# QQQ -> Get offset.
redirect( thread_hash )
return
except ValueError, error:
post_error = str( error )
print 'Content-Type: text/html; charset=utf-8'
print
print HTML_TOP
if post_error:
print post_error
# paging.
offset = get_offset( query_params )
if query_params.has_key( 'thread' ):
list_single_thread( query_params['thread'], offset )
else:
list_threads( offset )
print HTML_BOTTOM
if __name__ == '__main__':
handle()