1"""Generic FAQ Wizard. 2 3This is a CGI program that maintains a user-editable FAQ. It uses RCS 4to keep track of changes to individual FAQ entries. It is fully 5configurable; everything you might want to change when using this 6program to maintain some other FAQ than the Python FAQ is contained in 7the configuration module, faqconf.py. 8 9Note that this is not an executable script; it's an importable module. 10The actual script to place in cgi-bin is faqw.py. 11 12""" 13 14import sys, time, os, stat, re, cgi, faqconf 15from faqconf import * # This imports all uppercase names 16now = time.time() 17 18class FileError: 19 def __init__(self, file): 20 self.file = file 21 22class InvalidFile(FileError): 23 pass 24 25class NoSuchSection(FileError): 26 def __init__(self, section): 27 FileError.__init__(self, NEWFILENAME %(section, 1)) 28 self.section = section 29 30class NoSuchFile(FileError): 31 def __init__(self, file, why=None): 32 FileError.__init__(self, file) 33 self.why = why 34 35def escape(s): 36 s = s.replace('&', '&') 37 s = s.replace('<', '<') 38 s = s.replace('>', '>') 39 return s 40 41def escapeq(s): 42 s = escape(s) 43 s = s.replace('"', '"') 44 return s 45 46def _interpolate(format, args, kw): 47 try: 48 quote = kw['_quote'] 49 except KeyError: 50 quote = 1 51 d = (kw,) + args + (faqconf.__dict__,) 52 m = MagicDict(d, quote) 53 return format % m 54 55def interpolate(format, *args, **kw): 56 return _interpolate(format, args, kw) 57 58def emit(format, *args, **kw): 59 try: 60 f = kw['_file'] 61 except KeyError: 62 f = sys.stdout 63 f.write(_interpolate(format, args, kw)) 64 65translate_prog = None 66 67def translate(text, pre=0): 68 global translate_prog 69 if not translate_prog: 70 translate_prog = prog = re.compile( 71 r'\b(http|ftp|https)://\S+(\b|/)|\b[-.\w]+@[-.\w]+') 72 else: 73 prog = translate_prog 74 i = 0 75 list = [] 76 while 1: 77 m = prog.search(text, i) 78 if not m: 79 break 80 j = m.start() 81 list.append(escape(text[i:j])) 82 i = j 83 url = m.group(0) 84 while url[-1] in '();:,.?\'"<>': 85 url = url[:-1] 86 i = i + len(url) 87 url = escape(url) 88 if not pre or (pre and PROCESS_PREFORMAT): 89 if ':' in url: 90 repl = '<A HREF="%s">%s</A>' % (url, url) 91 else: 92 repl = '<A HREF="mailto:%s">%s</A>' % (url, url) 93 else: 94 repl = url 95 list.append(repl) 96 j = len(text) 97 list.append(escape(text[i:j])) 98 return ''.join(list) 99 100def emphasize(line): 101 return re.sub(r'\*([a-zA-Z]+)\*', r'<I>\1</I>', line) 102 103revparse_prog = None 104 105def revparse(rev): 106 global revparse_prog 107 if not revparse_prog: 108 revparse_prog = re.compile(r'^(\d{1,3})\.(\d{1,4})$') 109 m = revparse_prog.match(rev) 110 if not m: 111 return None 112 [major, minor] = map(int, m.group(1, 2)) 113 return major, minor 114 115logon = 0 116def log(text): 117 if logon: 118 logfile = open("logfile", "a") 119 logfile.write(text + "\n") 120 logfile.close() 121 122def load_cookies(): 123 if not os.environ.has_key('HTTP_COOKIE'): 124 return {} 125 raw = os.environ['HTTP_COOKIE'] 126 words = [s.strip() for s in raw.split(';')] 127 cookies = {} 128 for word in words: 129 i = word.find('=') 130 if i >= 0: 131 key, value = word[:i], word[i+1:] 132 cookies[key] = value 133 return cookies 134 135def load_my_cookie(): 136 cookies = load_cookies() 137 try: 138 value = cookies[COOKIE_NAME] 139 except KeyError: 140 return {} 141 import urllib 142 value = urllib.unquote(value) 143 words = value.split('/') 144 while len(words) < 3: 145 words.append('') 146 author = '/'.join(words[:-2]) 147 email = words[-2] 148 password = words[-1] 149 return {'author': author, 150 'email': email, 151 'password': password} 152 153def send_my_cookie(ui): 154 name = COOKIE_NAME 155 value = "%s/%s/%s" % (ui.author, ui.email, ui.password) 156 import urllib 157 value = urllib.quote(value) 158 then = now + COOKIE_LIFETIME 159 gmt = time.gmtime(then) 160 path = os.environ.get('SCRIPT_NAME', '/cgi-bin/') 161 print "Set-Cookie: %s=%s; path=%s;" % (name, value, path), 162 print time.strftime("expires=%a, %d-%b-%y %X GMT", gmt) 163 164class MagicDict: 165 166 def __init__(self, d, quote): 167 self.__d = d 168 self.__quote = quote 169 170 def __getitem__(self, key): 171 for d in self.__d: 172 try: 173 value = d[key] 174 if value: 175 value = str(value) 176 if self.__quote: 177 value = escapeq(value) 178 return value 179 except KeyError: 180 pass 181 return '' 182 183class UserInput: 184 185 def __init__(self): 186 self.__form = cgi.FieldStorage() 187 #log("\n\nbody: " + self.body) 188 189 def __getattr__(self, name): 190 if name[0] == '_': 191 raise AttributeError 192 try: 193 value = self.__form[name].value 194 except (TypeError, KeyError): 195 value = '' 196 else: 197 value = value.strip() 198 setattr(self, name, value) 199 return value 200 201 def __getitem__(self, key): 202 return getattr(self, key) 203 204class FaqEntry: 205 206 def __init__(self, fp, file, sec_num): 207 self.file = file 208 self.sec, self.num = sec_num 209 if fp: 210 import rfc822 211 self.__headers = rfc822.Message(fp) 212 self.body = fp.read().strip() 213 else: 214 self.__headers = {'title': "%d.%d. " % sec_num} 215 self.body = '' 216 217 def __getattr__(self, name): 218 if name[0] == '_': 219 raise AttributeError 220 key = '-'.join(name.split('_')) 221 try: 222 value = self.__headers[key] 223 except KeyError: 224 value = '' 225 setattr(self, name, value) 226 return value 227 228 def __getitem__(self, key): 229 return getattr(self, key) 230 231 def load_version(self): 232 command = interpolate(SH_RLOG_H, self) 233 p = os.popen(command) 234 version = '' 235 while 1: 236 line = p.readline() 237 if not line: 238 break 239 if line[:5] == 'head:': 240 version = line[5:].strip() 241 p.close() 242 self.version = version 243 244 def getmtime(self): 245 if not self.last_changed_date: 246 return 0 247 try: 248 return os.stat(self.file)[stat.ST_MTIME] 249 except os.error: 250 return 0 251 252 def emit_marks(self): 253 mtime = self.getmtime() 254 if mtime >= now - DT_VERY_RECENT: 255 emit(MARK_VERY_RECENT, self) 256 elif mtime >= now - DT_RECENT: 257 emit(MARK_RECENT, self) 258 259 def show(self, edit=1): 260 emit(ENTRY_HEADER1, self) 261 self.emit_marks() 262 emit(ENTRY_HEADER2, self) 263 pre = 0 264 raw = 0 265 for line in self.body.split('\n'): 266 # Allow the user to insert raw html into a FAQ answer 267 # (Skip Montanaro, with changes by Guido) 268 tag = line.rstrip().lower() 269 if tag == '<html>': 270 raw = 1 271 continue 272 if tag == '</html>': 273 raw = 0 274 continue 275 if raw: 276 print line 277 continue 278 if not line.strip(): 279 if pre: 280 print '</PRE>' 281 pre = 0 282 else: 283 print '<P>' 284 else: 285 if not line[0].isspace(): 286 if pre: 287 print '</PRE>' 288 pre = 0 289 else: 290 if not pre: 291 print '<PRE>' 292 pre = 1 293 if '/' in line or '@' in line: 294 line = translate(line, pre) 295 elif '<' in line or '&' in line: 296 line = escape(line) 297 if not pre and '*' in line: 298 line = emphasize(line) 299 print line 300 if pre: 301 print '</PRE>' 302 pre = 0 303 if edit: 304 print '<P>' 305 emit(ENTRY_FOOTER, self) 306 if self.last_changed_date: 307 emit(ENTRY_LOGINFO, self) 308 print '<P>' 309 310class FaqDir: 311 312 entryclass = FaqEntry 313 314 __okprog = re.compile(OKFILENAME) 315 316 def __init__(self, dir=os.curdir): 317 self.__dir = dir 318 self.__files = None 319 320 def __fill(self): 321 if self.__files is not None: 322 return 323 self.__files = files = [] 324 okprog = self.__okprog 325 for file in os.listdir(self.__dir): 326 if self.__okprog.match(file): 327 files.append(file) 328 files.sort() 329 330 def good(self, file): 331 return self.__okprog.match(file) 332 333 def parse(self, file): 334 m = self.good(file) 335 if not m: 336 return None 337 sec, num = m.group(1, 2) 338 return int(sec), int(num) 339 340 def list(self): 341 # XXX Caller shouldn't modify result 342 self.__fill() 343 return self.__files 344 345 def open(self, file): 346 sec_num = self.parse(file) 347 if not sec_num: 348 raise InvalidFile(file) 349 try: 350 fp = open(file) 351 except IOError, msg: 352 raise NoSuchFile(file, msg) 353 try: 354 return self.entryclass(fp, file, sec_num) 355 finally: 356 fp.close() 357 358 def show(self, file, edit=1): 359 self.open(file).show(edit=edit) 360 361 def new(self, section): 362 if not SECTION_TITLES.has_key(section): 363 raise NoSuchSection(section) 364 maxnum = 0 365 for file in self.list(): 366 sec, num = self.parse(file) 367 if sec == section: 368 maxnum = max(maxnum, num) 369 sec_num = (section, maxnum+1) 370 file = NEWFILENAME % sec_num 371 return self.entryclass(None, file, sec_num) 372 373class FaqWizard: 374 375 def __init__(self): 376 self.ui = UserInput() 377 self.dir = FaqDir() 378 379 def go(self): 380 print 'Content-type: text/html' 381 req = self.ui.req or 'home' 382 mname = 'do_%s' % req 383 try: 384 meth = getattr(self, mname) 385 except AttributeError: 386 self.error("Bad request type %r." % (req,)) 387 else: 388 try: 389 meth() 390 except InvalidFile, exc: 391 self.error("Invalid entry file name %s" % exc.file) 392 except NoSuchFile, exc: 393 self.error("No entry with file name %s" % exc.file) 394 except NoSuchSection, exc: 395 self.error("No section number %s" % exc.section) 396 self.epilogue() 397 398 def error(self, message, **kw): 399 self.prologue(T_ERROR) 400 emit(message, kw) 401 402 def prologue(self, title, entry=None, **kw): 403 emit(PROLOGUE, entry, kwdict=kw, title=escape(title)) 404 405 def epilogue(self): 406 emit(EPILOGUE) 407 408 def do_home(self): 409 self.prologue(T_HOME) 410 emit(HOME) 411 412 def do_debug(self): 413 self.prologue("FAQ Wizard Debugging") 414 form = cgi.FieldStorage() 415 cgi.print_form(form) 416 cgi.print_environ(os.environ) 417 cgi.print_directory() 418 cgi.print_arguments() 419 420 def do_search(self): 421 query = self.ui.query 422 if not query: 423 self.error("Empty query string!") 424 return 425 if self.ui.querytype == 'simple': 426 query = re.escape(query) 427 queries = [query] 428 elif self.ui.querytype in ('anykeywords', 'allkeywords'): 429 words = filter(None, re.split('\W+', query)) 430 if not words: 431 self.error("No keywords specified!") 432 return 433 words = map(lambda w: r'\b%s\b' % w, words) 434 if self.ui.querytype[:3] == 'any': 435 queries = ['|'.join(words)] 436 else: 437 # Each of the individual queries must match 438 queries = words 439 else: 440 # Default to regular expression 441 queries = [query] 442 self.prologue(T_SEARCH) 443 progs = [] 444 for query in queries: 445 if self.ui.casefold == 'no': 446 p = re.compile(query) 447 else: 448 p = re.compile(query, re.IGNORECASE) 449 progs.append(p) 450 hits = [] 451 for file in self.dir.list(): 452 try: 453 entry = self.dir.open(file) 454 except FileError: 455 constants 456 for p in progs: 457 if not p.search(entry.title) and not p.search(entry.body): 458 break 459 else: 460 hits.append(file) 461 if not hits: 462 emit(NO_HITS, self.ui, count=0) 463 elif len(hits) <= MAXHITS: 464 if len(hits) == 1: 465 emit(ONE_HIT, count=1) 466 else: 467 emit(FEW_HITS, count=len(hits)) 468 self.format_all(hits, headers=0) 469 else: 470 emit(MANY_HITS, count=len(hits)) 471 self.format_index(hits) 472 473 def do_all(self): 474 self.prologue(T_ALL) 475 files = self.dir.list() 476 self.last_changed(files) 477 self.format_index(files, localrefs=1) 478 self.format_all(files) 479 480 def do_compat(self): 481 files = self.dir.list() 482 emit(COMPAT) 483 self.last_changed(files) 484 self.format_index(files, localrefs=1) 485 self.format_all(files, edit=0) 486 sys.exit(0) # XXX Hack to suppress epilogue 487 488 def last_changed(self, files): 489 latest = 0 490 for file in files: 491 entry = self.dir.open(file) 492 if entry: 493 mtime = mtime = entry.getmtime() 494 if mtime > latest: 495 latest = mtime 496 print time.strftime(LAST_CHANGED, time.localtime(latest)) 497 emit(EXPLAIN_MARKS) 498 499 def format_all(self, files, edit=1, headers=1): 500 sec = 0 501 for file in files: 502 try: 503 entry = self.dir.open(file) 504 except NoSuchFile: 505 continue 506 if headers and entry.sec != sec: 507 sec = entry.sec 508 try: 509 title = SECTION_TITLES[sec] 510 except KeyError: 511 title = "Untitled" 512 emit("\n<HR>\n<H1>%(sec)s. %(title)s</H1>\n", 513 sec=sec, title=title) 514 entry.show(edit=edit) 515 516 def do_index(self): 517 self.prologue(T_INDEX) 518 files = self.dir.list() 519 self.last_changed(files) 520 self.format_index(files, add=1) 521 522 def format_index(self, files, add=0, localrefs=0): 523 sec = 0 524 for file in files: 525 try: 526 entry = self.dir.open(file) 527 except NoSuchFile: 528 continue 529 if entry.sec != sec: 530 if sec: 531 if add: 532 emit(INDEX_ADDSECTION, sec=sec) 533 emit(INDEX_ENDSECTION, sec=sec) 534 sec = entry.sec 535 try: 536 title = SECTION_TITLES[sec] 537 except KeyError: 538 title = "Untitled" 539 emit(INDEX_SECTION, sec=sec, title=title) 540 if localrefs: 541 emit(LOCAL_ENTRY, entry) 542 else: 543 emit(INDEX_ENTRY, entry) 544 entry.emit_marks() 545 if sec: 546 if add: 547 emit(INDEX_ADDSECTION, sec=sec) 548 emit(INDEX_ENDSECTION, sec=sec) 549 550 def do_recent(self): 551 if not self.ui.days: 552 days = 1 553 else: 554 days = float(self.ui.days) 555 try: 556 cutoff = now - days * 24 * 3600 557 except OverflowError: 558 cutoff = 0 559 list = [] 560 for file in self.dir.list(): 561 entry = self.dir.open(file) 562 if not entry: 563 continue 564 mtime = entry.getmtime() 565 if mtime >= cutoff: 566 list.append((mtime, file)) 567 list.sort() 568 list.reverse() 569 self.prologue(T_RECENT) 570 if days <= 1: 571 period = "%.2g hours" % (days*24) 572 else: 573 period = "%.6g days" % days 574 if not list: 575 emit(NO_RECENT, period=period) 576 elif len(list) == 1: 577 emit(ONE_RECENT, period=period) 578 else: 579 emit(SOME_RECENT, period=period, count=len(list)) 580 self.format_all(map(lambda (mtime, file): file, list), headers=0) 581 emit(TAIL_RECENT) 582 583 def do_roulette(self): 584 import random 585 files = self.dir.list() 586 if not files: 587 self.error("No entries.") 588 return 589 file = random.choice(files) 590 self.prologue(T_ROULETTE) 591 emit(ROULETTE) 592 self.dir.show(file) 593 594 def do_help(self): 595 self.prologue(T_HELP) 596 emit(HELP) 597 598 def do_show(self): 599 entry = self.dir.open(self.ui.file) 600 self.prologue(T_SHOW) 601 entry.show() 602 603 def do_add(self): 604 self.prologue(T_ADD) 605 emit(ADD_HEAD) 606 sections = SECTION_TITLES.items() 607 sections.sort() 608 for section, title in sections: 609 emit(ADD_SECTION, section=section, title=title) 610 emit(ADD_TAIL) 611 612 def do_delete(self): 613 self.prologue(T_DELETE) 614 emit(DELETE) 615 616 def do_log(self): 617 entry = self.dir.open(self.ui.file) 618 self.prologue(T_LOG, entry) 619 emit(LOG, entry) 620 self.rlog(interpolate(SH_RLOG, entry), entry) 621 622 def rlog(self, command, entry=None): 623 output = os.popen(command).read() 624 sys.stdout.write('<PRE>') 625 athead = 0 626 lines = output.split('\n') 627 while lines and not lines[-1]: 628 del lines[-1] 629 if lines: 630 line = lines[-1] 631 if line[:1] == '=' and len(line) >= 40 and \ 632 line == line[0]*len(line): 633 del lines[-1] 634 headrev = None 635 for line in lines: 636 if entry and athead and line[:9] == 'revision ': 637 rev = line[9:].split() 638 mami = revparse(rev) 639 if not mami: 640 print line 641 else: 642 emit(REVISIONLINK, entry, rev=rev, line=line) 643 if mami[1] > 1: 644 prev = "%d.%d" % (mami[0], mami[1]-1) 645 emit(DIFFLINK, entry, prev=prev, rev=rev) 646 if headrev: 647 emit(DIFFLINK, entry, prev=rev, rev=headrev) 648 else: 649 headrev = rev 650 print 651 athead = 0 652 else: 653 athead = 0 654 if line[:1] == '-' and len(line) >= 20 and \ 655 line == len(line) * line[0]: 656 athead = 1 657 sys.stdout.write('<HR>') 658 else: 659 print line 660 print '</PRE>' 661 662 def do_revision(self): 663 entry = self.dir.open(self.ui.file) 664 rev = self.ui.rev 665 mami = revparse(rev) 666 if not mami: 667 self.error("Invalid revision number: %r." % (rev,)) 668 self.prologue(T_REVISION, entry) 669 self.shell(interpolate(SH_REVISION, entry, rev=rev)) 670 671 def do_diff(self): 672 entry = self.dir.open(self.ui.file) 673 prev = self.ui.prev 674 rev = self.ui.rev 675 mami = revparse(rev) 676 if not mami: 677 self.error("Invalid revision number: %r." % (rev,)) 678 if prev: 679 if not revparse(prev): 680 self.error("Invalid previous revision number: %r." % (prev,)) 681 else: 682 prev = '%d.%d' % (mami[0], mami[1]) 683 self.prologue(T_DIFF, entry) 684 self.shell(interpolate(SH_RDIFF, entry, rev=rev, prev=prev)) 685 686 def shell(self, command): 687 output = os.popen(command).read() 688 sys.stdout.write('<PRE>') 689 print escape(output) 690 print '</PRE>' 691 692 def do_new(self): 693 entry = self.dir.new(section=int(self.ui.section)) 694 entry.version = '*new*' 695 self.prologue(T_EDIT) 696 emit(EDITHEAD) 697 emit(EDITFORM1, entry, editversion=entry.version) 698 emit(EDITFORM2, entry, load_my_cookie()) 699 emit(EDITFORM3) 700 entry.show(edit=0) 701 702 def do_edit(self): 703 entry = self.dir.open(self.ui.file) 704 entry.load_version() 705 self.prologue(T_EDIT) 706 emit(EDITHEAD) 707 emit(EDITFORM1, entry, editversion=entry.version) 708 emit(EDITFORM2, entry, load_my_cookie()) 709 emit(EDITFORM3) 710 entry.show(edit=0) 711 712 def do_review(self): 713 send_my_cookie(self.ui) 714 if self.ui.editversion == '*new*': 715 sec, num = self.dir.parse(self.ui.file) 716 entry = self.dir.new(section=sec) 717 entry.version = "*new*" 718 if entry.file != self.ui.file: 719 self.error("Commit version conflict!") 720 emit(NEWCONFLICT, self.ui, sec=sec, num=num) 721 return 722 else: 723 entry = self.dir.open(self.ui.file) 724 entry.load_version() 725 # Check that the FAQ entry number didn't change 726 if self.ui.title.split()[:1] != entry.title.split()[:1]: 727 self.error("Don't change the entry number please!") 728 return 729 # Check that the edited version is the current version 730 if entry.version != self.ui.editversion: 731 self.error("Commit version conflict!") 732 emit(VERSIONCONFLICT, entry, self.ui) 733 return 734 commit_ok = ((not PASSWORD 735 or self.ui.password == PASSWORD) 736 and self.ui.author 737 and '@' in self.ui.email 738 and self.ui.log) 739 if self.ui.commit: 740 if not commit_ok: 741 self.cantcommit() 742 else: 743 self.commit(entry) 744 return 745 self.prologue(T_REVIEW) 746 emit(REVIEWHEAD) 747 entry.body = self.ui.body 748 entry.title = self.ui.title 749 entry.show(edit=0) 750 emit(EDITFORM1, self.ui, entry) 751 if commit_ok: 752 emit(COMMIT) 753 else: 754 emit(NOCOMMIT_HEAD) 755 self.errordetail() 756 emit(NOCOMMIT_TAIL) 757 emit(EDITFORM2, self.ui, entry, load_my_cookie()) 758 emit(EDITFORM3) 759 760 def cantcommit(self): 761 self.prologue(T_CANTCOMMIT) 762 print CANTCOMMIT_HEAD 763 self.errordetail() 764 print CANTCOMMIT_TAIL 765 766 def errordetail(self): 767 if PASSWORD and self.ui.password != PASSWORD: 768 emit(NEED_PASSWD) 769 if not self.ui.log: 770 emit(NEED_LOG) 771 if not self.ui.author: 772 emit(NEED_AUTHOR) 773 if not self.ui.email: 774 emit(NEED_EMAIL) 775 776 def commit(self, entry): 777 file = entry.file 778 # Normalize line endings in body 779 if '\r' in self.ui.body: 780 self.ui.body = re.sub('\r\n?', '\n', self.ui.body) 781 # Normalize whitespace in title 782 self.ui.title = ' '.join(self.ui.title.split()) 783 # Check that there were any changes 784 if self.ui.body == entry.body and self.ui.title == entry.title: 785 self.error("You didn't make any changes!") 786 return 787 788 # need to lock here because otherwise the file exists and is not writable (on NT) 789 command = interpolate(SH_LOCK, file=file) 790 p = os.popen(command) 791 output = p.read() 792 793 try: 794 os.unlink(file) 795 except os.error: 796 pass 797 try: 798 f = open(file, 'w') 799 except IOError, why: 800 self.error(CANTWRITE, file=file, why=why) 801 return 802 date = time.ctime(now) 803 emit(FILEHEADER, self.ui, os.environ, date=date, _file=f, _quote=0) 804 f.write('\n') 805 f.write(self.ui.body) 806 f.write('\n') 807 f.close() 808 809 import tempfile 810 tf = tempfile.NamedTemporaryFile() 811 emit(LOGHEADER, self.ui, os.environ, date=date, _file=tf) 812 tf.flush() 813 tf.seek(0) 814 815 command = interpolate(SH_CHECKIN, file=file, tfn=tf.name) 816 log("\n\n" + command) 817 p = os.popen(command) 818 output = p.read() 819 sts = p.close() 820 log("output: " + output) 821 log("done: " + str(sts)) 822 log("TempFile:\n" + tf.read() + "end") 823 824 if not sts: 825 self.prologue(T_COMMITTED) 826 emit(COMMITTED) 827 else: 828 self.error(T_COMMITFAILED) 829 emit(COMMITFAILED, sts=sts) 830 print '<PRE>%s</PRE>' % escape(output) 831 832 try: 833 os.unlink(tf.name) 834 except os.error: 835 pass 836 837 entry = self.dir.open(file) 838 entry.show() 839 840wiz = FaqWizard() 841wiz.go() 842