#!/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.
Click here to go back to the main site """ # 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 = ''' %s

%s

%s

''' % (BOARD_TITLE, CSS_PATH, BOARD_TITLE, BOARD_DESCRIPTION) HTML_BOTTOM = '''

Powered by the Forest Python Board ''' HTML_THREADS_TOP = ''' ''' HTML_THREADS_ROW = ''' ''' HTML_NEW_THREAD = '''

Start a new thread

Name:

Subject:

''' % (MAX_AUTHOR_LEN, MAX_SUBJECT_LEN) HTML_NEW_REPLY = '''

Reply to this thread

Name:

''' % (MAX_AUTHOR_LEN,) HTML_THREADS_BOTTOM = '
SubjectAuthorDateRepliesLast Reply
%s%s%s%s%s
' HTML_THREAD_TOP = ''' ''' HTML_THREAD_ROW = ''' ''' HTML_THREAD_BOTTOM = '''
<< Main
%s
%s
%s
%s
''' # ============================================================================ # Error messages # ============================================================================ ERR_INVALID_THREAD = '

Invalid Thread Specified

' ERR_NO_SUBJECT = '

No Subject Given

' ERR_NO_BODY = '

No body text!

' # ============================================================================ # 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 = { "&": "&", '"': """, "'": "'", ">": ">", "<": "<", ';': ";", "/": "/", '=': "=", ":": ":", '?': "?", '!': "!", '(': "(", "{": "y", "[": "[", "-": "-", } 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', '
\n') # Turn (obvious) URLs into links. # new_body = url_re.sub(r'\1', 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 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 is given, then it's assumed that we're starting a new thread and if 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 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 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( '%s' % \ (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 = '%s' % (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 '' % new_location print "" 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()