wiki.pl 156 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080
  1. #! /usr/bin/perl
  2. # Copyright (C) 2001-2015
  3. # Alex Schroeder <alex@gnu.org>
  4. # Copyright (C) 2014-2015
  5. # Alex Jakimenko <alex.jakimenko@gmail.com>
  6. # Copyleft 2008 Brian Curry <http://www.raiazome.com>
  7. # ... including lots of patches from the UseModWiki site
  8. # Copyright (C) 2001, 2002 various authors
  9. # ... which was based on UseModWiki version 0.92 (April 21, 2001)
  10. # Copyright (C) 2000, 2001 Clifford A. Adams
  11. # <caadams@frontiernet.net> or <usemod@usemod.com>
  12. # ... which was based on the GPLed AtisWiki 0.3
  13. # Copyright (C) 1998 Markus Denker <marcus@ira.uka.de>
  14. # ... which was based on the LGPLed CVWiki CVS-patches
  15. # Copyright (C) 1997 Peter Merel
  16. # ... and The Original WikiWikiWeb
  17. # Copyright (C) 1996, 1997 Ward Cunningham <ward@c2.com>
  18. # (code reused with permission)
  19. # This program is free software: you can redistribute it and/or modify it under
  20. # the terms of the GNU General Public License as published by the Free Software
  21. # Foundation, either version 3 of the License, or (at your option) any later
  22. # version.
  23. #
  24. # This program is distributed in the hope that it will be useful, but WITHOUT
  25. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  26. # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
  27. #
  28. # You should have received a copy of the GNU General Public License along with
  29. # this program. If not, see <http://www.gnu.org/licenses/>.
  30. package OddMuse;
  31. use strict;
  32. use warnings;
  33. no warnings 'numeric';
  34. no warnings 'uninitialized';
  35. use utf8; # in case anybody ever adds UTF8 characters to the source
  36. use CGI qw/-utf8/;
  37. use CGI::Carp qw(fatalsToBrowser);
  38. use File::Glob ':glob';
  39. use Encode qw(encode_utf8 decode_utf8);
  40. use sigtrap 'handler' => \&HandleSignals, 'normal-signals', 'error-signals';
  41. local $| = 1; # Do not buffer output (localized for mod_perl)
  42. # Options:
  43. our ($ScriptName, $FullUrl, $PageDir, $TempDir, $LockDir, $KeepDir, $RssDir,
  44. $RcFile, $RcOldFile, $IndexFile, $NoEditFile, $VisitorFile, $DeleteFile, $RssLicense,
  45. $FreeLinkPattern, $LinkPattern, $FreeInterLinkPattern, $InterLinkPattern,
  46. $UrlPattern, $FullUrlPattern, $InterSitePattern,
  47. $UrlProtocols, $ImageExtensions, $LastUpdate,
  48. %LockOnCreation, %PlainTextPages, %AdminPages,
  49. @MyAdminCode, @MyFormChanges, @MyInitVariables, @MyMacros, @MyMaintenance,
  50. $DocumentHeader, %HtmlEnvironmentContainers, $FS, $Counter, @Debugging);
  51. # Internal variables:
  52. our ($q, $bol, $OpenPageName, %Page, %Translate, %IndexHash, @IndexList,
  53. @HtmlStack, @HtmlAttrStack, @Blocks, @Flags,
  54. %Includes, $FootnoteNumber, $CollectingJournal, $HeaderIsPrinted,
  55. %Locks, $Fragment, $Today, $ModulesDescription, %RssInterwikiTranslate,
  56. $Message, $Now, %RecentVisitors, %MyInc, $WikiDescription, %InterSite, %OldCookie);
  57. # Can be set outside the script: $DataDir, $UseConfig, $ConfigFile, $ModuleDir,
  58. # $ConfigPage, $AdminPass, $EditPass, $ScriptName, $FullUrl, $RunCGI.
  59. # 1 = load config file in the data directory
  60. our $UseConfig //= 1;
  61. # Main wiki directory
  62. our $DataDir;
  63. $DataDir ||= decode_utf8($ENV{WikiDataDir}) if $UseConfig;
  64. $DataDir ||= '/tmp/oddmuse'; # FIXME: /var/opt/oddmuse/wiki ?
  65. our $ConfigFile;
  66. $ConfigFile ||= $ENV{WikiConfigFile} if $UseConfig;
  67. our $ModuleDir;
  68. $ModuleDir ||= $ENV{WikiModuleDir} if $UseConfig;
  69. our $ConfigPage ||= '';
  70. # 1 = Run script as CGI instead of loading as module
  71. our $RunCGI //= 1;
  72. # 1 = allow page views using wiki.pl/PageName
  73. our $UsePathInfo = 1;
  74. # -1 = disabled, 0 = 10s; 1 = partial HTML cache; 2 = HTTP/1.1 caching
  75. our $UseCache = 2;
  76. our $SiteName = 'Wiki'; # Name of site (used for titles)
  77. our $HomePage = 'HomePage'; # Home page
  78. our $CookieName = 'Wiki'; # Name for this wiki (for multi-wiki sites)
  79. our $MaxPost = 1024 * 210; # Maximum 210K posts (about 200K for pages)
  80. our $StyleSheet = ''; # URL for CSS stylesheet (like '/wiki.css')
  81. our $StyleSheetPage = ''; # Page for CSS sheet
  82. our $LogoUrl = ''; # URL for site logo ('' for no logo)
  83. our $NotFoundPg = ''; # Page for not-found links ('' for blank pg)
  84. our $EditAllowed = 1; # 0 = no, 1 = yes, 2 = comments pages only, 3 = comments only
  85. our $AdminPass //= ''; # Whitespace separated passwords.
  86. our $EditPass //= ''; # Whitespace separated passwords.
  87. our $PassHashFunction //= ''; # Name of the function to create hashes
  88. our $PassSalt //= ''; # Salt will be added to any password before hashing
  89. our $BannedHosts = 'BannedHosts'; # Page for banned hosts
  90. our $BannedCanRead = 1; # 1 = banned cannot edit, 0 = banned cannot read
  91. our $BannedContent = 'BannedContent'; # Page for banned content (usually for link-ban)
  92. our $WikiLinks = ''; # 1 = LinkPattern is a link
  93. our $FreeLinks = 1; # 1 = [[some text]] is a link
  94. our $UseQuestionmark = 1; # 1 = append questionmark to links to nonexisting pages
  95. our $BracketText = 1; # 1 = [URL desc] uses a description for the URL
  96. our $BracketWiki = 1; # 1 = [WikiLink desc] uses a desc for the local link
  97. our $NetworkFile = 1; # 1 = file: is a valid protocol for URLs
  98. our $AllNetworkFiles = 0; # 1 = file:///foo is allowed -- the default allows only file://foo
  99. our $InterMap = 'InterMap'; # name of the intermap page, '' = disable
  100. our $RssInterwikiTranslate = 'RssInterwikiTranslate'; # name of RSS interwiki translation page, '' = disable
  101. $ENV{PATH} = '/bin:/usr/bin'; # Path used to find 'diff' and 'grep'
  102. our $UseDiff = 1; # 1 = use diff
  103. our $SurgeProtection = 1; # 1 = protect against leeches
  104. our $SurgeProtectionTime = 20; # Size of the protected window in seconds
  105. our $SurgeProtectionViews = 20; # How many page views to allow in this window
  106. our $DeletedPage = 'DeletedPage'; # Pages starting with this can be deleted
  107. our $RCName = 'RecentChanges'; # Name of changes page
  108. our @RcDays = qw(1 3 7 30 90); # Days for links on RecentChanges
  109. our $RcDefault = 30; # Default number of RecentChanges days
  110. our $KeepDays = 0; # Days to keep old revisions (0 means keep forever)
  111. our $KeepMajor = 1; # 1 = keep at least one major rev when expiring pages
  112. our $SummaryHours = 4; # Hours to offer the old subject when editing a page
  113. our $SummaryDefaultLength = 150; # Length of default text for summary (0 to disable)
  114. our $ShowEdits = 0; # 1 = major and show minor edits in recent changes
  115. our $ShowAll = 0; # 1 = show multiple edits per page in recent changes
  116. our $ShowRollbacks = 0; # 1 = show rollbacks in recent changes
  117. our $RecentLink = 1; # 1 = link to usernames
  118. our $PageCluster = ''; # name of cluster page, eg. 'Cluster' to enable
  119. our $InterWikiMoniker = ''; # InterWiki prefix for this wiki for RSS
  120. our $SiteDescription = ''; # RSS Description of this wiki
  121. our $RssStrip = '^\d\d\d\d-\d\d-\d\d_'; # Regexp to strip from feed item titles
  122. our $RssImageUrl = $LogoUrl; # URL to image to associate with your RSS feed
  123. our $RssRights = ''; # Copyright notice for RSS, usually an URL to the appropriate text
  124. our $RssExclude = 'RssExclude'; # name of the page that lists pages to be excluded from the feed
  125. our $RssCacheHours = 1; # How many hours to cache remote RSS files
  126. our $RssStyleSheet = ''; # External style sheet for RSS files
  127. our $UploadAllowed = 0; # 1 = yes, 0 = administrators only
  128. our @UploadTypes = ('image/jpeg', 'image/png'); # MIME types allowed, all allowed if empty list
  129. our $EmbedWiki = 0; # 1 = no headers/footers
  130. our $FooterNote = ''; # HTML for bottom of every page
  131. our $EditNote = ''; # HTML notice above buttons on edit page
  132. our $TopLinkBar = 1; # 0 = goto bar both at the top and bottom; 1 = top, 2 = bottom
  133. our $TopSearchForm = 1; # 0 = search form both at the top and bottom; 1 = top, 2 = bottom
  134. our $MatchingPages = 0; # 1 = search page content and page titles
  135. our @UserGotoBarPages = (); # List of pagenames
  136. our $UserGotoBar = ''; # HTML added to end of goto bar
  137. our $CommentsPrefix = ''; # prefix for comment pages, eg. 'Comments_on_' to enable
  138. our $CommentsPattern = undef; # regex used to match comment pages
  139. our $HtmlHeaders = ''; # Additional stuff to put in the HTML <head> section
  140. our $IndentLimit = 20; # Maximum depth of nested lists
  141. our $LanguageLimit = 3; # Number of matches req. for each language
  142. our $JournalLimit = 200; # how many pages can be collected in one go?
  143. our $PageNameLimit = 120; # max length of page name in bytes
  144. $DocumentHeader = qq(<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN")
  145. . qq( "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\n)
  146. . qq(<html xmlns="http://www.w3.org/1999/xhtml">);
  147. our @MyFooters = (\&GetCommentForm, \&WrapperEnd, \&DefaultFooter);
  148. # Checkboxes at the end of the index.
  149. our @IndexOptions = ();
  150. # Display short comments below the GotoBar for special days
  151. # Example: %SpecialDays = ('1-1' => 'New Year', '1-2' => 'Next Day');
  152. our %SpecialDays = ();
  153. # Replace regular expressions with inlined images
  154. # Example: %Smilies = (":-?D(?=\\W)" => '/pics/grin.png');
  155. our %Smilies = ();
  156. # Detect page languages when saving edits
  157. # Example: %Languages = ('de' => '\b(der|die|das|und|oder)\b');
  158. our %Languages = ();
  159. our @KnownLocks = qw(main diff index merge visitors); # locks to remove
  160. our $LockExpiration = 60; # How long before expirable locks are expired
  161. our %LockExpires = (diff=>1, index=>1, merge=>1, visitors=>1); # locks to expire after some time
  162. our %LockCleaners = (); # What to do if a job under a lock gets a signal like SIGINT. e.g. 'diff' => \&CleanDiff
  163. our %CookieParameters = (username=>'', pwd=>'', homepage=>'', theme=>'', css=>'', msg=>'', lang=>'', embed=>$EmbedWiki,
  164. toplinkbar=>$TopLinkBar, topsearchform=>$TopSearchForm, matchingpages=>$MatchingPages, );
  165. our %Action = (rc => \&BrowseRc, rollback => \&DoRollback,
  166. browse => \&BrowseResolvedPage, maintain => \&DoMaintain,
  167. random => \&DoRandom, pagelock => \&DoPageLock,
  168. history => \&DoHistory, editlock => \&DoEditLock,
  169. edit => \&DoEdit, version => \&DoShowVersion,
  170. download => \&DoDownload, rss => \&DoRss,
  171. unlock => \&DoUnlock, password => \&DoPassword,
  172. index => \&DoIndex, admin => \&DoAdminPage,
  173. clear => \&DoClearCache, debug => \&DoDebug,
  174. contrib => \&DoContributors, more => \&DoJournal);
  175. our @MyRules = (\&LinkRules, \&ListRule); # don't set this variable, add to it!
  176. our %RuleOrder = (\&LinkRules => 0, \&ListRule => 0);
  177. # The 'main' program, called at the end of this script file (aka. as handler)
  178. sub DoWikiRequest {
  179. Init();
  180. DoSurgeProtection();
  181. if (not $BannedCanRead and UserIsBanned() and not UserIsEditor()) {
  182. ReportError(T('Reading not allowed: user, ip, or network is blocked.'), '403 FORBIDDEN',
  183. 0, $q->p(ScriptLink('action=password', T('Login'), 'password')));
  184. }
  185. DoBrowseRequest();
  186. }
  187. sub ReportError { # fatal!
  188. my ($errmsg, $status, $log, @html) = @_;
  189. InitRequest(); # make sure we can report errors before InitRequest
  190. print GetHttpHeader('text/html', 'nocache', $status), GetHtmlHeader(T('Error')),
  191. $q->start_div({class=>"error"}), $q->h1(QuoteHtml($errmsg)), @html, $q->end_div,
  192. $q->end_html, "\n\n"; # newlines for FCGI because of exit()
  193. WriteStringToFile("$TempDir/error", '<body>' . $q->h1("$status $errmsg") . $q->Dump) if $log;
  194. map { ReleaseLockDir($_); } keys %Locks;
  195. exit 2;
  196. }
  197. sub Init {
  198. binmode(STDOUT, ':encoding(UTF-8)'); # this is where the HTML gets printed
  199. binmode(STDERR, ':encoding(UTF-8)'); # just in case somebody prints debug info to stderr
  200. InitDirConfig();
  201. $FS = "\x1e"; # The FS character is the RECORD SEPARATOR control char in ASCII
  202. $Message = ''; # Warnings and non-fatal errors.
  203. InitLinkPatterns(); # Link pattern can be changed in config files
  204. InitModules(); # Modules come first so that users can change module variables in config
  205. InitConfig(); # Config comes as early as possible; remember $q is not available here
  206. InitRequest(); # get $q with $MaxPost; set these in the config file
  207. InitCookie(); # After InitRequest, because $q is used
  208. InitVariables(); # After config, to change variables, after InitCookie for GetParam
  209. }
  210. sub InitModules {
  211. if ($UseConfig and $ModuleDir and IsDir($ModuleDir)) {
  212. foreach my $lib (Glob("$ModuleDir/*.p[ml]")) {
  213. if (not $MyInc{$lib}) {
  214. $MyInc{$lib} = 1; # Cannot use %INC in mod_perl settings
  215. my $file = encode_utf8($lib);
  216. do $file;
  217. $Message .= CGI::p("$lib: $@") if $@; # no $q exists, yet
  218. }
  219. }
  220. }
  221. }
  222. sub InitConfig {
  223. if ($UseConfig and $ConfigFile and not $INC{$ConfigFile} and IsFile($ConfigFile)) {
  224. do $ConfigFile; # these options must be set in a wrapper script or via the environment
  225. $Message .= CGI::p("$ConfigFile: $@") if $@; # remember, no $q exists, yet
  226. }
  227. if ($ConfigPage) { # $FS and $MaxPost must be set in config file!
  228. my ($status, $data) = ReadFile(GetPageFile(FreeToNormal($ConfigPage)));
  229. my $page = ParseData($data); # before InitVariables so GetPageContent won't work
  230. eval $page->{text} if $page->{text}; # perlcritic dislikes the use of eval here but we really mean it
  231. $Message .= CGI::p("$ConfigPage: $@") if $@;
  232. }
  233. }
  234. sub InitDirConfig {
  235. $PageDir = "$DataDir/page"; # Stores page data
  236. $KeepDir = "$DataDir/keep"; # Stores kept (old) page data
  237. $TempDir = "$DataDir/temp"; # Temporary files and locks
  238. $LockDir = "$TempDir/lock"; # DB is locked if this exists
  239. $NoEditFile = "$DataDir/noedit"; # Indicates that the site is read-only
  240. $RcFile = "$DataDir/rc.log"; # New RecentChanges logfile
  241. $RcOldFile = "$DataDir/oldrc.log"; # Old RecentChanges logfile
  242. $IndexFile = "$DataDir/pageidx"; # List of all pages
  243. $VisitorFile = "$DataDir/visitors.log"; # List of recent visitors
  244. $DeleteFile = "$DataDir/delete.log"; # Deletion logfile
  245. $RssDir = "$DataDir/rss"; # For rss feed cache
  246. $ConfigFile ||= "$DataDir/config"; # Config file with Perl code to execute
  247. $ModuleDir ||= "$DataDir/modules"; # For extensions (ending in .pm or .pl)
  248. }
  249. sub InitRequest { # set up $q
  250. $CGI::POST_MAX = $MaxPost;
  251. $q ||= new CGI;
  252. }
  253. sub InitVariables { # Init global session variables for mod_perl!
  254. $WikiDescription = $q->p($q->a({-href=>'http://www.oddmuse.org/'}, 'Oddmuse'),
  255. $Counter++ > 0 ? Ts('%s calls', $Counter) : '');
  256. $WikiDescription .= $ModulesDescription if $ModulesDescription;
  257. $HeaderIsPrinted = 0; # print HTTP headers only once
  258. $ScriptName //= $q->url(); # URL used in links
  259. $FullUrl ||= $ScriptName; # URL used in forms
  260. %Locks = ();
  261. @Blocks = ();
  262. @Flags = ();
  263. $Fragment = '';
  264. %RecentVisitors = ();
  265. $OpenPageName = ''; # Currently open page
  266. my $add_space = $CommentsPrefix =~ /[ \t_]$/;
  267. $$_ = FreeToNormal($$_) for # convert spaces to underscores on all configurable pagenames
  268. (\$HomePage, \$RCName, \$BannedHosts, \$InterMap, \$StyleSheetPage, \$CommentsPrefix,
  269. \$ConfigPage, \$NotFoundPg, \$RssInterwikiTranslate, \$BannedContent, \$RssExclude, );
  270. $CommentsPrefix .= '_' if $add_space;
  271. $CommentsPattern = "^$CommentsPrefix(.*)" unless defined $CommentsPattern or not $CommentsPrefix;
  272. @UserGotoBarPages = ($HomePage, $RCName) unless @UserGotoBarPages;
  273. my @pages = sort($BannedHosts, $StyleSheetPage, $ConfigPage, $InterMap,
  274. $RssInterwikiTranslate, $BannedContent);
  275. %AdminPages = map { $_ => 1} @pages, $RssExclude unless %AdminPages;
  276. %LockOnCreation = map { $_ => 1} @pages unless %LockOnCreation;
  277. %PlainTextPages = ($BannedHosts => 1, $BannedContent => 1,
  278. $StyleSheetPage => 1, $ConfigPage => 1) unless %PlainTextPages;
  279. delete $PlainTextPages{''}; # $ConfigPage and others might be empty.
  280. CreateDir($DataDir); # Create directory if it doesn't exist
  281. $Now = time; # Reset in case script is persistent
  282. my $ts = Modified($IndexFile); # always stat for multiple server processes
  283. ReInit() if not $ts or $LastUpdate != $ts; # reinit if another process changed files (requires $DataDir)
  284. $LastUpdate = $ts;
  285. unshift(@MyRules, \&MyRules) if defined(&MyRules) && (not @MyRules or $MyRules[0] != \&MyRules);
  286. @MyRules = sort {$RuleOrder{$a} <=> $RuleOrder{$b}} @MyRules; # default is 0
  287. ReportError(Ts('Cannot create %s', $DataDir) . ": $!", '500 INTERNAL SERVER ERROR') unless IsDir($DataDir);
  288. @IndexOptions = (['pages', T('Include normal pages'), 1, \&AllPagesList]);
  289. foreach my $sub (@MyInitVariables) {
  290. my $result = $sub->();
  291. $Message .= $q->p($@) if $@;
  292. }
  293. }
  294. sub ReInit { # init everything we need if we want to link to stuff
  295. my $id = shift; # when saving a page, what to do depends on the page being saved
  296. AllPagesList() unless $id;
  297. InterInit() if $InterMap and (not $id or $id eq $InterMap);
  298. %RssInterwikiTranslate = () if not $id or $id eq $RssInterwikiTranslate; # special since rarely used
  299. }
  300. sub InitCookie {
  301. undef $q->{'.cookies'}; # Clear cache if it exists (for SpeedyCGI)
  302. my $cookie = $q->cookie($CookieName);
  303. %OldCookie = split(/$FS/, UrlDecode($cookie));
  304. my %provided = map { $_ => 1 } $q->param;
  305. for my $key (keys %OldCookie) {
  306. SetParam($key, $OldCookie{$key}) unless $provided{$key};
  307. }
  308. CookieUsernameFix();
  309. CookieRollbackFix();
  310. }
  311. sub CookieUsernameFix {
  312. # Only valid usernames get stored in the new cookie.
  313. my $name = GetParam('username', '');
  314. $q->delete('username');
  315. if (not $name) {
  316. # do nothing
  317. } elsif ($WikiLinks and not $FreeLinks and $name !~ /^$LinkPattern$/) {
  318. $Message .= $q->p(Ts('Invalid UserName %s: not saved.', $name));
  319. } elsif ($FreeLinks and $name !~ /^$FreeLinkPattern$/) {
  320. $Message .= $q->p(Ts('Invalid UserName %s: not saved.', $name));
  321. } elsif (length($name) > 50) { # Too long
  322. $Message .= $q->p(T('UserName must be 50 characters or less: not saved'));
  323. } else {
  324. SetParam('username', $name);
  325. }
  326. }
  327. sub CookieRollbackFix {
  328. my @rollback = grep(/rollback-(\d+)/, $q->param);
  329. if (@rollback and $rollback[0] =~ /(\d+)/) {
  330. SetParam('to', $1);
  331. $q->delete('action');
  332. SetParam('action', 'rollback');
  333. }
  334. }
  335. sub GetParam {
  336. my ($name, $default) = @_;
  337. my $result = $q->param(encode_utf8($name));
  338. $result //= $default;
  339. return QuoteHtml($result); # you need to unquote anything that can have <tags>
  340. }
  341. sub SetParam {
  342. my ($name, $val) = @_;
  343. $q->param($name, $val);
  344. }
  345. sub InitLinkPatterns {
  346. my ($WikiWord, $QDelim);
  347. $QDelim = '(?:"")?'; # Optional quote delimiter (removed from the output)
  348. $WikiWord = '\p{Uppercase}+\p{Lowercase}+\p{Uppercase}\p{Alphabetic}*';
  349. $LinkPattern = "($WikiWord)$QDelim";
  350. $FreeLinkPattern = "([-,.()'%&?;<> _1-9A-Za-z\x{0080}-\x{fffd}]|[-,.()'%&?;<> _0-9A-Za-z\x{0080}-\x{fffd}][-,.()'%&?;<> _0-9A-Za-z\x{0080}-\x{fffd}]+)"; # disallow "0" and must match HTML and plain text (ie. > and &gt;)
  351. # Intersites must start with uppercase letter to avoid confusion with URLs.
  352. $InterSitePattern = '[A-Z\x{0080}-\x{fffd}]+[A-Za-z\x{0080}-\x{fffd}]+';
  353. $InterLinkPattern = "($InterSitePattern:[-a-zA-Z0-9\x{0080}-\x{fffd}_=!?#\$\@~`\%&*+\\/:;.,]*[-a-zA-Z0-9\x{0080}-\x{fffd}_=#\$\@~`\%&*+\\/])$QDelim";
  354. $FreeInterLinkPattern = "($InterSitePattern:[-a-zA-Z0-9\x{0080}-\x{fffd}_=!?#\$\@~`\%&*+\\/:;.,()' ]+)"; # plus space and other characters, and no restrictions on the end of the pattern
  355. $UrlProtocols = 'http|https|ftp|afs|news|nntp|mid|cid|mailto|wais|prospero|telnet|gopher|irc|feed';
  356. $UrlProtocols .= '|file' if $NetworkFile;
  357. my $UrlChars = '[-a-zA-Z0-9/@=+$_~*.,;:?!\'"()&#%]'; # see RFC 2396
  358. my $EndChars = '[-a-zA-Z0-9/@=+$_~*]'; # no punctuation at the end of the url.
  359. $UrlPattern = "((?:$UrlProtocols):$UrlChars+$EndChars)";
  360. $FullUrlPattern="((?:$UrlProtocols):$UrlChars+)"; # when used in square brackets
  361. $ImageExtensions = '(gif|jpg|jpeg|png|bmp|svg)';
  362. }
  363. sub Clean {
  364. my $block = shift;
  365. return 0 unless defined($block); # "0" must print
  366. return 1 if $block eq ''; # '' is the result of a dirty rule
  367. $Fragment .= $block;
  368. return 1;
  369. }
  370. sub Dirty { # arg 1 is the raw text; the real output must be printed instead
  371. if ($Fragment ne '') {
  372. $Fragment =~ s|<p>\s*</p>||g; # clean up extra paragraphs (see end of ApplyRules)
  373. print $Fragment;
  374. push(@Blocks, $Fragment);
  375. push(@Flags, 0);
  376. }
  377. push(@Blocks, shift);
  378. push(@Flags, 1);
  379. $Fragment = '';
  380. }
  381. sub ApplyRules {
  382. # locallinks: apply rules that create links depending on local config (incl. interlink!)
  383. my ($text, $locallinks, $withanchors, $revision, @tags) = @_; # $revision is used for images
  384. $text =~ s/\r\n/\n/g; # DOS to Unix
  385. $text =~ s/\n+$//g; # No trailing paragraphs
  386. return if $text eq ''; # allow the text '0'
  387. local $Fragment = ''; # the clean HTML fragment not yet on @Blocks
  388. local @Blocks = (); # the list of cached HTML blocks
  389. local @Flags = (); # a list for each block, 1 = dirty, 0 = clean
  390. Clean(join('', map { AddHtmlEnvironment($_) } @tags));
  391. if ($OpenPageName and $PlainTextPages{$OpenPageName}) { # there should be no $PlainTextPages{''}
  392. Clean(CloseHtmlEnvironments() . $q->pre($text));
  393. } elsif (my ($type) = TextIsFile($text)) { # TODO? $type defined here??
  394. Clean(CloseHtmlEnvironments() . $q->p(T('This page contains an uploaded file:'))
  395. . $q->p(GetDownloadLink($OpenPageName, (substr($type, 0, 6) eq 'image/'), $revision))
  396. . (length $Page{summary} > 0 ? $q->blockquote(QuoteHtml($Page{summary})) : $q->p(T('No summary was provided for this file.'))));
  397. } else {
  398. my $smileyregex = join "|", keys %Smilies;
  399. $smileyregex = qr/(?=$smileyregex)/;
  400. local $_ = $text;
  401. local $bol = 1;
  402. while (1) {
  403. # Block level elements should eat trailing empty lines to prevent empty p elements.
  404. if ($bol and m/\G(\s*\n)+/cg) {
  405. Clean(CloseHtmlEnvironments() . AddHtmlEnvironment('p'));
  406. } elsif ($bol and m/\G(\&lt;include(\s+(text|with-anchors))?\s+"(.*)"\&gt;[ \t]*\n?)/cgi) {
  407. # <include "uri..."> includes the text of the given URI verbatim
  408. Clean(CloseHtmlEnvironments());
  409. Dirty($1);
  410. my ($oldpos, $old_, $type, $uri) = ((pos), $_, $3, UnquoteHtml($4)); # remember, page content is quoted!
  411. if ($uri =~ /^($UrlProtocols):/) {
  412. if ($type eq 'text') {
  413. print $q->pre({class=>"include $uri"}, QuoteHtml(GetRaw($uri)));
  414. } else { # never use local links for remote pages, with a starting tag
  415. print $q->start_div({class=>"include"});
  416. ApplyRules(QuoteHtml(GetRaw($uri)), 0, ($type eq 'with-anchors'), undef, 'p');
  417. print $q->end_div();
  418. }
  419. } else {
  420. $Includes{$OpenPageName} = 1;
  421. local $OpenPageName = FreeToNormal($uri);
  422. if ($type eq 'text') {
  423. print $q->pre({class=>"include $OpenPageName"}, QuoteHtml(GetPageContent($OpenPageName)));
  424. } elsif (not $Includes{$OpenPageName}) { # with a starting tag, watch out for recursion
  425. print $q->start_div({class=>"include $OpenPageName"});
  426. ApplyRules(QuoteHtml(GetPageContent($OpenPageName)), $locallinks, $withanchors, undef, 'p');
  427. print $q->end_div();
  428. delete $Includes{$OpenPageName};
  429. } else {
  430. print $q->p({-class=>'error'}, $q->strong(Ts('Recursive include of %s!', $OpenPageName)));
  431. }
  432. }
  433. Clean(AddHtmlEnvironment('p')); # if dirty block is looked at later, this will disappear
  434. ($_, pos) = ($old_, $oldpos); # restore \G (assignment order matters!)
  435. } elsif ($bol and m/\G(\&lt;(journal|titles):?(\d*)((\s+|:)(\d*),?(\d*))?(\s+"(.*?)")?(\s+(reverse|past|future))?(\s+search\s+(.*))?\&gt;[ \t]*\n?)/cgi) {
  436. # <journal 10 "regexp"> includes 10 pages matching regexp
  437. Clean(CloseHtmlEnvironments());
  438. Dirty($1);
  439. my ($oldpos, $old_) = (pos, $_); # remember these because of the call to PrintJournal()
  440. PrintJournal($6, $7, $9, $11, $3, $13, $2);
  441. Clean(AddHtmlEnvironment('p')); # if dirty block is looked at later, this will disappear
  442. ($_, pos) = ($old_, $oldpos); # restore \G (assignment order matters!)
  443. } elsif ($bol and m/\G(\&lt;rss(\s+(\d*))?\s+(.*?)\&gt;[ \t]*\n?)/cgis) {
  444. # <rss "uri..."> stores the parsed RSS of the given URI
  445. Clean(CloseHtmlEnvironments());
  446. Dirty($1);
  447. my ($oldpos, $old_) = (pos, $_); # remember these because of the call to RSS()
  448. print RSS($3 || 15, split(/\s+/, UnquoteHtml($4)));
  449. Clean(AddHtmlEnvironment('p')); # if dirty block is looked at later, this will disappear
  450. ($_, pos) = ($old_, $oldpos); # restore \G (assignment order matters!)
  451. } elsif (/\G(&lt;search (.*?)&gt;)/cgis) {
  452. # <search regexp>
  453. Clean(CloseHtmlEnvironments());
  454. Dirty($1);
  455. my ($oldpos, $old_) = (pos, $_);
  456. print $q->start_div({-class=>'search'});
  457. SearchTitleAndBody($2, \&PrintSearchResult, SearchRegexp($2));
  458. print $q->end_div;
  459. Clean(AddHtmlEnvironment('p')); # if dirty block is looked at later, this will disappear
  460. ($_, pos) = ($old_, $oldpos); # restore \G (assignment order matters!)
  461. } elsif ($bol and m/\G(&lt;&lt;&lt;&lt;&lt;&lt;&lt; )/cg) {
  462. my ($str, $count, $limit, $oldpos) = ($1, 0, 100, pos);
  463. while (m/\G(.*\n)/cg and $count++ < $limit) {
  464. $str .= $1;
  465. last if (substr($1, 0, 29) eq '&gt;&gt;&gt;&gt;&gt;&gt;&gt; ');
  466. }
  467. if ($count >= $limit) {
  468. pos = $oldpos; # reset because we did not find a match
  469. Clean('&lt;&lt;&lt;&lt;&lt;&lt;&lt; ');
  470. } else {
  471. Clean(CloseHtmlEnvironments() . $q->pre({-class=>'conflict'}, $str) . AddHtmlEnvironment('p'));
  472. }
  473. } elsif ($bol and m/\G#REDIRECT/cg) {
  474. Clean('#REDIRECT');
  475. } elsif (%Smilies and m/\G$smileyregex/cg and Clean(SmileyReplace())) {
  476. } elsif (Clean(RunMyRules($locallinks, $withanchors))) {
  477. } elsif (m/\G\s*\n(\s*\n)+/cg) { # paragraphs: at least two newlines
  478. Clean(CloseHtmlEnvironments() . AddHtmlEnvironment('p')); # another one like this further up
  479. } elsif (m/\G&amp;([A-Za-z]+|#[0-9]+|#x[A-Za-f0-9]+);/cg) { # entity references
  480. Clean("&$1;");
  481. } elsif (m/\G\s+/cg) {
  482. Clean(' ');
  483. } elsif (m/\G([A-Za-z\x{0080}-\x{fffd}]+([ \t]+[a-z\x{0080}-\x{fffd}]+)*[ \t]+)/cg
  484. or m/\G([A-Za-z\x{0080}-\x{fffd}]+)/cg or m/\G(\S)/cg) {
  485. Clean($1); # multiple words but do not match http://foo
  486. } else {
  487. last;
  488. }
  489. $bol = (substr($_, pos() - 1, 1) eq "\n");
  490. }
  491. }
  492. pos = length $_; # notify module functions we've completed rule handling
  493. Clean(CloseHtmlEnvironments()); # last block -- close it, cache it
  494. if ($Fragment ne '') {
  495. $Fragment =~ s|<p>\s*</p>||g; # clean up extra paragraphs (see end Dirty())
  496. print $Fragment;
  497. push(@Blocks, $Fragment);
  498. push(@Flags, 0);
  499. }
  500. # this can be stored in the page cache -- see PrintCache
  501. return (join($FS, @Blocks), join($FS, @Flags));
  502. }
  503. sub ListRule {
  504. if ($bol && m/\G(\s*\n)*(\*+)[ \t]+/cg
  505. or InElement('li') && m/\G(\s*\n)+(\*+)[ \t]+/cg) {
  506. return CloseHtmlEnvironmentUntil('li')
  507. . OpenHtmlEnvironment('ul', length($2)) . AddHtmlEnvironment('li');
  508. }
  509. return;
  510. }
  511. sub LinkRules {
  512. my ($locallinks, $withanchors) = @_;
  513. if ($locallinks
  514. and ($BracketText && m/\G(\[$InterLinkPattern\s+([^\]]+?)\])/cg
  515. or $BracketText && m/\G(\[\[$FreeInterLinkPattern\|([^\]]+?)\]\])/cg
  516. or m/\G(\[$InterLinkPattern\])/cg or m/\G(\[\[\[$FreeInterLinkPattern\]\]\])/cg
  517. or m/\G($InterLinkPattern)/cg or m/\G(\[\[$FreeInterLinkPattern\]\])/cg)) {
  518. # [InterWiki:FooBar text] or [InterWiki:FooBar] or
  519. # InterWiki:FooBar or [[InterWiki:foo bar|text]] or
  520. # [[InterWiki:foo bar]] or [[[InterWiki:foo bar]]]-- Interlinks
  521. # can change when the intermap changes (local config, therefore
  522. # depend on $locallinks). The intermap is only read if
  523. # necessary, so if this not an interlink, we have to backtrack a
  524. # bit.
  525. my $bracket = (substr($1, 0, 1) eq '[') # but \[\[$FreeInterLinkPattern\]\] it not bracket!
  526. && !((substr($1, 0, 2) eq '[[') && (substr($1, 2, 1) ne '[') && index($1, '|') < 0);
  527. my $quote = (substr($1, 0, 2) eq '[[');
  528. my ($oldmatch, $output) = ($1, GetInterLink($2, $3, $bracket, $quote)); # $3 may be empty
  529. if ($oldmatch eq $output) { # no interlink
  530. my ($site, $rest) = split(/:/, $oldmatch, 2);
  531. Clean($site);
  532. pos = (pos) - length($rest) - 1; # skip site, but reparse rest
  533. } else {
  534. Dirty($oldmatch);
  535. print $output; # this is an interlink
  536. }
  537. } elsif ($BracketText && m/\G(\[$FullUrlPattern[|[:space:]]([^\]]+?)\])/cg
  538. or $BracketText && m/\G(\[\[$FullUrlPattern[|[:space:]]([^\]]+?)\]\])/cg
  539. or m/\G(\[$FullUrlPattern\])/cg or m/\G($UrlPattern)/cg) {
  540. # [URL text] makes [text] link to URL, [URL] makes footnotes [1]
  541. my ($str, $url, $text, $bracket, $rest) = ($1, $2, $3, (substr($1, 0, 1) eq '['), '');
  542. if ($url =~ /(&lt|&gt|&amp)$/) { # remove trailing partial named entitites and add them as
  543. $rest = $1; # back again at the end as trailing text.
  544. $url =~ s/&(lt|gt|amp)$//;
  545. }
  546. if ($bracket and not defined $text) { # [URL] is dirty because the number may change
  547. Dirty($str);
  548. print GetUrl($url, $text, $bracket), $rest;
  549. } else {
  550. Clean(GetUrl($url, $text, $bracket, not $bracket) . $rest); # $text may be empty, no images in brackets
  551. }
  552. } elsif ($WikiLinks && m/\G!$LinkPattern/cg) {
  553. Clean($1); # ! gets eaten
  554. } elsif ($WikiLinks && $locallinks
  555. && ($BracketWiki && m/\G(\[$LinkPattern\s+([^\]]+?)\])/cg
  556. or m/\G(\[$LinkPattern\])/cg or m/\G($LinkPattern)/cg)) {
  557. # [LocalPage text], [LocalPage], LocalPage
  558. Dirty($1);
  559. my $bracket = (substr($1, 0, 1) eq '[' and not $3);
  560. print GetPageOrEditLink($2, $3, $bracket);
  561. } elsif ($locallinks && $FreeLinks && (m/\G(\[\[image:$FreeLinkPattern\]\])/cg
  562. or m/\G(\[\[image:$FreeLinkPattern\|([^]|]+)\]\])/cg)) {
  563. # [[image:Free Link]], [[image:Free Link|alt text]]
  564. Dirty($1);
  565. print GetDownloadLink(FreeToNormal($2), 1, undef, UnquoteHtml($3));
  566. } elsif ($FreeLinks && $locallinks
  567. && ($BracketWiki && m/\G(\[\[$FreeLinkPattern\|([^\]]+)\]\])/cg
  568. or m/\G(\[\[\[$FreeLinkPattern\]\]\])/cg
  569. or m/\G(\[\[$FreeLinkPattern\]\])/cg)) {
  570. # [[Free Link|text]], [[[Free Link]]], [[Free Link]]
  571. Dirty($1);
  572. my $bracket = (substr($1, 0, 3) eq '[[[');
  573. print GetPageOrEditLink($2, $3, $bracket, 1); # $3 may be empty
  574. } else {
  575. return; # nothing matched
  576. }
  577. return ''; # one of the dirty rules matched (and they all are)
  578. }
  579. sub SetHtmlEnvironmentContainer {
  580. my ($html_tag, $html_tag_attr) = @_;
  581. $HtmlEnvironmentContainers{$html_tag} = defined $html_tag_attr ? (
  582. $HtmlEnvironmentContainers{$html_tag} ? '|' . $HtmlEnvironmentContainers{$html_tag} : '')
  583. . $html_tag_attr : '';
  584. }
  585. sub InElement { # is $html_tag in @HtmlStack?
  586. my ($html_tag, $html_tag_attr) = @_;
  587. my $i = 0;
  588. foreach my $html_tag_current (@HtmlStack) {
  589. return 1 if $html_tag_current eq $html_tag and
  590. ($html_tag_attr ? $HtmlAttrStack[$i] =~ m/$html_tag_attr/ : 1);
  591. $i++;
  592. } return '';
  593. }
  594. sub AddOrCloseHtmlEnvironment { # add $html_tag, if not already added; close, otherwise
  595. my ($html_tag, $html_tag_attr) = @_;
  596. return InElement ($html_tag, '^' . $html_tag_attr . '$')
  597. ? CloseHtmlEnvironment($html_tag, '^' . $html_tag_attr . '$')
  598. : AddHtmlEnvironment ($html_tag, $html_tag_attr);
  599. }
  600. sub AddHtmlEnvironment { # add a new $html_tag
  601. my ($html_tag, $html_tag_attr) = @_;
  602. $html_tag_attr //= '';
  603. if ($html_tag and not (@HtmlStack and $HtmlStack[0] eq $html_tag and
  604. ($html_tag_attr ? $HtmlAttrStack[0] =~ m/$html_tag_attr/ : 1))) {
  605. unshift(@HtmlStack, $html_tag);
  606. unshift(@HtmlAttrStack, $html_tag_attr);
  607. return '<' . $html_tag . ($html_tag_attr ? ' ' . $html_tag_attr : '') . '>';
  608. } return ''; # always return something
  609. }
  610. sub OpenHtmlEnvironment { # close the previous $html_tag and open a new one
  611. my ($html_tag, $depth, $html_tag_attr, $tag_regex) = @_;
  612. my ($html, $found, @stack) = ('', 0); # always return something
  613. while (@HtmlStack and $found < $depth) { # determine new stack
  614. my $tag = pop(@HtmlStack);
  615. $found++ if ($tag_regex ? $tag =~ $tag_regex : $tag eq $html_tag);
  616. unshift(@stack, $tag);
  617. }
  618. unshift(@stack, pop(@HtmlStack)) if @HtmlStack and $found < $depth; # nested sublist coming up, keep list item
  619. @HtmlStack = @stack unless $found; # if starting a new list
  620. $html .= CloseHtmlEnvironments(); # close remaining elements (or all elements if a new list)
  621. @HtmlStack = @stack if $found; # if not starting a new list
  622. $depth = $IndentLimit if $depth > $IndentLimit; # requested depth 0 makes no sense
  623. $html_tag_attr = qq/class="$html_tag_attr"/ # backwards-compatibility hack: classically, the third argument to this function was a single CSS class, rather than string of HTML tag attributes as in the second argument to the "AddHtmlEnvironment" function. To allow both sorts, we conditionally change this string to 'class="$html_tag_attr"' when this string is a single CSS class.
  624. if $html_tag_attr and $html_tag_attr !~ m/^\s*[[:alpha:]]@@+\s*=\s*('|").+\1/;
  625. splice(@HtmlAttrStack, 0, @HtmlAttrStack - @HtmlStack); # truncate to size of @HtmlStack
  626. foreach ($found .. $depth - 1) {
  627. unshift(@HtmlStack, $html_tag);
  628. unshift(@HtmlAttrStack, $html_tag_attr);
  629. $html .= $html_tag_attr ? "<$html_tag $html_tag_attr>" : "<$html_tag>";
  630. }
  631. return $html;
  632. }
  633. sub CloseHtmlEnvironments { # close all -- remember to use AddHtmlEnvironment('p') if required!
  634. return CloseHtmlEnvironmentUntil() if pos($_) == length($_); # close all HTML environments if we're are at the end of this page
  635. my $html = '';
  636. while (@HtmlStack) {
  637. defined $HtmlEnvironmentContainers{$HtmlStack[0]} and # avoid closing block level elements
  638. ($HtmlEnvironmentContainers{$HtmlStack[0]} ? $HtmlAttrStack[0] =~
  639. m/$HtmlEnvironmentContainers{$HtmlStack[0]}/ : 1) and return $html;
  640. shift(@HtmlAttrStack);
  641. $html .= '</' . shift(@HtmlStack) . '>';
  642. }
  643. return $html;
  644. }
  645. sub CloseHtmlEnvironment { # close environments up to and including $html_tag
  646. my $html = (@_ and InElement(@_)) ? CloseHtmlEnvironmentUntil(@_) : '';
  647. if (@HtmlStack and (not(@_) or defined $html)) {
  648. shift(@HtmlAttrStack);
  649. return $html . '</' . shift(@HtmlStack) . '>';
  650. }
  651. return $html || ''; # avoid returning undefined
  652. }
  653. sub CloseHtmlEnvironmentUntil { # close environments up to but not including $html_tag
  654. my ($html_tag, $html_tag_attr) = @_;
  655. my $html = '';
  656. while (@HtmlStack && (pos($_) == length($_) || # while there is an HTML tag-stack and we are at the end of this page or...
  657. !($html_tag ? $HtmlStack[0] eq $html_tag && # the top tag is not the desired tag and...
  658. ($html_tag_attr ? $HtmlAttrStack[0] =~ # its attributes do not match,
  659. m/$html_tag_attr/ : 1) : ''))) { # then...
  660. shift(@HtmlAttrStack); # shift off the top tag and
  661. $html .= '</' . shift(@HtmlStack) . '>'; # append it to our HTML string.
  662. }
  663. return $html;
  664. }
  665. sub SmileyReplace {
  666. foreach my $regexp (keys %Smilies) {
  667. if (m/\G($regexp)/cg) {
  668. return $q->img({-src=>$Smilies{$regexp}, -alt=>UnquoteHtml($1), -class=>'smiley'});
  669. }
  670. }
  671. }
  672. sub RunMyRules {
  673. my ($locallinks, $withanchors) = @_;
  674. foreach my $sub (@MyRules) {
  675. my $result = $sub->($locallinks, $withanchors);
  676. SetParam('msg', $@) if $@;
  677. return $result if defined($result);
  678. }
  679. return;
  680. }
  681. sub RunMyMacros {
  682. $_ = shift;
  683. foreach my $macro (@MyMacros) { $macro->() };
  684. return $_;
  685. }
  686. sub PrintWikiToHTML {
  687. my ($markup, $is_saving_cache, $revision, $is_locked) = @_;
  688. my ($blocks, $flags);
  689. $FootnoteNumber = 0;
  690. $markup =~ s/$FS//g if $markup; # Remove separators (paranoia)
  691. $markup = QuoteHtml($markup);
  692. ($blocks, $flags) = ApplyRules($markup, 1, $is_saving_cache, $revision, 'p');
  693. if ($is_saving_cache and not $revision and $Page{revision} # don't save revision 0 pages
  694. and $Page{blocks} ne $blocks and $Page{flags} ne $flags) {
  695. $Page{blocks} = $blocks;
  696. $Page{flags} = $flags;
  697. if ($is_locked or RequestLockDir('main')) { # not fatal!
  698. SavePage();
  699. ReleaseLock() unless $is_locked;
  700. }
  701. }
  702. }
  703. sub DoClearCache {
  704. return unless UserIsAdminOrError();
  705. RequestLockOrError();
  706. print GetHeader('', T('Clear Cache')), $q->start_div({-class=>'content clear'}),
  707. $q->p(T('Main lock obtained.')), '<p>';
  708. foreach my $id (AllPagesList()) {
  709. OpenPage($id);
  710. delete @Page{qw(blocks flags languages)};
  711. $Page{languages} = GetLanguages($Page{blocks}) unless TextIsFile($Page{blocks});
  712. SavePage();
  713. print $q->br(), GetPageLink($id);
  714. }
  715. print '</p>', $q->p(T('Main lock released.')), $q->end_div();
  716. utime time, time, $IndexFile; # touch index file
  717. ReleaseLock();
  718. PrintFooter();
  719. }
  720. sub QuoteHtml {
  721. my $html = shift;
  722. $html =~ s/&/&amp;/g;
  723. $html =~ s/</&lt;/g;
  724. $html =~ s/>/&gt;/g;
  725. $html =~ s/[\x00-\x08\x0b\x0c\x0e-\x1f]/ /g; # legal xml: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
  726. return $html;
  727. }
  728. sub UnquoteHtml {
  729. my $html = shift;
  730. $html =~ s/&lt;/</g;
  731. $html =~ s/&gt;/>/g;
  732. $html =~ s/&amp;/&/g;
  733. $html =~ s/%26/&/g;
  734. return $html;
  735. }
  736. sub UrlEncode {
  737. my $str = shift;
  738. return '' unless $str;
  739. my @letters = split(//, encode_utf8($str));
  740. my %safe = map {$_ => 1} ('a' .. 'z', 'A' .. 'Z', '0' .. '9', '-', '_', '.', '!', '~', '*', "'", '(', ')', '#');
  741. foreach my $letter (@letters) {
  742. $letter = sprintf("%%%02x", ord($letter)) unless $safe{$letter};
  743. }
  744. return join('', @letters);
  745. }
  746. sub UrlDecode {
  747. my $str = shift;
  748. $str =~ s/%([0-9a-f][0-9a-f])/chr(hex($1))/eg;
  749. return decode_utf8($str); # make internal string
  750. }
  751. sub QuoteRegexp {
  752. my $re = shift;
  753. $re =~ s/([\\\[\]\$()^.])/\\$1/g;
  754. return $re;
  755. }
  756. sub GetRaw {
  757. my $uri = shift;
  758. return unless eval { require LWP::UserAgent; };
  759. my $ua = LWP::UserAgent->new;
  760. my $response = $ua->get($uri);
  761. return $response->decoded_content if $response->is_success;
  762. }
  763. sub DoJournal {
  764. print GetHeader(undef, T('Journal'));
  765. print $q->start_div({-class=>'content journal'});
  766. PrintJournal(map { GetParam($_, ''); } qw(num num regexp mode offset search variation));
  767. print $q->end_div();
  768. PrintFooter();
  769. }
  770. sub JournalSort { $b cmp $a }
  771. sub PrintJournal {
  772. return if $CollectingJournal; # avoid infinite loops
  773. local $CollectingJournal = 1;
  774. my ($num, $numMore, $regexp, $mode, $offset, $search, $variation) = @_;
  775. $variation ||= 'journal';
  776. $regexp ||= '^\d\d\d\d-\d\d-\d\d';
  777. $num ||= 10;
  778. $numMore = $num unless $numMore ne '';
  779. $offset ||= 0;
  780. # FIXME: Should pass filtered list of pages to SearchTitleAndBody to save time?
  781. my @pages = sort JournalSort (grep(/$regexp/, $search ? SearchTitleAndBody($search) : AllPagesList()));
  782. @pages = reverse @pages if $mode eq 'reverse' or $mode eq 'future';
  783. $b = $Today // CalcDay($Now);
  784. if ($mode eq 'future' || $mode eq 'past') {
  785. my $compare = $mode eq 'future' ? -1 : 1;
  786. for (my $i = 0; $i < @pages; $i++) {
  787. $a = $pages[$i];
  788. if (JournalSort() == $compare) {
  789. @pages = @pages[$i .. $#pages];
  790. last;
  791. }
  792. }
  793. }
  794. return unless $pages[$offset];
  795. print $q->start_div({-class=>'journal'});
  796. my $next = $offset + PrintAllPages(1, 1, $num, $variation, @pages[$offset .. $#pages]);
  797. print $q->end_div();
  798. $regexp = UrlEncode($regexp);
  799. $search = UrlEncode($search);
  800. if ($pages[$next] and $numMore != 0) {
  801. print $q->p({-class=>'more'}, ScriptLink("action=more;num=$numMore;regexp=$regexp;search=$search;mode=$mode;offset=$next;variation=$variation", T('More...'), 'more'));
  802. }
  803. }
  804. sub PrintAllPages {
  805. my ($links, $comments, $num, $variation, @pages) = @_;
  806. my $lang = GetParam('lang', 0);
  807. my ($i, $n) = 0;
  808. for my $id (@pages) {
  809. last if $n >= $JournalLimit and not UserIsAdmin() or $num and $n >= $num;
  810. $i++; # pages looked at
  811. local ($OpenPageName, %Page); # this is local!
  812. OpenPage($id);
  813. my @languages = split(/,/, $Page{languages});
  814. next if $lang and @languages and not grep(/$lang/, @languages);
  815. next if PageMarkedForDeletion();
  816. next if substr($Page{text}, 0, 10) eq '#REDIRECT ';
  817. print $q->start_div({-class=>'page'}),
  818. $q->h1($links ? GetPageLink($id)
  819. : $q->a({-name=>$id}, UrlEncode(FreeToNormal($id))));
  820. if ($variation ne 'titles') {
  821. PrintPageHtml();
  822. PrintPageCommentsLink($id, $comments);
  823. }
  824. print $q->end_div();
  825. $n++; # pages actually printed
  826. }
  827. return $i;
  828. }
  829. sub PrintPageCommentsLink {
  830. my ($id, $comments) = @_;
  831. if ($comments and $CommentsPattern and $id !~ /$CommentsPattern/) {
  832. print $q->p({-class=>'comment'},
  833. GetPageLink($CommentsPrefix . $id, T('Comments on this page')));
  834. }
  835. }
  836. sub RSS {
  837. return if $CollectingJournal; # avoid infinite loops when using full=1
  838. local $CollectingJournal = 1;
  839. my $maxitems = shift;
  840. my @uris = @_;
  841. my %lines;
  842. if (not eval { require XML::RSS; }) {
  843. my $err = $@;
  844. return $q->div({-class=>'rss'}, $q->p({-class=>'error'}, $q->strong(T('XML::RSS is not available on this system.')), $err));
  845. }
  846. # All strings that are concatenated with strings returned by the RSS
  847. # feed must be decoded. Without this decoding, 'diff' and 'history'
  848. # translations will be double encoded when printing the result.
  849. my $tDiff = T('diff');
  850. my $tHistory = T('history');
  851. my $wikins = 'http://purl.org/rss/1.0/modules/wiki/';
  852. my $rdfns = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
  853. @uris = map { my $x = $_; $x =~ s/^"?(.*?)"?$/$1/; $x; } @uris; # strip quotes of uris
  854. my ($str, %data) = GetRss(@uris);
  855. foreach my $uri (keys %data) {
  856. my $data = $data{$uri};
  857. if (not $data) {
  858. $str .= $q->p({-class=>'error'}, $q->strong(Ts('%s returned no data, or LWP::UserAgent is not available.',
  859. $q->a({-href=>$uri}, $uri))));
  860. } else {
  861. my $rss = new XML::RSS;
  862. eval { local $SIG{__DIE__}; $rss->parse($data); };
  863. if ($@) {
  864. $str .= $q->p({-class=>'error'}, $q->strong(Ts('RSS parsing failed for %s', $q->a({-href=>$uri}, $uri)) . ': ' . $@));
  865. } else {
  866. my $interwiki;
  867. if (@uris > 1) {
  868. RssInterwikiTranslateInit(); # not needed anywhere else thus init only now and not in ReInit
  869. $interwiki = $rss->{channel}->{$wikins}->{interwiki};
  870. $interwiki =~ s/^\s+//; # when RDF is used, sometimes whitespace remains,
  871. $interwiki =~ s/\s+$//; # which breaks the test for an existing $interwiki below
  872. $interwiki ||= $rss->{channel}->{$rdfns}->{value};
  873. $interwiki = $RssInterwikiTranslate{$interwiki} if $RssInterwikiTranslate{$interwiki};
  874. $interwiki ||= $RssInterwikiTranslate{$uri};
  875. }
  876. my $num = 999;
  877. $str .= $q->p({-class=>'error'}, $q->strong(Ts('No items found in %s.', $q->a({-href=>$uri}, $uri))))
  878. unless @{$rss->{items}};
  879. foreach my $i (@{$rss->{items}}) {
  880. my $line;
  881. my $date = $i->{dc}->{date};
  882. if (not $date and $i->{pubDate}) {
  883. $date = $i->{pubDate};
  884. my %mon = (Jan=>1, Feb=>2, Mar=>3, Apr=>4, May=>5, Jun=>6,
  885. Jul=>7, Aug=>8, Sep=>9, Oct=>10, Nov=>11, Dec=>12);
  886. $date =~ s/^(?:[A-Z][a-z][a-z], )?(\d\d?) ([A-Z][a-z][a-z]) (\d\d(?:\d\d)?)/ # pubDate uses RFC 822
  887. sprintf('%04d-%02d-%02d', ($3 < 100 ? 1900 + $3 : $3), $mon{$2}, $1)/e;
  888. }
  889. $date ||= sprintf("%03d", $num--); # for RSS 0.91 feeds without date, descending
  890. my $title = $i->{title};
  891. my $description = $i->{description};
  892. if (not $title and $description) { # title may be missing in RSS 2.00
  893. $title = $description;
  894. $description = '';
  895. }
  896. $title = $i->{link} if not $title and $i->{link}; # if description and title are missing
  897. $line .= ' (' . $q->a({-href=>$i->{$wikins}->{diff}}, $tDiff) . ')' if $i->{$wikins}->{diff};
  898. $line .= ' (' . $q->a({-href=>$i->{$wikins}->{history}}, $tHistory) . ')' if $i->{$wikins}->{history};
  899. if ($title) {
  900. if ($i->{link}) {
  901. $line .= ' ' . $q->a({-href=>$i->{link}, -title=>$date},
  902. ($interwiki ? $interwiki . ':' : '') . $title);
  903. } else {
  904. $line .= ' ' . $title;
  905. }
  906. }
  907. my $contributor = $i->{dc}->{contributor};
  908. $contributor ||= $i->{$wikins}->{username};
  909. $contributor =~ s/^\s+//;
  910. $contributor =~ s/\s+$//;
  911. $contributor ||= $i->{$rdfns}->{value};
  912. $line .= $q->span({-class=>'contributor'}, $q->span(T(' . . . .') . ' ') . $contributor) if $contributor;
  913. if ($description) {
  914. if ($description =~ /</) {
  915. $line .= $q->div({-class=>'description'}, $description);
  916. } else {
  917. $line .= $q->span({class=>'dash'}, ' &#8211; ') . $q->strong({-class=>'description'}, $description);
  918. }
  919. }
  920. $date .= ' ' while ($lines{$date}); # make sure this is unique
  921. $lines{$date} = $line;
  922. }
  923. }
  924. }
  925. }
  926. my @lines = sort { $b cmp $a } keys %lines;
  927. @lines = @lines[0 .. $maxitems-1] if $maxitems and $#lines > $maxitems;
  928. my $date = '';
  929. foreach my $key (@lines) {
  930. my $line = $lines{$key};
  931. if ($key =~ /(\d\d\d\d(?:-\d?\d)?(?:-\d?\d)?)(?:[T ](\d?\d:\d\d))?/) {
  932. my ($day, $time) = ($1, $2);
  933. if ($day ne $date) {
  934. $str .= '</ul>' if $date; # close ul except for the first time where no open ul exists
  935. $date = $day;
  936. $str .= $q->p($q->strong($day)) . '<ul>';
  937. }
  938. $line = $q->span({-class=>'time'}, $time . ' UTC ') . $line if $time;
  939. } elsif (not $date) {
  940. $str .= '<ul>'; # if the feed doesn't have any dates we need to start the list anyhow
  941. $date = $Now; # to ensure the list starts only once
  942. }
  943. $str .= $q->li($line);
  944. }
  945. $str .= '</ul>' if $date;
  946. return $q->div({-class=>'rss'}, $str);
  947. }
  948. sub GetRss {
  949. my %todo = map {$_, GetRssFile($_)} @_;
  950. my %data = ();
  951. my $str = '';
  952. if (GetParam('cache', $UseCache) > 0) {
  953. foreach my $uri (keys %todo) { # read cached rss files if possible
  954. if ($Now - Modified($todo{$uri}) < $RssCacheHours * 3600) {
  955. $data{$uri} = ReadFile($todo{$uri});
  956. delete($todo{$uri}); # no need to fetch them below
  957. }
  958. }
  959. }
  960. my @need_cache = keys %todo;
  961. if (keys %todo > 1) { # try parallel access if available
  962. eval { # see code example in LWP::Parallel, not LWP::Parallel::UserAgent (no callbacks here)
  963. require LWP::Parallel::UserAgent;
  964. my $pua = LWP::Parallel::UserAgent->new();
  965. foreach my $uri (keys %todo) {
  966. if (my $res = $pua->register(HTTP::Request->new('GET', $uri))) {
  967. $str .= $res->error_as_HTML;
  968. }
  969. }
  970. %todo = (); # because the uris in the response may have changed due to redirects
  971. my $entries = $pua->wait();
  972. foreach (keys %$entries) {
  973. my $uri = $entries->{$_}->request->uri;
  974. $data{$uri} = $entries->{$_}->response->decoded_content;
  975. }
  976. }
  977. }
  978. foreach my $uri (keys %todo) { # default operation: synchronous fetching
  979. $data{$uri} = GetRaw($uri);
  980. }
  981. if (GetParam('cache', $UseCache) > 0) {
  982. CreateDir($RssDir);
  983. foreach my $uri (@need_cache) {
  984. my $data = $data{$uri};
  985. # possibly a Latin-1 file without encoding attribute will cause a problem?
  986. $data =~ s/encoding="[^"]*"/encoding="UTF-8"/; # content was converted
  987. WriteStringToFile(GetRssFile($uri), $data) if $data;
  988. }
  989. }
  990. return $str, %data;
  991. }
  992. sub GetRssFile {
  993. return $RssDir . '/' . UrlEncode(shift);
  994. }
  995. sub RssInterwikiTranslateInit {
  996. return unless $RssInterwikiTranslate;
  997. %RssInterwikiTranslate = ();
  998. foreach (split(/\n/, GetPageContent($RssInterwikiTranslate))) {
  999. if (/^ ([^ ]+)[ \t]+([^ ]+)$/) {
  1000. $RssInterwikiTranslate{$1} = $2;
  1001. }
  1002. }
  1003. }
  1004. sub GetInterSiteUrl {
  1005. my ($site, $page, $quote) = @_;
  1006. return unless $page;
  1007. $page = join('/', map { UrlEncode($_) } split(/\//, $page)) if $quote; # Foo:bar+baz is not quoted, [[Foo:bar baz]] is.
  1008. my $url = $InterSite{$site} or return;
  1009. $url =~ s/\%s/$page/g or $url .= $page;
  1010. return $url;
  1011. }
  1012. sub BracketLink { # brackets can be removed via CSS
  1013. return $q->span($q->span({class=>'bracket'}, '[') . (shift) . $q->span({class=>'bracket'}, ']'));
  1014. }
  1015. sub GetInterLink {
  1016. my ($id, $text, $bracket, $quote) = @_;
  1017. my ($site, $page) = split(/:/, $id, 2);
  1018. $page =~ s/&amp;/&/g; # Unquote common URL HTML
  1019. my $url = GetInterSiteUrl($site, $page, $quote);
  1020. my $class = 'inter ' . $site;
  1021. return "[$id $text]" if $text and $bracket and not $url;
  1022. return "[$id]" if $bracket and not $url;
  1023. return $id if not $url;
  1024. if ($bracket and not $text) {
  1025. $text = BracketLink(++$FootnoteNumber);
  1026. $class .= ' number';
  1027. } elsif (not $text) {
  1028. $text = $q->span({-class=>'site'}, $site)
  1029. . $q->span({-class=>'separator'}, ':')
  1030. . $q->span({-class=>'interpage'}, $page);
  1031. } elsif ($bracket) { # and $text is set
  1032. $class .= ' outside';
  1033. }
  1034. return $q->a({-href=>$url, -class=>$class}, $text);
  1035. }
  1036. sub InterInit {
  1037. %InterSite = ();
  1038. foreach (split(/\n/, GetPageContent($InterMap))) {
  1039. if (/^ ($InterSitePattern)[ \t]+([^ ]+)$/) {
  1040. $InterSite{$1} = $2;
  1041. }
  1042. }
  1043. }
  1044. sub GetUrl {
  1045. my ($url, $text, $bracket, $images) = @_;
  1046. $url =~ /^($UrlProtocols)/;
  1047. my $class = "url $1";
  1048. if ($NetworkFile && $url =~ m|^file:///| && !$AllNetworkFiles
  1049. or !$NetworkFile && $url =~ m|^file:|) {
  1050. # Only do remote file:// links. No file:///c|/windows.
  1051. return $url;
  1052. } elsif ($bracket and not defined $text) {
  1053. $text = BracketLink(++$FootnoteNumber);
  1054. $class .= ' number';
  1055. } elsif (not defined $text) {
  1056. $text = $url;
  1057. } elsif ($bracket) { # and $text is set
  1058. $class .= ' outside';
  1059. }
  1060. $url = UnquoteHtml($url); # links should be unquoted again
  1061. if ($images and $url =~ /^(http:|https:|ftp:).+\.$ImageExtensions$/i) {
  1062. return $q->img({-src=>$url, -alt=>$url, -class=>$class});
  1063. } else {
  1064. return $q->a({-href=>$url, -class=>$class}, $text);
  1065. }
  1066. }
  1067. sub GetPageOrEditLink { # use GetPageLink and GetEditLink if you know the result!
  1068. my ($id, $text, $bracket, $free) = @_;
  1069. $id = FreeToNormal($id);
  1070. my ($class, $resolved, $title, $exists) = ResolveId($id);
  1071. if (not $text and $resolved and $bracket) {
  1072. $text = BracketLink(++$FootnoteNumber);
  1073. $class .= ' number';
  1074. $title = NormalToFree($id);
  1075. }
  1076. my $link = $text || NormalToFree($id);
  1077. if ($resolved) { # anchors don't exist as pages, therefore do not use $exists
  1078. return ScriptLink(UrlEncode($resolved), $link, $class, undef, $title);
  1079. } else { # reproduce markup if $UseQuestionmark
  1080. return GetEditLink($id, UnquoteHtml($bracket ? "[$link]" : $link)) unless $UseQuestionmark;
  1081. $link = QuoteHtml($id) . GetEditLink($id, '?');
  1082. $link .= ($free ? '|' : ' ') . $text if $text and FreeToNormal($text) ne $id;
  1083. $link = "[[$link]]" if $free;
  1084. $link = "[$link]" if $bracket or not $free and $text;
  1085. return $link;
  1086. }
  1087. }
  1088. sub GetPageLink { # use if you want to force a link to local pages, whether it exists or not
  1089. my ($id, $name, $class, $accesskey) = @_;
  1090. $id = FreeToNormal($id);
  1091. $name ||= $id;
  1092. $class .= ' ' if $class;
  1093. return ScriptLink(UrlEncode($id), NormalToFree($name), $class . 'local',
  1094. undef, undef, $accesskey);
  1095. }
  1096. sub GetEditLink { # shortcut
  1097. my ($id, $name, $upload, $accesskey) = @_;
  1098. $id = FreeToNormal($id);
  1099. my $action = 'action=edit;id=' . UrlEncode($id);
  1100. $action .= ';upload=1' if $upload;
  1101. return ScriptLink($action, NormalToFree($name), 'edit', undef, T('Click to edit this page'), $accesskey);
  1102. }
  1103. sub ScriptUrl {
  1104. my $action = shift;
  1105. if ($action =~ /^($UrlProtocols)\%3a/ or $action =~ /^\%2f/) { # nearlinks and other URLs
  1106. $action =~ s/%([0-9a-f][0-9a-f])/chr(hex($1))/eg; # undo urlencode
  1107. # do nothing
  1108. } else {
  1109. $action = $ScriptName . (($UsePathInfo and index($action, '=') == -1) ? '/' : '?') . $action;
  1110. }
  1111. return $action unless wantarray;
  1112. return ($action, index($action, '=') != -1);
  1113. }
  1114. sub ScriptLink {
  1115. my ($action, $text, $class, $name, $title, $accesskey) = @_;
  1116. my ($url, $nofollow) = ScriptUrl($action);
  1117. my %params;
  1118. $params{-href} = $url;
  1119. $params{'-rel'} = 'nofollow' if $nofollow;
  1120. $params{'-class'} = $class if $class;
  1121. $params{'-name'} = $name if $name;
  1122. $params{'-title'} = $title if $title;
  1123. $params{'-accesskey'} = $accesskey if $accesskey;
  1124. return $q->a(\%params, $text);
  1125. }
  1126. sub GetDownloadLink {
  1127. my ($id, $image, $revision, $alt) = @_;
  1128. $alt ||= NormalToFree($id);
  1129. # if the page does not exist
  1130. return '[[' . ($image ? 'image' : 'download') . ':'
  1131. . ($UseQuestionmark ? QuoteHtml($id) . GetEditLink($id, '?', 1)
  1132. : GetEditLink($id, $id, 1)) . ']]'
  1133. unless $IndexHash{$id};
  1134. my $action;
  1135. if ($revision) {
  1136. $action = "action=download;id=" . UrlEncode($id) . ";revision=$revision";
  1137. } elsif ($UsePathInfo) {
  1138. $action = "download/" . UrlEncode($id);
  1139. } else {
  1140. $action = "action=download;id=" . UrlEncode($id);
  1141. }
  1142. if ($image) {
  1143. $action = $ScriptName . (($UsePathInfo and not $revision) ? '/' : '?') . $action;
  1144. return $action if $image == 2;
  1145. my $result = $q->img({-src=>$action, -alt=>UnquoteHtml($alt), -title=>UnquoteHtml($alt), -class=>'upload'});
  1146. $result = ScriptLink(UrlEncode($id), $result, 'image') unless $id eq $OpenPageName;
  1147. return $result;
  1148. } else {
  1149. return ScriptLink($action, $alt, 'upload');
  1150. }
  1151. }
  1152. sub PrintCache { # Use after OpenPage!
  1153. my @blocks = split($FS, $Page{blocks});
  1154. my @flags = split($FS, $Page{flags});
  1155. $FootnoteNumber = 0;
  1156. foreach my $block (@blocks) {
  1157. if (shift(@flags)) {
  1158. ApplyRules($block, 1, 1); # local links, anchors, current revision, no start tag
  1159. } else {
  1160. print $block;
  1161. }
  1162. }
  1163. }
  1164. sub PrintPageHtml { # print an open page
  1165. return unless GetParam('page', 1);
  1166. if ($Page{blocks} and defined $Page{flags} and GetParam('cache', $UseCache) > 0) {
  1167. PrintCache();
  1168. } else {
  1169. PrintWikiToHTML($Page{text}, 1); # save cache, current revision, no main lock
  1170. }
  1171. }
  1172. sub PrintPageDiff { # print diff for open page
  1173. my $diff = GetParam('diff', 0);
  1174. if ($UseDiff and $diff) {
  1175. PrintHtmlDiff($diff);
  1176. print $q->hr() if GetParam('page', 1);
  1177. }
  1178. }
  1179. sub ToString {
  1180. my ($sub_ref) = @_;
  1181. my $output;
  1182. open(my $outputFH, '>:encoding(UTF-8)', \$output) or die "Can't open memory file: $!";
  1183. my $oldFH = select $outputFH;
  1184. $sub_ref->();
  1185. select $oldFH;
  1186. close $outputFH;
  1187. my $output_fixed = $output; # Do not delete! This is a workarond for a perl bug.
  1188. return decode_utf8($output_fixed); # Otherwise UTF8 characters are SOMETIMES not decoded.
  1189. }
  1190. sub PageHtml {
  1191. my ($id, $limit, $error) = @_;
  1192. OpenPage($id);
  1193. my $diff = ToString \&PrintPageDiff;
  1194. return $error if $limit and length($diff) > $limit;
  1195. my $page = ToString \&PrintPageHtml;
  1196. return $diff . $q->p($error) if $limit and length($diff . $page) > $limit;
  1197. return $diff . $page;
  1198. }
  1199. sub T {
  1200. my $text = shift;
  1201. return $Translate{$text} || $text;
  1202. }
  1203. sub Ts {
  1204. my ($text, $string) = @_;
  1205. $text = T($text);
  1206. $text =~ s/\%s/$string/ if defined($string);
  1207. return $text;
  1208. }
  1209. sub Tss {
  1210. my $text = $_[0];
  1211. $text = T($text);
  1212. $text =~ s/\%([1-9])/$_[$1]/eg;
  1213. return $text;
  1214. }
  1215. sub GetId {
  1216. my $id = UnquoteHtml(GetParam('id', GetParam('title', ''))); # id=x or title=x -> x
  1217. if (not $id) {
  1218. $id ||= decode_utf8(join('_', $q->keywords)); # script?p+q -> p_q
  1219. }
  1220. if ($UsePathInfo) {
  1221. my @path = map { decode_utf8($_) } split(/\//, $q->path_info);
  1222. $id ||= pop(@path); # script/p/q -> q
  1223. foreach my $p (@path) {
  1224. SetParam($p, 1); # script/p/q -> p=1
  1225. }
  1226. }
  1227. return $id;
  1228. }
  1229. sub DoBrowseRequest {
  1230. # We can use the error message as the HTTP error code
  1231. ReportError(Ts('CGI Internal error: %s', $q->cgi_error), $q->cgi_error) if $q->cgi_error;
  1232. print $q->header(-status=>'304 NOT MODIFIED') and return if PageFresh(); # return value is ignored
  1233. my $id = GetId();
  1234. my $action = lc(GetParam('action', '')); # script?action=foo;id=bar
  1235. $action = 'download' if GetParam('download', '') and not $action; # script/download/id
  1236. if ($Action{$action}) {
  1237. &{$Action{$action}}($id);
  1238. } elsif ($action and defined &MyActions) {
  1239. eval { local $SIG{__DIE__}; MyActions(); };
  1240. } elsif ($action) {
  1241. ReportError(Ts('Invalid action parameter %s', $action), '501 NOT IMPLEMENTED');
  1242. } elsif (GetParam('match', '') ne '') {
  1243. SetParam('action', 'index'); # make sure this gets a NOINDEX
  1244. DoIndex();
  1245. } elsif (GetParam('search', '') ne '') { # allow search for "0"
  1246. SetParam('action', 'search'); # make sure this gets a NOINDEX
  1247. DoSearch();
  1248. } elsif (GetParam('title', '') and not GetParam('Cancel', '')) {
  1249. DoPost(GetParam('title', ''));
  1250. } else {
  1251. BrowseResolvedPage($id || $HomePage); # default action!
  1252. }
  1253. }
  1254. sub ValidId { # hack alert: returns error message if invalid, and unfortunately the empty string if valid!
  1255. my $id = FreeToNormal(shift);
  1256. return T('Page name is missing') unless $id;
  1257. require bytes;
  1258. return Ts('Page name is too long: %s', $id) if bytes::length($id) > $PageNameLimit;
  1259. return Ts('Invalid Page %s (must not end with .db)', $id) if $id =~ m|\.db$|;
  1260. return Ts('Invalid Page %s (must not end with .lck)', $id) if $id =~ m|\.lck$|;
  1261. return Ts('Invalid Page %s', $id) if $FreeLinks ? $id !~ m|^$FreeLinkPattern$| : $id !~ m|^$LinkPattern$|;
  1262. }
  1263. sub ValidIdOrDie {
  1264. my $id = shift;
  1265. my $error = ValidId($id);
  1266. ReportError($error, '400 BAD REQUEST') if $error;
  1267. return 1;
  1268. }
  1269. sub ResolveId { # return css class, resolved id, title (eg. for popups), exist-or-not
  1270. my $id = shift;
  1271. return ('local', $id, '', 1) if $IndexHash{$id};
  1272. return ('', '', '', '');
  1273. }
  1274. sub BrowseResolvedPage {
  1275. my $id = FreeToNormal(shift);
  1276. my ($class, $resolved, $title, $exists) = ResolveId($id);
  1277. if ($class and $class eq 'near' and not GetParam('rcclusteronly', 0)) { # nearlink (is url)
  1278. print $q->redirect({-uri=>$resolved});
  1279. } elsif ($class and $class eq 'alias') { # an anchor was found instead of a page
  1280. ReBrowsePage($resolved);
  1281. } elsif (not $resolved and $NotFoundPg and $id !~ /$CommentsPattern/) { # custom page-not-found message
  1282. BrowsePage($NotFoundPg);
  1283. } elsif ($resolved) { # an existing page was found
  1284. BrowsePage($resolved, GetParam('raw', 0));
  1285. } else { # new page!
  1286. BrowsePage($id, GetParam('raw', 0), undef, '404 NOT FOUND') if ValidIdOrDie($id);
  1287. }
  1288. }
  1289. sub NewText { # only if no revision is available
  1290. my $id = shift;
  1291. if ($CommentsPrefix and $id =~ /^($CommentsPrefix)/) {
  1292. return T('There are no comments, yet. Be the first to leave a comment!');
  1293. } elsif ($id eq $HomePage) {
  1294. return T('Welcome!');
  1295. } else {
  1296. return Ts('This page does not exist, but you can %s.',
  1297. '[' . ScriptUrl('action=edit;id=' . UrlEncode($id)) . ' '
  1298. . T('create it now') . ']');
  1299. }
  1300. }
  1301. sub BrowsePage {
  1302. my ($id, $raw, $comment, $status) = @_;
  1303. OpenPage($id);
  1304. my ($revisionPage, $revision) = GetTextRevision(GetParam('revision', ''));
  1305. my $text = $revisionPage->{text};
  1306. $text = NewText($id) unless $revision or $Page{revision} or $comment; # new text for new pages
  1307. # handle a single-level redirect
  1308. my $oldId = GetParam('oldid', '');
  1309. if ((substr($text, 0, 10) eq '#REDIRECT ')) {
  1310. if ($oldId) {
  1311. $Message .= $q->p(T('Too many redirections'));
  1312. } elsif ($revision) {
  1313. $Message .= $q->p(T('No redirection for old revisions'));
  1314. } elsif (($FreeLinks and $text =~ /^\#REDIRECT\s+\[\[$FreeLinkPattern\]\]/)
  1315. or ($WikiLinks and $text =~ /^\#REDIRECT\s+$LinkPattern/)) {
  1316. return ReBrowsePage(FreeToNormal($1), $id);
  1317. } else {
  1318. $Message .= $q->p(T('Invalid link pattern for #REDIRECT'));
  1319. }
  1320. }
  1321. # shortcut if we only need the raw text: no caching, no diffs, no html.
  1322. if ($raw) {
  1323. print GetHttpHeader('text/plain', $Page{ts}, $IndexHash{$id} ? undef : '404 NOT FOUND');
  1324. print $Page{ts} . " # Do not delete this line when editing!\n" if $raw == 2;
  1325. print $text;
  1326. return;
  1327. }
  1328. # normal page view
  1329. my $msg = GetParam('msg', '');
  1330. $Message .= $q->p($msg) if $msg; # show message if the page is shown
  1331. SetParam('msg', '');
  1332. print GetHeader($id, NormalToFree($id), $oldId, undef, $status);
  1333. my $showDiff = GetParam('diff', 0);
  1334. if ($UseDiff and $showDiff) {
  1335. PrintHtmlDiff($showDiff, GetParam('diffrevision'), $revisionPage, $Page{revision});
  1336. print $q->hr();
  1337. }
  1338. PrintPageContent($text, $revision, $comment);
  1339. SetParam('rcclusteronly', $id) if FreeToNormal(GetCluster($text)) eq $id; # automatically filter by cluster
  1340. PrintRcHtml($id);
  1341. PrintFooter($id, $revision, $comment, $revisionPage);
  1342. }
  1343. sub ReBrowsePage {
  1344. my ($id, $oldId) = map { UrlEncode($_); } @_; # encode before printing URL
  1345. if ($oldId) { # Target of #REDIRECT (loop breaking)
  1346. print GetRedirectPage("action=browse;oldid=$oldId;id=$id", $id);
  1347. } else {
  1348. print GetRedirectPage($id, $id);
  1349. }
  1350. }
  1351. sub GetRedirectPage {
  1352. my ($action, $name) = @_;
  1353. my ($url, $html);
  1354. if (GetParam('raw', 0)) {
  1355. $html = GetHttpHeader('text/plain');
  1356. $html .= Ts('Please go on to %s.', $action); # no redirect
  1357. return $html;
  1358. }
  1359. $url = $ScriptName . (($UsePathInfo and $action !~ /=/) ? '/' : '?') . $action;
  1360. my $nameLink = $q->a({-href=>$url}, $name);
  1361. my %headers = (-uri=>$url);
  1362. my $cookie = Cookie();
  1363. $headers{-cookie} = $cookie if $cookie;
  1364. return $q->redirect(%headers);
  1365. }
  1366. sub DoRandom {
  1367. my @pages = AllPagesList();
  1368. ReBrowsePage($pages[int(rand($#pages + 1))]);
  1369. }
  1370. sub PageFresh { # pages can depend on other pages (ie. last update), admin status, and css
  1371. return 1 if $q->http('HTTP_IF_NONE_MATCH') and GetParam('cache', $UseCache) >= 2
  1372. and $q->http('HTTP_IF_NONE_MATCH') eq PageEtag();
  1373. }
  1374. sub PageEtag {
  1375. my ($changed, %params) = CookieData();
  1376. return UrlEncode(join($FS, $LastUpdate||$Now, sort(values %params))); # no CTL in field values
  1377. }
  1378. sub FileFresh { # old files are never stale, current files are stale when the page was modified
  1379. return 1 if $q->http('HTTP_IF_NONE_MATCH') and GetParam('cache', $UseCache) >= 2
  1380. and (GetParam('revision', 0) or $q->http('HTTP_IF_NONE_MATCH') eq $Page{ts});
  1381. }
  1382. sub BrowseRc {
  1383. my $id = shift;
  1384. if (GetParam('raw', 0)) {
  1385. print GetHttpHeader('text/plain');
  1386. PrintRcText();
  1387. } else {
  1388. PrintRcHtml($id || $RCName, 1);
  1389. }
  1390. }
  1391. sub GetRcLines { # starttime, hash of seen pages to use as a second return value
  1392. my $starttime = shift || GetParam('from', 0) ||
  1393. $Now - GetParam('days', $RcDefault) * 86400; # 24*60*60
  1394. my $filterOnly = GetParam('rcfilteronly', '');
  1395. # these variables apply accross logfiles
  1396. my %match = $filterOnly ? map { $_ => 1 } SearchTitleAndBody($filterOnly) : ();
  1397. my %following = ();
  1398. my @result = ();
  1399. my $ts;
  1400. # check the first timestamp in the default file, maybe read old log file
  1401. if (open(my $F, '<:encoding(UTF-8)', encode_utf8($RcFile))) {
  1402. my $line = <$F>;
  1403. ($ts) = split(/$FS/, $line); # the first timestamp in the regular rc file
  1404. }
  1405. if (not $ts or $ts > $starttime) { # we need to read the old rc file, too
  1406. push(@result, GetRcLinesFor($RcOldFile, $starttime, \%match, \%following));
  1407. }
  1408. push(@result, GetRcLinesFor($RcFile, $starttime, \%match, \%following));
  1409. # GetRcLinesFor is trying to save memory space, but some operations
  1410. # can only happen once we have all the data.
  1411. return LatestChanges(StripRollbacks(@result));
  1412. }
  1413. sub LatestChanges {
  1414. my $all = GetParam('all', $ShowAll);
  1415. my @result = @_;
  1416. my %seen = ();
  1417. for (my $i = $#result; $i >= 0; $i--) {
  1418. my $id = $result[$i][1];
  1419. if ($all) {
  1420. $result[$i][9] = 1 unless $seen{$id}; # mark latest edit
  1421. } else {
  1422. splice(@result, $i, 1) if $seen{$id}; # remove older edits
  1423. }
  1424. $seen{$id} = 1;
  1425. }
  1426. my $to = GetParam('upto', 0);
  1427. if ($to) {
  1428. for (my $i = 0; $i < $#result; $i++) {
  1429. if ($result[$i][0] > $to) {
  1430. splice(@result, $i);
  1431. last;
  1432. }
  1433. }
  1434. }
  1435. return reverse @result;
  1436. }
  1437. sub StripRollbacks {
  1438. my @result = @_;
  1439. if (not (GetParam('all', $ShowAll) or GetParam('rollback', $ShowRollbacks))) { # strip rollbacks
  1440. my (%rollback);
  1441. for (my $i = $#result; $i >= 0; $i--) {
  1442. # some fields have a different meaning if looking at rollbacks
  1443. my ($ts, $id, $target_ts, $target_id) = @{$result[$i]};
  1444. if ($id eq '[[rollback]]') {
  1445. if ($target_id) {
  1446. $rollback{$target_id} = $target_ts; # single page rollback
  1447. splice(@result, $i, 1); # strip marker
  1448. } else {
  1449. my $end = $i;
  1450. while ($ts > $target_ts and $i > 0) {
  1451. $i--; # quickly skip all these lines
  1452. $ts = $result[$i][0];
  1453. }
  1454. splice(@result, $i + 1, $end - $i);
  1455. $i++; # compensate $i-- in for loop
  1456. }
  1457. } elsif ($rollback{$id} and $ts > $rollback{$id}) {
  1458. splice(@result, $i, 1); # strip rolled back single pages
  1459. }
  1460. }
  1461. } else { # just strip the marker left by DoRollback()
  1462. for (my $i = $#result; $i >= 0; $i--) {
  1463. splice(@result, $i, 1) if $result[$i][1] eq '[[rollback]]'; # id
  1464. }
  1465. }
  1466. return @result;
  1467. }
  1468. sub GetRcLinesFor {
  1469. my $file = shift;
  1470. my $starttime = shift;
  1471. my %match = %{$_[0]}; # deref
  1472. my %following = %{$_[1]}; # deref
  1473. # parameters
  1474. my $showminoredit = GetParam('showedit', $ShowEdits); # show minor edits
  1475. my $all = GetParam('all', $ShowAll);
  1476. my ($idOnly, $userOnly, $hostOnly, $clusterOnly, $filterOnly, $match, $lang,
  1477. $followup) = map { UnquoteHtml(GetParam($_, '')); }
  1478. qw(rcidonly rcuseronly rchostonly
  1479. rcclusteronly rcfilteronly match lang followup);
  1480. # parsing and filtering
  1481. my @result = ();
  1482. open(my $F, '<:encoding(UTF-8)', encode_utf8($file)) or return ();
  1483. while (my $line = <$F>) {
  1484. chomp($line);
  1485. my ($ts, $id, $minor, $summary, $host, $username, $revision,
  1486. $languages, $cluster) = split(/$FS/, $line);
  1487. next if $ts < $starttime;
  1488. $following{$id} = $ts if $followup and $followup eq $username;
  1489. next if $followup and (not $following{$id} or $ts <= $following{$id});
  1490. next if $idOnly and $idOnly ne $id;
  1491. next if $filterOnly and not $match{$id};
  1492. next if ($userOnly and $userOnly ne $username);
  1493. next if $minor == 1 and not $showminoredit; # skip minor edits (if [[rollback]] this is bogus)
  1494. next if not $minor and $showminoredit == 2; # skip major edits
  1495. next if $match and $id !~ /$match/i;
  1496. next if $hostOnly and $host !~ /$hostOnly/i;
  1497. my @languages = split(/,/, $languages);
  1498. next if $lang and @languages and not grep(/$lang/, @languages);
  1499. if ($PageCluster) {
  1500. ($cluster, $summary) = ($1, $2) if $summary =~ /^\[\[$FreeLinkPattern\]\] ?: *(.*)/
  1501. or $summary =~ /^$LinkPattern ?: *(.*)/;
  1502. next if ($clusterOnly and $clusterOnly ne $cluster);
  1503. $cluster = '' if $clusterOnly; # don't show cluster if $clusterOnly eq $cluster
  1504. if ($all < 2 and not $clusterOnly and $cluster) {
  1505. $summary = "$id: $summary"; # print the cluster instead of the page
  1506. $id = $cluster;
  1507. $revision = '';
  1508. }
  1509. } else {
  1510. $cluster = '';
  1511. }
  1512. $following{$id} = $ts if $followup and $followup eq $username;
  1513. push(@result, [$ts, $id, $minor, $summary, $host, $username, $revision,
  1514. \@languages, $cluster]);
  1515. }
  1516. return @result;
  1517. }
  1518. sub ProcessRcLines {
  1519. my ($printDailyTear, $printRCLine) = @_; # code references
  1520. # needed for output
  1521. my $date = '';
  1522. for my $line (GetRcLines()) {
  1523. my ($ts, $id, $minor, $summary, $host, $username, $revision, $languageref,
  1524. $cluster, $last) = @$line;
  1525. if ($date ne CalcDay($ts)) {
  1526. $date = CalcDay($ts);
  1527. $printDailyTear->($date);
  1528. }
  1529. $printRCLine->($id, $ts, $host, $username, $summary, $minor, $revision,
  1530. $languageref, $cluster, $last);
  1531. }
  1532. }
  1533. sub RcHeader {
  1534. my ($from, $upto, $html) = (GetParam('from', 0), GetParam('upto', 0), '');
  1535. my $days = GetParam('days') + 0 || $RcDefault; # force numeric $days
  1536. my $all = GetParam('all', $ShowAll);
  1537. my $edits = GetParam('showedit', $ShowEdits);
  1538. my $rollback = GetParam('rollback', $ShowRollbacks);
  1539. if ($from) {
  1540. $html .= $q->h2(Ts('Updates since %s', TimeToText(GetParam('from', 0))) . ' '
  1541. . ($upto ? Ts('up to %s', TimeToText($upto)) : ''));
  1542. } else {
  1543. $html .= $q->h2((GetParam('days', $RcDefault) != 1)
  1544. ? Ts('Updates in the last %s days', $days)
  1545. : Ts('Updates in the last day'));
  1546. }
  1547. my $action = '';
  1548. my ($idOnly, $userOnly, $hostOnly, $clusterOnly, $filterOnly,
  1549. $match, $lang, $followup) =
  1550. map {
  1551. my $val = GetParam($_, '');
  1552. $html .= $q->p($q->b('(' . Ts('for %s only', $val) . ')')) if $val;
  1553. $action .= ";$_=$val" if $val; # remember these parameters later!
  1554. $val;
  1555. } qw(rcidonly rcuseronly rchostonly rcclusteronly rcfilteronly
  1556. match lang followup);
  1557. my $rss = "action=rss$action;days=$days;all=$all;showedit=$edits";
  1558. if ($clusterOnly) {
  1559. $action = GetPageParameters('browse', $clusterOnly) . $action;
  1560. } else {
  1561. $action = "action=rc$action";
  1562. }
  1563. my @menu;
  1564. if ($all) {
  1565. push(@menu, ScriptLink("$action;days=$days;all=0;showedit=$edits",
  1566. T('List latest change per page only')));
  1567. } else {
  1568. push(@menu, ScriptLink("$action;days=$days;all=1;showedit=$edits",
  1569. T('List all changes')));
  1570. if ($rollback) {
  1571. push(@menu, ScriptLink("$action;days=$days;all=0;rollback=0;"
  1572. . "showedit=$edits", T('Skip rollbacks')));
  1573. } else {
  1574. push(@menu, ScriptLink("$action;days=$days;all=0;rollback=1;"
  1575. . "showedit=$edits", T('Include rollbacks')));
  1576. }
  1577. }
  1578. if ($edits) {
  1579. push(@menu, ScriptLink("$action;days=$days;all=$all;showedit=0",
  1580. T('List only major changes')));
  1581. } else {
  1582. push(@menu, ScriptLink("$action;days=$days;all=$all;showedit=1",
  1583. T('Include minor changes')));
  1584. }
  1585. return $html .
  1586. $q->p(join(' | ', (map { ScriptLink("$action;days=$_;all=$all;showedit=$edits", $_); } @RcDays)),
  1587. T('days'), $q->br(), @menu, $q->br(),
  1588. ScriptLink($action . ';from=' . ($LastUpdate + 1)
  1589. . ";all=$all;showedit=$edits", T('List later changes')),
  1590. ScriptLink($rss, T('RSS'), 'rss nopages nodiff'),
  1591. ScriptLink("$rss;full=1", T('RSS with pages'), 'rss pages nodiff'),
  1592. ScriptLink("$rss;full=1;diff=1", T('RSS with pages and diff'),
  1593. 'rss pages diff'));
  1594. }
  1595. sub GetScriptUrlWithRcParameters {
  1596. my $url = "$ScriptName?action=rss";
  1597. foreach my $param (qw(from upto days all showedit rollback rcidonly rcuseronly
  1598. rchostonly rcclusteronly rcfilteronly match lang
  1599. followup page diff full)) {
  1600. my $val = GetParam($param, undef);
  1601. $url .= ";$param=$val" if defined $val;
  1602. }
  1603. return $url;
  1604. }
  1605. sub GetFilterForm {
  1606. my $form = $q->strong(T('Filters'));
  1607. $form .= $q->input({-type=>'hidden', -name=>'action', -value=>'rc'});
  1608. $form .= $q->input({-type=>'hidden', -name=>'all', -value=>1}) if (GetParam('all', $ShowAll));
  1609. $form .= $q->input({-type=>'hidden', -name=>'showedit', -value=>1}) if (GetParam('showedit', $ShowEdits));
  1610. if (GetParam('days', $RcDefault) != $RcDefault) {
  1611. $form .= $q->input({-type=>'hidden', -name=>'days', -value=>GetParam('days', $RcDefault)});
  1612. }
  1613. my $table = '';
  1614. foreach my $h (['match' => T('Title:')],
  1615. ['rcfilteronly' => T('Title and Body:')],
  1616. ['rcuseronly' => T('Username:')], ['rchostonly' => T('Host:')],
  1617. ['followup' => T('Follow up to:')]) {
  1618. $table .= $q->Tr($q->td($q->label({-for=>$h->[0]}, $h->[1])),
  1619. $q->td($q->textfield(-name=>$h->[0], -id=>$h->[0], -size=>20)));
  1620. }
  1621. if (%Languages) {
  1622. $table .= $q->Tr($q->td($q->label({-for=>'rclang'}, T('Language:')))
  1623. . $q->td($q->textfield(-name=>'lang', -id=>'rclang', -size=>10,
  1624. -default=>GetParam('lang', ''))));
  1625. }
  1626. return GetFormStart(undef, 'get', 'filter') . $q->p($form) . $q->table($table)
  1627. . $q->p($q->submit('dofilter', T('Go!'))) . $q->end_form;
  1628. }
  1629. sub RcHtml {
  1630. my ($html, $inlist) = ('', 0);
  1631. # Optimize param fetches and translations out of main loop
  1632. my $all = GetParam('all', $ShowAll);
  1633. my $admin = UserIsAdmin();
  1634. my $rollback_was_possible = 0;
  1635. my $printDailyTear = sub {
  1636. my $date = shift;
  1637. if ($inlist) {
  1638. $html .= '</ul>';
  1639. $inlist = 0;
  1640. }
  1641. $html .= $q->p($q->strong($date));
  1642. if (not $inlist) {
  1643. $html .= '<ul>';
  1644. $inlist = 1;
  1645. }
  1646. };
  1647. my $printRCLine = sub {
  1648. my($id, $ts, $host, $username, $summary, $minor, $revision,
  1649. $languages, $cluster, $last) = @_;
  1650. my $all_revision = $last ? undef : $revision; # no revision for the last one
  1651. $host = QuoteHtml($host);
  1652. my $author = GetAuthorLink($host, $username);
  1653. my $sum = $summary ? $q->span({class=>'dash'}, ' &#8211; ')
  1654. . $q->strong(QuoteHtml($summary)) : '';
  1655. my $edit = $minor ? $q->em({class=>'type'}, T('(minor)')) : '';
  1656. my $lang = @{$languages}
  1657. ? $q->span({class=>'lang'}, '[' . join(', ', @{$languages}) . ']') : '';
  1658. my ($pagelink, $history, $diff, $rollback) = ('', '', '', '');
  1659. if ($all) {
  1660. $pagelink = GetOldPageLink('browse', $id, $all_revision, $id, $cluster);
  1661. my $rollback_is_possible = RollbackPossible($ts);
  1662. if ($admin and ($rollback_is_possible or $rollback_was_possible)) {
  1663. $rollback = $q->submit("rollback-$ts", T('rollback'));
  1664. $rollback_was_possible = $rollback_is_possible;
  1665. } else {
  1666. $rollback_was_possible = 0;
  1667. }
  1668. } elsif ($cluster) {
  1669. $pagelink = GetOldPageLink('browse', $id, $revision, $id, $cluster);
  1670. } else {
  1671. $pagelink = GetPageLink($id, $cluster);
  1672. $history = '(' . GetHistoryLink($id, T('history')) . ')';
  1673. }
  1674. if ($cluster and $PageCluster) {
  1675. $diff .= GetPageLink($PageCluster) . ':';
  1676. } elsif ($UseDiff and GetParam('diffrclink', 1)) {
  1677. if ($revision == 1) {
  1678. $diff .= '(' . $q->span({-class=>'new'}, T('new')) . ')';
  1679. } elsif ($all) {
  1680. $diff .= '(' . ScriptLinkDiff(2, $id, T('diff'), $all_revision) .')';
  1681. } else {
  1682. $diff .= '(' . ScriptLinkDiff($minor ? 2 : 1, $id, T('diff')) . ')';
  1683. }
  1684. }
  1685. $html .= $q->li($q->span({-class=>'time'}, CalcTime($ts)), $diff, $history,
  1686. $rollback, $pagelink, T(' . . . .'), $author, $sum, $lang,
  1687. $edit);
  1688. };
  1689. ProcessRcLines($printDailyTear, $printRCLine);
  1690. $html .= '</ul>' if $inlist;
  1691. # use delta between from and upto, or use days, whichever is available
  1692. my $to = GetParam('from', GetParam('upto', $Now - GetParam('days', $RcDefault) * 86400));
  1693. my $from = $to - (GetParam('upto') ? GetParam('upto') - GetParam('from') : GetParam('days', $RcDefault) * 86400);
  1694. my $more = "action=rc;from=$from;upto=$to";
  1695. foreach (qw(all showedit rollback rcidonly rcuseronly rchostonly
  1696. rcclusteronly rcfilteronly match lang followup)) {
  1697. my $val = GetParam($_, '');
  1698. $more .= ";$_=$val" if $val;
  1699. }
  1700. $html .= $q->p({-class=>'more'}, ScriptLink($more, T('More...'), 'more'));
  1701. return GetFormStart(undef, 'get', 'rc') . $html . $q->end_form;
  1702. }
  1703. sub PrintRcHtml { # to append RC to existing page, or action=rc directly
  1704. my ($id, $standalone) = @_;
  1705. my $rc = ($id eq $RCName or $id eq T($RCName) or T($id) eq $RCName);
  1706. if ($standalone) {
  1707. print GetHeader('', $rc ? NormalToFree($id) : Ts('All changes for %s', NormalToFree($id)));
  1708. }
  1709. if ($standalone or $rc or GetParam('rcclusteronly', '')) {
  1710. print $q->start_div({-class=>'rc'});
  1711. print $q->hr() unless $standalone or GetParam('embed', $EmbedWiki);
  1712. print RcHeader() . RcHtml() . GetFilterForm() . $q->end_div();
  1713. }
  1714. PrintFooter($id) if $standalone;
  1715. }
  1716. sub RcTextItem {
  1717. my ($name, $value) = @_;
  1718. $value = UnquoteHtml($value);
  1719. $value =~ s/\n+$//;
  1720. $value =~ s/\n+/\n /;
  1721. return $value ? $name . ': ' . $value . "\n" : '';
  1722. }
  1723. sub RcTextRevision {
  1724. my($id, $ts, $host, $username, $summary, $minor, $revision,
  1725. $languages, $cluster, $last) = @_;
  1726. my $link = $ScriptName
  1727. . (GetParam('all', $ShowAll) && ! $last
  1728. ? '?' . GetPageParameters('browse', $id, $revision, $cluster, $last)
  1729. : ($UsePathInfo ? '/' : '?') . UrlEncode($id));
  1730. print "\n", RcTextItem('title', NormalToFree($id)),
  1731. RcTextItem('description', $summary),
  1732. RcTextItem('generator', GetAuthor($host, $username)),
  1733. RcTextItem('language', join(', ', @{$languages})), RcTextItem('link', $link),
  1734. RcTextItem('last-modified', TimeToW3($ts)),
  1735. RcTextItem('revision', $revision),
  1736. RcTextItem('minor', $minor);
  1737. }
  1738. sub PrintRcText { # print text rss header and call ProcessRcLines
  1739. local $RecentLink = 0;
  1740. print RcTextItem('title', $SiteName),
  1741. RcTextItem('description', $SiteDescription), RcTextItem('link', $ScriptName),
  1742. RcTextItem('generator', 'Oddmuse'), RcTextItem('rights', $RssRights);
  1743. ProcessRcLines(sub {}, \&RcTextRevision);
  1744. }
  1745. sub GetRcRss {
  1746. my $date = TimeToRFC822($LastUpdate);
  1747. my %excluded = ();
  1748. if (GetParam("exclude", 1)) {
  1749. foreach (split(/\n/, GetPageContent($RssExclude))) {
  1750. if (/^ ([^ ]+)[ \t]*$/) { # only read lines with one word after one space
  1751. $excluded{$1} = 1;
  1752. }
  1753. }
  1754. }
  1755. my $rss = qq{<?xml version="1.0" encoding="UTF-8"?>\n};
  1756. if ($RssStyleSheet =~ /\.(xslt?|xml)$/) {
  1757. $rss .= qq{<?xml-stylesheet type="text/xml" href="$RssStyleSheet" ?>\n};
  1758. } elsif ($RssStyleSheet) {
  1759. $rss .= qq{<?xml-stylesheet type="text/css" href="$RssStyleSheet" ?>\n};
  1760. }
  1761. $rss .= qq{<rss version="2.0"
  1762. xmlns:wiki="http://purl.org/rss/1.0/modules/wiki/"
  1763. xmlns:dc="http://purl.org/dc/elements/1.1/"
  1764. xmlns:cc="http://web.resource.org/cc/"
  1765. xmlns:atom="http://www.w3.org/2005/Atom">
  1766. <channel>
  1767. <docs>http://blogs.law.harvard.edu/tech/rss</docs>
  1768. };
  1769. my $title = QuoteHtml($SiteName) . ': ' . GetParam('title', QuoteHtml(NormalToFree($HomePage)));
  1770. $rss .= "<title>$title</title>\n";
  1771. $rss .= "<link>" . ScriptUrl($HomePage) . "</link>\n";
  1772. $rss .= qq{<atom:link href="} . GetScriptUrlWithRcParameters()
  1773. . qq{" rel="self" type="application/rss+xml" />\n};
  1774. if ($SiteDescription) {
  1775. $rss .= "<description>" . QuoteHtml($SiteDescription) . "</description>\n"
  1776. }
  1777. $rss .= "<pubDate>$date</pubDate>\n";
  1778. $rss .= "<lastBuildDate>$date</lastBuildDate>\n";
  1779. $rss .= "<generator>Oddmuse</generator>\n";
  1780. $rss .= "<copyright>$RssRights</copyright>\n" if $RssRights;
  1781. if ($RssLicense) {
  1782. $rss .= join('', map {"<cc:license>" . QuoteHtml($_) . "</cc:license>\n"}
  1783. (ref $RssLicense eq 'ARRAY' ? @$RssLicense : $RssLicense))
  1784. }
  1785. $rss .= "<wiki:interwiki>$InterWikiMoniker</wiki:interwiki>\n" if $InterWikiMoniker;
  1786. if ($RssImageUrl) {
  1787. $rss .= "<image>\n";
  1788. $rss .= "<url>$RssImageUrl</url>\n";
  1789. $rss .= "<title>$title</title>\n"; # the same as the channel
  1790. $rss .= "<link>$ScriptName</link>\n"; # the same as the channel
  1791. $rss .= "</image>\n";
  1792. }
  1793. my $limit = GetParam("rsslimit", 15); # Only take the first 15 entries
  1794. my $count = 0;
  1795. ProcessRcLines(sub {}, sub {
  1796. my $id = shift;
  1797. return if $excluded{$id} or ($limit ne 'all' and $count++ >= $limit);
  1798. $rss .= "\n" . RssItem($id, @_);
  1799. });
  1800. $rss .= "</channel>\n</rss>\n";
  1801. return $rss;
  1802. }
  1803. sub RssItem {
  1804. my ($id, $ts, $host, $username, $summary, $minor, $revision,
  1805. $languages, $cluster, $last) = @_;
  1806. my $name = ItemName($id);
  1807. if (GetParam('full', 0)) { # full page means summary is not shown
  1808. $summary = PageHtml($id, 50 * 1024, T('This page is too big to send over RSS.'));
  1809. } else {
  1810. $summary = QuoteHtml($summary); # page summary must be quoted
  1811. }
  1812. my $date = TimeToRFC822($ts);
  1813. $username = QuoteHtml($username);
  1814. $username ||= $host;
  1815. my $rss = "<item>\n";
  1816. $rss .= "<title>$name</title>\n";
  1817. my $link = ScriptUrl(GetParam('all', $cluster)
  1818. ? GetPageParameters('browse', $id, $revision, $cluster, $last)
  1819. : UrlEncode($id));
  1820. $rss .= "<link>$link</link>\n<guid>$link</guid>\n";
  1821. $rss .= "<description>" . QuoteHtml($summary) . "</description>\n" if $summary;
  1822. $rss .= "<pubDate>" . $date . "</pubDate>\n";
  1823. $rss .= "<comments>" . ScriptUrl($CommentsPrefix . UrlEncode($id))
  1824. . "</comments>\n" if $CommentsPattern and $id !~ /$CommentsPattern/;
  1825. $rss .= "<dc:contributor>" . $username . "</dc:contributor>\n" if $username;
  1826. $rss .= "<wiki:status>" . (1 == $revision ? 'new' : 'updated') . "</wiki:status>\n";
  1827. $rss .= "<wiki:importance>" . ($minor ? 'minor' : 'major') . "</wiki:importance>\n";
  1828. $rss .= "<wiki:version>" . $revision . "</wiki:version>\n";
  1829. $rss .= "<wiki:history>" . ScriptUrl("action=history;id=" . UrlEncode($id))
  1830. . "</wiki:history>\n";
  1831. $rss .= "<wiki:diff>" . ScriptUrl("action=browse;diff=1;id=" . UrlEncode($id))
  1832. . "</wiki:diff>\n" if $UseDiff and GetParam('diffrclink', 1);
  1833. return $rss . "</item>\n";
  1834. }
  1835. sub DoRss {
  1836. print GetHttpHeader('application/xml');
  1837. print GetRcRss();
  1838. }
  1839. sub DoHistory {
  1840. my $id = shift;
  1841. ValidIdOrDie($id);
  1842. OpenPage($id);
  1843. if (GetParam('raw', 0)) {
  1844. DoRawHistory($id);
  1845. } else {
  1846. DoHtmlHistory($id);
  1847. }
  1848. }
  1849. sub DoRawHistory {
  1850. my ($id) = @_;
  1851. print GetHttpHeader('text/plain'),
  1852. RcTextItem('title', Ts('History of %s', NormalToFree($OpenPageName))),
  1853. RcTextItem('date', TimeToText($Now)),
  1854. RcTextItem('link', ScriptUrl("action=history;id=$OpenPageName;raw=1")),
  1855. RcTextItem('generator', 'Oddmuse');
  1856. SetParam('all', 1);
  1857. my @languages = split(/,/, $Page{languages});
  1858. RcTextRevision($id, $Page{ts}, $Page{host}, $Page{username}, $Page{summary},
  1859. $Page{minor}, $Page{revision}, \@languages, undef, 1);
  1860. foreach my $revision (GetKeepRevisions($OpenPageName)) {
  1861. my $keep = GetKeptRevision($revision);
  1862. @languages = split(/,/, $keep->{languages});
  1863. RcTextRevision($id, $keep->{ts}, $keep->{host}, $keep->{username},
  1864. $keep->{summary}, $keep->{minor}, $keep->{revision}, \@languages);
  1865. }
  1866. }
  1867. sub DoHtmlHistory {
  1868. my ($id) = @_;
  1869. print GetHeader('', Ts('History of %s', NormalToFree($id)));
  1870. my $row = 0;
  1871. my $rollback = UserCanEdit($id, 0) && (GetParam('username', '')
  1872. or UserIsEditor());
  1873. my $date = CalcDay($Page{ts});
  1874. my @html = (GetHistoryLine($id, \%Page, $row++, $rollback, $date, 1));
  1875. foreach my $revision (GetKeepRevisions($OpenPageName)) {
  1876. my $keep = GetKeptRevision($revision);
  1877. my $new = CalcDay($keep->{ts});
  1878. push(@html, GetHistoryLine($id, $keep, $row++, $rollback,
  1879. $new, $new ne $date));
  1880. $date = $new;
  1881. }
  1882. @html = (GetFormStart(undef, 'get', 'history'),
  1883. $q->p($q->submit({-name=>T('Compare')}),
  1884. # don't use $q->hidden here!
  1885. $q->input({-type=>'hidden', -name=>'action', -value=>'browse'}),
  1886. $q->input({-type=>'hidden', -name=>'diff', -value=>'1'}),
  1887. $q->input({-type=>'hidden', -name=>'id', -value=>$id})),
  1888. $q->table({-class=>'history'}, @html),
  1889. $q->p($q->submit({-name=>T('Compare')})),
  1890. $q->end_form()) if $UseDiff;
  1891. if ($KeepDays and $rollback and $Page{revision}) {
  1892. push(@html, $q->p(ScriptLink('title=' . UrlEncode($id) . ';text='
  1893. . UrlEncode($DeletedPage) . ';summary='
  1894. . UrlEncode(T('Deleted')),
  1895. T('Mark this page for deletion'))));
  1896. }
  1897. print $q->div({-class=>'content history'}, @html);
  1898. PrintFooter($id, 'history');
  1899. }
  1900. sub GetHistoryLine {
  1901. my ($id, $dataref, $row, $rollback, $date, $newday) = @_;
  1902. my %data = %$dataref;
  1903. my $revision = $data{revision};
  1904. return $q->p(T('No other revisions available')) unless $revision;
  1905. my $html = CalcTime($data{ts});
  1906. if ($row == 0) { # current revision
  1907. $html .= ' (' . T('current') . ')' if $rollback;
  1908. $html .= ' ' . GetPageLink($id, Ts('Revision %s', $revision));
  1909. } else {
  1910. $html .= ' ' . $q->submit("rollback-$data{ts}", T('rollback')) if $rollback;
  1911. $html .= ' ' . GetOldPageLink('browse', $id, $revision,
  1912. Ts('Revision %s', $revision));
  1913. }
  1914. my $host = $data{host} || $data{ip};
  1915. $html .= T(' . . . .') . ' ' . GetAuthorLink($host, $data{username});
  1916. $html .= $q->span({class=>'dash'}, ' &#8211; ')
  1917. . $q->strong(QuoteHtml($data{summary})) if $data{summary};
  1918. $html .= ' ' . $q->em({class=>'type'}, T('(minor)')) . ' ' if $data{minor};
  1919. if ($UseDiff) {
  1920. my %attr1 = (-type=>'radio', -name=>'diffrevision', -value=>$revision);
  1921. $attr1{-checked} = 'checked' if $row == 1;
  1922. my %attr2 = (-type=>'radio', -name=>'revision', -value=> $row ? $revision : '');
  1923. $attr2{-checked} = 'checked' if $row == 0; # first row is special
  1924. $html = $q->Tr($q->td($q->input(\%attr1)), $q->td($q->input(\%attr2)), $q->td($html));
  1925. $html = $q->Tr($q->td({-colspan=>3}, $q->strong($date))) . $html if $newday;
  1926. } else {
  1927. $html .= $q->br();
  1928. $html = $q->strong($date) . $q->br() . $html if $newday;
  1929. }
  1930. return $html;
  1931. }
  1932. sub DoContributors {
  1933. my $id = shift;
  1934. SetParam('rcidonly', $id);
  1935. SetParam('all', 1);
  1936. print GetHeader('', Ts('Contributors to %s', NormalToFree($id || $SiteName)));
  1937. my %contrib = ();
  1938. for my $line (GetRcLines(1)) {
  1939. my ($ts, $pagename, $minor, $summary, $host, $username) = @$line;
  1940. $contrib{$username}++ if $username;
  1941. }
  1942. print $q->div({-class=>'content contrib'},
  1943. $q->p(map { GetPageLink($_) } sort(keys %contrib)));
  1944. PrintFooter();
  1945. }
  1946. sub RollbackPossible {
  1947. my $ts = shift; # there can be no rollback to the most recent change(s) made (1s resolution!)
  1948. return $ts != $LastUpdate && (!$KeepDays || ($Now - $ts) < $KeepDays * 86400); # 24*60*60
  1949. }
  1950. sub DoRollback {
  1951. my $page = shift;
  1952. my $to = GetParam('to', 0);
  1953. ReportError(T('Missing target for rollback.'), '400 BAD REQUEST') unless $to;
  1954. ReportError(T('Target for rollback is too far back.'), '400 BAD REQUEST') unless $page or RollbackPossible($to);
  1955. ReportError(T('A username is required for ordinary users.'), '403 FORBIDDEN') unless GetParam('username', '') or UserIsEditor();
  1956. my @ids = ();
  1957. if (not $page) { # cannot just use list length because of ('')
  1958. return unless UserIsAdminOrError(); # only admins can do mass changes
  1959. SetParam('showedit', 1); # make GetRcLines return minor edits as well
  1960. SetParam('all', 1); # prevent LatestChanges from interfering
  1961. SetParam('rollback', 1); # prevent StripRollbacks from interfering
  1962. my %ids = map { my ($ts, $id) = @$_; $id => 1; } # make unique via hash
  1963. GetRcLines($to); # list all the pages edited since $to
  1964. @ids = keys %ids;
  1965. } else {
  1966. @ids = ($page);
  1967. }
  1968. RequestLockOrError();
  1969. print GetHeader('', T('Rolling back changes')),
  1970. $q->start_div({-class=>'content rollback'}), $q->start_p();
  1971. foreach my $id (@ids) {
  1972. OpenPage($id);
  1973. my ($text, $minor, $ts) = GetTextAtTime($to);
  1974. if ($Page{text} eq $text) {
  1975. print T("The two revisions are the same."), $q->br() if $page; # no message when doing mass revert
  1976. } elsif (not UserCanEdit($id, 1)) {
  1977. print Ts('Editing not allowed: %s is read-only.', $id), $q->br();
  1978. } elsif (not UserIsEditor() and my $rule = BannedContent($text)) {
  1979. print Ts('Rollback of %s would restore banned content.', $id), $rule, $q->br();
  1980. } else {
  1981. Save($id, $text, Ts('Rollback to %s', TimeToText($to)), $minor, ($Page{host} ne $q->remote_addr()));
  1982. print Ts('%s rolled back', GetPageLink($id)), ($ts ? ' ' . Ts('to %s', TimeToText($to)) : ''), $q->br();
  1983. }
  1984. }
  1985. WriteRcLog('[[rollback]]', $page, $to); # leave marker
  1986. print $q->end_p() . $q->end_div();
  1987. ReleaseLock();
  1988. PrintFooter($page, 'edit');
  1989. }
  1990. sub DoAdminPage {
  1991. my ($id, @rest) = @_;
  1992. my @menu = ();
  1993. push(@menu, ScriptLink('action=index', T('Index of all pages'), 'index')) if $Action{index};
  1994. push(@menu, ScriptLink('action=version', T('Wiki Version'), 'version')) if $Action{version};
  1995. push(@menu, ScriptLink('action=password', T('Password'), 'password')) if $Action{password};
  1996. push(@menu, ScriptLink('action=maintain', T('Run maintenance'), 'maintain')) if $Action{maintain};
  1997. my @locks;
  1998. for my $pattern (@KnownLocks) {
  1999. for my $name (Glob($pattern)) {
  2000. if (IsDir($LockDir . $name)) {
  2001. push(@locks, $name);
  2002. }
  2003. }
  2004. }
  2005. if (@locks and $Action{unlock}) {
  2006. push(@menu, ScriptLink('action=unlock', T('Unlock Wiki'), 'unlock') . ' (' . join(', ', @locks) . ')');
  2007. };
  2008. if (UserIsAdmin()) {
  2009. if ($Action{editlock}) {
  2010. if (IsFile("$DataDir/noedit")) {
  2011. push(@menu, ScriptLink('action=editlock;set=0', T('Unlock site'), 'editlock 0'));
  2012. } else {
  2013. push(@menu, ScriptLink('action=editlock;set=1', T('Lock site'), 'editlock 1'));
  2014. }
  2015. }
  2016. if ($id and $Action{pagelock}) {
  2017. my $title = NormalToFree($id);
  2018. if (IsFile(GetLockedPageFile($id))) {
  2019. push(@menu, ScriptLink('action=pagelock;set=0;id=' . UrlEncode($id),
  2020. Ts('Unlock %s', $title), 'pagelock 0'));
  2021. } else {
  2022. push(@menu, ScriptLink('action=pagelock;set=1;id=' . UrlEncode($id),
  2023. Ts('Lock %s', $title), 'pagelock 1'));
  2024. }
  2025. }
  2026. push(@menu, ScriptLink('action=clear', T('Clear Cache'), 'clear')) if $Action{clear};
  2027. }
  2028. foreach my $sub (@MyAdminCode) {
  2029. $sub->($id, \@menu, \@rest);
  2030. $Message .= $q->p($@) if $@; # since this happens before GetHeader is called, the message will be shown
  2031. }
  2032. print GetHeader('', T('Administration')),
  2033. $q->div({-class=>'content admin'}, $q->p(T('Actions:')), $q->ul($q->li(\@menu)),
  2034. $q->p(T('Important pages:')) . $q->ul(map { $q->li(GetPageOrEditLink($_, NormalToFree($_))) if $_;
  2035. } sort keys %AdminPages),
  2036. $q->p(Ts('To mark a page for deletion, put <strong>%s</strong> on the first line.',
  2037. $DeletedPage)), @rest);
  2038. PrintFooter();
  2039. }
  2040. sub GetPageParameters {
  2041. my ($action, $id, $revision, $cluster, $last) = @_;
  2042. $id = FreeToNormal($id);
  2043. my $link = "action=$action;id=" . UrlEncode($id);
  2044. $link .= ";revision=$revision" if $revision and not $last;
  2045. $link .= ';rcclusteronly=' . UrlEncode($cluster) if $cluster;
  2046. return $link;
  2047. }
  2048. sub GetOldPageLink {
  2049. my ($action, $id, $revision, $name, $cluster, $last) = @_;
  2050. return ScriptLink(GetPageParameters($action, $id, $revision, $cluster, $last),
  2051. NormalToFree($name), 'revision');
  2052. }
  2053. sub GetSearchLink {
  2054. my ($text, $class, $name, $title) = @_;
  2055. my $id = UrlEncode(QuoteRegexp('"' . $text . '"'));
  2056. $name = UrlEncode($name);
  2057. $text = NormalToFree($text);
  2058. $id =~ s/_/+/g; # Search for url-escaped spaces
  2059. return ScriptLink('search=' . $id, $text, $class, $name, $title);
  2060. }
  2061. sub ScriptLinkDiff {
  2062. my ($diff, $id, $text, $new, $old) = @_;
  2063. my $action = 'action=browse;diff=' . $diff . ';id=' . UrlEncode($id);
  2064. $action .= ";diffrevision=$old" if $old;
  2065. $action .= ";revision=$new" if $new;
  2066. return ScriptLink($action, $text, 'diff');
  2067. }
  2068. sub GetAuthor {
  2069. my ($host, $username) = @_;
  2070. return $username . ' ' . Ts('from %s', $host) if $username and $host;
  2071. return $username if $username;
  2072. return T($host); # could be 'Anonymous'
  2073. }
  2074. sub GetAuthorLink {
  2075. my ($host, $username) = @_;
  2076. $username = FreeToNormal($username);
  2077. my $name = NormalToFree($username);
  2078. if (ValidId($username) ne '') { # ValidId() returns error string
  2079. $username = ''; # Just pretend it isn't there.
  2080. }
  2081. if ($username and $RecentLink) {
  2082. return ScriptLink(UrlEncode($username), $name, 'author', undef, $host);
  2083. } elsif ($username) {
  2084. return $q->span({-class=>'author'}, $name);
  2085. }
  2086. return T($host); # could be 'Anonymous'
  2087. }
  2088. sub GetHistoryLink {
  2089. my ($id, $text) = @_;
  2090. my $action = 'action=history;id=' . UrlEncode(FreeToNormal($id));
  2091. return ScriptLink($action, $text, 'history');
  2092. }
  2093. sub GetRCLink {
  2094. my ($id, $text) = @_;
  2095. return ScriptLink('action=rc;all=1;from=1;showedit=1;rcidonly='
  2096. . UrlEncode(FreeToNormal($id)), $text, 'rc');
  2097. }
  2098. sub GetHeader {
  2099. my ($id, $title, $oldId, $nocache, $status) = @_;
  2100. my $embed = GetParam('embed', $EmbedWiki);
  2101. my $result = GetHttpHeader('text/html', $nocache, $status);
  2102. if ($oldId) {
  2103. $Message .= $q->p('(' . Ts('redirected from %s', GetEditLink($oldId, $oldId)) . ')');
  2104. }
  2105. $result .= GetHtmlHeader(Ts('%s:', $SiteName) . ' ' . UnWiki($title), $id);
  2106. if ($embed) {
  2107. $result .= $q->div({-class=>'header'}, $q->div({-class=>'message'}, $Message)) if $Message;
  2108. return $result;
  2109. }
  2110. $result .= GetHeaderDiv($id, $title, $oldId, $embed);
  2111. return $result . $q->start_div({-class=>'wrapper'});
  2112. }
  2113. sub GetHeaderDiv {
  2114. my ($id, $title, $oldId, $embed) = @_;
  2115. my $result .= $q->start_div({-class=>'header'});
  2116. if (not $embed and $LogoUrl) {
  2117. my $url = $IndexHash{$LogoUrl} ? GetDownloadLink($LogoUrl, 2) : $LogoUrl;
  2118. $result .= ScriptLink(UrlEncode($HomePage), $q->img({-src=>$url, -alt=>T('[Home]'), -class=>'logo'}), 'logo');
  2119. }
  2120. $result .= $q->start_div({-class=>'menu'});
  2121. if (GetParam('toplinkbar', $TopLinkBar) != 2) {
  2122. $result .= GetGotoBar($id);
  2123. if (%SpecialDays) {
  2124. my ($sec, $min, $hour, $mday, $mon, $year) = gmtime($Now);
  2125. if ($SpecialDays{($mon + 1) . '-' . $mday}) {
  2126. $result .= $q->br() . $q->span({-class=>'specialdays'},
  2127. $SpecialDays{($mon + 1) . '-' . $mday});
  2128. }
  2129. }
  2130. }
  2131. $result .= GetSearchForm() if GetParam('topsearchform', $TopSearchForm) != 2;
  2132. $result .= $q->end_div();
  2133. $result .= $q->div({-class=>'message'}, $Message) if $Message;
  2134. $result .= GetHeaderTitle($id, $title, $oldId);
  2135. $result .= $q->end_div();
  2136. return $result;
  2137. }
  2138. sub GetHeaderTitle {
  2139. my ($id, $title, $oldId) = @_;
  2140. return $q->h1($title) if $id eq '';
  2141. return $q->h1(GetSearchLink($id, '', '', T('Click to search for references to this page')));
  2142. }
  2143. sub GetHttpHeader {
  2144. return if $HeaderIsPrinted; # When calling ReportError, we don't know whether HTTP headers have
  2145. $HeaderIsPrinted = 1; # already been printed. We want them printed just once.
  2146. my ($type, $ts, $status, $encoding) = @_;
  2147. $q->charset($type =~ m!^(text/|application/xml)! ? 'utf-8' : ''); # text/plain, text/html, application/xml: UTF-8
  2148. my %headers = (-cache_control=>($UseCache < 0 ? 'no-cache' : 'max-age=10'));
  2149. # Set $ts when serving raw content that cannot be modified by cookie
  2150. # parameters; or 'nocache'; or undef. If you provide a $ts, the last-modified
  2151. # header generated will by used by HTTP/1.0 clients. If you provide no $ts,
  2152. # the etag header generated will be used by HTTP/1.1 clients. In this
  2153. # situation, cookie parameters can influence the look of the page and we
  2154. # cannot rely on $LastUpdate. HTTP/1.0 clients will ignore etags. See RFC 2616
  2155. # section 13.3.4.
  2156. if (GetParam('cache', $UseCache) >= 2 and $ts ne 'nocache') {
  2157. $headers{'-last-modified'} = TimeToRFC822($ts) if $ts;
  2158. $headers{-etag} = PageEtag();
  2159. }
  2160. $headers{-type} = GetParam('mime-type', $type);
  2161. $headers{-status} = $status if $status;
  2162. $headers{-Content_Encoding} = $encoding if $encoding;
  2163. my $cookie = Cookie();
  2164. $headers{-cookie} = $cookie if $cookie;
  2165. if ($q->request_method() eq 'HEAD') {
  2166. print $q->header(%headers), "\n\n"; # add newlines for FCGI because of exit()
  2167. exit; # total shortcut -- HEAD never expects anything other than the header!
  2168. }
  2169. return $q->header(%headers);
  2170. }
  2171. sub CookieData {
  2172. my ($changed, %params);
  2173. foreach my $key (keys %CookieParameters) {
  2174. my $default = $CookieParameters{$key};
  2175. my $value = GetParam($key, $default);
  2176. $params{$key} = $value if $value ne $default;
  2177. # The cookie is considered to have changed under the following
  2178. # condition: If the value was already set, and the new value is
  2179. # not the same as the old value, or if there was no old value, and
  2180. # the new value is not the default.
  2181. my $change = (defined $OldCookie{$key} ? ($value ne $OldCookie{$key}) : ($value ne $default));
  2182. $changed = 1 if $change; # note if any parameter changed and needs storing
  2183. }
  2184. return $changed, %params;
  2185. }
  2186. sub Cookie {
  2187. my ($changed, %params) = CookieData(); # params are URL encoded
  2188. if ($changed) {
  2189. my $cookie = join(UrlEncode($FS), %params); # no CTL in field values
  2190. return $q->cookie(-name=>$CookieName, -value=>$cookie, -expires=>'+2y', secure=>$ENV{'HTTPS'}, httponly=>1);
  2191. }
  2192. return '';
  2193. }
  2194. sub GetHtmlHeader { # always HTML!
  2195. my ($title, $id) = @_;
  2196. my $edit_link = '';
  2197. $edit_link = '<link rel="alternate" type="application/wiki" title="'
  2198. . T('Edit this page') . '" href="'
  2199. . ScriptUrl('action=edit;id=' . UrlEncode($id)) . '" />' if $id;
  2200. return $DocumentHeader
  2201. . $q->head($q->title($title) . $edit_link
  2202. . GetCss() . GetRobots() . GetFeeds() . $HtmlHeaders
  2203. . '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />')
  2204. . '<body class="' . GetParam('theme', 'default') . '">';
  2205. }
  2206. sub GetRobots { # NOINDEX for non-browse pages.
  2207. if (GetParam('action', 'browse') eq 'browse' and not GetParam('revision', '')) {
  2208. return '<meta name="robots" content="INDEX,FOLLOW" />';
  2209. } else {
  2210. return '<meta name="robots" content="NOINDEX,FOLLOW" />';
  2211. }
  2212. }
  2213. sub GetFeeds { # default for $HtmlHeaders
  2214. my $html = '<link rel="alternate" type="application/rss+xml" title="'
  2215. . QuoteHtml($SiteName) . '" href="' . $ScriptName . '?action=rss" />';
  2216. my $id = GetId(); # runs during Init, not during DoBrowseRequest
  2217. $html .= '<link rel="alternate" type="application/rss+xml" title="'
  2218. . QuoteHtml("$SiteName: $id") . '" href="' . $ScriptName
  2219. . '?action=rss;rcidonly=' . UrlEncode(FreeToNormal($id)) . '" />' if $id;
  2220. my $username = GetParam('username', '');
  2221. $html .= '<link rel="alternate" type="application/rss+xml" '
  2222. . 'title="Follow-ups for ' . NormalToFree($username) . '" '
  2223. . 'href="' . ScriptUrl('action=rss;followup=' . UrlEncode($username))
  2224. . '" />' if $username;
  2225. return $html;
  2226. }
  2227. sub GetCss { # prevent javascript injection
  2228. my @css = map { my $x = $_; $x =~ s/\".*//; $x; } split(/\s+/, GetParam('css', ''));
  2229. push (@css, ref $StyleSheet ? @$StyleSheet : $StyleSheet) if $StyleSheet and not @css;
  2230. if ($IndexHash{$StyleSheetPage} and not @css) {
  2231. push (@css, "$ScriptName?action=browse;id=" . UrlEncode($StyleSheetPage) . ";raw=1;mime-type=text/css")
  2232. }
  2233. push (@css, 'https://oddmuse.org/default.css') unless @css;
  2234. return join('', map { qq(<link type="text/css" rel="stylesheet" href="$_" />) } @css);
  2235. }
  2236. sub PrintPageContent {
  2237. my ($text, $revision, $comment) = @_;
  2238. print $q->start_div({-class=>'content browse'});
  2239. if ($revision eq '' and $Page{blocks} and GetParam('cache', $UseCache) > 0) {
  2240. PrintCache();
  2241. } else {
  2242. my $savecache = ($Page{revision} > 0 and $revision eq ''); # new page not cached
  2243. PrintWikiToHTML($text, $savecache, $revision); # unlocked, with anchors, unlocked
  2244. }
  2245. if ($comment) {
  2246. print $q->start_div({-class=>'preview'}), $q->hr();
  2247. print $q->h2(T('Preview:'));
  2248. # no caching, current revision, unlocked
  2249. PrintWikiToHTML(AddComment('', $comment));
  2250. print $q->hr(), $q->h2(T('Preview only, not yet saved')), $q->end_div();
  2251. }
  2252. print $q->end_div();
  2253. }
  2254. sub PrintFooter {
  2255. my ($id, $rev, $comment, $page) = @_;
  2256. if (GetParam('embed', $EmbedWiki)) {
  2257. print $q->end_html, "\n";
  2258. return;
  2259. }
  2260. PrintMyContent($id) if defined(&PrintMyContent);
  2261. foreach my $sub (@MyFooters) {
  2262. print $sub->(@_);
  2263. }
  2264. print $q->end_html, "\n";
  2265. }
  2266. sub WrapperEnd { # called via @MyFooters
  2267. return $q->start_div({-class=>'wrapper close'}) . $q->end_div() . $q->end_div(); # closes content
  2268. }
  2269. sub DefaultFooter { # called via @MyFooters
  2270. my ($id, $rev, $comment, $page) = @_;
  2271. my $html = $q->start_div({-class=>'footer'}) . $q->hr();
  2272. $html .= GetGotoBar($id) if GetParam('toplinkbar', $TopLinkBar) != 1;
  2273. $html .= GetFooterLinks($id, $rev);
  2274. $html .= GetFooterTimestamp($id, $rev, $page);
  2275. $html .= GetSearchForm() if GetParam('topsearchform', $TopSearchForm) != 1;
  2276. if ($DataDir =~ m|/tmp/|) {
  2277. $html .= $q->p($q->strong(T('Warning') . ': ')
  2278. . Ts('Database is stored in temporary directory %s', $DataDir));
  2279. }
  2280. $html .= T($FooterNote) if $FooterNote;
  2281. $html .= $q->p(Ts('%s seconds', (time - $Now))) if GetParam('timing', 0);
  2282. $html .= $q->end_div();
  2283. return $html;
  2284. }
  2285. sub GetFooterTimestamp {
  2286. my ($id, $rev, $page) = @_;
  2287. $page //= \%Page;
  2288. if ($id and $rev ne 'history' and $rev ne 'edit' and $page->{revision}) {
  2289. my @elements = (($rev eq '' ? T('Last edited') : T('Edited')), TimeToText($page->{ts}),
  2290. Ts('by %s', GetAuthorLink($page->{host}, $page->{username})));
  2291. push(@elements, ScriptLinkDiff(2, $id, T('(diff)'), $rev)) if $UseDiff and $page->{revision} > 1;
  2292. return $q->div({-class=>'time'}, @elements);
  2293. }
  2294. return '';
  2295. }
  2296. sub GetFooterLinks {
  2297. my ($id, $rev) = @_;
  2298. my @elements;
  2299. if ($id and $rev ne 'history' and $rev ne 'edit') {
  2300. if ($CommentsPattern) {
  2301. if ($id =~ /$CommentsPattern/) {
  2302. push(@elements, GetPageLink($1, undef, 'original', T('a'))) if $1;
  2303. } else {
  2304. push(@elements, GetPageLink($CommentsPrefix . $id, undef, 'comment', T('c')));
  2305. }
  2306. }
  2307. if (UserCanEdit($id, 0)) {
  2308. if ($rev) { # showing old revision
  2309. push(@elements, GetOldPageLink('edit', $id, $rev, Ts('Edit revision %s of this page', $rev)));
  2310. } else { # showing current revision
  2311. push(@elements, GetEditLink($id, T('Edit this page'), undef, T('e')));
  2312. }
  2313. } else { # no permission or generated page
  2314. push(@elements, ScriptLink('action=password', T('This page is read-only'), 'password'));
  2315. }
  2316. }
  2317. push(@elements, GetHistoryLink($id, T('View other revisions'))) if $Action{history} and $id and $rev ne 'history';
  2318. push(@elements, GetPageLink($id, T('View current revision')),
  2319. GetRCLink($id, T('View all changes'))) if $Action{history} and $rev ne '';
  2320. if ($Action{contrib} and $id and $rev eq 'history') {
  2321. push(@elements, ScriptLink("action=contrib;id=" . UrlEncode($id), T('View contributors'), 'contrib'));
  2322. }
  2323. if ($Action{admin} and GetParam('action', '') ne 'admin') {
  2324. my $action = 'action=admin';
  2325. $action .= ';id=' . UrlEncode($id) if $id;
  2326. push(@elements, ScriptLink($action, T('Administration'), 'admin'));
  2327. }
  2328. return @elements ? $q->div({-class=>'edit bar'}, @elements) : '';
  2329. }
  2330. sub GetCommentForm {
  2331. my ($id, $rev, $comment) = @_;
  2332. if ($CommentsPattern ne '' and $id and $rev ne 'history' and $rev ne 'edit'
  2333. and $id =~ /$CommentsPattern/ and UserCanEdit($id, 0, 1)) {
  2334. my $html = $q->div({-class=>'comment'},
  2335. GetFormStart(undef, undef, 'comment'),
  2336. $q->p(GetHiddenValue('title', $id),
  2337. $q->label({-for=>'aftertext', -accesskey=>T('c')},
  2338. T('Add your comment here:')), $q->br(),
  2339. GetTextArea('aftertext', $comment, 10)),
  2340. $EditNote,
  2341. $q->p($q->span({-class=>'username'},
  2342. $q->label({-for=>'username'}, T('Username:')), ' ',
  2343. $q->textfield(-name=>'username', -id=>'username',
  2344. -default=>GetParam('username', ''),
  2345. -override=>1, -size=>20, -maxlength=>50)),
  2346. $q->span({-class=>'homepage'},
  2347. $q->label({-for=>'homepage'}, T('Homepage URL:')), ' ',
  2348. $q->textfield(-name=>'homepage', -id=>'homepage',
  2349. -default=>GetParam('homepage', ''),
  2350. -override=>1, -size=>40, -maxlength=>100))),
  2351. $q->p($q->submit(-name=>'Save', -accesskey=>T('s'), -value=>T('Save')), ' ',
  2352. $q->submit(-name=>'Preview', -accesskey=>T('p'), -value=>T('Preview'))),
  2353. $q->end_form());
  2354. foreach my $sub (@MyFormChanges) {
  2355. $html = $sub->($html, 'comment');
  2356. }
  2357. return $html;
  2358. }
  2359. return '';
  2360. }
  2361. sub GetFormStart {
  2362. my ($ignore, $method, $class) = @_;
  2363. $method ||= 'post';
  2364. $class ||= 'form';
  2365. return $q->start_multipart_form(-method=>$method, -action=>$FullUrl,
  2366. -accept_charset=>'utf-8', -class=>$class);
  2367. }
  2368. sub GetSearchForm {
  2369. my $html = GetFormStart(undef, 'get', 'search') . $q->start_p;
  2370. $html .= $q->label({-for=>'search'}, T('Search:')) . ' '
  2371. . $q->textfield(-name=>'search', -id=>'search', -size=>20, -accesskey=>T('f')) . ' ';
  2372. if (GetParam('search') ne '' and UserIsAdmin()) { # see DoBrowseRequest
  2373. $html .= $q->label({-for=>'replace'}, T('Replace:')) . ' '
  2374. . $q->textfield(-name=>'replace', -id=>'replace', -size=>20) . ' '
  2375. . $q->label({-for=>'delete', -title=>'If you want to replace matches with the empty string'}, T('Delete')) . ' '
  2376. . $q->input({-type=>'checkbox', -name=>'delete'})
  2377. . $q->submit('preview', T('Preview'));
  2378. }
  2379. if (GetParam('matchingpages', $MatchingPages)) {
  2380. $html .= $q->label({-for=>'matchingpage'}, T('Filter:')) . ' '
  2381. . $q->textfield(-name=>'match', -id=>'matchingpage', -size=>20) . ' ';
  2382. }
  2383. if (%Languages) {
  2384. $html .= $q->label({-for=>'searchlang'}, T('Language:')) . ' '
  2385. . $q->textfield(-name=>'lang', -id=>'searchlang', -size=>10, -default=>GetParam('lang', '')) . ' ';
  2386. }
  2387. $html .= $q->submit('dosearch', T('Go!')) . $q->end_p . $q->end_form;
  2388. return $html;
  2389. }
  2390. sub GetGotoBar { # ignore $id parameter
  2391. return $q->span({-class=>'gotobar bar'}, (map { GetPageLink($_) }
  2392. @UserGotoBarPages), $UserGotoBar);
  2393. }
  2394. sub PrintHtmlDiff {
  2395. my ($type, $old, $page, $current) = @_;
  2396. $page //= \%Page;
  2397. $current //= $page->{revision};
  2398. $type = 2 if $old or $page->{revision} != $current; # explicit revisions means minor diffs!
  2399. my $summary = $page->{$type == 1 ? 'lastmajorsummary' : 'summary'};
  2400. my $intro = T('Last edit');
  2401. my $diff;
  2402. # use the cached diff if possible
  2403. if (not $old or $old == $page->{$type == 1 ? 'lastmajor' : 'revision'} - 1) {
  2404. $diff = GetCacheDiff($type == 1 ? 'major' : 'minor', $page);
  2405. $old = $page->{$type == 1 ? 'lastmajor' : 'revision'} - 1 if not $old;
  2406. }
  2407. # if there was no cached diff: compute it, and new intro
  2408. if (not $diff and $old > 0) {
  2409. ($diff, my $keptPage) = GetKeptDiff($page->{text}, $old);
  2410. my $to = $page->{revision} != $current ? Ts('revision %s', $page->{revision}) : T('current revision');
  2411. $intro = Tss('Difference between revision %1 and %2', $old, $to);
  2412. }
  2413. # if this is the last major diff and there are minor diffs to look at, and we
  2414. # didn't request a particular old revision
  2415. if ($type == 1 and $page->{lastmajor} and $page->{lastmajor} != $current) {
  2416. $intro = Ts('Last major edit (%s)', ScriptLinkDiff(2, $OpenPageName, T('later minor edits'),
  2417. undef, $page->{lastmajor} || 1));
  2418. }
  2419. $diff =~ s!<p><strong>(.*?)</strong></p>!'<p><strong>' . T($1) . '</strong></p>'!eg;
  2420. $diff ||= T('No diff available.');
  2421. print $q->div({-class=>'diff'}, $q->p($q->b($intro)),
  2422. $summary ? $q->p({-class=>'summary'}, T('Summary:') . ' ' . QuoteHtml($summary)) : '',
  2423. $diff);
  2424. }
  2425. sub GetCacheDiff {
  2426. my ($type, $page) = @_;
  2427. my $diff = $page->{"diff-$type"};
  2428. $diff = $page->{"diff-minor"} if $diff eq '1'; # if major eq minor diff
  2429. return $diff;
  2430. }
  2431. sub GetKeptDiff {
  2432. my ($new, $revision) = @_;
  2433. $revision ||= 1;
  2434. my ($revisionPage, $rev) = GetTextRevision($revision, 1);
  2435. return '', $revisionPage unless $rev;
  2436. return T("The two revisions are the same."), $revisionPage if $revisionPage->{text} eq $new;
  2437. return GetDiff($revisionPage->{text}, $new, $rev), $revisionPage;
  2438. }
  2439. sub DoDiff { # Actualy call the diff program
  2440. CreateDir($TempDir);
  2441. my $oldName = "$TempDir/old";
  2442. my $newName = "$TempDir/new";
  2443. RequestLockDir('diff') or return '';
  2444. WriteStringToFile($oldName, $_[0]);
  2445. WriteStringToFile($newName, $_[1]);
  2446. my $diff_out = decode_utf8(`diff -- \Q$oldName\E \Q$newName\E`);
  2447. ReleaseLockDir('diff');
  2448. $diff_out =~ s/\n\K\\ No newline.*\n//g; # Get rid of common complaint.
  2449. # No need to unlink temp files--next diff will just overwrite.
  2450. return $diff_out;
  2451. }
  2452. sub GetDiff {
  2453. my ($old, $new, $revision) = @_;
  2454. my $old_is_file = (TextIsFile($old))[0] || '';
  2455. my $old_is_image = ($old_is_file =~ /^image\//);
  2456. my $new_is_file = TextIsFile($new);
  2457. if ($old_is_file or $new_is_file) {
  2458. return $q->p($q->strong(T('Old revision:')))
  2459. . $q->div({-class=>'old'}, # don't pring new revision, because that's the one that gets shown!
  2460. $q->p($old_is_file ? GetDownloadLink($OpenPageName, $old_is_image, $revision) : $old))
  2461. }
  2462. $old =~ s/[\r\n]+/\n/g;
  2463. $new =~ s/[\r\n]+/\n/g;
  2464. return ImproveDiff(DoDiff($old, $new));
  2465. }
  2466. sub ImproveDiff { # NO NEED TO BE called within a diff lock
  2467. my $diff = QuoteHtml(shift);
  2468. $diff =~ tr/\r//d;
  2469. my @hunks = split (/^(\d+,?\d*[adc]\d+,?\d*\n)/m, $diff);
  2470. my $result = shift (@hunks); # intro
  2471. while ($#hunks > 0) { # at least one header and a real hunk
  2472. my $header = shift (@hunks);
  2473. $header =~ s|^(\d+.*c.*)|<p><strong>Changed:</strong></p>| # T('Changed:')
  2474. or $header =~ s|^(\d+.*d.*)|<p><strong>Deleted:</strong></p>| # T('Deleted:')
  2475. or $header =~ s|^(\d+.*a.*)|<p><strong>Added:</strong></p>|; # T('Added:')
  2476. $result .= $header;
  2477. my $chunk = shift (@hunks);
  2478. my ($old, $new) = split (/\n---\n/, $chunk, 2);
  2479. if ($old and $new) {
  2480. ($old, $new) = DiffMarkWords($old, $new);
  2481. $result .= "$old<p><strong>to</strong></p>\n$new"; # T('to')
  2482. } else {
  2483. if (substr($chunk, 0, 2) eq '&g') {
  2484. $result .= DiffAddPrefix(DiffStripPrefix($chunk), '&gt; ', 'new');
  2485. } else {
  2486. $result .= DiffAddPrefix(DiffStripPrefix($chunk), '&lt; ', 'old');
  2487. }
  2488. }
  2489. }
  2490. return $result;
  2491. }
  2492. sub DiffMarkWords {
  2493. my ($old, $new) = map { DiffStripPrefix($_) } @_;
  2494. my @diffs = grep(/^\d/, split(/\n/, DoDiff(join("\n", split(/\s+|\b/, $old)) . "\n",
  2495. join("\n", split(/\s+|\b/, $new)) . "\n")));
  2496. foreach my $diff (reverse @diffs) { # so that new html tags don't confuse word counts
  2497. my ($start1, $end1, $type, $start2, $end2) = $diff =~ /^(\d+),?(\d*)([adc])(\d+),?(\d*)$/gm;
  2498. if ($type eq 'd' or $type eq 'c') {
  2499. $end1 ||= $start1;
  2500. $old = DiffHtmlMarkWords($old, $start1, $end1);
  2501. }
  2502. if ($type eq 'a' or $type eq 'c') {
  2503. $end2 ||= $start2;
  2504. $new = DiffHtmlMarkWords($new, $start2, $end2);
  2505. }
  2506. }
  2507. return (DiffAddPrefix($old, '&lt; ', 'old'),
  2508. DiffAddPrefix($new, '&gt; ', 'new'));
  2509. }
  2510. sub DiffHtmlMarkWords {
  2511. my ($text, $start, $end) = @_;
  2512. my @fragments = split(/(\s+|\b)/, $text);
  2513. splice(@fragments, 2 * ($start - 1), 0, '<strong class="changes">');
  2514. splice(@fragments, 2 * $end, 0, '</strong>');
  2515. my $result = join('', @fragments);
  2516. $result =~ s!&<(/?)strong([^>]*)>(amp|[gl]t);!<$1strong$2>&$3;!g;
  2517. $result =~ s!&(amp|[gl]t)<(/?)strong([^>]*)>;!&$1;<$2strong$3>!g;
  2518. return $result;
  2519. }
  2520. sub DiffStripPrefix {
  2521. my $str = shift;
  2522. $str =~ s/^&[lg]t; //gm;
  2523. return $str;
  2524. }
  2525. sub DiffAddPrefix {
  2526. my ($str, $prefix, $class) = @_;
  2527. my @lines = split(/\n/, $str);
  2528. for my $line (@lines) {
  2529. $line = $prefix . $line;
  2530. }
  2531. return $q->div({-class=>$class}, $q->p(join($q->br(), @lines)));
  2532. }
  2533. sub ParseData {
  2534. my $data = shift;
  2535. my %result;
  2536. while ($data =~ /(\S+?): (.*?)(?=\n[^ \t]|\Z)/gs) {
  2537. my ($key, $value) = ($1, $2);
  2538. $value =~ s/\n\t/\n/g;
  2539. $result{$key} = $value;
  2540. }
  2541. # return unless %result; # undef instead of empty hash # TODO should we do that?
  2542. return wantarray ? %result : \%result; # return list sometimes for compatibility
  2543. }
  2544. sub OpenPage { # Sets global variables
  2545. my $id = shift;
  2546. return if $OpenPageName eq $id;
  2547. if ($IndexHash{$id}) {
  2548. %Page = %{ParseData(ReadFileOrDie(GetPageFile($id)))};
  2549. } else {
  2550. %Page = ();
  2551. $Page{ts} = $Now;
  2552. $Page{revision} = 0;
  2553. }
  2554. $OpenPageName = $id;
  2555. }
  2556. sub GetTextAtTime { # call with opened page, return $minor if all pages between now and $ts are minor!
  2557. my $ts = shift;
  2558. my $minor = $Page{minor};
  2559. return ($Page{text}, $minor, 0) if $Page{ts} <= $ts; # current page is old enough
  2560. return ($DeletedPage, $minor, 0) if $Page{revision} == 1 and $Page{ts} > $ts; # created after $ts
  2561. my $keep = {}; # info may be needed after the loop
  2562. foreach my $revision (GetKeepRevisions($OpenPageName)) {
  2563. $keep = GetKeptRevision($revision);
  2564. # $minor = 0 unless defined $keep; # TODO?
  2565. $minor = 0 if not $keep->{minor} and $keep->{ts} >= $ts; # ignore keep{minor} if keep{ts} is too old
  2566. return ($keep->{text}, $minor, 0) if $keep->{ts} <= $ts;
  2567. }
  2568. return ($DeletedPage, $minor, 0) if $keep->{revision} == 1; # then the page was created after $ts!
  2569. return ($keep->{text}, $minor, $keep->{ts}); # the oldest revision available is not old enough
  2570. }
  2571. sub GetTextRevision {
  2572. my ($revision, $quiet) = @_;
  2573. $revision =~ s/\D//g; # Remove non-numeric chars
  2574. return wantarray ? (\%Page, $revision) : \%Page unless $revision and $revision ne $Page{revision};
  2575. my $keep = GetKeptRevision($revision);
  2576. if (not defined $keep) {
  2577. $Message .= $q->p(Ts('Revision %s not available', $revision)
  2578. . ' (' . T('showing current revision instead') . ')') unless $quiet;
  2579. return wantarray ? (\%Page, '') : \%Page;
  2580. }
  2581. $Message .= $q->p(Ts('Showing revision %s', $revision)) unless $quiet;
  2582. return wantarray ? ($keep, $revision) : $keep;
  2583. }
  2584. sub GetPageContent {
  2585. my $id = shift;
  2586. return ParseData(ReadFileOrDie(GetPageFile($id)))->{text} if $IndexHash{$id};
  2587. return '';
  2588. }
  2589. sub GetKeptRevision { # Call after OpenPage
  2590. my ($status, $data) = ReadFile(GetKeepFile($OpenPageName, (shift)));
  2591. return unless $status;
  2592. return ParseData($data);
  2593. }
  2594. sub GetPageFile {
  2595. my ($id) = @_;
  2596. return "$PageDir/$id.pg";
  2597. }
  2598. sub GetKeepFile {
  2599. my ($id, $revision) = @_; die "No revision for $id" unless $revision; #FIXME
  2600. return GetKeepDir($id) . "/$revision.kp";
  2601. }
  2602. sub GetKeepDir {
  2603. my $id = shift; die 'No id' unless $id; #FIXME
  2604. return "$KeepDir/$id";
  2605. }
  2606. sub GetKeepFiles {
  2607. return Glob(GetKeepDir(shift) . '/*.kp'); # files such as 1.kp, 2.kp, etc.
  2608. }
  2609. sub GetKeepRevisions {
  2610. my @result = sort {$b <=> $a} map { m/([0-9]+)\.kp$/; $1; } GetKeepFiles(shift);
  2611. return @result;
  2612. }
  2613. # Always call SavePage within a lock.
  2614. sub SavePage { # updating the cache will not change timestamp and revision!
  2615. ReportError(T('Cannot save a nameless page.'), '400 BAD REQUEST', 1) unless $OpenPageName;
  2616. ReportError(T('Cannot save a page without revision.'), '400 BAD REQUEST', 1) unless $Page{revision};
  2617. CreateDir($PageDir);
  2618. WriteStringToFile(GetPageFile($OpenPageName), EncodePage(%Page));
  2619. }
  2620. sub SaveKeepFile {
  2621. return if ($Page{revision} < 1); # Don't keep 'empty' revision
  2622. delete $Page{blocks}; # delete some info from the page
  2623. delete $Page{flags};
  2624. delete $Page{'diff-major'};
  2625. delete $Page{'diff-minor'};
  2626. $Page{'keep-ts'} = $Now; # expire only $KeepDays from $Now!
  2627. CreateDir($KeepDir);
  2628. CreateDir(GetKeepDir($OpenPageName));
  2629. WriteStringToFile(GetKeepFile($OpenPageName, $Page{revision}), EncodePage(%Page));
  2630. }
  2631. sub EncodePage {
  2632. my @data = @_;
  2633. my $result = '';
  2634. $result .= (shift @data) . ': ' . EscapeNewlines(shift @data) . "\n" while (@data);
  2635. return $result;
  2636. }
  2637. sub EscapeNewlines {
  2638. $_[0] =~ s/\n/\n\t/g; # modify original instead of copying
  2639. return $_[0];
  2640. }
  2641. sub ExpireAllKeepFiles {
  2642. foreach my $name (AllPagesList()) {
  2643. print $q->br(), GetPageLink($name);
  2644. OpenPage($name);
  2645. my $delete = PageDeletable();
  2646. if ($delete) {
  2647. my $status = DeletePage($OpenPageName);
  2648. print ' ', ($status ? T('not deleted:') . ' ' . $status : T('deleted'));
  2649. } else {
  2650. ExpireKeepFiles();
  2651. }
  2652. }
  2653. }
  2654. sub ExpireKeepFiles { # call with opened page
  2655. return unless $KeepDays;
  2656. my $expirets = $Now - ($KeepDays * 86400); # 24*60*60
  2657. foreach my $revision (GetKeepRevisions($OpenPageName)) {
  2658. my $keep = GetKeptRevision($revision);
  2659. next if $keep->{'keep-ts'} >= $expirets;
  2660. next if $KeepMajor and $keep->{revision} == $Page{lastmajor};
  2661. Unlink(GetKeepFile($OpenPageName, $revision));
  2662. }
  2663. }
  2664. sub ReadFile {
  2665. if (open(my $IN, '<:encoding(UTF-8)', encode_utf8(shift))) {
  2666. local $/ = undef; # Read complete files
  2667. my $data=<$IN>;
  2668. close $IN;
  2669. return (1, $data);
  2670. }
  2671. return (0, '');
  2672. }
  2673. sub ReadFileOrDie {
  2674. my ($file) = @_;
  2675. my ($status, $data);
  2676. ($status, $data) = ReadFile($file);
  2677. if (not $status) {
  2678. ReportError(Ts('Cannot open %s', $file) . ": $!", '500 INTERNAL SERVER ERROR');
  2679. }
  2680. return $data;
  2681. }
  2682. sub WriteStringToFile {
  2683. my ($file, $string) = @_;
  2684. open(my $OUT, '>:encoding(UTF-8)', encode_utf8($file))
  2685. or ReportError(Ts('Cannot write %s', $file) . ": $!", '500 INTERNAL SERVER ERROR');
  2686. print $OUT $string;
  2687. close($OUT);
  2688. }
  2689. sub AppendStringToFile {
  2690. my ($file, $string) = @_;
  2691. open(my $OUT, '>>:encoding(UTF-8)', encode_utf8($file))
  2692. or ReportError(Ts('Cannot write %s', $file) . ": $!", '500 INTERNAL SERVER ERROR');
  2693. print $OUT $string;
  2694. close($OUT);
  2695. }
  2696. sub IsFile { return -f encode_utf8(shift); }
  2697. sub IsDir { return -d encode_utf8(shift); }
  2698. sub ZeroSize { return -z encode_utf8(shift); }
  2699. sub Unlink { return unlink(map { encode_utf8($_) } @_); }
  2700. sub Modified { return (stat(encode_utf8(shift)))[9]; }
  2701. sub Glob { return map { decode_utf8($_) } bsd_glob(encode_utf8(shift)); }
  2702. sub Rename { return rename(encode_utf8($_[0]), encode_utf8($_[1])); }
  2703. sub RemoveDir { return rmdir(encode_utf8(shift)); }
  2704. sub ChangeDir { return chdir(encode_utf8(shift)); }
  2705. sub CreateDir {
  2706. my ($newdir) = @_;
  2707. return if IsDir($newdir);
  2708. mkdir(encode_utf8($newdir), 0775)
  2709. or ReportError(Ts('Cannot create %s', $newdir) . ": $!", '500 INTERNAL SERVER ERROR');
  2710. }
  2711. sub GetLockedPageFile {
  2712. my $id = shift;
  2713. return "$PageDir/$id.lck";
  2714. }
  2715. sub RequestLockDir {
  2716. my ($name, $tries, $wait, $error, $retried) = @_;
  2717. $tries ||= 4;
  2718. $wait ||= 2;
  2719. CreateDir($TempDir);
  2720. my $lock = $LockDir . $name;
  2721. my $n = 0;
  2722. # Cannot use CreateDir because we don't want to skip mkdir if the directory
  2723. # already exists.
  2724. while (mkdir(encode_utf8($lock), 0555) == 0) {
  2725. if ($n++ >= $tries) {
  2726. my $ts = Modified($lock);
  2727. if ($Now - $ts > $LockExpiration and $LockExpires{$name} and not $retried) { # XXX should we remove this now?
  2728. ReleaseLockDir($name); # try to expire lock (no checking)
  2729. return 1 if RequestLockDir($name, undef, undef, undef, 1);
  2730. }
  2731. return 0 unless $error;
  2732. ReportError(Ts('Could not get %s lock', $name) . ": $!. ",
  2733. '503 SERVICE UNAVAILABLE', undef,
  2734. Ts('The lock was created %s.', CalcTimeSince($Now - $ts))
  2735. . ($retried && ' ' . T('Maybe the user running this script is no longer allowed to remove the lock directory?'))
  2736. . ' ' . T('Sometimes locks are left behind if a job crashes.') . ' '
  2737. . ($Now - $ts < 600 ? T('After ten minutes, you could try to unlock the wiki.')
  2738. : ScriptLink('action=unlock', T('Unlock Wiki'), 'unlock')));
  2739. }
  2740. sleep($wait);
  2741. }
  2742. $Locks{$name} = 1;
  2743. return 1;
  2744. }
  2745. sub HandleSignals {
  2746. my ($signal) = @_; # TODO should we pass it to CleanLock?
  2747. CleanLock($_) foreach keys %Locks;
  2748. exit; # let's count it as graceful exit
  2749. }
  2750. sub CleanLock {
  2751. my ($name) = @_;
  2752. $LockCleaners{$name}->() if exists $LockCleaners{$name};
  2753. ReleaseLockDir($name); # TODO should we log this?
  2754. }
  2755. sub ReleaseLockDir {
  2756. my $name = shift; # We don't check whether we succeeded.
  2757. RemoveDir($LockDir . $name); # Before fixing, make sure we only call this
  2758. delete $Locks{$name}; # when we know the lock exists.
  2759. }
  2760. sub RequestLockOrError {
  2761. return RequestLockDir('main', 10, 3, 1); # 10 tries, 3 second wait, die on error
  2762. }
  2763. sub ReleaseLock {
  2764. ReleaseLockDir('main');
  2765. }
  2766. sub ForceReleaseLock {
  2767. my $pattern = shift;
  2768. my $forced;
  2769. foreach my $name (Glob($pattern)) {
  2770. # First try to obtain lock (in case of normal edit lock)
  2771. $forced = 1 unless RequestLockDir($name, 5, 3, 0);
  2772. ReleaseLockDir($name); # Release the lock, even if we didn't get it. This should not happen.
  2773. }
  2774. return $forced;
  2775. }
  2776. sub DoUnlock {
  2777. my $message = '';
  2778. print GetHeader('', T('Unlock Wiki'), undef, 'nocache');
  2779. print $q->p(T('This operation may take several seconds...'));
  2780. for my $lock (@KnownLocks) {
  2781. if (ForceReleaseLock($lock)) {
  2782. $message .= $q->p(Ts('Forced unlock of %s lock.', $lock));
  2783. }
  2784. }
  2785. print $message || $q->p(T('No unlock required.'));
  2786. PrintFooter();
  2787. }
  2788. sub CalcDay {
  2789. my ($sec, $min, $hour, $mday, $mon, $year) = gmtime(shift);
  2790. return sprintf('%4d-%02d-%02d', $year + 1900, $mon + 1, $mday);
  2791. }
  2792. sub CalcTime {
  2793. my ($sec, $min, $hour, $mday, $mon, $year) = gmtime(shift);
  2794. return sprintf('%02d:%02d UTC', $hour, $min);
  2795. }
  2796. sub CalcTimeSince {
  2797. my $total = shift;
  2798. return Ts('%s hours ago', int($total/3600)) if ($total >= 7200);
  2799. return T('1 hour ago') if ($total >= 3600);
  2800. return Ts('%s minutes ago', int($total/60)) if ($total >= 120);
  2801. return T('1 minute ago') if ($total >= 60);
  2802. return Ts('%s seconds ago', int($total)) if ($total >= 2);
  2803. return T('1 second ago') if ($total == 1);
  2804. return T('just now');
  2805. }
  2806. sub TimeToText {
  2807. my $t = shift;
  2808. return CalcDay($t) . ' ' . CalcTime($t);
  2809. }
  2810. sub TimeToW3 { # Complete date plus hours and minutes: YYYY-MM-DDThh:mmTZD (eg 1997-07-16T19:20+01:00)
  2811. my ($sec, $min, $hour, $mday, $mon, $year) = gmtime(shift); # use special UTC designator ("Z")
  2812. return sprintf('%4d-%02d-%02dT%02d:%02dZ', $year + 1900, $mon + 1, $mday, $hour, $min);
  2813. }
  2814. sub TimeToRFC822 {
  2815. my ($sec, $min, $hour, $mday, $mon, $year, $wday) = gmtime(shift); # Sat, 07 Sep 2002 00:00:01 GMT
  2816. return sprintf("%s, %02d %s %04d %02d:%02d:%02d GMT", qw(Sun Mon Tue Wed Thu Fri Sat)[$wday], $mday,
  2817. qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec)[$mon], $year + 1900, $hour, $min, $sec);
  2818. }
  2819. sub GetHiddenValue {
  2820. my ($name, $value) = @_;
  2821. return $q->input({-type=>"hidden", -name=>$name, -value=>$value});
  2822. }
  2823. sub FreeToNormal { # trim all spaces and convert them to underlines
  2824. my $id = shift;
  2825. return '' unless $id;
  2826. $id =~ s/ /_/g;
  2827. $id =~ s/__+/_/g;
  2828. $id =~ s/^_//;
  2829. $id =~ s/_$//;
  2830. return UnquoteHtml($id);
  2831. }
  2832. sub ItemName {
  2833. my $id = shift; # id
  2834. return NormalToFree($id) unless GetParam('short', 1) and $RssStrip;
  2835. my $comment = $id =~ s/^($CommentsPrefix)//; # strip first so that ^ works
  2836. $id =~ s/^$RssStrip//;
  2837. $id = $CommentsPrefix . $id if $comment;
  2838. return NormalToFree($id);
  2839. }
  2840. sub NormalToFree { # returns HTML quoted title with spaces
  2841. my $title = shift;
  2842. $title =~ s/_/ /g;
  2843. return QuoteHtml($title);
  2844. }
  2845. sub UnWiki {
  2846. my $str = shift;
  2847. return $str unless $WikiLinks and $str =~ /^$LinkPattern$/;
  2848. $str =~ s/([[:lower:]])([[:upper:]])/$1 $2/g;
  2849. return $str;
  2850. }
  2851. sub DoEdit {
  2852. my ($id, $newText, $preview) = @_;
  2853. UserCanEditOrDie($id);
  2854. my $upload = GetParam('upload', undef);
  2855. if ($upload and not $UploadAllowed and not UserIsAdmin()) {
  2856. ReportError(T('Only administrators can upload files.'), '403 FORBIDDEN');
  2857. }
  2858. OpenPage($id);
  2859. my ($revisionPage, $revision) = GetTextRevision(GetParam('revision', ''), 1); # maybe revision reset!
  2860. my $oldText = $preview ? $newText : $revisionPage->{text};
  2861. my $isFile = TextIsFile($oldText);
  2862. $upload //= $isFile;
  2863. if ($upload and not $UploadAllowed and not UserIsAdmin()) {
  2864. ReportError(T('Only administrators can upload files.'), '403 FORBIDDEN');
  2865. }
  2866. if ($upload) { # shortcut lots of code
  2867. $revision = '';
  2868. $preview = 0;
  2869. } elsif ($isFile) {
  2870. $oldText = '';
  2871. }
  2872. my $header;
  2873. if ($revision and not $upload) {
  2874. $header = Ts('Editing revision %s of', $revision) . ' ' . NormalToFree($id);
  2875. } else {
  2876. $header = Ts('Editing %s', NormalToFree($id));
  2877. }
  2878. print GetHeader('', $header), $q->start_div({-class=>'content edit'});
  2879. if ($preview and not $upload) {
  2880. print $q->start_div({-class=>'preview'});
  2881. print $q->h2(T('Preview:'));
  2882. PrintWikiToHTML($oldText); # no caching, current revision, unlocked
  2883. print $q->hr(), $q->h2(T('Preview only, not yet saved')), $q->end_div();
  2884. }
  2885. if ($revision) {
  2886. print $q->strong(Ts('Editing old revision %s.', $revision) . ' '
  2887. . T('Saving this page will replace the latest revision with this text.'))
  2888. }
  2889. print GetEditForm($id, $upload, $oldText, $revision), $q->end_div();
  2890. PrintFooter($id, 'edit');
  2891. }
  2892. sub GetEditForm {
  2893. my ($page_name, $upload, $oldText, $revision) = @_;
  2894. my $html = GetFormStart(undef, undef, $upload ? 'edit upload' : 'edit text') # protected by questionasker
  2895. .$q->p(GetHiddenValue("title", $page_name),
  2896. ($revision ? GetHiddenValue('revision', $revision) : ''),
  2897. GetHiddenValue('oldtime', GetParam('oldtime', $Page{ts})), # prefer parameter over actual timestamp
  2898. ($upload ? GetUpload() : GetTextArea('text', $oldText)));
  2899. my $summary = UnquoteHtml(GetParam('summary', ''))
  2900. || ($Now - $Page{ts} < ($SummaryHours * 3600) ? $Page{summary} : '');
  2901. $html .= $q->p(T('Summary:').$q->br().GetTextArea('summary', $summary, 2))
  2902. .$q->p($q->checkbox(-name=>'recent_edit', -checked=>(GetParam('recent_edit', '') eq 'on'),
  2903. -label=>T('This change is a minor edit.')));
  2904. $html .= T($EditNote) if $EditNote; # Allow translation
  2905. my $username = GetParam('username', '');
  2906. $html .= $q->p($q->label({-for=>'username'}, T('Username:')).' '
  2907. .$q->textfield(-name=>'username', -id=>'username', -default=>$username,
  2908. -override=>1, -size=>20, -maxlength=>50))
  2909. .$q->p($q->submit(-name=>'Save', -accesskey=>T('s'), -value=>T('Save')),
  2910. ($upload ? '' : ' ' . $q->submit(-name=>'Preview', -accesskey=>T('p'), -value=>T('Preview'))).
  2911. ' '.$q->submit(-name=>'Cancel', -value=>T('Cancel')));
  2912. if ($upload) {
  2913. $html .= $q->p(ScriptLink('action=edit;upload=0;id=' . UrlEncode($page_name), T('Replace this file with text'), 'upload'));
  2914. } elsif ($UploadAllowed or UserIsAdmin()) {
  2915. $html .= $q->p(ScriptLink('action=edit;upload=1;id=' . UrlEncode($page_name), T('Replace this text with a file'), 'upload'));
  2916. }
  2917. $html .= $q->end_form();
  2918. foreach my $sub (@MyFormChanges) {
  2919. $html = $sub->($html, 'edit', $upload);
  2920. }
  2921. return $html;
  2922. }
  2923. sub GetTextArea {
  2924. my ($name, $text, $rows) = @_;
  2925. return $q->textarea(-id=>$name, -name=>$name, -default=>$text, -rows=>$rows || 25, -columns=>78, -override=>1);
  2926. }
  2927. sub GetUpload {
  2928. return T('File to upload:') . ' ' . $q->filefield(-name=>'file', -size=>50, -maxlength=>100);
  2929. }
  2930. sub DoDownload {
  2931. my $id = shift;
  2932. OpenPage($id) if ValidIdOrDie($id);
  2933. print $q->header(-status=>'304 NOT MODIFIED') and return if FileFresh(); # FileFresh needs an OpenPage!
  2934. my ($revisionPage, $revision) = GetTextRevision(GetParam('revision', '')); # maybe revision reset!
  2935. my $text = $revisionPage->{text};
  2936. if (my ($type, $encoding) = TextIsFile($text)) {
  2937. my ($data) = $text =~ /^[^\n]*\n(.*)/s;
  2938. my %allowed = map {$_ => 1} @UploadTypes;
  2939. if (@UploadTypes and not $allowed{$type}) {
  2940. ReportError(Ts('Files of type %s are not allowed.', $type), '415 UNSUPPORTED MEDIA TYPE');
  2941. }
  2942. print GetHttpHeader($type, $Page{ts}, undef, $encoding);
  2943. require MIME::Base64;
  2944. binmode(STDOUT, ":pop:raw"); # need to pop utf8 for Windows users!?
  2945. print MIME::Base64::decode($data);
  2946. } else {
  2947. print GetHttpHeader('text/plain', $Page{ts});
  2948. print $text;
  2949. }
  2950. }
  2951. sub DoPassword {
  2952. my $id = shift;
  2953. print GetHeader('', T('Password')), $q->start_div({-class=>'content password'});
  2954. print $q->p(T('Your password is saved in a cookie, if you have cookies enabled. Cookies may get lost if you connect from another machine, from another account, or using another software.'));
  2955. if (not $AdminPass and not $EditPass) {
  2956. print $q->p(T('This site does not use admin or editor passwords.'));
  2957. } else {
  2958. if (UserIsAdmin()) {
  2959. print $q->p(T('You are currently an administrator on this site.'));
  2960. } elsif (UserIsEditor()) {
  2961. print $q->p(T('You are currently an editor on this site.'));
  2962. } else {
  2963. print $q->p(T('You are a normal user on this site.'));
  2964. if (not GetParam('pwd')) {
  2965. print $q->p(T('You do not have a password set.'));
  2966. } else {
  2967. print $q->p(T('Your password does not match any of the administrator or editor passwords.'));
  2968. }
  2969. }
  2970. print GetFormStart(undef, undef, 'password'),
  2971. $q->p(GetHiddenValue('action', 'password'), T('Password:'), ' ',
  2972. $q->password_field(-name=>'pwd', -size=>20, -maxlength=>64),
  2973. $q->hidden(-name=>'id', -value=>$id),
  2974. $q->submit(-name=>'Save', -accesskey=>T('s'), -value=>T('Save'))),
  2975. $q->end_form;
  2976. }
  2977. if ($id) {
  2978. print $q->p(ScriptLink('action=browse;id=' . UrlEncode($id) . ';time=' . time,
  2979. Ts('Return to %s', NormalToFree($id))));
  2980. }
  2981. print $q->end_div();
  2982. PrintFooter();
  2983. }
  2984. sub UserIsEditorOrError {
  2985. UserIsEditor()
  2986. or ReportError(T('This operation is restricted to site editors only...'), '403 FORBIDDEN');
  2987. return 1;
  2988. }
  2989. sub UserIsAdminOrError {
  2990. UserIsAdmin()
  2991. or ReportError(T('This operation is restricted to administrators only...'), '403 FORBIDDEN');
  2992. return 1;
  2993. }
  2994. sub UserCanEditOrDie {
  2995. my $id = shift;
  2996. ValidIdOrDie($id);
  2997. if (not UserCanEdit($id, 1)) {
  2998. my $rule = UserIsBanned();
  2999. if ($rule) {
  3000. ReportError(T('Edit Denied'), '403 FORBIDDEN', undef,
  3001. $q->p(T('Editing not allowed: user, ip, or network is blocked.')),
  3002. $q->p(T('Contact the wiki administrator for more information.')),
  3003. $q->p(Ts('The rule %s matched for you.', $rule) . ' '
  3004. . Ts('See %s for more information.', GetPageLink($BannedHosts))));
  3005. } else {
  3006. ReportError(T('Edit Denied'), '403 FORBIDDEN', undef,
  3007. $q->p(Ts('Editing not allowed: %s is read-only.', NormalToFree($id))));
  3008. }
  3009. }
  3010. }
  3011. sub UserCanEdit {
  3012. my ($id, $editing, $comment) = @_;
  3013. return 0 if $id eq 'SampleUndefinedPage' or $id eq T('SampleUndefinedPage')
  3014. or $id eq 'Sample_Undefined_Page' or $id eq T('Sample_Undefined_Page');
  3015. return 1 if UserIsAdmin();
  3016. return 0 if $id ne '' and IsFile(GetLockedPageFile($id));
  3017. return 0 if $LockOnCreation{$id} and not IsFile(GetPageFile($id)); # new page
  3018. return 1 if UserIsEditor();
  3019. return 0 if not $EditAllowed or IsFile($NoEditFile);
  3020. return 0 if $editing and UserIsBanned(); # this call is more expensive
  3021. return 0 if $EditAllowed >= 2 and (not $CommentsPattern or $id !~ /$CommentsPattern/);
  3022. return 1 if $EditAllowed >= 3 and GetParam('recent_edit', '') ne 'on' # disallow minor comments
  3023. and ($comment or (GetParam('aftertext', '') and not GetParam('text', '')));
  3024. return 0 if $EditAllowed >= 3;
  3025. return 1;
  3026. }
  3027. sub UserIsBanned {
  3028. return 0 if GetParam('action', '') eq 'password'; # login is always ok
  3029. my $host = $q->remote_addr();
  3030. foreach (split(/\n/, GetPageContent($BannedHosts))) {
  3031. if (/^\s*([^#]\S+)/) { # all lines except empty lines and comments, trim whitespace
  3032. my $regexp = $1;
  3033. return $regexp if ($host =~ /$regexp/i);
  3034. }
  3035. }
  3036. return 0;
  3037. }
  3038. sub UserIsAdmin {
  3039. return UserHasPassword(GetParam('pwd', ''), $AdminPass);
  3040. }
  3041. sub UserIsEditor {
  3042. return 1 if UserIsAdmin(); # Admin includes editor
  3043. return UserHasPassword(GetParam('pwd', ''), $EditPass);
  3044. }
  3045. sub UserHasPassword {
  3046. my ($pwd, $pass) = @_;
  3047. return 0 unless $pass;
  3048. if ($PassHashFunction ne '') {
  3049. no strict 'refs'; # TODO this is kept for compatibility. Feel free to remove it later (comment written on 2015-07-14)
  3050. $pwd = $PassHashFunction->($pwd . $PassSalt);
  3051. }
  3052. foreach (split(/\s+/, $pass)) {
  3053. return 1 if $pwd eq $_;
  3054. }
  3055. return 0;
  3056. }
  3057. sub BannedContent {
  3058. my $str = shift;
  3059. my @urls = $str =~ /$FullUrlPattern/g;
  3060. foreach (split(/\n/, GetPageContent($BannedContent))) {
  3061. next unless m/^\s*([^#]+?)\s*(#\s*(\d\d\d\d-\d\d-\d\d\s*)?(.*))?$/;
  3062. my ($regexp, $comment, $re) = ($1, $4, undef);
  3063. foreach my $url (@urls) {
  3064. eval { $re = qr/$regexp/i; };
  3065. if (defined($re) and $url =~ $re) {
  3066. return Tss('Rule "%1" matched "%2" on this page.', $regexp, $url) . ' '
  3067. . ($comment ? Ts('Reason: %s.', $comment) : T('Reason unknown.')) . ' '
  3068. . Ts('See %s for more information.', GetPageLink($BannedContent));
  3069. }
  3070. }
  3071. }
  3072. return 0;
  3073. }
  3074. sub SortIndex {
  3075. my ($A, $B) = ($a, $b);
  3076. my $aIsComment = $A =~ s/^$CommentsPrefix//;
  3077. $B =~ s/^$CommentsPrefix//;
  3078. return $aIsComment ? 1 : -1 if $A eq $B;
  3079. $A cmp $B;
  3080. }
  3081. sub DoIndex {
  3082. my $raw = GetParam('raw', 0);
  3083. my $match = GetParam('match', '');
  3084. my @pages = ();
  3085. my @menu = ($q->label({-for=>'indexmatch'}, T('Filter:')) . ' '
  3086. . $q->textfield(-name=>'match', -id=>'indexmatch', -size=>20));
  3087. foreach my $data (@IndexOptions) {
  3088. my ($option, $text, $default, $sub) = @$data;
  3089. my $value = GetParam($option, $default); # HTML checkbox warning!
  3090. $value = 0 if GetParam('manual', 0) and $value ne 'on';
  3091. push(@pages, $sub->()) if $value;
  3092. push(@menu, $q->checkbox(-name=>$option, -checked=>$value, -label=>$text));
  3093. }
  3094. @pages = grep /$match/i, @pages if $match;
  3095. @pages = sort SortIndex @pages;
  3096. if ($raw) {
  3097. print GetHttpHeader('text/plain'); # and ignore @menu
  3098. } else {
  3099. print GetHeader('', T('Index of all pages'));
  3100. push(@menu, GetHiddenValue('manual', 1) . $q->submit(-value=>T('Go!')));
  3101. push(@menu, $q->b(Ts('(for %s)', GetParam('lang', '')))) if GetParam('lang', '');
  3102. print $q->start_div({-class=>'content index'}),
  3103. GetFormStart(undef, 'get', 'index'), GetHiddenValue('action', 'index'),
  3104. $q->p(join($q->br(), @menu)), $q->end_form(),
  3105. $q->h2(Ts('%s pages found.', ($#pages + 1))), $q->start_p();
  3106. }
  3107. PrintPage($_) foreach (@pages);
  3108. print $q->end_p(), $q->end_div() unless $raw;
  3109. PrintFooter() unless $raw;
  3110. }
  3111. sub PrintPage {
  3112. my $id = shift;
  3113. my $lang = GetParam('lang', 0);
  3114. if ($lang) {
  3115. OpenPage($id);
  3116. my @languages = split(/,/, $Page{languages});
  3117. next if (@languages and not grep(/$lang/, @languages));
  3118. }
  3119. if (GetParam('raw', 0)) {
  3120. if (GetParam('search', '') and GetParam('context', 1)) {
  3121. print "title: $id\n\n"; # for near links without full search
  3122. } else {
  3123. print $id, "\n";
  3124. }
  3125. } else {
  3126. print GetPageOrEditLink($id, NormalToFree($id)), $q->br();
  3127. }
  3128. }
  3129. sub AllPagesList {
  3130. my $refresh = GetParam('refresh', 0);
  3131. return @IndexList if @IndexList and not $refresh;
  3132. SetParam('refresh', 0) if $refresh;
  3133. return @IndexList if not $refresh and IsFile($IndexFile) and ReadIndex();
  3134. # If open fails just refresh the index
  3135. RefreshIndex();
  3136. return @IndexList;
  3137. }
  3138. sub ReadIndex {
  3139. my ($status, $rawIndex) = ReadFile($IndexFile); # not fatal
  3140. if ($status) {
  3141. @IndexList = split(/ /, $rawIndex);
  3142. %IndexHash = map {$_ => 1} @IndexList;
  3143. return @IndexList;
  3144. }
  3145. return;
  3146. }
  3147. sub WriteIndex {
  3148. WriteStringToFile($IndexFile, join(' ', @IndexList));
  3149. }
  3150. sub RefreshIndex {
  3151. @IndexList = ();
  3152. %IndexHash = ();
  3153. # If file exists and cannot be changed, error!
  3154. my $locked = RequestLockDir('index', undef, undef, IsFile($IndexFile));
  3155. foreach (Glob("$PageDir/*.pg"), Glob("$PageDir/.*.pg")) {
  3156. next unless m|/.*/(.+)\.pg$|;
  3157. my $id = $1;
  3158. push(@IndexList, $id);
  3159. $IndexHash{$id} = 1;
  3160. }
  3161. WriteIndex() if $locked;
  3162. ReleaseLockDir('index') if $locked;
  3163. }
  3164. sub AddToIndex {
  3165. my ($id) = @_;
  3166. $IndexHash{$id} = 1;
  3167. @IndexList = sort(keys %IndexHash);
  3168. WriteIndex();
  3169. }
  3170. sub DoSearch {
  3171. my $string = shift || GetParam('search', '');
  3172. my $re = UnquoteHtml($string);
  3173. return DoIndex() if $string eq '';
  3174. eval { qr/$re/ } or $re = quotemeta($re);
  3175. my $replacement = GetParam('replace', undef);
  3176. my $raw = GetParam('raw', '');
  3177. my @results;
  3178. if ($replacement or GetParam('delete', 0)) {
  3179. return unless UserIsAdminOrError();
  3180. if (GetParam('preview', '')) { # Preview button was used
  3181. print GetHeader('', Ts('Preview: %s', $string . " &#x2192; " . $replacement));
  3182. print $q->start_div({-class=>'content replacement'});
  3183. print GetFormStart(undef, 'post', 'replace');
  3184. print GetHiddenValue('search', $string);
  3185. print GetHiddenValue('replace', $replacement);
  3186. print GetHiddenValue('delete', GetParam('delete', 0));
  3187. print $q->submit(-value=>T('Go!')) . $q->end_form();
  3188. @results = ReplaceAndDiff($re, UnquoteHtml($replacement));
  3189. } else {
  3190. print GetHeader('', Ts('Replaced: %s', $string . " &#x2192; " . $replacement));
  3191. print $q->start_div({-class=>'content replacement'});
  3192. @results = ReplaceAndSave($re, UnquoteHtml($replacement));
  3193. foreach (@results) {
  3194. PrintSearchResult($_, quotemeta($replacement || $re)); # the replacement is not a valid regex
  3195. }
  3196. }
  3197. } else {
  3198. if ($raw) {
  3199. print GetHttpHeader('text/plain');
  3200. print RcTextItem('title', Ts('Search for: %s', $string)), RcTextItem('date', TimeToText($Now)),
  3201. RcTextItem('link', $q->url(-path_info=>1, -query=>1)), "\n" if GetParam('context', 1);
  3202. } else {
  3203. print GetHeader('', Ts('Search for: %s', $string)), $q->start_div({-class=>'content search'});
  3204. print $q->p({-class=>'links'}, SearchMenu($string));
  3205. }
  3206. @results = SearchTitleAndBody($re, \&PrintSearchResult, SearchRegexp($re));
  3207. }
  3208. print SearchResultCount($#results + 1), $q->end_div() unless $raw;
  3209. PrintFooter() unless $raw;
  3210. }
  3211. sub SearchMenu {
  3212. return ScriptLink('action=rc;rcfilteronly=' . UrlEncode(shift),
  3213. T('View changes for these pages'));
  3214. }
  3215. sub SearchResultCount { $q->p({-class=>'result'}, Ts('%s pages found.', (shift))); }
  3216. sub PageIsUploadedFile {
  3217. my $id = shift;
  3218. return if $OpenPageName eq $id;
  3219. if ($IndexHash{$id}) {
  3220. my $file = GetPageFile($id);
  3221. open(my $FILE, '<:encoding(UTF-8)', encode_utf8($file))
  3222. or ReportError(Ts('Cannot open %s', GetPageFile($id))
  3223. . ": $!", '500 INTERNAL SERVER ERROR');
  3224. while (defined($_ = <$FILE>) and $_ !~ /^text: /) {
  3225. } # read lines until we get to the text key
  3226. close $FILE;
  3227. return TextIsFile(substr($_, 6)); # pass "#FILE image/png\n" to the test
  3228. }
  3229. }
  3230. sub SearchTitleAndBody {
  3231. my ($regex, $func, @args) = @_;
  3232. my @found;
  3233. my $lang = GetParam('lang', '');
  3234. foreach my $id (Filtered($regex, AllPagesList())) {
  3235. my $name = NormalToFree($id);
  3236. my ($text) = PageIsUploadedFile($id); # set to mime-type if this is an uploaded file
  3237. if (not $text) { # not uploaded file, therefore allow searching of page body
  3238. local ($OpenPageName, %Page); # this is local!
  3239. OpenPage($id); # this opens a page twice if it is not uploaded, but that's ok
  3240. if ($lang) {
  3241. my @languages = split(/,/, $Page{languages});
  3242. next if (@languages and not grep(/$lang/, @languages));
  3243. }
  3244. $text = $Page{text};
  3245. }
  3246. if (SearchString($regex, $name . "\n" . $text)) { # the real search code
  3247. push(@found, $id);
  3248. $func->($id, @args) if $func;
  3249. }
  3250. }
  3251. return @found;
  3252. }
  3253. sub Filtered { # this is overwriten in extensions such as tags.pl
  3254. return @_[1 .. $#_]; # ignores $regex and returns all pages
  3255. }
  3256. sub SearchString {
  3257. my ($string, $data) = @_;
  3258. my @strings = grep /./, $string =~ /\"([^\"]+)\"|(\S+)/g; # skip null entries
  3259. foreach my $str (@strings) {
  3260. return 0 unless ($data =~ /$str/i);
  3261. }
  3262. return 1;
  3263. }
  3264. sub SearchRegexp {
  3265. my $regexp = join '|', map { index($_, '|') == -1 ? $_ : "($_)" }
  3266. grep /./, shift =~ /\"([^\"]+)\"|(\S+)/g; # this acts as OR
  3267. $regexp =~ s/\\s/[[:space:]]/g;
  3268. return $regexp;
  3269. }
  3270. sub PrintSearchResult {
  3271. my ($name, $regex) = @_;
  3272. return PrintPage($name) if not GetParam('context', 1);
  3273. OpenPage($name); # should be open already, just making sure!
  3274. my $text = $Page{text};
  3275. my ($type) = TextIsFile($text); # MIME type if an uploaded file
  3276. my %entry;
  3277. # get the page, filter it, remove all tags
  3278. $text =~ s/$FS//g; # Remove separators (paranoia)
  3279. $text =~ s/[\s]+/ /g; # Shrink whitespace
  3280. $text =~ s/([-_=\\*\\.]){10,}/$1$1$1$1$1/g ; # e.g. shrink "----------"
  3281. $entry{title} = $name;
  3282. $entry{description} = $type || SearchHighlight(QuoteHtml(SearchExtract($text, $regex)), QuoteHtml($regex));
  3283. $entry{size} = int((length($text) / 1024) + 1) . 'K';
  3284. $entry{'last-modified'} = TimeToText($Page{ts});
  3285. $entry{username} = $Page{username};
  3286. $entry{host} = $Page{host};
  3287. PrintSearchResultEntry(\%entry);
  3288. }
  3289. sub PrintSearchResultEntry {
  3290. my %entry = %{(shift)}; # get value from reference
  3291. if (GetParam('raw', 0)) {
  3292. $entry{generator} = GetAuthor($entry{host}, $entry{username});
  3293. foreach my $key (qw(title description size last-modified generator username host)) {
  3294. print RcTextItem($key, $entry{$key});
  3295. }
  3296. print RcTextItem('link', "$ScriptName?$entry{title}"), "\n";
  3297. } else {
  3298. my $author = GetAuthorLink($entry{host}, $entry{username});
  3299. $author ||= $entry{generator};
  3300. my $id = $entry{title};
  3301. my ($class, $resolved, $title, $exists) = ResolveId($id);
  3302. my $text = NormalToFree($id);
  3303. my $result = $q->span({-class=>'result'}, ScriptLink(UrlEncode($resolved), $text, $class, undef, $title));
  3304. my $description = $entry{description};
  3305. $description = $q->br() . $description if $description;
  3306. my $info = $entry{size};
  3307. $info .= ' - ' if $info;
  3308. $info .= T('last updated') . ' ' . $entry{'last-modified'} if $entry{'last-modified'};
  3309. $info .= ' ' . T('by') . ' ' . $author if $author;
  3310. $info = $q->br() . $q->span({-class=>'info'}, $info) if $info;
  3311. print $q->p($result, $description, $info);
  3312. }
  3313. }
  3314. sub SearchHighlight {
  3315. my ($data, $regex) = @_;
  3316. $data =~ s/($regex)/<strong>$1<\/strong>/gi unless GetParam('raw');
  3317. return $data;
  3318. }
  3319. sub SearchExtract {
  3320. my ($data, $regex) = @_;
  3321. my ($snippetlen, $maxsnippets) = (100, 4); # these seem nice.
  3322. # show a snippet from the beginning of the document
  3323. my $j = index($data, ' ', $snippetlen); # end on word boundary
  3324. my $t = substr($data, 0, $j);
  3325. my $result = $t . ' . . .';
  3326. $data = substr($data, $j); # to avoid rematching
  3327. my $jsnippet = 0 ;
  3328. while ($jsnippet < $maxsnippets and $data =~ m/($regex)/i) {
  3329. $jsnippet++;
  3330. if (($j = index($data, $1)) > -1 ) {
  3331. # get substr containing (start of) match, ending on word boundaries
  3332. my $start = index($data, ' ', $j - $snippetlen / 2);
  3333. $start = 0 if $start == -1;
  3334. my $end = index($data, ' ', $j + $snippetlen / 2);
  3335. $end = length($data) if $end == -1;
  3336. $t = substr($data, $start, $end - $start);
  3337. $result .= $t . ' . . .';
  3338. # truncate text to avoid rematching the same string.
  3339. $data = substr($data, $end);
  3340. }
  3341. }
  3342. return $result;
  3343. }
  3344. sub ReplaceAndSave {
  3345. my ($from, $to) = @_;
  3346. RequestLockOrError(); # fatal
  3347. my @result = Replace($from, $to, 1, sub {
  3348. my ($id, $new) = @_;
  3349. Save($id, $new, $from . ' → ' . $to, 1, ($Page{host} ne $q->remote_addr()));
  3350. });
  3351. ReleaseLock();
  3352. return @result;
  3353. }
  3354. sub ReplaceAndDiff {
  3355. my ($from, $to) = @_;
  3356. my @found = Replace($from, $to, 0, sub {
  3357. my ($id, $new) = @_;
  3358. print $q->h2(GetPageLink($id)), $q->div({-class=>'diff'}, ImproveDiff(DoDiff($Page{text}, $new)));
  3359. });
  3360. if (@found > GetParam('offset', 0) + GetParam('num', 10)) {
  3361. my $more = "search=" . UrlEncode($from) . ";preview=1"
  3362. . ";offset=" . (GetParam('num', 10) + GetParam('offset', 0))
  3363. . ";num=" . GetParam('num', 10);
  3364. $more .= ";replace=" . UrlEncode($to) if $to;
  3365. $more .= ";delete=1" unless $to;
  3366. print $q->p({-class=>'more'}, ScriptLink($more, T('More...'), 'more'));
  3367. }
  3368. return @found;
  3369. }
  3370. sub Replace {
  3371. my ($from, $to, $all, $func) = @_; # $func takes $id and $new text
  3372. my $lang = GetParam('lang', '');
  3373. my $num = GetParam('num', 10);
  3374. my $offset = GetParam('offset', 0);
  3375. my @result;
  3376. foreach my $id (AllPagesList()) {
  3377. OpenPage($id);
  3378. if ($lang) {
  3379. my @languages = split(/,/, $Page{languages});
  3380. next if (@languages and not grep(/$lang/, @languages));
  3381. }
  3382. $_ = $Page{text};
  3383. my $replacement = sub {
  3384. my ($o1, $o2, $o3, $o4, $o5, $o6, $o7, $o8, $o9) = ($1, $2, $3, $4, $5, $6, $7, $8, $9);
  3385. my $str = $to;
  3386. $str =~ s/\$([1-9])/'$o' . $1/eeg;
  3387. $str
  3388. };
  3389. if (s/$from/$replacement->()/egi) { # allows use of backreferences
  3390. push (@result, $id);
  3391. $func->($id, $_) if $all or @result > $offset and @result <= $offset + $num;
  3392. }
  3393. }
  3394. return @result;
  3395. }
  3396. sub DoPost {
  3397. my $id = FreeToNormal(shift);
  3398. UserCanEditOrDie($id);
  3399. # Lock before getting old page to prevent races
  3400. RequestLockOrError(); # fatal
  3401. OpenPage($id);
  3402. my $old = $Page{text};
  3403. my $string = UnquoteHtml(GetParam('text', undef));
  3404. $string =~ s/(\r|$FS)//g;
  3405. my ($type) = TextIsFile($string); # MIME type if an uploaded file
  3406. my $filename = GetParam('file', undef);
  3407. if (($filename or $type) and not $UploadAllowed and not UserIsAdmin()) {
  3408. ReportError(T('Only administrators can upload files.'), '403 FORBIDDEN');
  3409. }
  3410. my $comment = UnquoteHtml(GetParam('aftertext', undef));
  3411. $comment =~ s/(\r|$FS)//g;
  3412. if (defined $comment and $comment eq '') {
  3413. ReleaseLock();
  3414. return ReBrowsePage($id);
  3415. }
  3416. if ($filename) { # upload file
  3417. my $file = $q->upload('file');
  3418. if (not $file and $q->cgi_error) {
  3419. ReportError(Ts('Transfer Error: %s', $q->cgi_error), '500 INTERNAL SERVER ERROR');
  3420. }
  3421. ReportError(T('Browser reports no file info.'), '500 INTERNAL SERVER ERROR') unless $q->uploadInfo($filename);
  3422. $type = $q->uploadInfo($filename)->{'Content-Type'};
  3423. ReportError(T('Browser reports no file type.'), '415 UNSUPPORTED MEDIA TYPE') unless $type;
  3424. local $/ = undef; # Read complete files
  3425. my $content = <$file>; # Apparently we cannot count on <$file> to always work within the eval!?
  3426. my $encoding = substr($content, 0, 2) eq "\x1f\x8b" ? 'gzip' : '';
  3427. eval { require MIME::Base64; $_ = MIME::Base64::encode($content) };
  3428. $string = "#FILE $type $encoding\n" . $_;
  3429. } else { # ordinary text edit
  3430. $string = AddComment($old, $comment) if defined $comment;
  3431. if ($comment and substr($string, 0, length($DeletedPage)) eq $DeletedPage) { # look ma, no regexp!
  3432. $string = substr($string, length($DeletedPage)); # undelete pages when adding a comment
  3433. }
  3434. $string .= "\n" if ($string !~ /\n$/); # add trailing newline
  3435. $string = RunMyMacros($string); # run macros on text pages only
  3436. }
  3437. my %allowed = map {$_ => 1} @UploadTypes;
  3438. if (@UploadTypes and $type and not $allowed{$type}) {
  3439. ReportError(Ts('Files of type %s are not allowed.', $type), '415 UNSUPPORTED MEDIA TYPE');
  3440. }
  3441. # Banned Content
  3442. my $summary = GetSummary();
  3443. if (not UserIsEditor()) {
  3444. my $rule = BannedContent($string) || BannedContent($summary);
  3445. ReportError(T('Edit Denied'), '403 FORBIDDEN', undef, $q->p(T('The page contains banned text.')),
  3446. $q->p(T('Contact the wiki administrator for more information.')), $q->p($rule)) if $rule;
  3447. }
  3448. # rebrowse if no changes
  3449. my $oldrev = $Page{revision};
  3450. if (GetParam('Preview', '')) { # Preview button was used
  3451. ReleaseLock();
  3452. if (defined $comment) {
  3453. BrowsePage($id, 0, RunMyMacros($comment)); # show macros in preview
  3454. } else {
  3455. DoEdit($id, $string, 1);
  3456. }
  3457. return;
  3458. } elsif ($old eq $string) {
  3459. ReleaseLock(); # No changes -- just show the same page again
  3460. return ReBrowsePage($id);
  3461. } elsif ($oldrev == 0 and $string eq "\n") {
  3462. ReportError(T('No changes to be saved.'), '400 BAD REQUEST'); # don't fake page creation because of webdav
  3463. }
  3464. my $newAuthor = 0;
  3465. if ($oldrev) { # the first author (no old revision) is not considered to be "new"
  3466. # prefer usernames for potential new author detection
  3467. $newAuthor = 1 if not $Page{username} or $Page{username} ne GetParam('username', '');
  3468. $newAuthor = 1 if not $q->remote_addr() or not $Page{host} or $q->remote_addr() ne $Page{host};
  3469. }
  3470. my $oldtime = $Page{ts};
  3471. my $myoldtime = GetParam('oldtime', ''); # maybe empty!
  3472. # Handle raw edits with the meta info on the first line
  3473. if (GetParam('raw', 0) == 2 and $string =~ /^([0-9]+).*\n((.*\n)*.*)/) {
  3474. $myoldtime = $1;
  3475. $string = $2;
  3476. }
  3477. my $generalwarning = 0;
  3478. if ($newAuthor and $oldtime ne $myoldtime and not defined $comment) {
  3479. if ($myoldtime) {
  3480. my ($ancestor) = GetTextAtTime($myoldtime);
  3481. if ($ancestor and $old ne $ancestor) {
  3482. my $new = MergeRevisions($string, $ancestor, $old);
  3483. if ($new) {
  3484. $string = $new;
  3485. if ($new =~ /^<<<<<<</m and $new =~ /^>>>>>>>/m) {
  3486. SetParam('msg', Ts('This page was changed by somebody else %s.',
  3487. CalcTimeSince($Now - $Page{ts}))
  3488. . ' ' . T('The changes conflict. Please check the page again.'));
  3489. } # else no conflict
  3490. } else {
  3491. $generalwarning = 1;
  3492. } # else merge revision didn't work
  3493. } # else nobody changed the page in the mean time (same text)
  3494. } else {
  3495. $generalwarning = 1;
  3496. } # no way to be sure since myoldtime is missing
  3497. } # same author or nobody changed the page in the mean time (same timestamp)
  3498. if ($generalwarning and ($Now - $Page{ts}) < 600) {
  3499. SetParam('msg', Ts('This page was changed by somebody else %s.',
  3500. CalcTimeSince($Now - $Page{ts}))
  3501. . ' ' . T('Please check whether you overwrote those changes.'));
  3502. }
  3503. Save($id, $string, $summary, (GetParam('recent_edit', '') eq 'on'), $filename);
  3504. ReleaseLock();
  3505. ReBrowsePage($id);
  3506. }
  3507. sub GetSummary {
  3508. my $text = GetParam('aftertext', '') || ($Page{revision} > 0 ? '' : GetParam('text', ''));
  3509. return '' if $text =~ /^#FILE /;
  3510. if ($SummaryDefaultLength and length($text) > $SummaryDefaultLength) {
  3511. $text = substr($text, 0, $SummaryDefaultLength);
  3512. $text =~ s/\s*\S*$/ . . ./;
  3513. }
  3514. my $summary = GetParam('summary', '') || $text; # not GetParam('summary', $text) work because '' is defined
  3515. $summary =~ s/$FS|[\r\n]+/ /g; # remove linebreaks and separator characters
  3516. $summary =~ s/\[$FullUrlPattern\s+(.*?)\]/$2/g; # fix common annoyance when copying text to summary
  3517. $summary =~ s/\[$FullUrlPattern\]//g;
  3518. $summary =~ s/\[\[$FreeLinkPattern\]\]/$1/g;
  3519. return UnquoteHtml($summary);
  3520. }
  3521. sub AddComment {
  3522. my ($string, $comment) = @_;
  3523. $comment =~ s/\r//g; # Remove "\r"-s (0x0d) from the string
  3524. $comment =~ s/\s+$//g; # Remove whitespace at the end
  3525. if ($comment ne '') {
  3526. my $author = GetParam('username', T('Anonymous'));
  3527. my $homepage = GetParam('homepage', '');
  3528. $homepage = 'http://' . $homepage if $homepage and $homepage !~ /^($UrlProtocols):/;
  3529. $author = "[$homepage $author]" if $homepage;
  3530. $string .= "\n----\n\n" if $string and $string ne "\n";
  3531. $string .= $comment . "\n\n"
  3532. . '-- ' . $author . ' ' . TimeToText($Now) . "\n\n";
  3533. }
  3534. return $string;
  3535. }
  3536. sub Save { # call within lock, with opened page
  3537. my ($id, $new, $summary, $minor, $upload) = @_;
  3538. my $user = GetParam('username', '');
  3539. my $host = $q->remote_addr();
  3540. my $revision = $Page{revision} + 1;
  3541. my $old = $Page{text};
  3542. my $olddiff = $Page{'diff-major'} == '1' ? $Page{'diff-minor'} : $Page{'diff-major'};
  3543. if ($revision == 1 and IsFile($IndexFile) and not Unlink($IndexFile)) { # regenerate index on next request
  3544. SetParam('msg', Ts('Cannot delete the index file %s.', $IndexFile)
  3545. . ' ' . T('Please check the directory permissions.')
  3546. . ' ' . T('Your changes were not saved.'));
  3547. return 0;
  3548. }
  3549. ReInit($id);
  3550. TouchIndexFile();
  3551. SaveKeepFile(); # deletes blocks, flags, diff-major, and diff-minor, and sets keep-ts
  3552. ExpireKeepFiles();
  3553. $Page{lastmajor} = $revision unless $minor;
  3554. $Page{lastmajorsummary} = $summary unless $minor;
  3555. @Page{qw(ts revision summary username host minor text)} =
  3556. ($Now, $revision, $summary, $user, $host, $minor, $new);
  3557. if ($UseDiff and $UseCache > 1 and $revision > 1 and not $upload and not TextIsFile($old)) {
  3558. UpdateDiffs($old, $new, $olddiff); # sets diff-major and diff-minor
  3559. }
  3560. my $languages;
  3561. $languages = GetLanguages($new) unless $upload;
  3562. $Page{languages} = $languages;
  3563. SavePage();
  3564. if ($revision == 1 and $LockOnCreation{$id}) {
  3565. WriteStringToFile(GetLockedPageFile($id), 'LockOnCreation');
  3566. }
  3567. WriteRcLog($id, $summary, $minor, $revision, $user, $host, $languages, GetCluster($new));
  3568. AddToIndex($id) if ($revision == 1);
  3569. }
  3570. sub TouchIndexFile {
  3571. my $ts = time;
  3572. utime $ts, $ts, $IndexFile;
  3573. $LastUpdate = $Now = $ts;
  3574. }
  3575. sub GetLanguages {
  3576. my $text = shift;
  3577. my @result;
  3578. for my $lang (sort keys %Languages) {
  3579. my @matches = $text =~ /$Languages{$lang}/gi;
  3580. push(@result, $lang) if $#matches >= $LanguageLimit;
  3581. }
  3582. return join(',', @result);
  3583. }
  3584. sub GetCluster {
  3585. $_ = shift;
  3586. return '' unless $PageCluster;
  3587. return $1 if ($WikiLinks && /^$LinkPattern\n/)
  3588. or ($FreeLinks && /^\[\[$FreeLinkPattern\]\]\n/);
  3589. }
  3590. sub MergeRevisions { # merge change from file2 to file3 into file1
  3591. my ($file1, $file2, $file3) = @_;
  3592. my ($name1, $name2, $name3) = ("$TempDir/file1", "$TempDir/file2", "$TempDir/file3");
  3593. CreateDir($TempDir);
  3594. RequestLockDir('merge') or return T('Could not get a lock to merge!');
  3595. WriteStringToFile($name1, $file1);
  3596. WriteStringToFile($name2, $file2);
  3597. WriteStringToFile($name3, $file3);
  3598. my ($you, $ancestor, $other) = (T('you'), T('ancestor'), T('other'));
  3599. my $output = decode_utf8(`diff3 -m -L \Q$you\E -L \Q$ancestor\E -L \Q$other\E -- \Q$name1\E \Q$name2\E \Q$name3\E`);
  3600. ReleaseLockDir('merge'); # don't unlink temp files--next merge will just overwrite.
  3601. return $output;
  3602. }
  3603. # Note: all diff and recent-list operations should be done within locks.
  3604. sub WriteRcLog {
  3605. my ($id, $summary, $minor, $revision, $username, $host, $languages, $cluster) = @_;
  3606. my $line = join($FS, $Now, $id, $minor, $summary, $host,
  3607. $username, $revision, $languages, $cluster);
  3608. AppendStringToFile($RcFile, $line . "\n");
  3609. }
  3610. sub UpdateDiffs { # this could be optimized, but isn't frequent enough
  3611. my ($old, $new, $olddiff) = @_;
  3612. $Page{'diff-minor'} = GetDiff($old, $new); # create new diff-minor
  3613. # 1 is a special value for GetCacheDiff telling it to use diff-minor
  3614. $Page{'diff-major'} = $Page{lastmajor} == $Page{revision} ? 1 : $olddiff;
  3615. }
  3616. sub DoMaintain {
  3617. print GetHeader('', T('Run Maintenance')), $q->start_div({-class=>'content maintain'});
  3618. my $fname = "$DataDir/maintain";
  3619. if (not UserIsAdmin()) {
  3620. if (IsFile($fname) and $Now - Modified($fname) < 0.5) {
  3621. print $q->p(T('Maintenance not done.') . ' ' . T('(Maintenance can only be done once every 12 hours.)')
  3622. . ' ', T('Remove the "maintain" file or wait.')), $q->end_div();
  3623. PrintFooter();
  3624. return;
  3625. }
  3626. }
  3627. print '<p>', T('Expiring keep files and deleting pages marked for deletion');
  3628. ExpireAllKeepFiles();
  3629. print '</p>';
  3630. RequestLockOrError();
  3631. print $q->p(T('Main lock obtained.'));
  3632. print $q->p(Ts('Moving part of the %s log file.', $RCName));
  3633. # Determine the number of days to go back
  3634. my $days = 0;
  3635. foreach (@RcDays) {
  3636. $days = $_ if $_ > $days;
  3637. }
  3638. my $starttime = $Now - $days * 86400; # 24*60*60
  3639. # Read the current file
  3640. my ($status, $data) = ReadFile($RcFile);
  3641. if (not $status) {
  3642. print $q->p($q->strong(Ts('Could not open %s log file', $RCName) . ':') . ' ' . $RcFile),
  3643. $q->p(T('Error was') . ':'), $q->pre($!), $q->p(T('Note: This error is normal if no changes have been made.'));
  3644. }
  3645. # Move the old stuff from rc to temp
  3646. my @rc = split(/\n/, $data);
  3647. my @tmp = ();
  3648. for my $line (@rc) {
  3649. my ($ts, $id, $minor, $summary, $host, @rest) = split(/$FS/, $line);
  3650. last if $ts >= $starttime;
  3651. push(@tmp, join($FS, $ts, $id, $minor, $summary, 'Anonymous', @rest));
  3652. }
  3653. print $q->p(Ts('Moving %s log entries.', scalar(@tmp)));
  3654. if (@tmp) {
  3655. # Write new files, and backups
  3656. AppendStringToFile($RcOldFile, join("\n", @tmp) . "\n");
  3657. WriteStringToFile($RcFile . '.old', $data);
  3658. splice(@rc, 0, scalar(@tmp)); # strip
  3659. WriteStringToFile($RcFile, @rc ? join("\n", @rc) . "\n" : '');
  3660. }
  3661. if (opendir(DIR, $RssDir)) { # cleanup if they should expire anyway
  3662. foreach (readdir(DIR)) {
  3663. Unlink("$RssDir/$_") if $Now - Modified($_) > $RssCacheHours * 3600;
  3664. }
  3665. closedir DIR;
  3666. }
  3667. foreach my $func (@MyMaintenance) {
  3668. $func->();
  3669. }
  3670. WriteStringToFile($fname, 'Maintenance done at ' . TimeToText($Now));
  3671. ReleaseLock();
  3672. print $q->p(T('Main lock released.')), $q->end_div();
  3673. PrintFooter();
  3674. }
  3675. sub PageDeletable {
  3676. return unless $KeepDays;
  3677. my $expirets = $Now - ($KeepDays * 86400); # 24*60*60
  3678. return 0 if $Page{ts} >= $expirets;
  3679. return PageMarkedForDeletion();
  3680. }
  3681. sub PageMarkedForDeletion {
  3682. # Only pages explicitly marked for deletion or whitespace-only pages
  3683. # are deleted; taking into account the very rare possiblity of a
  3684. # read error and the page text being undefined.
  3685. return 1 if defined $Page{text} and $Page{text} =~ /^\s*$/;
  3686. return $DeletedPage && substr($Page{text}, 0, length($DeletedPage)) eq $DeletedPage;
  3687. }
  3688. sub DeletePage { # Delete must be done inside locks.
  3689. my $id = shift;
  3690. ValidIdOrDie($id);
  3691. AppendStringToFile($DeleteFile, "$id\n");
  3692. foreach my $name (GetPageFile($id), GetKeepFiles($id), GetKeepDir($id), GetLockedPageFile($id), $IndexFile) {
  3693. Unlink($name) if IsFile($name);
  3694. RemoveDir($name) if IsDir($name);
  3695. }
  3696. ReInit($id);
  3697. delete $IndexHash{$id};
  3698. @IndexList = sort(keys %IndexHash);
  3699. return ''; # no error
  3700. }
  3701. sub DoEditLock {
  3702. return unless UserIsAdminOrError();
  3703. print GetHeader('', T('Set or Remove global edit lock'));
  3704. my $fname = "$NoEditFile";
  3705. if (GetParam("set", 1)) {
  3706. WriteStringToFile($fname, 'editing locked.');
  3707. } else {
  3708. Unlink($fname);
  3709. }
  3710. utime time, time, $IndexFile; # touch index file
  3711. print $q->p(IsFile($fname) ? T('Edit lock created.') : T('Edit lock removed.'));
  3712. PrintFooter();
  3713. }
  3714. sub DoPageLock {
  3715. return unless UserIsAdminOrError();
  3716. print GetHeader('', T('Set or Remove page edit lock'));
  3717. my $id = GetParam('id', '');
  3718. ValidIdOrDie($id);
  3719. my $fname = GetLockedPageFile($id);
  3720. if (GetParam('set', 1)) {
  3721. WriteStringToFile($fname, 'editing locked.');
  3722. } else {
  3723. Unlink($fname);
  3724. }
  3725. utime time, time, $IndexFile; # touch index file
  3726. print $q->p(IsFile($fname) ? Ts('Lock for %s created.', GetPageLink($id))
  3727. : Ts('Lock for %s removed.', GetPageLink($id)));
  3728. PrintFooter();
  3729. }
  3730. sub DoShowVersion {
  3731. print GetHeader('', T('Displaying Wiki Version')), $q->start_div({-class=>'content version'});
  3732. print $WikiDescription, $q->p($q->server_software()),
  3733. $q->p(sprintf('Perl v%vd', $^V)),
  3734. $q->p($ENV{MOD_PERL} ? $ENV{MOD_PERL} : "no mod_perl"), $q->p('CGI: ', $CGI::VERSION),
  3735. $q->p('LWP::UserAgent ', eval { local $SIG{__DIE__}; require LWP::UserAgent; $LWP::UserAgent::VERSION; }),
  3736. $q->p('XML::RSS: ', eval { local $SIG{__DIE__}; require XML::RSS; $XML::RSS::VERSION; }),
  3737. $q->p('XML::Parser: ', eval { local $SIG{__DIE__}; $XML::Parser::VERSION; });
  3738. print $q->p('diff: ' . (`diff --version` || $!)), $q->p('diff3: ' . (`diff3 --version` || $!)) if $UseDiff;
  3739. print $q->end_div();
  3740. PrintFooter();
  3741. }
  3742. sub DoDebug {
  3743. print GetHeader('', T('Debugging Information')),
  3744. $q->start_div({-class=>'content debug'});
  3745. foreach my $func (@Debugging) { $func->() }
  3746. print $q->end_div();
  3747. PrintFooter();
  3748. }
  3749. sub DoSurgeProtection {
  3750. return unless $SurgeProtection;
  3751. my $name = GetParam('username', $q->remote_addr());
  3752. return unless $name;
  3753. ReadRecentVisitors();
  3754. AddRecentVisitor($name);
  3755. if (RequestLockDir('visitors')) { # not fatal
  3756. WriteRecentVisitors();
  3757. ReleaseLockDir('visitors');
  3758. if (DelayRequired($name)) {
  3759. ReportError(Ts('Too many connections by %s', $name)
  3760. . ': ' . Tss('Please do not fetch more than %1 pages in %2 seconds.',
  3761. $SurgeProtectionViews, $SurgeProtectionTime),
  3762. '503 SERVICE UNAVAILABLE');
  3763. }
  3764. } elsif (GetParam('action', '') ne 'unlock') {
  3765. ReportError(Ts('Could not get %s lock', 'visitors') . ': ' . Ts('Check whether the web server can create the directory %s and whether it can create files in it.', $TempDir), '503 SERVICE UNAVAILABLE');
  3766. }
  3767. }
  3768. sub DelayRequired {
  3769. my $name = shift;
  3770. my @entries = @{$RecentVisitors{$name}};
  3771. my $ts = $entries[$SurgeProtectionViews];
  3772. return ($Now - $ts) < $SurgeProtectionTime;
  3773. }
  3774. sub AddRecentVisitor {
  3775. my $name = shift;
  3776. my $value = $RecentVisitors{$name};
  3777. my @entries = ($Now);
  3778. push(@entries, @{$value}) if $value;
  3779. $RecentVisitors{$name} = \@entries;
  3780. }
  3781. sub ReadRecentVisitors {
  3782. my ($status, $data) = ReadFile($VisitorFile);
  3783. %RecentVisitors = ();
  3784. return unless $status;
  3785. foreach (split(/\n/, $data)) {
  3786. my @entries = split /$FS/;
  3787. my $name = shift(@entries);
  3788. $RecentVisitors{$name} = \@entries if $name;
  3789. }
  3790. }
  3791. sub WriteRecentVisitors {
  3792. my $data = '';
  3793. my $limit = $Now - $SurgeProtectionTime;
  3794. foreach my $name (keys %RecentVisitors) {
  3795. my @entries = @{$RecentVisitors{$name}};
  3796. if ($entries[0] >= $limit) { # if the most recent one is too old, do not keep
  3797. $data .= join($FS, $name, @entries[0 .. $SurgeProtectionViews - 1]) . "\n";
  3798. }
  3799. }
  3800. WriteStringToFile($VisitorFile, $data);
  3801. }
  3802. sub TextIsFile { $_[0] =~ /^#FILE (\S+) ?(\S+)?\n/ }
  3803. sub AddModuleDescription { # cannot use $q here because this is module init time
  3804. my ($filename, $page, $dir, $tag) = @_;
  3805. my $src = "http://git.savannah.gnu.org/cgit/oddmuse.git/tree/modules/$dir" . UrlEncode($filename) . ($tag ? '?' . $tag : '');
  3806. my $doc = 'http://www.oddmuse.org/cgi-bin/oddmuse/' . UrlEncode(FreeToNormal($page));
  3807. $ModulesDescription .= "<p><a href=\"$src\">" . QuoteHtml($filename) . "</a>" . ($tag ? " ($tag)" : '');
  3808. $ModulesDescription .= T(', see') . " <a href=\"$doc\">" . QuoteHtml($page) . "</a>" if $page;
  3809. $ModulesDescription .= "</p>";
  3810. }
  3811. DoWikiRequest() if $RunCGI and not exists $ENV{MOD_PERL}; # Do everything.
  3812. 1; # In case we are loaded from elsewhere