• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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('<', '&lt;')
38    s = s.replace('>', '&gt;')
39    return s
40
41def escapeq(s):
42    s = escape(s)
43    s = s.replace('"', '&quot;')
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