offpunk.py 167 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215
  1. #!/usr/bin/env python3
  2. # Offpunk Offline Gemini client
  3. # Derived from AV-98 by Solderpunk,
  4. # (C) 2021, 2022 Ploum <offpunk@ploum.eu>
  5. # (C) 2019, 2020 Solderpunk <solderpunk@sdf.org>
  6. # With contributions from:
  7. # - danceka <hannu.hartikainen@gmail.com>
  8. # - <jprjr@tilde.club>
  9. # - <vee@vnsf.xyz>
  10. # - Klaus Alexander Seistrup <klaus@seistrup.dk>
  11. # - govynnus <govynnus@sdf.org>
  12. # - Björn Wärmedal <bjorn.warmedal@gmail.com>
  13. # - <jake@rmgr.dev>
  14. _VERSION = "1.4"
  15. global BETA
  16. BETA = False
  17. import argparse
  18. import cmd
  19. import cgi
  20. import codecs
  21. import datetime
  22. import fnmatch
  23. import getpass
  24. import glob
  25. import hashlib
  26. import io
  27. import mimetypes
  28. import os
  29. import os.path
  30. import filecmp
  31. import random
  32. import shlex
  33. import shutil
  34. import socket
  35. import sqlite3
  36. import ssl
  37. from ssl import CertificateError
  38. import sys
  39. import tempfile
  40. import time
  41. import urllib.parse
  42. import uuid
  43. import webbrowser
  44. import html
  45. import subprocess
  46. def run(cmd,direct_output=False):
  47. if not direct_output:
  48. result = subprocess.check_output(cmd,shell=True)
  49. return result.decode()
  50. else:
  51. subprocess.run(cmd,shell=True)
  52. try:
  53. import setproctitle
  54. setproctitle.setproctitle("offpunk")
  55. _HAS_SETPROCTITLE = True
  56. except ModuleNotFoundError:
  57. _HAS_SETPROCTITLE = False
  58. import textwrap
  59. global TERM_WIDTH
  60. TERM_WIDTH = 80
  61. def term_width():
  62. width = TERM_WIDTH
  63. cur = shutil.get_terminal_size()[0]
  64. if cur < width:
  65. width = cur
  66. return width
  67. try:
  68. from PIL import Image
  69. _HAS_PIL = True
  70. except ModuleNotFoundError:
  71. _HAS_PIL = False
  72. _HAS_TIMG = shutil.which('timg')
  73. _HAS_CHAFA = shutil.which('chafa')
  74. _NEW_CHAFA = False
  75. # All this code to know if we render image inline or not
  76. if _HAS_CHAFA:
  77. # starting with 1.10, chafa can return only one frame
  78. # which allows us to drop dependancy for PIL
  79. output = run("chafa --version")
  80. # with chafa < 1.10, --version was returned to stderr instead of stdout.
  81. if output != '':
  82. _NEW_CHAFA = True
  83. if _NEW_CHAFA :
  84. _RENDER_IMAGE = True
  85. elif _HAS_TIMG :
  86. _RENDER_IMAGE = True
  87. elif _HAS_CHAFA and _HAS_PIL:
  88. _RENDER_IMAGE = True
  89. else:
  90. _RENDER_IMAGE = False
  91. print("To render images inline, you need either chafa or timg.")
  92. if not _NEW_CHAFA and not _HAS_TIMG:
  93. print("Before Chafa 1.10, you also need python-pil")
  94. #return ANSI text that can be show by less
  95. def inline_image(img_file,width):
  96. #Chafa is faster than timg inline. Let use that one by default
  97. inline = None
  98. ansi_img = ""
  99. #We avoid errors by not trying to render non-image files
  100. if shutil.which("file"):
  101. mime = run("file -b --mime-type \"%s\""%img_file).strip()
  102. if not "image" in mime:
  103. return ansi_img
  104. if _HAS_CHAFA:
  105. if _HAS_PIL and not _NEW_CHAFA:
  106. # this code is a hack to remove frames from animated gif
  107. img_obj = Image.open(img_file)
  108. if hasattr(img_obj,"n_frames") and img_obj.n_frames > 1:
  109. # we remove all frames but the first one
  110. img_obj.save(img_file,format="gif",save_all=False)
  111. inline = "chafa --bg white -s %s -f symbols"
  112. elif _NEW_CHAFA:
  113. inline = "chafa --bg white -t 1 -s %s -f symbols --animate=off"
  114. if not inline and _HAS_TIMG:
  115. inline = "timg --frames=1 -p q -g %sx1000"
  116. if inline:
  117. cmd = inline%width+ " \"%s\""%img_file
  118. try:
  119. ansi_img = run(cmd)
  120. except Exception as err:
  121. ansi_img = "***image failed : %s***\n" %err
  122. return ansi_img
  123. def terminal_image(img_file):
  124. #Render by timg is better than old chafa.
  125. # it is also centered
  126. cmd = None
  127. if _HAS_TIMG:
  128. cmd = "timg --loops=1 -C"
  129. elif _HAS_CHAFA:
  130. cmd = "chafa -d 0 --bg white -t 1 -w 1"
  131. if cmd:
  132. cmd = cmd + " \"%s\""%img_file
  133. run(cmd,direct_output=True)
  134. _HAS_XSEL = shutil.which('xsel')
  135. _HAS_XDGOPEN = shutil.which('xdg-open')
  136. try:
  137. from cryptography import x509
  138. from cryptography.hazmat.backends import default_backend
  139. _HAS_CRYPTOGRAPHY = True
  140. _BACKEND = default_backend()
  141. except ModuleNotFoundError:
  142. _HAS_CRYPTOGRAPHY = False
  143. try:
  144. import requests
  145. _DO_HTTP = True
  146. except ModuleNotFoundError:
  147. _DO_HTTP = False
  148. try:
  149. from readability import Document
  150. _HAS_READABILITY = True
  151. except ModuleNotFoundError:
  152. _HAS_READABILITY = False
  153. try:
  154. from bs4 import BeautifulSoup
  155. from bs4 import Comment
  156. _HAS_SOUP = True
  157. except ModuleNotFoundError:
  158. _HAS_SOUP = False
  159. _DO_HTML = _HAS_SOUP #and _HAS_READABILITY
  160. if _DO_HTML and not _HAS_READABILITY:
  161. print("To improve your web experience (less cruft in webpages),")
  162. print("please install python3-reability or readability-lxml")
  163. try:
  164. import feedparser
  165. _DO_FEED = True
  166. except ModuleNotFoundError:
  167. _DO_FEED = False
  168. ## Config directories
  169. ## We implement our own python-xdg to avoid conflict with existing libraries.
  170. _home = os.path.expanduser('~')
  171. data_home = os.environ.get('XDG_DATA_HOME') or \
  172. os.path.join(_home,'.local','share')
  173. config_home = os.environ.get('XDG_CONFIG_HOME') or \
  174. os.path.join(_home,'.config')
  175. cache_home = os.environ.get('XDG_CACHE_HOME') or\
  176. os.path.join(_home,'.cache')
  177. _CACHE_PATH = os.path.join(cache_home,"offpunk/")
  178. _CONFIG_DIR = os.path.join(config_home,"offpunk/")
  179. _DATA_DIR = os.path.join(data_home,"offpunk/")
  180. _old_config = os.path.expanduser("~/.offpunk/")
  181. ## Look for pre-existing config directory, if any
  182. if os.path.exists(_old_config):
  183. _CONFIG_DIR = _old_config
  184. #if no XDG .local/share and not XDG .config, we use the old config
  185. if not os.path.exists(data_home) and os.path.exists(_old_config):
  186. _DATA_DIR = _CONFIG_DIR
  187. _MAX_REDIRECTS = 5
  188. _MAX_CACHE_SIZE = 10
  189. _MAX_CACHE_AGE_SECS = 180
  190. _GREP = "grep --color=auto"
  191. less_version = 0
  192. if not shutil.which("less"):
  193. print("Please install the pager \"less\" to run Offpunk.")
  194. print("If you wish to use another pager, send your request to offpunk@ploum.eu.")
  195. print("(I’m really curious to hear about people not having \"less\" on their system.)")
  196. sys.exit()
  197. output = run("less --version")
  198. # We get less Version (which is the only integer on the first line)
  199. words = output.split("\n")[0].split()
  200. less_version = 0
  201. for w in words:
  202. if w.isdigit():
  203. less_version = int(w)
  204. # restoring position only works for version of less > 572
  205. if less_version >= 572:
  206. _LESS_RESTORE_POSITION = True
  207. else:
  208. _LESS_RESTORE_POSITION = False
  209. #_DEFAULT_LESS = "less -EXFRfM -PMurl\ lines\ \%lt-\%lb/\%L\ \%Pb\%$ %s"
  210. # -E : quit when reaching end of file (to behave like "cat")
  211. # -F : quit if content fits the screen (behave like "cat")
  212. # -X : does not clear the screen
  213. # -R : interpret ANSI colors correctly
  214. # -f : suppress warning for some contents
  215. # -M : long prompt (to have info about where you are in the file)
  216. # -W : hilite the new first line after a page skip (space)
  217. # -i : ignore case in search
  218. # -S : do not wrap long lines. Wrapping is done by offpunk, longlines
  219. # are there on purpose (surch in asciiart)
  220. #--incsearch : incremental search starting rev581
  221. if less_version >= 581:
  222. less_base = "less --incsearch --save-marks -~ -XRfMWiS"
  223. elif less_version >= 572:
  224. less_base = "less --save-marks -XRfMWiS"
  225. else:
  226. less_base = "less -XRfMWiS"
  227. _DEFAULT_LESS = less_base + " \"+''\" %s"
  228. _DEFAULT_CAT = less_base + " -EF %s"
  229. def less_cmd(file, histfile=None,cat=False,grep=None):
  230. file = "\"%s\""%file
  231. if histfile:
  232. prefix = "LESSHISTFILE=%s "%histfile
  233. else:
  234. prefix = ""
  235. if cat:
  236. cmd_str = prefix + _DEFAULT_CAT % file
  237. elif grep:
  238. grep_cmd = _GREP
  239. #case insensitive for lowercase search
  240. if grep.islower():
  241. grep_cmd += " -i"
  242. cmd_str = prefix + _DEFAULT_CAT % file + "|" + grep_cmd + " %s"%grep
  243. else:
  244. cmd_str = prefix + _DEFAULT_LESS % file
  245. run(cmd_str,direct_output=True)
  246. # Command abbreviations
  247. _ABBREVS = {
  248. "a": "add",
  249. "b": "back",
  250. "bb": "blackbox",
  251. "bm": "bookmarks",
  252. "book": "bookmarks",
  253. "cp": "copy",
  254. "f": "forward",
  255. "g": "go",
  256. "h": "history",
  257. "hist": "history",
  258. "l": "view",
  259. "less": "view",
  260. "man": "help",
  261. "mv": "move",
  262. "n": "next",
  263. "off": "offline",
  264. "on": "online",
  265. "p": "previous",
  266. "prev": "previous",
  267. "q": "quit",
  268. "r": "reload",
  269. "s": "save",
  270. "se": "search",
  271. "/": "search",
  272. "t": "tour",
  273. "u": "up",
  274. "v": "view",
  275. }
  276. _MIME_HANDLERS = {
  277. "application/pdf": "zathura %s",
  278. "audio/mpeg": "mpg123 %s",
  279. "audio/ogg": "ogg123 %s",
  280. "image/*": "feh -. %s",
  281. #"text/html": "lynx -dump -force_html %s",
  282. }
  283. # monkey-patch Gemini support in urllib.parse
  284. # see https://github.com/python/cpython/blob/master/Lib/urllib/parse.py
  285. urllib.parse.uses_relative.append("gemini")
  286. urllib.parse.uses_netloc.append("gemini")
  287. urllib.parse.uses_relative.append("spartan")
  288. urllib.parse.uses_netloc.append("spartan")
  289. #An IPV6 URL should be put between []
  290. #We try to detect them has location with more than 2 ":"
  291. def fix_ipv6_url(url):
  292. if not url or url.startswith("mailto"):
  293. return url
  294. if "://" in url:
  295. schema, schemaless = url.split("://",maxsplit=1)
  296. else:
  297. schema, schemaless = None, url
  298. if "/" in schemaless:
  299. netloc, rest = schemaless.split("/",1)
  300. if netloc.count(":") > 2 and "[" not in netloc and "]" not in netloc:
  301. schemaless = "[" + netloc + "]" + "/" + rest
  302. elif schemaless.count(":") > 2:
  303. schemaless = "[" + schemaless + "]/"
  304. if schema:
  305. return schema + "://" + schemaless
  306. return schemaless
  307. # This list is also used as a list of supported protocols
  308. standard_ports = {
  309. "gemini" : 1965,
  310. "gopher" : 70,
  311. "http" : 80,
  312. "https" : 443,
  313. "spartan": 300,
  314. }
  315. # First, we define the different content->text renderers, outside of the rest
  316. # (They could later be factorized in other files or replaced)
  317. class AbstractRenderer():
  318. def __init__(self,content,url,center=True):
  319. self.url = url
  320. self.body = content
  321. #there’s one rendered text and one links table per mode
  322. self.rendered_text = {}
  323. self.links = {}
  324. self.images = {}
  325. self.title = None
  326. self.validity = True
  327. self.temp_file = {}
  328. self.less_histfile = {}
  329. self.center = center
  330. #This class hold an internal representation of the HTML text
  331. #This is an experiment to rewrite the HTML renderer. Currently not used.
  332. class representation:
  333. def __init__(self,width,title=None,center=True):
  334. self.title=title
  335. self.center = center
  336. self.final_text = ""
  337. self.opened = []
  338. self.width = width
  339. self.last_line = ""
  340. self.last_line_colors = {}
  341. self.last_line_center = False
  342. self.new_paragraph = True
  343. self.i_indent = ""
  344. self.s_indent = ""
  345. self.r_indent = ""
  346. self.current_indent = ""
  347. self.disabled_indents = None
  348. # each color is an [open,close] pair code
  349. self.colors = {
  350. "bold" : ["1","22"],
  351. "faint" : ["2","22"],
  352. "italic" : ["3","23"],
  353. "underline": ["4","24"],
  354. "red" : ["31","39"],
  355. "yellow" : ["33","39"],
  356. "blue" : ["34","39"],
  357. }
  358. def _insert(self,color,open=True):
  359. if open: o = 0
  360. else: o = 1
  361. pos = len(self.last_line)
  362. #we remember the position where to insert color codes
  363. if not pos in self.last_line_colors:
  364. self.last_line_colors[pos] = []
  365. #Two inverse code cancel each other
  366. if [color,int(not o)] in self.last_line_colors[pos]:
  367. self.last_line_colors[pos].remove([color,int(not o)])
  368. else:
  369. self.last_line_colors[pos].append([color,o])#+color+str(o))
  370. # Take self.last line and add ANSI codes to it before adding it to
  371. # self.final_text.
  372. def _endline(self):
  373. if len(self.last_line.strip()) > 0:
  374. for c in self.opened:
  375. self._insert(c,open=False)
  376. nextline = ""
  377. added_char = 0
  378. #we insert the color code at the saved positions
  379. while len (self.last_line_colors) > 0:
  380. pos,colors = self.last_line_colors.popitem()
  381. #popitem itterates LIFO.
  382. #So we go, backward, to the pos (starting at the end of last_line)
  383. nextline = self.last_line[pos:] + nextline
  384. ansicol = "\x1b["
  385. for c,o in colors:
  386. ansicol += self.colors[c][o] + ";"
  387. ansicol = ansicol[:-1]+"m"
  388. nextline = ansicol + nextline
  389. added_char += len(ansicol)
  390. self.last_line = self.last_line[:pos]
  391. nextline = self.last_line + nextline
  392. if self.last_line_center:
  393. #we have to care about the ansi char while centering
  394. width = term_width() + added_char
  395. nextline = nextline.strip().center(width)
  396. self.last_line_center = False
  397. else:
  398. #should we lstrip the nextline in the addition ?
  399. nextline = self.current_indent + nextline.lstrip() + self.r_indent
  400. self.current_indent = self.s_indent
  401. self.final_text += nextline
  402. self.last_line = ""
  403. self.final_text += "\n"
  404. for c in self.opened:
  405. self._insert(c,open=True)
  406. else:
  407. self.last_line = ""
  408. def center_line(self):
  409. self.last_line_center = True
  410. def open_color(self,color):
  411. if color in self.colors and color not in self.opened:
  412. self._insert(color,open=True)
  413. self.opened.append(color)
  414. def close_color(self,color):
  415. if color in self.colors and color in self.opened:
  416. self._insert(color,open=False)
  417. self.opened.remove(color)
  418. def close_all(self):
  419. if len(self.colors) > 0:
  420. self.last_line += "\x1b[0m"
  421. self.opened.clear()
  422. def startindent(self,indent,sub=None,reverse=None):
  423. self._endline()
  424. self.i_indent = indent
  425. self.current_indent = indent
  426. if sub:
  427. self.s_indent = sub
  428. else:
  429. self.s_indent = indent
  430. if reverse:
  431. self.r_indent = reverse
  432. else:
  433. self.r_indent = ""
  434. def endindent(self):
  435. self._endline()
  436. self.i_indent = ""
  437. self.s_indent = ""
  438. self.r_indent = ""
  439. self.current_indent = ""
  440. def _disable_indents(self):
  441. self.disabled_indents = []
  442. self.disabled_indents.append(self.current_indent)
  443. self.disabled_indents.append(self.i_indent)
  444. self.disabled_indents.append(self.s_indent)
  445. self.disabled_indents.append(self.r_indent)
  446. self.endindent()
  447. def _enable_indents(self):
  448. if self.disabled_indents:
  449. self.current_indent = self.disabled_indents[0]
  450. self.i_indent = self.disabled_indents[1]
  451. self.s_indent = self.disabled_indents[2]
  452. self.r_indent = self.disabled_indents[3]
  453. self.disabled_indents = None
  454. def newline(self):
  455. self._endline()
  456. #A new paragraph implies 2 newlines (1 blank line between paragraphs)
  457. #But it is only used if didn’t already started one to avoid plenty
  458. #of blank lines. force=True allows to bypass that limit.
  459. #new_paragraph becomes false as soon as text is entered into it
  460. def newparagraph(self,force=False):
  461. if force or not self.new_paragraph:
  462. self._endline()
  463. self.final_text += "\n"
  464. self.new_paragraph = True
  465. def add_space(self):
  466. if len(self.last_line) > 0 and self.last_line[-1] != " ":
  467. self.last_line += " "
  468. def _title_first(self,intext=None):
  469. if self.title:
  470. if not self.title == intext:
  471. self._disable_indents()
  472. self.open_color("blue")
  473. self.open_color("bold")
  474. self.open_color("underline")
  475. self.add_text(self.title)
  476. self.close_all()
  477. self.newparagraph()
  478. self._enable_indents()
  479. self.title = None
  480. # Beware, blocks are not wrapped nor indented and left untouched!
  481. # They are mostly useful for pictures and preformatted text.
  482. def add_block(self,intext):
  483. # If necessary, we add the title before a block
  484. self._title_first()
  485. # we don’t want to indent blocks
  486. self._endline()
  487. self._disable_indents()
  488. self.final_text += self.current_indent + intext
  489. self.new_paragraph = False
  490. self._endline()
  491. self._enable_indents()
  492. def add_text(self,intext):
  493. self._title_first(intext=intext)
  494. lines = []
  495. last = (self.last_line + intext)
  496. self.last_line = ""
  497. # With the following, we basically cancel adding only spaces
  498. # on an empty line
  499. if len(last.strip()) > 0:
  500. self.new_paragraph = False
  501. else:
  502. last = last.strip()
  503. if len(last) > self.width:
  504. width = self.width - len(self.current_indent) - len(self.r_indent)
  505. spaces_left = len(last) - len(last.lstrip())
  506. spaces_right = len(last) - len(last.rstrip())
  507. lines = textwrap.wrap(last,width,drop_whitespace=True)
  508. self.last_line += spaces_left*" "
  509. while len(lines) > 1:
  510. l = lines.pop(0)
  511. self.last_line += l
  512. self._endline()
  513. if len(lines) == 1:
  514. li = lines[0]
  515. self.last_line += li + spaces_right*" "
  516. else:
  517. self.last_line = last
  518. def get_final(self):
  519. self.close_all()
  520. self._endline()
  521. #if no content, we still add the title
  522. self._title_first()
  523. lines = self.final_text.splitlines()
  524. lines2 = []
  525. termspace = shutil.get_terminal_size()[0]
  526. #Following code instert blanck spaces to center the content
  527. if self.center and termspace > term_width():
  528. margin = int((termspace - term_width())//2)
  529. else:
  530. margin = 0
  531. for l in lines :
  532. lines2.append(margin*" "+l)
  533. return "\n".join(lines2)
  534. def get_subscribe_links(self):
  535. return [[self.url,self.get_mime(),self.get_title()]]
  536. def is_valid(self):
  537. return self.validity
  538. def get_links(self,mode="links_only"):
  539. if mode not in self.links :
  540. prepared_body = self.prepare(self.body,mode=mode)
  541. results = self.render(prepared_body,mode=mode)
  542. if results:
  543. self.links[mode] = results[1]
  544. return self.links[mode]
  545. def get_title(self):
  546. return "Abstract title"
  547. # This function return a list of URL which should be downloaded
  548. # before displaying the page (images in HTML pages, typically)
  549. def get_images(self,mode="readable"):
  550. if not mode in self.images:
  551. self.get_body(mode=mode)
  552. # we also invalidate the body that was done without images
  553. self.rendered_text.pop(mode)
  554. if mode in self.images:
  555. return self.images[mode]
  556. else:
  557. return []
  558. #This function will give gemtext to the gemtext renderer
  559. def prepare(self,body,mode=None):
  560. return body
  561. def get_body(self,width=None,mode="readable"):
  562. if not width:
  563. width = term_width()
  564. if mode not in self.rendered_text:
  565. prepared_body = self.prepare(self.body,mode=mode)
  566. result = self.render(prepared_body,width=width,mode=mode)
  567. if result:
  568. self.rendered_text[mode] = result[0]
  569. self.links[mode] = result[1]
  570. return self.rendered_text[mode]
  571. def _window_title(self,title,info=None):
  572. title_r = self.representation(term_width())
  573. title_r.open_color("red")
  574. title_r.open_color("bold")
  575. title_r.add_text(title)
  576. title_r.close_color("bold")
  577. if info:
  578. title_r.add_text(" (%s)"%info)
  579. title_r.close_color("red")
  580. return title_r.get_final()
  581. def display(self,mode="readable",window_title="",window_info=None,grep=None):
  582. wtitle = self._window_title(window_title,info=window_info)
  583. body = wtitle + "\n" + self.get_body(mode=mode)
  584. if not body:
  585. return False
  586. # We actually put the body in a tmpfile before giving it to less
  587. if mode not in self.temp_file:
  588. tmpf = tempfile.NamedTemporaryFile("w", encoding="UTF-8", delete=False)
  589. self.temp_file[mode] = tmpf.name
  590. tmpf.write(body)
  591. tmpf.close()
  592. if mode not in self.less_histfile:
  593. firsttime = True
  594. tmpf = tempfile.NamedTemporaryFile("w", encoding="UTF-8", delete=False)
  595. self.less_histfile[mode] = tmpf.name
  596. else:
  597. firsttime = False
  598. less_cmd(self.temp_file[mode], histfile=self.less_histfile[mode],cat=firsttime,grep=grep)
  599. return True
  600. def get_temp_file(self,mode="readable"):
  601. if mode in self.temp_file:
  602. return self.temp_file[mode]
  603. else:
  604. return None
  605. # An instance of AbstractRenderer should have a self.render(body,width,mode) method.
  606. # 3 modes are used : readable (by default), full and links_only (the fastest, when
  607. # rendered content is not used, only the links are needed)
  608. # The prepare() function is called before the rendering. It is useful if
  609. # your renderer output in a format suitable for another existing renderer (such as gemtext)
  610. # Gemtext Rendering Engine
  611. class GemtextRenderer(AbstractRenderer):
  612. def get_mime(self):
  613. return "text/gemini"
  614. def get_title(self):
  615. if self.title:
  616. return self.title
  617. elif self.body:
  618. lines = self.body.splitlines()
  619. for line in lines:
  620. if line.startswith("#"):
  621. self.title = line.strip("#").strip()
  622. return self.title
  623. if len(lines) > 0:
  624. # If not title found, we take the first 50 char
  625. # of the first line
  626. title_line = lines[0].strip()
  627. if len(title_line) > 50:
  628. title_line = title_line[:49] + "…"
  629. self.title = title_line
  630. return self.title
  631. else:
  632. self.title = "Empty Page"
  633. return self.title
  634. else:
  635. return "Unknown Gopher Page"
  636. #render_gemtext
  637. def render(self,gemtext, width=None,mode=None):
  638. if not width:
  639. width = term_width()
  640. r = self.representation(width)
  641. links = []
  642. preformatted = False
  643. def format_link(url,index,name=None):
  644. if "://" in url:
  645. protocol,adress = url.split("://",maxsplit=1)
  646. protocol = " %s" %protocol
  647. else:
  648. adress = url
  649. protocol = ""
  650. if "gemini" in protocol or "list" in protocol:
  651. protocol = ""
  652. if not name:
  653. name = adress
  654. line = "[%d%s] %s" % (index, protocol, name)
  655. return line
  656. for line in gemtext.splitlines():
  657. r.newline()
  658. if line.startswith("```"):
  659. preformatted = not preformatted
  660. elif preformatted:
  661. # infinite line to not wrap preformated
  662. r.add_block(line+"\n")
  663. elif len(line.strip()) == 0:
  664. r.newparagraph(force=True)
  665. elif line.startswith("=>"):
  666. strippedline = line[2:].strip()
  667. if strippedline:
  668. links.append(strippedline)
  669. splitted = strippedline.split(maxsplit=1)
  670. url = splitted[0]
  671. name = None
  672. if len(splitted) > 1:
  673. name = splitted[1]
  674. link = format_link(url,len(links),name=name)
  675. #r.open_color("blue")
  676. #r.open_color("faint")
  677. #r.open_color("underline")
  678. startpos = link.find("] ") + 2
  679. r.startindent("",sub=startpos*" ")
  680. r.add_text(link)
  681. r.endindent()
  682. #r.close_all()
  683. elif line.startswith("* "):
  684. line = line[1:].lstrip("\t ")
  685. r.startindent("• ",sub=" ")
  686. r.add_text(line)
  687. r.endindent()
  688. elif line.startswith(">"):
  689. line = line[1:].lstrip("\t ")
  690. r.startindent("> ")
  691. r.add_text(line)
  692. r.endindent()
  693. elif line.startswith("###"):
  694. line = line[3:].lstrip("\t ")
  695. r.open_color("blue")
  696. r.add_text(line)
  697. r.close_color("blue")
  698. elif line.startswith("##"):
  699. line = line[2:].lstrip("\t ")
  700. r.open_color("blue")
  701. r.add_text(line)
  702. r.close_color("blue")
  703. elif line.startswith("#"):
  704. line = line[1:].lstrip("\t ")
  705. if not self.title:
  706. self.title = line
  707. r.open_color("bold")
  708. r.open_color("blue")
  709. r.open_color("underline")
  710. r.add_text(line)
  711. r.close_color("underline")
  712. r.close_color("bold")
  713. r.close_color("blue")
  714. else:
  715. r.add_text(line.rstrip())
  716. return r.get_final(), links
  717. class GopherRenderer(AbstractRenderer):
  718. def get_mime(self):
  719. return "text/gopher"
  720. def get_title(self):
  721. if not self.title:
  722. self.title = ""
  723. if self.body:
  724. firstline = self.body.splitlines()[0]
  725. firstline = firstline.split("\t")[0]
  726. if firstline.startswith("i"):
  727. firstline = firstline[1:]
  728. self.title = firstline
  729. return self.title
  730. #menu_or_text
  731. def render(self,body,width=None,mode=None):
  732. if not width:
  733. width = term_width()
  734. try:
  735. render,links = self._render_goph(body,width=width,mode=mode)
  736. except Exception as err:
  737. print("Error rendering Gopher ",err)
  738. r = self.representation(width)
  739. r.add_block(body)
  740. render = r.get_final()
  741. links = []
  742. return render,links
  743. def _render_goph(self,body,width=None,mode=None):
  744. if not width:
  745. width = term_width()
  746. # This was copied straight from Agena (then later adapted)
  747. links = []
  748. r = self.representation(width)
  749. for line in self.body.split("\n"):
  750. r.newline()
  751. if line.startswith("i"):
  752. towrap = line[1:].split("\t")[0]
  753. if len(towrap.strip()) > 0:
  754. r.add_text(towrap)
  755. else:
  756. r.newparagraph()
  757. elif not line.strip() in [".",""]:
  758. parts = line.split("\t")
  759. parts[-1] = parts[-1].strip()
  760. if parts[-1] == "+":
  761. parts = parts[:-1]
  762. if len(parts) == 4:
  763. name,path,host,port = parts
  764. itemtype = name[0]
  765. name = name[1:]
  766. if port == "70":
  767. port = ""
  768. else:
  769. port = ":%s"%port
  770. if itemtype == "h" and path.startswith("URL:"):
  771. url = path[4:]
  772. else:
  773. if not path.startswith("/"):
  774. path = "/"+path
  775. url = "gopher://%s%s/%s%s" %(host,port,itemtype,path)
  776. url = url.replace(" ","%20")
  777. linkline = url + " " + name
  778. links.append(linkline)
  779. towrap = "[%s] "%len(links)+ name
  780. r.add_text(towrap)
  781. else:
  782. r.add_text(line)
  783. return r.get_final(),links
  784. class FolderRenderer(GemtextRenderer):
  785. def get_mime(self):
  786. return "Directory"
  787. def prepare(self,body,mode=None):
  788. def get_first_line(l):
  789. path = os.path.join(listdir,l+".gmi")
  790. with open(path) as f:
  791. first_line = f.readline().strip()
  792. f.close()
  793. if first_line.startswith("#"):
  794. return first_line
  795. else:
  796. return None
  797. def write_list(l):
  798. body = ""
  799. for li in l:
  800. path = "list:///%s"%li
  801. gi = GeminiItem(path)
  802. size = len(gi.get_links())
  803. body += "=> %s %s (%s items)\n" %(str(path),li,size)
  804. return body
  805. listdir = os.path.join(_DATA_DIR,"lists")
  806. if self.url != listdir:
  807. return "This is folder %s" %self.url
  808. else:
  809. self.title = "My lists"
  810. lists = []
  811. if os.path.exists(listdir):
  812. listfiles = os.listdir(listdir)
  813. if len(listfiles) > 0:
  814. for l in listfiles:
  815. #removing the .gmi at the end of the name
  816. lists.append(l[:-4])
  817. if len(lists) > 0:
  818. body = ""
  819. my_lists = []
  820. system_lists = []
  821. subscriptions = []
  822. frozen = []
  823. lists.sort()
  824. for l in lists:
  825. if l in ["history","to_fetch","archives","tour"]:
  826. system_lists.append(l)
  827. else:
  828. first_line = get_first_line(l)
  829. if first_line and "#subscribed" in first_line:
  830. subscriptions.append(l)
  831. elif first_line and "#frozen" in first_line:
  832. frozen.append(l)
  833. else:
  834. my_lists.append(l)
  835. if len(my_lists) > 0:
  836. body+= "\n## Bookmarks Lists (updated during sync)\n"
  837. body += write_list(my_lists)
  838. if len(subscriptions) > 0:
  839. body +="\n## Subscriptions (new links in those are added to tour)\n"
  840. body += write_list(subscriptions)
  841. if len(frozen) > 0:
  842. body +="\n## Frozen (fetched but never updated)\n"
  843. body += write_list(frozen)
  844. if len(system_lists) > 0:
  845. body +="\n## System Lists\n"
  846. body += write_list(system_lists)
  847. return body
  848. class FeedRenderer(GemtextRenderer):
  849. def get_mime(self):
  850. return "application/rss+xml"
  851. def is_valid(self):
  852. if _DO_FEED:
  853. parsed = feedparser.parse(self.body)
  854. else:
  855. return False
  856. if parsed.bozo:
  857. return False
  858. else:
  859. #If no content, then fallback to HTML
  860. return len(parsed.entries) > 0
  861. def get_title(self):
  862. if not self.title:
  863. self.get_body()
  864. return self.title
  865. def prepare(self,content,mode="readable",width=None):
  866. if not width:
  867. width = term_width()
  868. self.title = "RSS/Atom feed"
  869. page = ""
  870. if _DO_FEED:
  871. parsed = feedparser.parse(content)
  872. else:
  873. page += "Please install python-feedparser to handle RSS/Atom feeds\n"
  874. self.validity = False
  875. return page
  876. if parsed.bozo:
  877. page += "Invalid RSS feed\n\n"
  878. page += str(parsed.bozo_exception)
  879. self.validity = False
  880. else:
  881. if "title" in parsed.feed:
  882. t = parsed.feed.title
  883. else:
  884. t = "Unknown"
  885. self.title = "%s (XML feed)" %t
  886. title = "# %s"%self.title
  887. page += title + "\n"
  888. if "updated" in parsed.feed:
  889. page += "Last updated on %s\n\n" %parsed.feed.updated
  890. if "subtitle" in parsed.feed:
  891. page += parsed.feed.subtitle + "\n"
  892. if "link" in parsed.feed:
  893. page += "=> %s\n" %parsed.feed.link
  894. page += "\n## Entries\n"
  895. if len(parsed.entries) < 1:
  896. self.validity = False
  897. for i in parsed.entries:
  898. line = "=> %s " %i.link
  899. if "published" in i:
  900. pub_date = time.strftime("%Y-%m-%d",i.published_parsed)
  901. line += pub_date + " : "
  902. line += "%s" %(i.title)
  903. if "author" in i:
  904. line += " (by %s)"%i.author
  905. page += line + "\n"
  906. if mode == "full":
  907. if "summary" in i:
  908. html = HtmlRenderer(i.summary,self.url,center=False)
  909. rendered = html.get_body(width=None,mode="full")
  910. page += rendered
  911. page += "\n"
  912. return page
  913. class ImageRenderer(AbstractRenderer):
  914. def get_mime(self):
  915. return "image/*"
  916. def is_valid(self):
  917. if _RENDER_IMAGE:
  918. return True
  919. else:
  920. return False
  921. def get_links(self,mode=None):
  922. return []
  923. def get_title(self):
  924. return "Picture file"
  925. def render(self,img,width=None,mode=None):
  926. #with inline, we use symbols to be rendered with less.
  927. #else we use the best possible renderer.
  928. if mode == "links_only":
  929. return "", []
  930. if not width:
  931. width = term_width()
  932. spaces = 0
  933. else:
  934. spaces = int((term_width() - width)//2)
  935. ansi_img = inline_image(img,width)
  936. #Now centering the image
  937. lines = ansi_img.splitlines()
  938. new_img = ""
  939. for l in lines:
  940. new_img += spaces*" " + l + "\n"
  941. return new_img, []
  942. def display(self,mode=None,window_title=None,window_info=None,grep=None):
  943. if window_title:
  944. print(self._window_title(window_title,info=window_info))
  945. terminal_image(self.body)
  946. return True
  947. class HtmlRenderer(AbstractRenderer):
  948. def get_mime(self):
  949. return "text/html"
  950. def is_valid(self):
  951. if not _DO_HTML:
  952. print("HTML document detected. Please install python-bs4 and python-readability.")
  953. return _DO_HTML and self.validity
  954. def get_subscribe_links(self):
  955. subs = [[self.url,self.get_mime(),self.get_title()]]
  956. soup = BeautifulSoup(self.body, 'html.parser')
  957. links = soup.find_all("link",rel="alternate",recursive=True)
  958. for l in links:
  959. ty = l.get("type")
  960. if ty :
  961. if "rss" in ty or "atom" in ty or "feed" in ty:
  962. subs.append([l.get("href"),ty,l.get("title")])
  963. return subs
  964. def get_title(self):
  965. if self.title:
  966. return self.title
  967. elif self.body:
  968. if _HAS_READABILITY:
  969. try:
  970. readable = Document(self.body)
  971. self.title = readable.short_title()
  972. return self.title
  973. except Exception as err:
  974. pass
  975. soup = BeautifulSoup(self.body,"html.parser")
  976. self.title = str(soup.title.string)
  977. else:
  978. return ""
  979. # Our own HTML engine (crazy, isn’t it?)
  980. # Return [rendered_body, list_of_links]
  981. # mode is either links_only, readable or full
  982. def render(self,body,mode="readable",width=None,add_title=True):
  983. if not width:
  984. width = term_width()
  985. if not _DO_HTML:
  986. print("HTML document detected. Please install python-bs4 and python-readability.")
  987. return
  988. # This method recursively parse the HTML
  989. r = self.representation(width,title=self.get_title(),center=self.center)
  990. links = []
  991. # You know how bad html is when you realize that space sometimes meaningful, somtimes not.
  992. # CR are not meaniningful. Except that, somethimes, they should be interpreted as spaces.
  993. # HTML is real crap. At least the one people are generating.
  994. def render_image(src,width=40,mode=None):
  995. ansi_img = ""
  996. abs_url = urllib.parse.urljoin(self.url, src)
  997. if _RENDER_IMAGE and mode != "links_only" and src:
  998. try:
  999. #4 followings line are there to translate the URL into cache path
  1000. g = GeminiItem(abs_url)
  1001. if g.is_cache_valid():
  1002. img = g.get_cache_path()
  1003. renderer = ImageRenderer(img,abs_url)
  1004. # Image are 40px wide except if terminal is smaller
  1005. if width > 40:
  1006. size = 40
  1007. else:
  1008. size = width
  1009. ansi_img = "\n" + renderer.get_body(width=size,mode="inline")
  1010. except Exception as err:
  1011. #we sometimes encounter really bad formatted files or URL
  1012. ansi_img = textwrap.fill("[BAD IMG] %s"%src,width) + "\n"
  1013. return ansi_img
  1014. def sanitize_string(string):
  1015. #never start with a "\n"
  1016. #string = string.lstrip("\n")
  1017. string = string.replace("\r","").replace("\n", " ").replace("\t"," ")
  1018. endspace = string.endswith(" ") or string.endswith("\xa0")
  1019. startspace = string.startswith(" ") or string.startswith("\xa0")
  1020. toreturn = string.replace("\n", " ").replace("\t"," ").strip()
  1021. while " " in toreturn:
  1022. toreturn = toreturn.replace(" "," ")
  1023. toreturn = html.unescape(toreturn)
  1024. if endspace and not toreturn.endswith(" ") and not toreturn.endswith("\xa0"):
  1025. toreturn += " "
  1026. if startspace and not toreturn.startswith(" ") and not toreturn.startswith("\xa0"):
  1027. toreturn = " " + toreturn
  1028. return toreturn
  1029. def recursive_render(element,indent="",preformatted=False):
  1030. if element.name == "blockquote":
  1031. r.newparagraph()
  1032. r.startindent(" ",reverse=" ")
  1033. for child in element.children:
  1034. r.open_color("italic")
  1035. recursive_render(child,indent="\t")
  1036. r.close_color("italic")
  1037. r.endindent()
  1038. elif element.name in ["div","p"]:
  1039. r.newparagraph()
  1040. for child in element.children:
  1041. recursive_render(child,indent=indent)
  1042. r.newparagraph()
  1043. elif element.name in ["span"]:
  1044. r.add_space()
  1045. for child in element.children:
  1046. recursive_render(child,indent=indent)
  1047. r.add_space()
  1048. elif element.name in ["h1","h2","h3","h4","h5","h6"]:
  1049. r.open_color("blue")
  1050. if element.name in ["h1"]:
  1051. r.open_color("bold")
  1052. r.open_color("underline")
  1053. elif element.name in ["h2"]:
  1054. r.open_color("bold")
  1055. elif element.name in ["h5","h6"]:
  1056. r.open_color("faint")
  1057. for child in element.children:
  1058. r.newparagraph()
  1059. recursive_render(child)
  1060. r.newparagraph()
  1061. r.close_all()
  1062. elif element.name in ["code","tt"]:
  1063. for child in element.children:
  1064. recursive_render(child,indent=indent,preformatted=True)
  1065. elif element.name in ["pre"]:
  1066. r.newparagraph()
  1067. r.add_block(element.text)
  1068. r.newparagraph()
  1069. elif element.name in ["li"]:
  1070. r.startindent(" • ",sub=" ")
  1071. for child in element.children:
  1072. recursive_render(child,indent=indent)
  1073. r.endindent()
  1074. elif element.name in ["tr"]:
  1075. r.startindent("|",reverse="|")
  1076. for child in element.children:
  1077. recursive_render(child,indent=indent)
  1078. r.endindent()
  1079. elif element.name in ["td","th"]:
  1080. r.add_text("| ")
  1081. for child in element.children:
  1082. recursive_render(child)
  1083. r.add_text(" |")
  1084. # italics
  1085. elif element.name in ["em","i"]:
  1086. r.open_color("italic")
  1087. for child in element.children:
  1088. recursive_render(child,indent=indent,preformatted=preformatted)
  1089. r.close_color("italic")
  1090. #bold
  1091. elif element.name in ["b","strong"]:
  1092. r.open_color("bold")
  1093. for child in element.children:
  1094. recursive_render(child,indent=indent,preformatted=preformatted)
  1095. r.close_color("bold")
  1096. elif element.name == "a":
  1097. link = element.get('href')
  1098. # support for images nested in links
  1099. if link:
  1100. text = ""
  1101. imgtext = ""
  1102. #we display images first in a link
  1103. for child in element.children:
  1104. if child.name == "img":
  1105. recursive_render(child)
  1106. imgtext = "[IMG LINK %s]"
  1107. links.append(link+" "+text)
  1108. link_id = str(len(links))
  1109. r.open_color("blue")
  1110. r.open_color("faint")
  1111. for child in element.children:
  1112. if child.name != "img":
  1113. recursive_render(child,preformatted=preformatted)
  1114. if imgtext != "":
  1115. r.center_line()
  1116. r.add_text(imgtext%link_id)
  1117. else:
  1118. r.add_text(" [%s]"%link_id)
  1119. r.close_color("blue")
  1120. r.close_color("faint")
  1121. else:
  1122. #No real link found
  1123. for child in element.children:
  1124. recursive_render(child,preformatted=preformatted)
  1125. elif element.name == "img":
  1126. src = element.get("src")
  1127. text = ""
  1128. ansi_img = render_image(src,width=width,mode=mode)
  1129. alt = element.get("alt")
  1130. if alt:
  1131. alt = sanitize_string(alt)
  1132. text += "[IMG] %s"%alt
  1133. else:
  1134. text += "[IMG]"
  1135. if src:
  1136. links.append(src+" "+text)
  1137. if not mode in self.images:
  1138. self.images[mode] = []
  1139. abs_url = urllib.parse.urljoin(self.url, src)
  1140. self.images[mode].append(abs_url)
  1141. link_id = " [%s]"%(len(links))
  1142. r.add_block(ansi_img)
  1143. r.open_color("faint")
  1144. r.open_color("yellow")
  1145. r.center_line()
  1146. r.add_text(text + link_id)
  1147. r.close_color("faint")
  1148. r.close_color("yellow")
  1149. r.newline()
  1150. elif element.name == "br":
  1151. r.newline()
  1152. elif element.name not in ["script","style","template"] and type(element) != Comment:
  1153. if element.string:
  1154. if preformatted :
  1155. r.open_color("faint")
  1156. r.add_text(element.string)
  1157. r.close_color("faint")
  1158. else:
  1159. s = sanitize_string(element.string)
  1160. if len(s.strip()) > 0:
  1161. r.add_text(s)
  1162. else:
  1163. for child in element.children:
  1164. recursive_render(child,indent=indent)
  1165. # the real render_html hearth
  1166. if mode == "full":
  1167. summary = body
  1168. elif _HAS_READABILITY:
  1169. try:
  1170. readable = Document(body)
  1171. summary = readable.summary()
  1172. except Exception as err:
  1173. summary = body
  1174. else:
  1175. summary = body
  1176. soup = BeautifulSoup(summary, 'html.parser')
  1177. #soup = BeautifulSoup(summary, 'html5lib')
  1178. if soup :
  1179. if soup.body :
  1180. recursive_render(soup.body)
  1181. else:
  1182. recursive_render(soup)
  1183. return r.get_final(),links
  1184. # Mapping mimetypes with renderers
  1185. # (any content with a mimetype text/* not listed here will be rendered with as GemText)
  1186. _FORMAT_RENDERERS = {
  1187. "text/gemini": GemtextRenderer,
  1188. "text/html" : HtmlRenderer,
  1189. "text/xml" : FeedRenderer,
  1190. "application/xml" : FeedRenderer,
  1191. "application/rss+xml" : FeedRenderer,
  1192. "application/atom+xml" : FeedRenderer,
  1193. "text/gopher": GopherRenderer,
  1194. "image/*": ImageRenderer
  1195. }
  1196. # Offpunk is organized as follow:
  1197. # - a GeminiClient instance which handles the browsing of GeminiItems (= pages).
  1198. # - There’s only one GeminiClient. Each page is a GeminiItem (name is historical, as
  1199. # it could be non-gemini content)
  1200. # - A GeminiItem is created with an URL from which it will derives content.
  1201. # - Content include : a title, a body (raw source) and a renderer. The renderer will provide
  1202. # ANSI rendered version of the content and a list of links
  1203. # - Each GeminiItem generates a "cache_path" in which it maintains a cached version of its content.
  1204. class GeminiItem():
  1205. def __init__(self, url, name=""):
  1206. if "://" not in url and ("./" not in url and url[0] != "/"):
  1207. if not url.startswith("mailto:"):
  1208. url = "gemini://" + url
  1209. self.last_mode = None
  1210. findmode = url.split("##offpunk_mode=")
  1211. if len(findmode) > 1:
  1212. self.url = findmode[0]
  1213. if findmode[1] in ["full"] or findmode[1].isnumeric():
  1214. self.last_mode = findmode[1]
  1215. else:
  1216. self.url = url
  1217. self.url = fix_ipv6_url(self.url).strip()
  1218. self._cache_path = None
  1219. self.name = name
  1220. self.mime = None
  1221. self.renderer = None
  1222. self.body = None
  1223. parsed = urllib.parse.urlparse(self.url)
  1224. if url[0] == "/" or url.startswith("./"):
  1225. self.scheme = "file"
  1226. else:
  1227. self.scheme = parsed.scheme
  1228. if self.scheme in ["file","mailto","list"]:
  1229. self.local = True
  1230. self.host = ""
  1231. self.port = None
  1232. # file:// is 7 char
  1233. if self.url.startswith("file://"):
  1234. self.path = self.url[7:]
  1235. elif self.scheme == "mailto":
  1236. self.path = parsed.path
  1237. elif self.url.startswith("list://"):
  1238. listdir = os.path.join(_DATA_DIR,"lists")
  1239. listname = self.url[7:].lstrip("/")
  1240. if listname in [""]:
  1241. self.name = "My Lists"
  1242. self.path = listdir
  1243. else:
  1244. self.name = listname
  1245. self.path = os.path.join(listdir, "%s.gmi"%listname)
  1246. else:
  1247. self.path = self.url
  1248. else:
  1249. self.local = False
  1250. self.host = parsed.hostname
  1251. self.port = parsed.port or standard_ports.get(self.scheme, 0)
  1252. # special gopher selector case
  1253. if self.scheme == "gopher":
  1254. if parsed.path and parsed.path[0] == "/" and len(parsed.path) > 1:
  1255. splitted = parsed.path.split("/")
  1256. # We check if we have well a gopher type
  1257. if len(splitted[1]) == 1:
  1258. itemtype = parsed.path[1]
  1259. selector = parsed.path[2:]
  1260. else:
  1261. itemtype = "1"
  1262. selector = parsed.path
  1263. self.path = selector
  1264. else:
  1265. itemtype = "1"
  1266. self.path = parsed.path
  1267. if itemtype == "0":
  1268. self.mime = "text/gemini"
  1269. elif itemtype == "1":
  1270. self.mime = "text/gopher"
  1271. elif itemtype == "h":
  1272. self.mime = "text/html"
  1273. elif itemtype in ("9","g","I","s"):
  1274. self.mime = "binary"
  1275. else:
  1276. self.mime = "text/gopher"
  1277. else:
  1278. self.path = parsed.path
  1279. if parsed.query:
  1280. # we don’t add the query if path is too long because path above 260 char
  1281. # are not supported and crash python.
  1282. # Also, very long query are usually useless stuff
  1283. if len(self.path+parsed.query) < 258:
  1284. self.path += "/" + parsed.query
  1285. def get_cache_path(self):
  1286. if self._cache_path and not os.path.isdir(self._cache_path):
  1287. return self._cache_path
  1288. elif self.local:
  1289. self._cache_path = self.path
  1290. #if not local, we create a local cache path.
  1291. else:
  1292. self._cache_path = os.path.expanduser(_CACHE_PATH + self.scheme +\
  1293. "/" + self.host + self.path)
  1294. #There’s an OS limitation of 260 characters per path.
  1295. #We will thus cut the path enough to add the index afterward
  1296. self._cache_path = self._cache_path[:249]
  1297. # FIXME : this is a gross hack to give a name to
  1298. # index files. This will break if the index is not
  1299. # index.gmi. I don’t know how to know the real name
  1300. # of the file. But first, we need to ensure that the domain name
  1301. # finish by "/". Else, the cache will create a file, not a folder.
  1302. if self.scheme.startswith("http"):
  1303. index = "index.html"
  1304. elif self.scheme == "gopher":
  1305. index = "index.txt"
  1306. else:
  1307. index = "index.gmi"
  1308. if self.path == "" or os.path.isdir(self._cache_path):
  1309. if not self._cache_path.endswith("/"):
  1310. self._cache_path += "/"
  1311. if not self.url.endswith("/"):
  1312. self.url += "/"
  1313. if self._cache_path.endswith("/"):
  1314. self._cache_path += index
  1315. #sometimes, the index itself is a dir
  1316. #like when folder/index.gmi?param has been created
  1317. #and we try to access folder
  1318. if os.path.isdir(self._cache_path):
  1319. self._cache_path += "/" + index
  1320. return self._cache_path
  1321. def get_capsule_title(self):
  1322. #small intelligence to try to find a good name for a capsule
  1323. #we try to find eithe ~username or /users/username
  1324. #else we fallback to hostname
  1325. if self.local:
  1326. if self.name != "":
  1327. red_title = self.name
  1328. else:
  1329. red_title = self.path
  1330. else:
  1331. red_title = self.host
  1332. if "user" in self.path:
  1333. i = 0
  1334. splitted = self.path.split("/")
  1335. while i < (len(splitted)-1):
  1336. if splitted[i].startswith("user"):
  1337. red_title = splitted[i+1]
  1338. i += 1
  1339. if "~" in self.path:
  1340. for pp in self.path.split("/"):
  1341. if pp.startswith("~"):
  1342. red_title = pp[1:]
  1343. return red_title
  1344. def get_page_title(self):
  1345. title = ""
  1346. if not self.renderer:
  1347. self._set_renderer()
  1348. if self.renderer:
  1349. title = self.renderer.get_title()
  1350. if not title or len(title) == 0:
  1351. title = self.get_capsule_title()
  1352. else:
  1353. title += " (%s)" %self.get_capsule_title()
  1354. return title
  1355. def is_cache_valid(self,validity=0):
  1356. # Validity is the acceptable time for
  1357. # a cache to be valid (in seconds)
  1358. # If 0, then any cache is considered as valid
  1359. # (use validity = 1 if you want to refresh everything)
  1360. cache = self.get_cache_path()
  1361. if self.local:
  1362. return os.path.exists(cache)
  1363. elif cache :
  1364. # If path is too long, we always return True to avoid
  1365. # fetching it.
  1366. if len(cache) > 259:
  1367. print("We return False because path is too long")
  1368. return False
  1369. if os.path.exists(cache) and not os.path.isdir(cache):
  1370. if validity > 0 :
  1371. last_modification = self.cache_last_modified()
  1372. now = time.time()
  1373. age = now - last_modification
  1374. return age < validity
  1375. else:
  1376. return True
  1377. else:
  1378. #Cache has not been build
  1379. return False
  1380. else:
  1381. #There’s not even a cache!
  1382. return False
  1383. def cache_last_modified(self):
  1384. path = self.get_cache_path()
  1385. if path:
  1386. return os.path.getmtime(path)
  1387. elif self.local:
  1388. return 0
  1389. else:
  1390. print("ERROR : NO CACHE in cache_last_modified")
  1391. return None
  1392. def get_body(self,as_file=False):
  1393. if self.body and not as_file:
  1394. return self.body
  1395. if self.is_cache_valid():
  1396. path = self.get_cache_path()
  1397. else:
  1398. path = None
  1399. if path:
  1400. # There’s on OS limit on path length
  1401. if len(path) > 259:
  1402. toreturn = "Path is too long. This is an OS limitation.\n\n"
  1403. toreturn += self.url
  1404. return toreturn
  1405. elif as_file:
  1406. return path
  1407. else:
  1408. with open(path) as f:
  1409. body = f.read()
  1410. f.close()
  1411. return body
  1412. else:
  1413. #print("ERROR: NO CACHE for %s" %self._cache_path)
  1414. return None
  1415. def get_images(self,mode=None):
  1416. if not self.renderer:
  1417. self._set_renderer()
  1418. if self.renderer:
  1419. return self.renderer.get_images(mode=mode)
  1420. else:
  1421. return []
  1422. # This method is used to load once the list of links in a gi
  1423. # Links can be followed, after a space, by a description/title
  1424. def get_links(self,mode=None):
  1425. links = []
  1426. toreturn = []
  1427. if not self.renderer:
  1428. self._set_renderer()
  1429. if self.renderer:
  1430. if not mode:
  1431. mode = self.last_mode
  1432. links = self.renderer.get_links(mode=mode)
  1433. for l in links:
  1434. #split between link and potential name
  1435. splitted = l.split(maxsplit=1)
  1436. url = self.absolutise_url(splitted[0])
  1437. if looks_like_url(url):
  1438. if len(splitted) > 1:
  1439. #We add a name only for Gopher items
  1440. if url.startswith("gopher://"):
  1441. newgi = GeminiItem(url,name=splitted[1])
  1442. else:
  1443. newgi = GeminiItem(url)
  1444. else:
  1445. newgi = GeminiItem(url)
  1446. toreturn.append(newgi)
  1447. return toreturn
  1448. def get_link(self,nb):
  1449. # == None allows to return False, even if the list is empty
  1450. links = self.get_links()
  1451. if len(links) < nb:
  1452. print("Index too high! No link %s for %s" %(nb,self.url))
  1453. return None
  1454. else:
  1455. return links[nb-1]
  1456. def get_subscribe_links(self):
  1457. if not self.renderer:
  1458. self._set_renderer()
  1459. if self.renderer:
  1460. subs = self.renderer.get_subscribe_links()
  1461. abssubs = []
  1462. # some rss links are relatives
  1463. for s in subs:
  1464. s[0] = self.absolutise_url(s[0])
  1465. abssubs.append(s)
  1466. return abssubs
  1467. else:
  1468. return []
  1469. def _set_renderer(self,mime=None):
  1470. if self.local and os.path.isdir(self.get_cache_path()):
  1471. self.renderer = FolderRenderer("",self.get_cache_path())
  1472. return
  1473. if not mime:
  1474. mime = self.get_mime()
  1475. #we don’t even have a mime (so probably we don’t have a cache)
  1476. if not mime:
  1477. return
  1478. mime_to_use = []
  1479. for m in _FORMAT_RENDERERS:
  1480. if fnmatch.fnmatch(mime, m):
  1481. mime_to_use.append(m)
  1482. if len(mime_to_use) > 0:
  1483. current_mime = mime_to_use[0]
  1484. func = _FORMAT_RENDERERS[current_mime]
  1485. if current_mime.startswith("text"):
  1486. self.renderer = func(self.get_body(),self.url)
  1487. # We double check if the renderer is correct.
  1488. # If not, we fallback to html
  1489. # (this is currently only for XHTML, often being
  1490. # mislabelled as xml thus RSS feeds)
  1491. if not self.renderer.is_valid():
  1492. func = _FORMAT_RENDERERS["text/html"]
  1493. #print("Set (fallback)RENDERER to html instead of %s"%mime)
  1494. self.renderer = func(self.get_body(),self.url)
  1495. else:
  1496. #we don’t parse text, we give the file to the renderer
  1497. self.renderer = func(self.get_cache_path(),self.url)
  1498. if not self.renderer.is_valid():
  1499. self.renderer = None
  1500. def display(self,mode=None,grep=None):
  1501. if not self.renderer:
  1502. self._set_renderer()
  1503. if self.renderer and self.renderer.is_valid():
  1504. if not mode:
  1505. mode = self.last_mode
  1506. else:
  1507. self.last_mode = mode
  1508. title = self.get_capsule_title()
  1509. if self.is_cache_valid(): #and self.offline_only and not self.local:
  1510. nbr = len(self.get_links(mode=mode))
  1511. if self.local:
  1512. title += " (%s items)"%nbr
  1513. str_last = "local file"
  1514. else:
  1515. str_last = "last accessed on %s" %time.ctime(self.cache_last_modified())
  1516. title += " (%s links)"%nbr
  1517. return self.renderer.display(mode=mode,window_title=title,window_info=str_last,grep=grep)
  1518. else:
  1519. return False
  1520. else:
  1521. return False
  1522. def get_filename(self):
  1523. filename = os.path.basename(self.get_cache_path())
  1524. return filename
  1525. def get_temp_filename(self):
  1526. tmpf = None
  1527. if not self.renderer:
  1528. self._set_renderer()
  1529. if self.renderer and self.renderer.is_valid():
  1530. tmpf = self.renderer.get_temp_file()
  1531. if not tmpf:
  1532. tmpf = self.get_cache_path()
  1533. return tmpf
  1534. def write_body(self,body,mime=None):
  1535. ## body is a copy of the raw gemtext
  1536. ## Write_body() also create the cache !
  1537. # DEFAULT GEMINI MIME
  1538. self.body = body
  1539. if mime:
  1540. self.mime, mime_options = cgi.parse_header(mime)
  1541. if not self.local:
  1542. if self.mime and self.mime.startswith("text/"):
  1543. mode = "w"
  1544. else:
  1545. mode = "wb"
  1546. cache_dir = os.path.dirname(self.get_cache_path())
  1547. # If the subdirectory already exists as a file (not a folder)
  1548. # We remove it (happens when accessing URL/subfolder before
  1549. # URL/subfolder/file.gmi.
  1550. # This causes loss of data in the cache
  1551. # proper solution would be to save "sufolder" as "sufolder/index.gmi"
  1552. # If the subdirectory doesn’t exist, we recursively try to find one
  1553. # until it exists to avoid a file blocking the creation of folders
  1554. root_dir = cache_dir
  1555. while not os.path.exists(root_dir):
  1556. root_dir = os.path.dirname(root_dir)
  1557. if os.path.isfile(root_dir):
  1558. os.remove(root_dir)
  1559. os.makedirs(cache_dir,exist_ok=True)
  1560. with open(self.get_cache_path(), mode=mode) as f:
  1561. f.write(body)
  1562. f.close()
  1563. def get_mime(self):
  1564. #Beware, this one is really a shaddy ad-hoc function
  1565. if self.mime:
  1566. return self.mime
  1567. elif self.is_cache_valid():
  1568. path = self.get_cache_path()
  1569. if self.scheme == "mailto":
  1570. mime = "mailto"
  1571. elif os.path.isdir(path):
  1572. mime = "Local Folder"
  1573. elif path.endswith(".gmi"):
  1574. mime = "text/gemini"
  1575. elif shutil.which("file") :
  1576. mime = run("file -b --mime-type \"%s\""%path).strip()
  1577. mime2,encoding = mimetypes.guess_type(path,strict=False)
  1578. #If we hesitate between html and xml, takes the xml one
  1579. #because the FeedRendered fallback to HtmlRenderer
  1580. if mime2 and mime != mime2 and "html" in mime and "xml" in mime2:
  1581. mime = "text/xml"
  1582. #Some xml/html document are considered as octet-stream
  1583. if mime == "application/octet-stream":
  1584. mime = "text/xml"
  1585. else:
  1586. mime,encoding = mimetypes.guess_type(path,strict=False)
  1587. #gmi Mimetype is not recognized yet
  1588. if not mime and not shutil.which("file") :
  1589. print("Cannot guess the mime type of the file. Please install \"file\".")
  1590. print("(and send me an email, I’m curious of systems without \"file\" installed!")
  1591. if mime.startswith("text") and mime not in _FORMAT_RENDERERS:
  1592. if mime2 and mime2 in _FORMAT_RENDERERS:
  1593. mime = mime2
  1594. else:
  1595. #by default, we consider it’s gemini except for html
  1596. mime = "text/gemini"
  1597. self.mime = mime
  1598. return self.mime
  1599. def set_error(self,err):
  1600. # If we get an error, we want to keep an existing cache
  1601. # but we need to touch it or to create an empty one
  1602. # to avoid hitting the error at each refresh
  1603. cache = self.get_cache_path()
  1604. if self.is_cache_valid():
  1605. os.utime(cache)
  1606. else:
  1607. cache_dir = os.path.dirname(cache)
  1608. root_dir = cache_dir
  1609. while not os.path.exists(root_dir):
  1610. root_dir = os.path.dirname(root_dir)
  1611. if os.path.isfile(root_dir):
  1612. os.remove(root_dir)
  1613. os.makedirs(cache_dir,exist_ok=True)
  1614. if os.path.isdir(cache_dir):
  1615. with open(cache, "w") as cache:
  1616. cache.write(str(datetime.datetime.now())+"\n")
  1617. cache.write("ERROR while caching %s\n\n" %self.url)
  1618. cache.write("*****\n\n")
  1619. cache.write(str(type(err)) + " = " + str(err))
  1620. #cache.write("\n" + str(err.with_traceback(None)))
  1621. cache.write("\n*****\n\n")
  1622. cache.write("If you believe this error was temporary, type ""reload"".\n")
  1623. cache.write("The ressource will be tentatively fetched during next sync.\n")
  1624. cache.close()
  1625. def root(self):
  1626. return GeminiItem(self._derive_url("/"))
  1627. def up(self,level=1):
  1628. path = self.path.rstrip('/')
  1629. count = 0
  1630. while count < level:
  1631. pathbits = list(os.path.split(path))
  1632. # Don't try to go higher than root or in config
  1633. if self.local or len(pathbits) == 1 :
  1634. return self
  1635. # Get rid of bottom component
  1636. if len(pathbits) > 1:
  1637. pathbits.pop()
  1638. path = os.path.join(*pathbits)
  1639. count += 1
  1640. if self.scheme == "gopher":
  1641. path = "/1" + path
  1642. return GeminiItem(self._derive_url(path))
  1643. def query(self, query):
  1644. query = urllib.parse.quote(query)
  1645. return GeminiItem(self._derive_url(query=query))
  1646. def _derive_url(self, path="", query=""):
  1647. """
  1648. A thin wrapper around urlunparse which avoids inserting standard ports
  1649. into URLs just to keep things clean.
  1650. """
  1651. if not self.port or self.port == standard_ports[self.scheme] :
  1652. host = self.host
  1653. else:
  1654. host = self.host + ":" + str(self.port)
  1655. return urllib.parse.urlunparse((self.scheme,host,path or self.path, "", query, ""))
  1656. def absolutise_url(self, relative_url):
  1657. """
  1658. Convert a relative URL to an absolute URL by using the URL of this
  1659. GeminiItem as a base.
  1660. """
  1661. abs_url = urllib.parse.urljoin(self.url, relative_url)
  1662. return abs_url
  1663. def url_mode(self):
  1664. url = self.url
  1665. if self.last_mode and self.last_mode != "readable":
  1666. url += "##offpunk_mode=" + self.last_mode
  1667. return url
  1668. def to_map_line(self):
  1669. return "=> {} {}\n".format(self.url_mode(), self.get_page_title())
  1670. CRLF = '\r\n'
  1671. # Cheap and cheerful URL detector
  1672. def looks_like_url(word):
  1673. try:
  1674. if not word.strip():
  1675. return False
  1676. url = fix_ipv6_url(word).strip()
  1677. parsed = urllib.parse.urlparse(url)
  1678. #sometimes, urllib crashed only when requesting the port
  1679. port = parsed.port
  1680. mailto = word.startswith("mailto:")
  1681. scheme = word.split("://")[0]
  1682. start = scheme in standard_ports
  1683. local = scheme in ["file","list"]
  1684. if not start and not local and not mailto:
  1685. return looks_like_url("gemini://"+word)
  1686. elif mailto:
  1687. return "@" in word
  1688. elif not local:
  1689. return "." in word
  1690. else:
  1691. return "/" in word
  1692. except ValueError:
  1693. return False
  1694. class UserAbortException(Exception):
  1695. pass
  1696. # GeminiClient Decorators
  1697. def needs_gi(inner):
  1698. def outer(self, *args, **kwargs):
  1699. if not self.gi:
  1700. print("You need to 'go' somewhere, first")
  1701. return None
  1702. else:
  1703. return inner(self, *args, **kwargs)
  1704. outer.__doc__ = inner.__doc__
  1705. return outer
  1706. def restricted(inner):
  1707. def outer(self, *args, **kwargs):
  1708. if self.restricted:
  1709. print("Sorry, this command is not available in restricted mode!")
  1710. return None
  1711. else:
  1712. return inner(self, *args, **kwargs)
  1713. outer.__doc__ = inner.__doc__
  1714. return outer
  1715. class GeminiClient(cmd.Cmd):
  1716. def __init__(self, completekey="tab", restricted=False, synconly=False):
  1717. cmd.Cmd.__init__(self)
  1718. # Set umask so that nothing we create can be read by anybody else.
  1719. # The certificate cache and TOFU database contain "browser history"
  1720. # type sensitivie information.
  1721. os.umask(0o077)
  1722. self.no_cert_prompt = "\x1b[38;5;76m" + "ON" + "\x1b[38;5;255m" + "> " + "\x1b[0m"
  1723. self.cert_prompt = "\x1b[38;5;202m" + "ON" + "\x1b[38;5;255m"
  1724. self.offline_prompt = "\x1b[38;5;76m" + "OFF" + "\x1b[38;5;255m" + "> " + "\x1b[0m"
  1725. self.prompt = self.no_cert_prompt
  1726. self.gi = None
  1727. self.hist_index = 0
  1728. self.index = []
  1729. self.index_index = -1
  1730. self.lookup = self.index
  1731. self.marks = {}
  1732. self.page_index = 0
  1733. self.permanent_redirects = {}
  1734. self.previous_redirectors = set()
  1735. # Sync-only mode is restriced by design
  1736. self.restricted = restricted or synconly
  1737. self.visited_hosts = set()
  1738. self.offline_only = False
  1739. self.sync_only = False
  1740. self.support_http = _DO_HTTP
  1741. self.automatic_choice = "n"
  1742. self.client_certs = {
  1743. "active": None
  1744. }
  1745. self.active_cert_domains = []
  1746. self.active_is_transient = False
  1747. self.transient_certs_created = []
  1748. self.options = {
  1749. "debug" : False,
  1750. "beta" : False,
  1751. "ipv6" : True,
  1752. "timeout" : 600,
  1753. "short_timeout" : 5,
  1754. "width" : 72,
  1755. "auto_follow_redirects" : True,
  1756. "tls_mode" : "tofu",
  1757. "archives_size" : 200,
  1758. "history_size" : 200,
  1759. "max_size_download" : 10,
  1760. "editor" : None,
  1761. "download_images_first" : True,
  1762. "redirects" : True,
  1763. }
  1764. self.redirects = {
  1765. "twitter.com" : "nitter.42l.fr",
  1766. "youtube.com" : "yewtu.be",
  1767. "reddit.com" : "libredd.it",
  1768. "old.reddit.com": "libredd.it",
  1769. "medium.com" : "scribe.rip",
  1770. }
  1771. global TERM_WIDTH
  1772. TERM_WIDTH = self.options["width"]
  1773. self.log = {
  1774. "start_time": time.time(),
  1775. "requests": 0,
  1776. "ipv4_requests": 0,
  1777. "ipv6_requests": 0,
  1778. "bytes_recvd": 0,
  1779. "ipv4_bytes_recvd": 0,
  1780. "ipv6_bytes_recvd": 0,
  1781. "dns_failures": 0,
  1782. "refused_connections": 0,
  1783. "reset_connections": 0,
  1784. "timeouts": 0,
  1785. "cache_hits": 0,
  1786. }
  1787. self._connect_to_tofu_db()
  1788. def _connect_to_tofu_db(self):
  1789. db_path = os.path.join(_CONFIG_DIR, "tofu.db")
  1790. self.db_conn = sqlite3.connect(db_path)
  1791. self.db_cur = self.db_conn.cursor()
  1792. self.db_cur.execute("""CREATE TABLE IF NOT EXISTS cert_cache
  1793. (hostname text, address text, fingerprint text,
  1794. first_seen date, last_seen date, count integer)""")
  1795. def _go_to_gi(self, gi, update_hist=True, check_cache=True, handle=True,\
  1796. mode=None,limit_size=False):
  1797. """This method might be considered "the heart of Offpunk".
  1798. Everything involved in fetching a gemini resource happens here:
  1799. sending the request over the network, parsing the response,
  1800. storing the response in a temporary file, choosing
  1801. and calling a handler program, and updating the history.
  1802. Nothing is returned."""
  1803. if not gi:
  1804. return
  1805. # Don't try to speak to servers running other protocols
  1806. elif gi.scheme == "mailto":
  1807. if handle and not self.sync_only:
  1808. resp = input("Send an email to %s Y/N? " %gi.path)
  1809. self.gi = gi
  1810. if resp.strip().lower() in ("y", "yes"):
  1811. if _HAS_XDGOPEN :
  1812. cmd = "xdg-open \"mailto:%s\"" %gi.path
  1813. run(cmd,direct_output=True)
  1814. else:
  1815. print("Cannot find a mail client to send mail to %s" %gi.path)
  1816. print("Please install xdg-open (usually from xdg-util package)")
  1817. return
  1818. elif gi.scheme not in ["file","list"] and gi.scheme not in standard_ports \
  1819. and not self.sync_only:
  1820. print("Sorry, no support for {} links.".format(gi.scheme))
  1821. return
  1822. # Obey permanent redirects
  1823. if gi.url in self.permanent_redirects:
  1824. new_gi = GeminiItem(self.permanent_redirects[gi.url], name=gi.name)
  1825. self._go_to_gi(new_gi)
  1826. return
  1827. # Use cache or mark as to_fetch if resource is not cached
  1828. # Why is this code useful ? It set the mimetype !
  1829. if self.offline_only:
  1830. if not gi.is_cache_valid():
  1831. self.get_list("to_fetch")
  1832. r = self.list_add_line("to_fetch",gi=gi,verbose=False)
  1833. if r:
  1834. print("%s not available, marked for syncing"%gi.url)
  1835. else:
  1836. print("%s already marked for syncing"%gi.url)
  1837. return
  1838. # check if local file exists.
  1839. if gi.local and not os.path.exists(gi.path):
  1840. print("Local file %s does not exist!" %gi.path)
  1841. return
  1842. elif not self.offline_only and not gi.local:
  1843. try:
  1844. if gi.scheme in ("http", "https"):
  1845. if self.support_http:
  1846. if limit_size:
  1847. # Let’s cap automatic downloads to 20Mo
  1848. max_download = int(self.options["max_size_download"])*1000000
  1849. else:
  1850. max_download = None
  1851. gi = self._fetch_http(gi,max_length=max_download)
  1852. elif handle and not self.sync_only:
  1853. if not _DO_HTTP:
  1854. print("Install python3-requests to handle http requests natively")
  1855. webbrowser.open_new_tab(gi.url)
  1856. return
  1857. else:
  1858. return
  1859. elif gi.scheme in ("gopher"):
  1860. gi = self._fetch_gopher(gi,timeout=self.options["short_timeout"])
  1861. elif gi.scheme in ("spartan"):
  1862. gi = self._fetch_spartan(gi)
  1863. else:
  1864. gi = self._fetch_over_network(gi)
  1865. except UserAbortException:
  1866. return
  1867. except Exception as err:
  1868. gi.set_error(err)
  1869. # Print an error message
  1870. # we fail silently when sync_only
  1871. print_error = not self.sync_only
  1872. if isinstance(err, socket.gaierror):
  1873. self.log["dns_failures"] += 1
  1874. if print_error:
  1875. print("ERROR: DNS error!")
  1876. elif isinstance(err, ConnectionRefusedError):
  1877. self.log["refused_connections"] += 1
  1878. if print_error:
  1879. print("ERROR1: Connection refused!")
  1880. elif isinstance(err, ConnectionResetError):
  1881. self.log["reset_connections"] += 1
  1882. if print_error:
  1883. print("ERROR2: Connection reset!")
  1884. elif isinstance(err, (TimeoutError, socket.timeout)):
  1885. self.log["timeouts"] += 1
  1886. if print_error:
  1887. print("""ERROR3: Connection timed out!
  1888. Slow internet connection? Use 'set timeout' to be more patient.""")
  1889. elif isinstance(err, FileExistsError):
  1890. print("""ERROR5: Trying to create a directory which already exists
  1891. in the cache : """)
  1892. print(err)
  1893. else:
  1894. if print_error:
  1895. print("ERROR4: " + str(type(err)) + " : " + str(err))
  1896. print("\n" + str(err.with_traceback(None)))
  1897. return
  1898. # Pass file to handler, unless we were asked not to
  1899. if gi :
  1900. display = handle and not self.sync_only
  1901. if display and _RENDER_IMAGE and self.options["download_images_first"] \
  1902. and not self.offline_only:
  1903. # We download images first
  1904. for image in gi.get_images(mode=mode):
  1905. if image:
  1906. img_gi = GeminiItem(image)
  1907. if not img_gi.is_cache_valid():
  1908. width = term_width() - 1
  1909. toprint = "Downloading %s" %image
  1910. toprint = toprint[:width]
  1911. toprint += " "*(width-len(toprint))
  1912. print(toprint,end="\r")
  1913. self._go_to_gi(img_gi, update_hist=False, check_cache=True, \
  1914. handle=False,limit_size=True)
  1915. if display and gi.display(mode=mode):
  1916. self.index = gi.get_links()
  1917. self.lookup = self.index
  1918. self.page_index = 0
  1919. self.index_index = -1
  1920. # Update state (external files are not added to history)
  1921. self.gi = gi
  1922. if update_hist and not self.sync_only:
  1923. self._update_history(gi)
  1924. elif display :
  1925. cmd_str = self._get_handler_cmd(gi.get_mime())
  1926. try:
  1927. # get tmpfile from gi !
  1928. tmpfile = "\"%s\""%gi.get_body(as_file=True)
  1929. run(cmd_str%tmpfile,direct_output=True)
  1930. except FileNotFoundError:
  1931. print("Handler program %s not found!" % shlex.split(cmd_str)[0])
  1932. print("You can use the ! command to specify another handler program or pipeline.")
  1933. def _fetch_http(self,gi,max_length=None):
  1934. def set_error(item,length,max_length):
  1935. err = "Size of %s is %s Mo\n"%(item.url,length)
  1936. err += "Offpunk only download automatically content under %s Mo\n" %(max_length/1000000)
  1937. err += "To retrieve this content anyway, type 'reload'."
  1938. item.set_error(err)
  1939. return item
  1940. header = {}
  1941. header["User-Agent"] = "Offpunk browser v%s"%_VERSION
  1942. parsed = urllib.parse.urlparse(gi.url)
  1943. # Code to translate URLs to better frontends (think twitter.com -> nitter)
  1944. if self.options["redirects"]:
  1945. netloc = parsed.netloc
  1946. if netloc.startswith("www."):
  1947. netloc = netloc[4:]
  1948. if netloc in self.redirects:
  1949. parsed = parsed._replace(netloc = self.redirects[netloc])
  1950. url = urllib.parse.urlunparse(parsed)
  1951. with requests.get(url,headers=header, stream=True,timeout=5) as response:
  1952. #print("This is header for %s"%gi.url)
  1953. #print(response.headers)
  1954. if "content-type" in response.headers:
  1955. mime = response.headers['content-type']
  1956. else:
  1957. mime = None
  1958. if "content-length" in response.headers:
  1959. length = int(response.headers['content-length'])
  1960. else:
  1961. length = 0
  1962. if max_length and length > max_length:
  1963. response.close()
  1964. return set_error(gi,str(length/1000000),max_length)
  1965. elif max_length and length == 0:
  1966. body = b''
  1967. for r in response.iter_content():
  1968. body += r
  1969. #We divide max_size for streamed content
  1970. #in order to catch them faster
  1971. if sys.getsizeof(body) > max_length/2:
  1972. response.close()
  1973. return set_error(gi,"streaming",max_length)
  1974. response.close()
  1975. else:
  1976. body = response.content
  1977. response.close()
  1978. if mime and "text/" in mime:
  1979. body = body.decode("UTF-8","replace")
  1980. gi.write_body(body,mime)
  1981. return gi
  1982. def _fetch_gopher(self,gi,timeout=10):
  1983. if not looks_like_url(gi.url):
  1984. print("%s is not a valide url" %gi.url)
  1985. parsed =urllib.parse.urlparse(gi.url)
  1986. host = parsed.hostname
  1987. port = parsed.port or 70
  1988. if parsed.path and parsed.path[0] == "/" and len(parsed.path) > 1:
  1989. splitted = parsed.path.split("/")
  1990. # We check if we have well a gopher type
  1991. if len(splitted[1]) == 1:
  1992. itemtype = parsed.path[1]
  1993. selector = parsed.path[2:]
  1994. else:
  1995. itemtype = "1"
  1996. selector = parsed.path
  1997. else:
  1998. itemtype = "1"
  1999. selector = parsed.path
  2000. addresses = socket.getaddrinfo(host, port, family=0,type=socket.SOCK_STREAM)
  2001. s = socket.create_connection((host,port))
  2002. for address in addresses:
  2003. self._debug("Connecting to: " + str(address[4]))
  2004. s = socket.socket(address[0], address[1])
  2005. s.settimeout(timeout)
  2006. try:
  2007. s.connect(address[4])
  2008. break
  2009. except OSError as e:
  2010. err = e
  2011. else:
  2012. # If we couldn't connect to *any* of the addresses, just
  2013. # bubble up the exception from the last attempt and deny
  2014. # knowledge of earlier failures.
  2015. raise err
  2016. if parsed.query:
  2017. request = selector + "\t" + parsed.query
  2018. else:
  2019. request = selector
  2020. request += "\r\n"
  2021. s.sendall(request.encode("UTF-8"))
  2022. response = s.makefile("rb").read()
  2023. # Transcode response into UTF-8
  2024. #if itemtype in ("0","1","h"):
  2025. if not itemtype in ("9","g","I","s"):
  2026. # Try most common encodings
  2027. for encoding in ("UTF-8", "ISO-8859-1"):
  2028. try:
  2029. response = response.decode("UTF-8")
  2030. break
  2031. except UnicodeDecodeError:
  2032. pass
  2033. else:
  2034. # try to find encoding
  2035. #if _HAS_CHARDET:
  2036. detected = chardet.detect(response)
  2037. response = response.decode(detected["encoding"])
  2038. #else:
  2039. #raise UnicodeDecodeError
  2040. if itemtype == "0":
  2041. mime = "text/gemini"
  2042. elif itemtype == "1":
  2043. mime = "text/gopher"
  2044. elif itemtype == "h":
  2045. mime = "text/html"
  2046. elif itemtype in ("9","g","I","s"):
  2047. mime = None
  2048. else:
  2049. # by default, we should consider Gopher
  2050. mime = "text/gopher"
  2051. gi.write_body(response,mime)
  2052. return gi
  2053. # Copied from reference spartan client by Michael Lazar
  2054. def _fetch_spartan(self,gi):
  2055. url_parts = urllib.parse.urlparse(gi.url)
  2056. host = url_parts.hostname
  2057. port = url_parts.port or 300
  2058. path = url_parts.path or "/"
  2059. query = url_parts.query
  2060. redirect_url = None
  2061. with socket.create_connection((host,port)) as sock:
  2062. if query:
  2063. data = urllib.parse.unquote_to_bytes(query)
  2064. else:
  2065. data = b""
  2066. encoded_host = host.encode("idna")
  2067. ascii_path = urllib.parse.unquote_to_bytes(path)
  2068. encoded_path = urllib.parse.quote_from_bytes(ascii_path).encode("ascii")
  2069. sock.send(b"%s %s %d\r\n" % (encoded_host,encoded_path,len(data)))
  2070. fp = sock.makefile("rb")
  2071. response = fp.readline(4096).decode("ascii").strip("\r\n")
  2072. parts = response.split(" ",maxsplit=1)
  2073. code,meta = int(parts[0]),parts[1]
  2074. if code == 2:
  2075. body = fp.read()
  2076. if meta.startswith("text"):
  2077. body = body.decode("UTF-8")
  2078. gi.write_body(body,meta)
  2079. elif code == 3:
  2080. redirect_url = url_parts._replace(path=meta).geturl()
  2081. else:
  2082. gi.set_error("Spartan code %s: Error %s"%(code,meta))
  2083. if redirect_url:
  2084. gi = GeminiItem(redirect_url)
  2085. self._fetch_spartan(gi)
  2086. return gi
  2087. # fetch_over_network will modify with gi.write_body(body,mime)
  2088. # before returning the gi
  2089. def _fetch_over_network(self, gi):
  2090. # Be careful with client certificates!
  2091. # Are we crossing a domain boundary?
  2092. if self.active_cert_domains and gi.host not in self.active_cert_domains:
  2093. if self.active_is_transient:
  2094. print("Permanently delete currently active transient certificate?")
  2095. resp = input("Y/N? ")
  2096. if resp.strip().lower() in ("y", "yes"):
  2097. print("Destroying certificate.")
  2098. self._deactivate_client_cert()
  2099. else:
  2100. print("Staying here.")
  2101. raise UserAbortException()
  2102. else:
  2103. print("PRIVACY ALERT: Deactivate client cert before connecting to a new domain?")
  2104. resp = input("Y/N? ")
  2105. if resp.strip().lower() in ("n", "no"):
  2106. print("Keeping certificate active for {}".format(gi.host))
  2107. else:
  2108. print("Deactivating certificate.")
  2109. self._deactivate_client_cert()
  2110. # Suggest reactivating previous certs
  2111. if not self.client_certs["active"] and gi.host in self.client_certs:
  2112. print("PRIVACY ALERT: Reactivate previously used client cert for {}?".format(gi.host))
  2113. resp = input("Y/N? ")
  2114. if resp.strip().lower() in ("y", "yes"):
  2115. self._activate_client_cert(*self.client_certs[gi.host])
  2116. else:
  2117. print("Remaining unidentified.")
  2118. self.client_certs.pop(gi.host)
  2119. # Is this a local file?
  2120. if gi.local:
  2121. address, f = None, open(gi.path, "rb")
  2122. else:
  2123. address, f = self._send_request(gi)
  2124. # Spec dictates <META> should not exceed 1024 bytes,
  2125. # so maximum valid header length is 1027 bytes.
  2126. header = f.readline(1027)
  2127. header = header.decode("UTF-8")
  2128. if not header or header[-1] != '\n':
  2129. raise RuntimeError("Received invalid header from server!")
  2130. header = header.strip()
  2131. self._debug("Response header: %s." % header)
  2132. # Validate header
  2133. status, meta = header.split(maxsplit=1)
  2134. if len(meta) > 1024 or len(status) != 2 or not status.isnumeric():
  2135. f.close()
  2136. raise RuntimeError("Received invalid header from server!")
  2137. # Update redirect loop/maze escaping state
  2138. if not status.startswith("3"):
  2139. self.previous_redirectors = set()
  2140. # Handle non-SUCCESS headers, which don't have a response body
  2141. # Inputs
  2142. if status.startswith("1"):
  2143. if self.sync_only:
  2144. return None
  2145. else:
  2146. print(meta)
  2147. if status == "11":
  2148. user_input = getpass.getpass("> ")
  2149. else:
  2150. user_input = input("> ")
  2151. return self._fetch_over_network(gi.query(user_input))
  2152. # Redirects
  2153. elif status.startswith("3"):
  2154. new_gi = GeminiItem(gi.absolutise_url(meta))
  2155. if new_gi.url == gi.url:
  2156. raise RuntimeError("URL redirects to itself!")
  2157. elif new_gi.url in self.previous_redirectors:
  2158. raise RuntimeError("Caught in redirect loop!")
  2159. elif len(self.previous_redirectors) == _MAX_REDIRECTS:
  2160. raise RuntimeError("Refusing to follow more than %d consecutive redirects!" % _MAX_REDIRECTS)
  2161. elif self.sync_only:
  2162. follow = self.automatic_choice
  2163. # Never follow cross-domain redirects without asking
  2164. elif new_gi.host != gi.host:
  2165. follow = input("Follow cross-domain redirect to %s? (y/n) " % new_gi.url)
  2166. # Never follow cross-protocol redirects without asking
  2167. elif new_gi.scheme != gi.scheme:
  2168. follow = input("Follow cross-protocol redirect to %s? (y/n) " % new_gi.url)
  2169. # Don't follow *any* redirect without asking if auto-follow is off
  2170. elif not self.options["auto_follow_redirects"]:
  2171. follow = input("Follow redirect to %s? (y/n) " % new_gi.url)
  2172. # Otherwise, follow away
  2173. else:
  2174. follow = "yes"
  2175. if follow.strip().lower() not in ("y", "yes"):
  2176. raise UserAbortException()
  2177. self._debug("Following redirect to %s." % new_gi.url)
  2178. self._debug("This is consecutive redirect number %d." % len(self.previous_redirectors))
  2179. self.previous_redirectors.add(gi.url)
  2180. if status == "31":
  2181. # Permanent redirect
  2182. self.permanent_redirects[gi.url] = new_gi.url
  2183. return self._fetch_over_network(new_gi)
  2184. # Errors
  2185. elif status.startswith("4") or status.startswith("5"):
  2186. raise RuntimeError(meta)
  2187. # Client cert
  2188. elif status.startswith("6"):
  2189. self._handle_cert_request(meta)
  2190. return self._fetch_over_network(gi)
  2191. # Invalid status
  2192. elif not status.startswith("2"):
  2193. raise RuntimeError("Server returned undefined status code %s!" % status)
  2194. # If we're here, this must be a success and there's a response body
  2195. assert status.startswith("2")
  2196. mime = meta
  2197. # Read the response body over the network
  2198. fbody = f.read()
  2199. # DEFAULT GEMINI MIME
  2200. if mime == "":
  2201. mime = "text/gemini; charset=utf-8"
  2202. shortmime, mime_options = cgi.parse_header(mime)
  2203. if "charset" in mime_options:
  2204. try:
  2205. codecs.lookup(mime_options["charset"])
  2206. except LookupError:
  2207. raise RuntimeError("Header declared unknown encoding %s" % value)
  2208. if shortmime.startswith("text/"):
  2209. #Get the charset and default to UTF-8 in none
  2210. encoding = mime_options.get("charset", "UTF-8")
  2211. try:
  2212. body = fbody.decode(encoding)
  2213. except UnicodeError:
  2214. raise RuntimeError("Could not decode response body using %s\
  2215. encoding declared in header!" % encoding)
  2216. else:
  2217. body = fbody
  2218. gi.write_body(body,mime)
  2219. return gi
  2220. def _send_request(self, gi):
  2221. """Send a selector to a given host and port.
  2222. Returns the resolved address and binary file with the reply."""
  2223. host, port = gi.host, gi.port
  2224. # Do DNS resolution
  2225. addresses = self._get_addresses(host, port)
  2226. # Prepare TLS context
  2227. protocol = ssl.PROTOCOL_TLS_CLIENT if sys.version_info.minor >=6 else ssl.PROTOCOL_TLSv1_2
  2228. context = ssl.SSLContext(protocol)
  2229. # Use CAs or TOFU
  2230. if self.options["tls_mode"] == "ca":
  2231. context.verify_mode = ssl.CERT_REQUIRED
  2232. context.check_hostname = True
  2233. context.load_default_certs()
  2234. else:
  2235. context.check_hostname = False
  2236. context.verify_mode = ssl.CERT_NONE
  2237. # Impose minimum TLS version
  2238. ## In 3.7 and above, this is easy...
  2239. if sys.version_info.minor >= 7:
  2240. context.minimum_version = ssl.TLSVersion.TLSv1_2
  2241. ## Otherwise, it seems very hard...
  2242. ## The below is less strict than it ought to be, but trying to disable
  2243. ## TLS v1.1 here using ssl.OP_NO_TLSv1_1 produces unexpected failures
  2244. ## with recent versions of OpenSSL. What a mess...
  2245. else:
  2246. context.options |= ssl.OP_NO_SSLv3
  2247. context.options |= ssl.OP_NO_SSLv2
  2248. # Try to enforce sensible ciphers
  2249. try:
  2250. context.set_ciphers("AESGCM+ECDHE:AESGCM+DHE:CHACHA20+ECDHE:CHACHA20+DHE:!DSS:!SHA1:!MD5:@STRENGTH")
  2251. except ssl.SSLError:
  2252. # Rely on the server to only support sensible things, I guess...
  2253. pass
  2254. # Load client certificate if needed
  2255. if self.client_certs["active"]:
  2256. certfile, keyfile = self.client_certs["active"]
  2257. context.load_cert_chain(certfile, keyfile)
  2258. # Connect to remote host by any address possible
  2259. err = None
  2260. for address in addresses:
  2261. self._debug("Connecting to: " + str(address[4]))
  2262. s = socket.socket(address[0], address[1])
  2263. if self.sync_only:
  2264. timeout = self.options["short_timeout"]
  2265. else:
  2266. timeout = self.options["timeout"]
  2267. s.settimeout(timeout)
  2268. s = context.wrap_socket(s, server_hostname = gi.host)
  2269. try:
  2270. s.connect(address[4])
  2271. break
  2272. except OSError as e:
  2273. err = e
  2274. else:
  2275. # If we couldn't connect to *any* of the addresses, just
  2276. # bubble up the exception from the last attempt and deny
  2277. # knowledge of earlier failures.
  2278. raise err
  2279. if sys.version_info.minor >=5:
  2280. self._debug("Established {} connection.".format(s.version()))
  2281. self._debug("Cipher is: {}.".format(s.cipher()))
  2282. # Do TOFU
  2283. if self.options["tls_mode"] != "ca":
  2284. cert = s.getpeercert(binary_form=True)
  2285. self._validate_cert(address[4][0], host, cert)
  2286. # Remember that we showed the current cert to this domain...
  2287. if self.client_certs["active"]:
  2288. self.active_cert_domains.append(gi.host)
  2289. self.client_certs[gi.host] = self.client_certs["active"]
  2290. # Send request and wrap response in a file descriptor
  2291. self._debug("Sending %s<CRLF>" % gi.url)
  2292. s.sendall((gi.url + CRLF).encode("UTF-8"))
  2293. mf= s.makefile(mode = "rb")
  2294. return address, mf
  2295. def _get_addresses(self, host, port):
  2296. # DNS lookup - will get IPv4 and IPv6 records if IPv6 is enabled
  2297. if ":" in host:
  2298. # This is likely a literal IPv6 address, so we can *only* ask for
  2299. # IPv6 addresses or getaddrinfo will complain
  2300. family_mask = socket.AF_INET6
  2301. elif socket.has_ipv6 and self.options["ipv6"]:
  2302. # Accept either IPv4 or IPv6 addresses
  2303. family_mask = 0
  2304. else:
  2305. # IPv4 only
  2306. family_mask = socket.AF_INET
  2307. addresses = socket.getaddrinfo(host, port, family=family_mask,
  2308. type=socket.SOCK_STREAM)
  2309. # Sort addresses so IPv6 ones come first
  2310. addresses.sort(key=lambda add: add[0] == socket.AF_INET6, reverse=True)
  2311. return addresses
  2312. def _handle_cert_request(self, meta):
  2313. # Don't do client cert stuff in restricted mode, as in principle
  2314. # it could be used to fill up the disk by creating a whole lot of
  2315. # certificates
  2316. if self.restricted:
  2317. print("The server is requesting a client certificate.")
  2318. print("These are not supported in restricted mode, sorry.")
  2319. raise UserAbortException()
  2320. print("SERVER SAYS: ", meta)
  2321. # Present different messages for different 6x statuses, but
  2322. # handle them the same.
  2323. if status in ("64", "65"):
  2324. print("The server rejected your certificate because it is either expired or not yet valid.")
  2325. elif status == "63":
  2326. print("The server did not accept your certificate.")
  2327. print("You may need to e.g. coordinate with the admin to get your certificate fingerprint whitelisted.")
  2328. else:
  2329. print("The site {} is requesting a client certificate.".format(gi.host))
  2330. print("This will allow the site to recognise you across requests.")
  2331. # Give the user choices
  2332. print("What do you want to do?")
  2333. print("1. Give up.")
  2334. print("2. Generate a new transient certificate.")
  2335. print("3. Generate a new persistent certificate.")
  2336. print("4. Load a previously generated persistent.")
  2337. print("5. Load certificate from an external file.")
  2338. if self.sync_only:
  2339. choice = 1
  2340. else:
  2341. choice = input("> ").strip()
  2342. if choice == "2":
  2343. self._generate_transient_cert_cert()
  2344. elif choice == "3":
  2345. self._generate_persistent_client_cert()
  2346. elif choice == "4":
  2347. self._choose_client_cert()
  2348. elif choice == "5":
  2349. self._load_client_cert()
  2350. else:
  2351. print("Giving up.")
  2352. raise UserAbortException()
  2353. def _validate_cert(self, address, host, cert):
  2354. """
  2355. Validate a TLS certificate in TOFU mode.
  2356. If the cryptography module is installed:
  2357. - Check the certificate Common Name or SAN matches `host`
  2358. - Check the certificate's not valid before date is in the past
  2359. - Check the certificate's not valid after date is in the future
  2360. Whether the cryptography module is installed or not, check the
  2361. certificate's fingerprint against the TOFU database to see if we've
  2362. previously encountered a different certificate for this IP address and
  2363. hostname.
  2364. """
  2365. now = datetime.datetime.utcnow()
  2366. if _HAS_CRYPTOGRAPHY:
  2367. # Using the cryptography module we can get detailed access
  2368. # to the properties of even self-signed certs, unlike in
  2369. # the standard ssl library...
  2370. c = x509.load_der_x509_certificate(cert, _BACKEND)
  2371. # Check certificate validity dates
  2372. if c.not_valid_before >= now:
  2373. raise CertificateError("Certificate not valid until: {}!".format(c.not_valid_before))
  2374. elif c.not_valid_after <= now:
  2375. raise CertificateError("Certificate expired as of: {})!".format(c.not_valid_after))
  2376. # Check certificate hostnames
  2377. names = []
  2378. common_name = c.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)
  2379. if common_name:
  2380. names.append(common_name[0].value)
  2381. try:
  2382. names.extend([alt.value for alt in c.extensions.get_extension_for_oid(x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value])
  2383. except x509.ExtensionNotFound:
  2384. pass
  2385. names = set(names)
  2386. for name in names:
  2387. try:
  2388. ssl._dnsname_match(name, host)
  2389. break
  2390. except CertificateError:
  2391. continue
  2392. else:
  2393. # If we didn't break out, none of the names were valid
  2394. raise CertificateError("Hostname does not match certificate common name or any alternative names.")
  2395. sha = hashlib.sha256()
  2396. sha.update(cert)
  2397. fingerprint = sha.hexdigest()
  2398. # Have we been here before?
  2399. self.db_cur.execute("""SELECT fingerprint, first_seen, last_seen, count
  2400. FROM cert_cache
  2401. WHERE hostname=? AND address=?""", (host, address))
  2402. cached_certs = self.db_cur.fetchall()
  2403. # If so, check for a match
  2404. if cached_certs:
  2405. max_count = 0
  2406. most_frequent_cert = None
  2407. for cached_fingerprint, first, last, count in cached_certs:
  2408. if count > max_count:
  2409. max_count = count
  2410. most_frequent_cert = cached_fingerprint
  2411. if fingerprint == cached_fingerprint:
  2412. # Matched!
  2413. self._debug("TOFU: Accepting previously seen ({} times) certificate {}".format(count, fingerprint))
  2414. self.db_cur.execute("""UPDATE cert_cache
  2415. SET last_seen=?, count=?
  2416. WHERE hostname=? AND address=? AND fingerprint=?""",
  2417. (now, count+1, host, address, fingerprint))
  2418. self.db_conn.commit()
  2419. break
  2420. else:
  2421. if _HAS_CRYPTOGRAPHY:
  2422. # Load the most frequently seen certificate to see if it has
  2423. # expired
  2424. certdir = os.path.join(_CONFIG_DIR, "cert_cache")
  2425. with open(os.path.join(certdir, most_frequent_cert+".crt"), "rb") as fp:
  2426. previous_cert = fp.read()
  2427. previous_cert = x509.load_der_x509_certificate(previous_cert, _BACKEND)
  2428. previous_ttl = previous_cert.not_valid_after - now
  2429. print(previous_ttl)
  2430. self._debug("TOFU: Unrecognised certificate {}! Raising the alarm...".format(fingerprint))
  2431. print("****************************************")
  2432. print("[SECURITY WARNING] Unrecognised certificate!")
  2433. print("The certificate presented for {} ({}) has never been seen before.".format(host, address))
  2434. print("This MIGHT be a Man-in-the-Middle attack.")
  2435. print("A different certificate has previously been seen {} times.".format(max_count))
  2436. if _HAS_CRYPTOGRAPHY:
  2437. if previous_ttl < datetime.timedelta():
  2438. print("That certificate has expired, which reduces suspicion somewhat.")
  2439. else:
  2440. print("That certificate is still valid for: {}".format(previous_ttl))
  2441. print("****************************************")
  2442. print("Attempt to verify the new certificate fingerprint out-of-band:")
  2443. print(fingerprint)
  2444. if self.sync_only:
  2445. choice = self.automatic_choice
  2446. else:
  2447. choice = input("Accept this new certificate? Y/N ").strip().lower()
  2448. if choice in ("y", "yes"):
  2449. self.db_cur.execute("""INSERT INTO cert_cache
  2450. VALUES (?, ?, ?, ?, ?, ?)""",
  2451. (host, address, fingerprint, now, now, 1))
  2452. self.db_conn.commit()
  2453. with open(os.path.join(certdir, fingerprint+".crt"), "wb") as fp:
  2454. fp.write(cert)
  2455. else:
  2456. raise Exception("TOFU Failure!")
  2457. # If not, cache this cert
  2458. else:
  2459. self._debug("TOFU: Blindly trusting first ever certificate for this host!")
  2460. self.db_cur.execute("""INSERT INTO cert_cache
  2461. VALUES (?, ?, ?, ?, ?, ?)""",
  2462. (host, address, fingerprint, now, now, 1))
  2463. self.db_conn.commit()
  2464. certdir = os.path.join(_CONFIG_DIR, "cert_cache")
  2465. if not os.path.exists(certdir):
  2466. os.makedirs(certdir)
  2467. with open(os.path.join(certdir, fingerprint+".crt"), "wb") as fp:
  2468. fp.write(cert)
  2469. def _get_handler_cmd(self, mimetype):
  2470. # Now look for a handler for this mimetype
  2471. # Consider exact matches before wildcard matches
  2472. exact_matches = []
  2473. wildcard_matches = []
  2474. for handled_mime, cmd_str in _MIME_HANDLERS.items():
  2475. if "*" in handled_mime:
  2476. wildcard_matches.append((handled_mime, cmd_str))
  2477. else:
  2478. exact_matches.append((handled_mime, cmd_str))
  2479. for handled_mime, cmd_str in exact_matches + wildcard_matches:
  2480. if fnmatch.fnmatch(mimetype, handled_mime):
  2481. break
  2482. else:
  2483. # Use "xdg-open" as a last resort.
  2484. if _HAS_XDGOPEN:
  2485. cmd_str = "xdg-open \"%s\""
  2486. else:
  2487. cmd_str = "echo ""Can’t find how to open %s"""
  2488. print("Please install xdg-open (usually from xdg-util package)")
  2489. self._debug("Using handler: %s" % cmd_str)
  2490. return cmd_str
  2491. #TODO: remove format_geminiitem
  2492. def _format_geminiitem(self, index, gi, url=False):
  2493. protocol = "" if gi.scheme == "gemini" else " %s" % gi.scheme
  2494. line = "[%d%s] %s" % (index, protocol, gi.name or gi.url)
  2495. if gi.name and url:
  2496. line += " (%s)" % gi.url
  2497. return line
  2498. def _show_lookup(self, offset=0, end=None, url=False):
  2499. for n, gi in enumerate(self.lookup[offset:end]):
  2500. print(self._format_geminiitem(n+offset+1, gi, url))
  2501. def _update_history(self, gi):
  2502. # We never update while in sync_only
  2503. if self.sync_only:
  2504. return
  2505. # We don’t add lists to history
  2506. #if not gi or os.path.join(_DATA_DIR,"lists") in gi.url:
  2507. # return
  2508. histlist = self.get_list("history")
  2509. links = self.list_get_links("history")
  2510. # avoid duplicate
  2511. length = len(links)
  2512. if length > self.options["history_size"]:
  2513. length = self.options["history_size"]
  2514. if length > 0 and links[self.hist_index] == gi:
  2515. return
  2516. self.list_add_top("history",limit=self.options["history_size"],truncate_lines=self.hist_index)
  2517. self.hist_index = 0
  2518. def _log_visit(self, gi, address, size):
  2519. if not address:
  2520. return
  2521. self.log["requests"] += 1
  2522. self.log["bytes_recvd"] += size
  2523. self.visited_hosts.add(address)
  2524. if address[0] == socket.AF_INET:
  2525. self.log["ipv4_requests"] += 1
  2526. self.log["ipv4_bytes_recvd"] += size
  2527. elif address[0] == socket.AF_INET6:
  2528. self.log["ipv6_requests"] += 1
  2529. self.log["ipv6_bytes_recvd"] += size
  2530. def _debug(self, debug_text):
  2531. if not self.options["debug"]:
  2532. return
  2533. debug_text = "\x1b[0;32m[DEBUG] " + debug_text + "\x1b[0m"
  2534. print(debug_text)
  2535. def _load_client_cert(self):
  2536. """
  2537. Interactively load a TLS client certificate from the filesystem in PEM
  2538. format.
  2539. """
  2540. print("Loading client certificate file, in PEM format (blank line to cancel)")
  2541. certfile = input("Certfile path: ").strip()
  2542. if not certfile:
  2543. print("Aborting.")
  2544. return
  2545. certfile = os.path.expanduser(certfile)
  2546. if not os.path.isfile(certfile):
  2547. print("Certificate file {} does not exist.".format(certfile))
  2548. return
  2549. print("Loading private key file, in PEM format (blank line to cancel)")
  2550. keyfile = input("Keyfile path: ").strip()
  2551. if not keyfile:
  2552. print("Aborting.")
  2553. return
  2554. keyfile = os.path.expanduser(keyfile)
  2555. if not os.path.isfile(keyfile):
  2556. print("Private key file {} does not exist.".format(keyfile))
  2557. return
  2558. self._activate_client_cert(certfile, keyfile)
  2559. def _generate_transient_cert_cert(self):
  2560. """
  2561. Use `openssl` command to generate a new transient client certificate
  2562. with 24 hours of validity.
  2563. """
  2564. certdir = os.path.join(_CONFIG_DIR, "transient_certs")
  2565. name = str(uuid.uuid4())
  2566. self._generate_client_cert(certdir, name, transient=True)
  2567. self.active_is_transient = True
  2568. self.transient_certs_created.append(name)
  2569. def _generate_persistent_client_cert(self):
  2570. """
  2571. Interactively use `openssl` command to generate a new persistent client
  2572. certificate with one year of validity.
  2573. """
  2574. certdir = os.path.join(_CONFIG_DIR, "client_certs")
  2575. print("What do you want to name this new certificate?")
  2576. print("Answering `mycert` will create `{0}/mycert.crt` and `{0}/mycert.key`".format(certdir))
  2577. name = input("> ")
  2578. if not name.strip():
  2579. print("Aborting.")
  2580. return
  2581. self._generate_client_cert(certdir, name)
  2582. def _generate_client_cert(self, certdir, basename, transient=False):
  2583. """
  2584. Use `openssl` binary to generate a client certificate (which may be
  2585. transient or persistent) and save the certificate and private key to the
  2586. specified directory with the specified basename.
  2587. """
  2588. if not os.path.exists(certdir):
  2589. os.makedirs(certdir)
  2590. certfile = os.path.join(certdir, basename+".crt")
  2591. keyfile = os.path.join(certdir, basename+".key")
  2592. cmd = "openssl req -x509 -newkey rsa:2048 -days {} -nodes -keyout {} -out {}".format(1 if transient else 365, keyfile, certfile)
  2593. if transient:
  2594. cmd += " -subj '/CN={}'".format(basename)
  2595. os.system(cmd)
  2596. self._activate_client_cert(certfile, keyfile)
  2597. def _choose_client_cert(self):
  2598. """
  2599. Interactively select a previously generated client certificate and
  2600. activate it.
  2601. """
  2602. certdir = os.path.join(_CONFIG_DIR, "client_certs")
  2603. certs = glob.glob(os.path.join(certdir, "*.crt"))
  2604. if len(certs) == 0:
  2605. print("There are no previously generated certificates.")
  2606. return
  2607. certdir = {}
  2608. for n, cert in enumerate(certs):
  2609. certdir[str(n+1)] = (cert, os.path.splitext(cert)[0] + ".key")
  2610. print("{}. {}".format(n+1, os.path.splitext(os.path.basename(cert))[0]))
  2611. choice = input("> ").strip()
  2612. if choice in certdir:
  2613. certfile, keyfile = certdir[choice]
  2614. self._activate_client_cert(certfile, keyfile)
  2615. else:
  2616. print("What?")
  2617. def _activate_client_cert(self, certfile, keyfile):
  2618. self.client_certs["active"] = (certfile, keyfile)
  2619. self.active_cert_domains = []
  2620. self.prompt = self.cert_prompt + "+" + os.path.basename(certfile).replace('.crt','') + "> " + "\x1b[0m"
  2621. self._debug("Using ID {} / {}.".format(*self.client_certs["active"]))
  2622. def _deactivate_client_cert(self):
  2623. if self.active_is_transient:
  2624. for filename in self.client_certs["active"]:
  2625. os.remove(filename)
  2626. for domain in self.active_cert_domains:
  2627. self.client_certs.pop(domain)
  2628. self.client_certs["active"] = None
  2629. self.active_cert_domains = []
  2630. self.prompt = self.no_cert_prompt
  2631. self.active_is_transient = False
  2632. # Cmd implementation follows
  2633. def default(self, line):
  2634. if line.strip() == "EOF":
  2635. return self.onecmd("quit")
  2636. elif line.strip() == "..":
  2637. return self.do_up()
  2638. elif line.startswith("/"):
  2639. return self.do_find(line[1:])
  2640. # Expand abbreviated commands
  2641. first_word = line.split()[0].strip()
  2642. if first_word in _ABBREVS:
  2643. full_cmd = _ABBREVS[first_word]
  2644. expanded = line.replace(first_word, full_cmd, 1)
  2645. return self.onecmd(expanded)
  2646. # Try to access it like an URL
  2647. if looks_like_url(line):
  2648. return self.do_go(line)
  2649. # Try to parse numerical index for lookup table
  2650. try:
  2651. n = int(line.strip())
  2652. except ValueError:
  2653. print("What?")
  2654. return
  2655. try:
  2656. gi = self.lookup[n-1]
  2657. except IndexError:
  2658. print ("Index too high!")
  2659. return
  2660. self.index_index = n
  2661. self._go_to_gi(gi)
  2662. ### Settings
  2663. @restricted
  2664. def do_set(self, line):
  2665. """View or set various options."""
  2666. if not line.strip():
  2667. # Show all current settings
  2668. for option in sorted(self.options.keys()):
  2669. print("%s %s" % (option, self.options[option]))
  2670. elif len(line.split()) == 1 :
  2671. # Show current value of one specific setting
  2672. option = line.strip()
  2673. if option == "redirects":
  2674. print("redirects : %s" %self.options["redirects"])
  2675. if self.options["redirects"]:
  2676. toprint = "Redirections are enabled. (disable with \"set redirects false\")\n"
  2677. else:
  2678. toprint = "Redirections are disabled. (enable with \"set redirects true\")\n"
  2679. toprint += "--------------------------\n"
  2680. for r in self.redirects:
  2681. toprint += ("%s\t->\t%s\n" %(r,self.redirects[r]))
  2682. toprint +="\nTo add new, use \"set redirects origine.com destination.org\""
  2683. print(toprint)
  2684. elif option in self.options:
  2685. print("%s %s" % (option, self.options[option]))
  2686. else:
  2687. print("Unrecognised option %s" % option)
  2688. else:
  2689. # Set value of one specific setting
  2690. option, value = line.split(" ", 1)
  2691. if option not in self.options:
  2692. print("Unrecognised option %s" % option)
  2693. return
  2694. # Validate / convert values
  2695. elif option == "tls_mode":
  2696. if value.lower() not in ("ca", "tofu"):
  2697. print("TLS mode must be `ca` or `tofu`!")
  2698. return
  2699. elif option == "width":
  2700. if value.isnumeric():
  2701. value = int(value)
  2702. print("changing width to ",value)
  2703. global TERM_WIDTH
  2704. TERM_WIDTH = value
  2705. else:
  2706. print("%s is not a valid width (integer required)"%value)
  2707. elif option == "redirects" and len(value.split(" ")) > 1:
  2708. if len(value.split(" ")) <= 1:
  2709. if value.lower() in ["true","false"]:
  2710. if value.lower() == "true":
  2711. value = True
  2712. toprint = "Redirections are enabled\n"
  2713. toprint = "------------------------\n"
  2714. for r in self.redirects:
  2715. toprint += ("%s\t->\t%s\n" %(r,self.redirects[r]))
  2716. print(toprint)
  2717. else:
  2718. value = False
  2719. print("Redirections are disabled")
  2720. elif value in self.redirects:
  2721. print("%s is redirected to %s" %(value,self.redirects[value]))
  2722. else:
  2723. print("Please add a destination where to redirect %s" %value)
  2724. else:
  2725. orig, dest = value.split(" ",1)
  2726. self.redirects[orig] = dest
  2727. print("%s will now be redirected to %s" %(orig,dest))
  2728. elif option == "beta":
  2729. if value.lower() == "true":
  2730. global BETA
  2731. BETA = True
  2732. print("Experimental features are enabled.")
  2733. else:
  2734. # global BETA
  2735. BETA = False
  2736. print("Experimental features are disabled.")
  2737. self.options["beta"] = BETA
  2738. elif value.isnumeric():
  2739. value = int(value)
  2740. elif value.lower() == "false":
  2741. value = False
  2742. elif value.lower() == "true":
  2743. value = True
  2744. else:
  2745. try:
  2746. value = float(value)
  2747. except ValueError:
  2748. pass
  2749. self.options[option] = value
  2750. @restricted
  2751. def do_cert(self, line):
  2752. """Manage client certificates"""
  2753. print("Managing client certificates")
  2754. if self.client_certs["active"]:
  2755. print("Active certificate: {}".format(self.client_certs["active"][0]))
  2756. print("1. Deactivate client certificate.")
  2757. print("2. Generate new certificate.")
  2758. print("3. Load previously generated certificate.")
  2759. print("4. Load externally created client certificate from file.")
  2760. print("Enter blank line to exit certificate manager.")
  2761. choice = input("> ").strip()
  2762. if choice == "1":
  2763. print("Deactivating client certificate.")
  2764. self._deactivate_client_cert()
  2765. elif choice == "2":
  2766. self._generate_persistent_client_cert()
  2767. elif choice == "3":
  2768. self._choose_client_cert()
  2769. elif choice == "4":
  2770. self._load_client_cert()
  2771. else:
  2772. print("Aborting.")
  2773. @restricted
  2774. def do_handler(self, line):
  2775. """View or set handler commands for different MIME types."""
  2776. if not line.strip():
  2777. # Show all current handlers
  2778. for mime in sorted(_MIME_HANDLERS.keys()):
  2779. print("%s %s" % (mime, _MIME_HANDLERS[mime]))
  2780. elif len(line.split()) == 1:
  2781. mime = line.strip()
  2782. if mime in _MIME_HANDLERS:
  2783. print("%s %s" % (mime, _MIME_HANDLERS[mime]))
  2784. else:
  2785. print("No handler set for MIME type %s" % mime)
  2786. else:
  2787. mime, handler = line.split(" ", 1)
  2788. _MIME_HANDLERS[mime] = handler
  2789. if "%s" not in handler:
  2790. print("Are you sure you don't want to pass the filename to the handler?")
  2791. def do_abbrevs(self, *args):
  2792. """Print all Offpunk command abbreviations."""
  2793. header = "Command Abbreviations:"
  2794. self.stdout.write("\n{}\n".format(str(header)))
  2795. if self.ruler:
  2796. self.stdout.write("{}\n".format(str(self.ruler * len(header))))
  2797. for k, v in _ABBREVS.items():
  2798. self.stdout.write("{:<7} {}\n".format(k, v))
  2799. self.stdout.write("\n")
  2800. def do_offline(self, *args):
  2801. """Use Offpunk offline by only accessing cached content"""
  2802. if self.offline_only:
  2803. print("Offline and undisturbed.")
  2804. else:
  2805. self.offline_only = True
  2806. self.prompt = self.offline_prompt
  2807. print("Offpunk is now offline and will only access cached content")
  2808. def do_online(self, *args):
  2809. """Use Offpunk online with a direct connection"""
  2810. if self.offline_only:
  2811. self.offline_only = False
  2812. self.prompt = self.no_cert_prompt
  2813. print("Offpunk is online and will access the network")
  2814. else:
  2815. print("Already online. Try offline.")
  2816. def do_copy(self, arg):
  2817. """Copy the content of the last visited page as gemtext in the clipboard.
  2818. Use with "url" as argument to only copy the adress.
  2819. Use with "raw" to copy ANSI content as seen in your terminal (not gemtext).
  2820. Use with "cache" to copy the path of the cached content."""
  2821. if self.gi:
  2822. if _HAS_XSEL:
  2823. args = arg.split()
  2824. if args and args[0] == "url":
  2825. if len(args) > 1 and args[1].isdecimal():
  2826. gi = self.index[int(args[1])-1]
  2827. url = gi.url
  2828. else:
  2829. url = self.gi.url
  2830. run("echo %s |xsel -b -i" % url,direct_output=True)
  2831. elif args and args[0] == "raw":
  2832. run("cat \"%s\" |xsel -b -i" % self.gi.get_temp_filename(),direct_output=True)
  2833. elif args and args[0] == "cache":
  2834. run("echo %s |xsel -b -i" % self.gi.get_cache_path(), direct_output=True)
  2835. else:
  2836. run("cat \"%s\" |xsel -b -i" % self.gi.get_body(as_file=True), direct_output=True)
  2837. else:
  2838. print("Please install xsel to use copy")
  2839. else:
  2840. print("No content to copy, visit a page first")
  2841. ### Stuff for getting around
  2842. def do_go(self, line):
  2843. """Go to a gemini URL or marked item."""
  2844. line = line.strip()
  2845. if not line:
  2846. if shutil.which('xsel'):
  2847. clipboards = []
  2848. urls = []
  2849. for selec in ["-p","-s","-b"]:
  2850. try:
  2851. clipboards.append(run("xsel "+selec))
  2852. except Exception as err:
  2853. #print("Skippink clipboard %s because %s"%(selec,err))
  2854. pass
  2855. for u in clipboards:
  2856. if "://" in u and looks_like_url(u) and u not in urls :
  2857. urls.append(u)
  2858. if len(urls) > 1:
  2859. self.lookup = []
  2860. for u in urls:
  2861. self.lookup.append(GeminiItem(u))
  2862. print("Where do you want to go today?")
  2863. self._show_lookup()
  2864. elif len(urls) == 1:
  2865. self.do_go(urls[0])
  2866. else:
  2867. print("Go where? (hint: simply copy an URL in your clipboard)")
  2868. else:
  2869. print("Go where? (hint: install xsel to go to copied URLs)")
  2870. # First, check for possible marks
  2871. elif line in self.marks:
  2872. gi = self.marks[line]
  2873. self._go_to_gi(gi)
  2874. # or a local file
  2875. elif os.path.exists(os.path.expanduser(line)):
  2876. self._go_to_gi(GeminiItem(line))
  2877. # If this isn't a mark, treat it as a URL
  2878. elif looks_like_url(line):
  2879. self._go_to_gi(GeminiItem(line))
  2880. else:
  2881. print("%s is not a valid URL to go"%line)
  2882. @needs_gi
  2883. def do_reload(self, *args):
  2884. """Reload the current URL."""
  2885. if self.offline_only:
  2886. self.get_list("to_fetch")
  2887. r = self.list_add_line("to_fetch",gi=self.gi,verbose=False)
  2888. if r:
  2889. print("%s marked for syncing" %self.gi.url)
  2890. else:
  2891. print("%s already marked for syncing" %self.gi.url)
  2892. else:
  2893. self._go_to_gi(self.gi, check_cache=False)
  2894. @needs_gi
  2895. def do_up(self, *args):
  2896. """Go up one directory in the path.
  2897. Take an integer as argument to go up multiple times."""
  2898. level = 1
  2899. if args[0].isnumeric():
  2900. level = int(args[0])
  2901. elif args[0] != "":
  2902. print("Up only take integer as arguments")
  2903. self._go_to_gi(self.gi.up(level=level))
  2904. def do_back(self, *args):
  2905. """Go back to the previous gemini item."""
  2906. histfile = self.get_list("history")
  2907. links = self.list_get_links("history")
  2908. if self.hist_index >= len(links) -1:
  2909. return
  2910. self.hist_index += 1
  2911. gi = links[self.hist_index]
  2912. self._go_to_gi(gi, update_hist=False)
  2913. def do_forward(self, *args):
  2914. """Go forward to the next gemini item."""
  2915. histfile = self.get_list("history")
  2916. links = self.list_get_links("history")
  2917. if self.hist_index <= 0:
  2918. return
  2919. self.hist_index -= 1
  2920. gi = links[self.hist_index]
  2921. self._go_to_gi(gi, update_hist=False)
  2922. @needs_gi
  2923. def do_root(self, *args):
  2924. """Go to root selector of the server hosting current item."""
  2925. self._go_to_gi(self.gi.root())
  2926. def do_tour(self, line):
  2927. """Add index items as waypoints on a tour, which is basically a FIFO
  2928. queue of gemini items.
  2929. `tour` or `t` alone brings you to the next item in your tour.
  2930. Items can be added with `tour 1 2 3 4` or ranges like `tour 1-4`.
  2931. All items in current menu can be added with `tour *`.
  2932. Current item can be added back to the end of the tour with `tour .`.
  2933. Current tour can be listed with `tour ls` and scrubbed with `tour clear`."""
  2934. # Creating the tour list if needed
  2935. self.get_list("tour")
  2936. line = line.strip()
  2937. if not line:
  2938. # Fly to next waypoint on tour
  2939. if len(self.list_get_links("tour")) < 1:
  2940. print("End of tour.")
  2941. else:
  2942. url = self.list_go_to_line("1","tour")
  2943. if url:
  2944. self.list_rm_url(url,"tour")
  2945. elif line == "ls":
  2946. self.list_show("tour")
  2947. elif line == "clear":
  2948. for l in self.list_get_links("tour"):
  2949. self.list_rm_url(l.url_mode(),"tour")
  2950. elif line == "*":
  2951. for l in self.lookup:
  2952. self.list_add_line("tour",gi=l,verbose=False)
  2953. elif line == ".":
  2954. self.list_add_line("tour",verbose=False)
  2955. elif looks_like_url(line):
  2956. self.list_add_line("tour",gi=GeminiItem(line))
  2957. else:
  2958. for index in line.split():
  2959. try:
  2960. pair = index.split('-')
  2961. if len(pair) == 1:
  2962. # Just a single index
  2963. n = int(index)
  2964. gi = self.lookup[n-1]
  2965. self.list_add_line("tour",gi=gi,verbose=False)
  2966. elif len(pair) == 2:
  2967. # Two endpoints for a range of indices
  2968. if int(pair[0]) < int(pair[1]):
  2969. for n in range(int(pair[0]), int(pair[1]) + 1):
  2970. gi = self.lookup[n-1]
  2971. self.list_add_line("tour",gi=gi,verbose=False)
  2972. else:
  2973. for n in range(int(pair[0]), int(pair[1]) - 1, -1):
  2974. gi = self.lookup[n-1]
  2975. self.list_add_line("tour",gi=gi,verbose=False)
  2976. else:
  2977. # Syntax error
  2978. print("Invalid use of range syntax %s, skipping" % index)
  2979. except ValueError:
  2980. print("Non-numeric index %s, skipping." % index)
  2981. except IndexError:
  2982. print("Invalid index %d, skipping." % n)
  2983. @needs_gi
  2984. def do_mark(self, line):
  2985. """Mark the current item with a single letter. This letter can then
  2986. be passed to the 'go' command to return to the current item later.
  2987. Think of it like marks in vi: 'mark a'='ma' and 'go a'=''a'.
  2988. Marks are temporary until shutdown (not saved to disk)."""
  2989. line = line.strip()
  2990. if not line:
  2991. for mark, gi in self.marks.items():
  2992. print("[%s] %s (%s)" % (mark, gi.name, gi.url))
  2993. elif line.isalpha() and len(line) == 1:
  2994. self.marks[line] = self.gi
  2995. else:
  2996. print("Invalid mark, must be one letter")
  2997. @needs_gi
  2998. def do_info(self,line):
  2999. """Display information about current page."""
  3000. out = self.gi.get_page_title() + "\n\n"
  3001. out += "URL : " + self.gi.url + "\n"
  3002. out += "Path : " + self.gi.path + "\n"
  3003. out += "Mime : " + self.gi.get_mime() + "\n"
  3004. out += "Cache : " + self.gi.get_cache_path() + "\n"
  3005. tmp = self.gi.get_temp_filename()
  3006. if tmp != self.gi.get_cache_path():
  3007. out += "Tempfile : " + self.gi.get_temp_filename() + "\n"
  3008. if self.gi.renderer :
  3009. rend = str(self.gi.renderer.__class__)
  3010. rend = rend.lstrip("<class '__main__.").rstrip("'>")
  3011. else:
  3012. rend = "None"
  3013. out += "Renderer : " + rend + "\n\n"
  3014. lists = []
  3015. for l in self.list_lists():
  3016. if self.list_has_url(self.gi.url,l):
  3017. lists.append(l)
  3018. if len(lists) > 0:
  3019. out += "Page appeard in following lists :\n"
  3020. for l in lists:
  3021. if not self.list_is_system(l):
  3022. status = "normal list"
  3023. if self.list_is_subscribed(l):
  3024. status = "subscription"
  3025. elif self.list_is_frozen(l):
  3026. status = "frozen list"
  3027. out += " • %s\t(%s)\n" %(l,status)
  3028. for l in lists:
  3029. if self.list_is_system(l):
  3030. out += " • %s\n" %l
  3031. else:
  3032. out += "Page is not save in any list"
  3033. print(out)
  3034. def do_version(self, line):
  3035. """Display version and system information."""
  3036. def has(value):
  3037. if value:
  3038. return "\t\x1b[1;32mInstalled\x1b[0m\n"
  3039. else:
  3040. return "\t\x1b[1;31mNot Installed\x1b[0m\n"
  3041. output = "Offpunk " + _VERSION + "\n"
  3042. output += "===========\n"
  3043. output += "Highly recommended:\n"
  3044. output += " - python-cryptography : " + has(_HAS_CRYPTOGRAPHY)
  3045. output += " - xdg-open : " + has(_HAS_XDGOPEN)
  3046. output += "\nWeb browsing:\n"
  3047. output += " - python-requests : " + has(_DO_HTTP)
  3048. output += " - python-feedparser : " + has(_DO_FEED)
  3049. output += " - python-bs4 : " + has(_HAS_SOUP)
  3050. output += " - python-readability : " + has(_HAS_READABILITY)
  3051. output += " - timg : " + has(_HAS_TIMG)
  3052. if _NEW_CHAFA:
  3053. output += " - chafa 1.10+ : " + has(_HAS_CHAFA)
  3054. else:
  3055. output += " - chafa : " + has(_HAS_CHAFA)
  3056. output += " - python-pil : " + has(_HAS_PIL)
  3057. output += "\nNice to have:\n"
  3058. output += " - python-setproctitle : " + has(_HAS_SETPROCTITLE)
  3059. output += " - xsel : " + has(_HAS_XSEL)
  3060. output += "\nFeatures :\n"
  3061. if _NEW_CHAFA:
  3062. output += " - Render images (chafa or timg) : " + has(_RENDER_IMAGE)
  3063. else:
  3064. output += " - Render images (python-pil, chafa or timg) : " + has(_RENDER_IMAGE)
  3065. output += " - Render HTML (bs4, readability) : " + has(_DO_HTML)
  3066. output += " - Render Atom/RSS feeds (feedparser) : " + has(_DO_FEED)
  3067. output += " - Connect to http/https (requests) : " + has(_DO_HTTP)
  3068. output += " - copy to/from clipboard (xsel) : " + has(_HAS_XSEL)
  3069. output += " - restore last position (less 572+) : " + has(_LESS_RESTORE_POSITION)
  3070. output += "\n"
  3071. output += "Config directory : " + _CONFIG_DIR + "\n"
  3072. output += "User Data directory : " + _DATA_DIR + "\n"
  3073. output += "Cache directoy : " + _CACHE_PATH
  3074. print(output)
  3075. ### Stuff that modifies the lookup table
  3076. def do_ls(self, line):
  3077. """List contents of current index.
  3078. Use 'ls -l' to see URLs."""
  3079. self.lookup = self.index
  3080. self._show_lookup(url = "-l" in line)
  3081. self.page_index = 0
  3082. def do_gus(self, line):
  3083. """Submit a search query to the geminispace.info search engine."""
  3084. gus = GeminiItem("gemini://geminispace.info/search")
  3085. self._go_to_gi(gus.query(line))
  3086. def do_history(self, *args):
  3087. """Display history."""
  3088. self.list_show("history")
  3089. @needs_gi
  3090. def do_find(self, searchterm):
  3091. """Find in current page by displaying only relevant lines (grep)."""
  3092. self.gi.display(grep=searchterm)
  3093. def emptyline(self):
  3094. """Page through index ten lines at a time."""
  3095. i = self.page_index
  3096. if i > len(self.lookup):
  3097. return
  3098. self._show_lookup(offset=i, end=i+10)
  3099. self.page_index += 10
  3100. ### Stuff that does something to most recently viewed item
  3101. @needs_gi
  3102. def do_cat(self, *args):
  3103. """Run most recently visited item through "cat" command."""
  3104. run("cat \"%s\"" % self.gi.get_temp_filename(),direct_output=True)
  3105. @needs_gi
  3106. def do_view(self, *args):
  3107. """Run most recently visited item through "less" command, restoring \
  3108. previous position.
  3109. Use "view normal" to see the default article view on html page.
  3110. Use "view full" to see a complete html page instead of the article view.
  3111. Use "view feed" to see the the linked feed of the page (in any).
  3112. Use "view feeds" to see available feeds on this page.
  3113. (full, feed, feeds have no effect on non-html content)."""
  3114. if self.gi and args and args[0] != "":
  3115. if args[0] in ["full","debug"]:
  3116. self._go_to_gi(self.gi,mode=args[0])
  3117. elif args[0] in ["normal","readable"]:
  3118. self._go_to_gi(self.gi,mode="readable")
  3119. elif args[0] == "feed":
  3120. subs = self.gi.get_subscribe_links()
  3121. if len(subs) > 1:
  3122. self.do_go(subs[1][0])
  3123. elif "rss" in subs[0][1] or "atom" in subs[0][1]:
  3124. print("%s is already a feed" %self.gi.url)
  3125. else:
  3126. print("No other feed found on %s"%self.gi.url)
  3127. elif args[0] == "feeds":
  3128. subs = self.gi.get_subscribe_links()
  3129. stri = "Available views :\n"
  3130. counter = 0
  3131. for s in subs:
  3132. counter += 1
  3133. stri += "[%s] %s [%s]\n"%(counter,s[0],s[1])
  3134. stri += "Which view do you want to see ? >"
  3135. ans = input(stri)
  3136. if ans.isdigit() and 0 < int(ans) <= len(subs):
  3137. self.do_go(subs[int(ans)-1][0])
  3138. else:
  3139. print("Valid argument for view are : normal, full, feed, feeds")
  3140. else:
  3141. self._go_to_gi(self.gi)
  3142. @needs_gi
  3143. def do_open(self, *args):
  3144. """Open current item with the configured handler or xdg-open.
  3145. Uses "open url" to open current URL in a browser.
  3146. see "handler" command to set your handler."""
  3147. if args[0] == "url":
  3148. run("xdg-open %s" %self.gi.url,direct_output=True)
  3149. else:
  3150. cmd_str = self._get_handler_cmd(self.gi.get_mime())
  3151. file_path = "\"%s\"" %self.gi.get_body(as_file=True)
  3152. cmd_str = cmd_str % file_path
  3153. run(cmd_str,direct_output=True)
  3154. @restricted
  3155. @needs_gi
  3156. def do_shell(self, line):
  3157. """'cat' most recently visited item through a shell pipeline.
  3158. '!' is an useful shortcut."""
  3159. run("cat \"%s\" |" % self.gi.get_temp_filename() + line,direct_output=True)
  3160. @restricted
  3161. @needs_gi
  3162. def do_save(self, line):
  3163. """Save an item to the filesystem.
  3164. 'save n filename' saves menu item n to the specified filename.
  3165. 'save filename' saves the last viewed item to the specified filename.
  3166. 'save n' saves menu item n to an automagic filename."""
  3167. args = line.strip().split()
  3168. # First things first, figure out what our arguments are
  3169. if len(args) == 0:
  3170. # No arguments given at all
  3171. # Save current item, if there is one, to a file whose name is
  3172. # inferred from the gemini path
  3173. if not self.gi.is_cache_valid():
  3174. print("You cannot save if not cached!")
  3175. return
  3176. else:
  3177. index = None
  3178. filename = None
  3179. elif len(args) == 1:
  3180. # One argument given
  3181. # If it's numeric, treat it as an index, and infer the filename
  3182. try:
  3183. index = int(args[0])
  3184. filename = None
  3185. # If it's not numeric, treat it as a filename and
  3186. # save the current item
  3187. except ValueError:
  3188. index = None
  3189. filename = os.path.expanduser(args[0])
  3190. elif len(args) == 2:
  3191. # Two arguments given
  3192. # Treat first as an index and second as filename
  3193. index, filename = args
  3194. try:
  3195. index = int(index)
  3196. except ValueError:
  3197. print("First argument is not a valid item index!")
  3198. return
  3199. filename = os.path.expanduser(filename)
  3200. else:
  3201. print("You must provide an index, a filename, or both.")
  3202. return
  3203. # Next, fetch the item to save, if it's not the current one.
  3204. if index:
  3205. last_gi = self.gi
  3206. try:
  3207. gi = self.lookup[index-1]
  3208. self._go_to_gi(gi, update_hist = False, handle = False)
  3209. except IndexError:
  3210. print ("Index too high!")
  3211. self.gi = last_gi
  3212. return
  3213. else:
  3214. gi = self.gi
  3215. # Derive filename from current GI's path, if one hasn't been set
  3216. if not filename:
  3217. filename = gi.get_filename()
  3218. # Check for filename collisions and actually do the save if safe
  3219. if os.path.exists(filename):
  3220. print("File %s already exists!" % filename)
  3221. else:
  3222. # Don't use _get_active_tmpfile() here, because we want to save the
  3223. # "source code" of menus, not the rendered view - this way Offpunk
  3224. # can navigate to it later.
  3225. path = gi.get_body(as_file=True)
  3226. if os.path.isdir(path):
  3227. print("Can’t save %s because it’s a folder, not a file"%path)
  3228. else:
  3229. print("Saved to %s" % filename)
  3230. shutil.copyfile(path, filename)
  3231. # Restore gi if necessary
  3232. if index != None:
  3233. self._go_to_gi(last_gi, handle=False)
  3234. @needs_gi
  3235. def do_url(self, *args):
  3236. """Print URL of most recently visited item."""
  3237. print(self.gi.url)
  3238. ### Bookmarking stuff
  3239. @restricted
  3240. @needs_gi
  3241. def do_add(self, line):
  3242. """Add the current URL to the list specied as argument.
  3243. If no argument given, URL is added to Bookmarks."""
  3244. args = line.split()
  3245. if len(args) < 1 :
  3246. list = "bookmarks"
  3247. if not self.list_path(list):
  3248. self.list_create(list)
  3249. self.list_add_line(list)
  3250. else:
  3251. self.list_add_line(args[0])
  3252. # Get the list file name, creating or migrating it if needed.
  3253. # Migrate bookmarks/tour/to_fetch from XDG_CONFIG to XDG_DATA
  3254. # We migrate only if the file exists in XDG_CONFIG and not XDG_DATA
  3255. def get_list(self,list):
  3256. list_path = self.list_path(list)
  3257. if not list_path:
  3258. old_file_gmi = os.path.join(_CONFIG_DIR,list + ".gmi")
  3259. old_file_nogmi = os.path.join(_CONFIG_DIR,list)
  3260. target = os.path.join(_DATA_DIR,"lists")
  3261. if os.path.exists(old_file_gmi):
  3262. shutil.move(old_file_gmi,target)
  3263. elif os.path.exists(old_file_nogmi):
  3264. targetgmi = os.path.join(target,list+".gmi")
  3265. shutil.move(old_file_nogmi,targetgmi)
  3266. else:
  3267. if list == "subscribed":
  3268. title = "Subscriptions #subscribed (new links in those pages will be added to tour)"
  3269. elif list == "to_fetch":
  3270. title = "Links requested and to be fetched during the next --sync"
  3271. else:
  3272. title = None
  3273. self.list_create(list, title=title)
  3274. list_path = self.list_path(list)
  3275. return list_path
  3276. def do_subscribe(self,line):
  3277. """Subscribe to current page by saving it in the "subscribed" list.
  3278. If a new link is found in the page during a --sync, the new link is automatically
  3279. fetched and added to your next tour.
  3280. To unsubscribe, remove the page from the "subscribed" list."""
  3281. subs = self.gi.get_subscribe_links()
  3282. if len(subs) > 1:
  3283. stri = "Multiple feeds have been found :\n"
  3284. elif "rss" in subs[0][1] or "atom" in subs[0][1] :
  3285. stri = "This page is already a feed:\n"
  3286. else:
  3287. stri = "No feed detected. You can still watch the page :\n"
  3288. counter = 0
  3289. for l in subs:
  3290. link = l[0]
  3291. already = []
  3292. for li in self.list_lists():
  3293. if self.list_is_subscribed(li):
  3294. if self.list_has_url(link,li):
  3295. already.append(li)
  3296. stri += "[%s] %s [%s]\n"%(counter+1,link,l[1])
  3297. if len(already) > 0:
  3298. stri += "\t -> (already subscribed through lists %s)\n"%(str(already))
  3299. counter += 1
  3300. stri += "\n"
  3301. stri += "Which feed do you want to subsribe ? > "
  3302. ans = input(stri)
  3303. if ans.isdigit() and 0 < int(ans) <= len(subs):
  3304. sublink,mime,title = subs[int(ans)-1]
  3305. else:
  3306. sublink,title = None,None
  3307. if sublink:
  3308. sublink = self.gi.absolutise_url(sublink)
  3309. gi = GeminiItem(sublink,name=title)
  3310. list_path = self.get_list("subscribed")
  3311. added = self.list_add_line("subscribed",gi=gi,verbose=False)
  3312. if added :
  3313. print("Subscribed to %s" %sublink)
  3314. else:
  3315. print("You are already subscribed to %s"%sublink)
  3316. else:
  3317. print("No subscription registered")
  3318. def do_bookmarks(self, line):
  3319. """Show or access the bookmarks menu.
  3320. 'bookmarks' shows all bookmarks.
  3321. 'bookmarks n' navigates immediately to item n in the bookmark menu.
  3322. Bookmarks are stored using the 'add' command."""
  3323. list_path = self.get_list("bookmarks")
  3324. args = line.strip()
  3325. if len(args.split()) > 1 or (args and not args.isnumeric()):
  3326. print("bookmarks command takes a single integer argument!")
  3327. elif args:
  3328. self.list_go_to_line(args,"bookmarks")
  3329. else:
  3330. self.list_show("bookmarks")
  3331. def do_archive(self,args):
  3332. """Archive current page by removing it from every list and adding it to
  3333. archives, which is a special historical list limited in size. It is similar to `move archives`."""
  3334. for li in self.list_lists():
  3335. if li not in ["archives", "history"]:
  3336. deleted = self.list_rm_url(self.gi.url_mode(),li)
  3337. if deleted:
  3338. print("Removed from %s"%li)
  3339. self.list_add_top("archives",limit=self.options["archives_size"])
  3340. print("Archiving: %s"%self.gi.get_page_title())
  3341. print("\x1b[2;34mCurrent maximum size of archives : %s\x1b[0m" %self.options["archives_size"])
  3342. def list_add_line(self,list,gi=None,verbose=True):
  3343. list_path = self.list_path(list)
  3344. if not list_path:
  3345. print("List %s does not exist. Create it with ""list create %s"""%(list,list))
  3346. return False
  3347. else:
  3348. if not gi:
  3349. gi = self.gi
  3350. # first we check if url already exists in the file
  3351. with open(list_path,"r") as l_file:
  3352. lines = l_file.readlines()
  3353. l_file.close()
  3354. for l in lines:
  3355. sp = l.split()
  3356. if gi.url_mode() in sp:
  3357. if verbose:
  3358. print("%s already in %s."%(gi.url,list))
  3359. return False
  3360. with open(list_path,"a") as l_file:
  3361. l_file.write(gi.to_map_line())
  3362. l_file.close()
  3363. if verbose:
  3364. print("%s added to %s" %(gi.url,list))
  3365. return True
  3366. def list_add_top(self,list,limit=0,truncate_lines=0):
  3367. if not self.gi:
  3368. return
  3369. stri = self.gi.to_map_line().strip("\n")
  3370. if list == "archives":
  3371. stri += ", archived on "
  3372. elif list == "history":
  3373. stri += ", visited on "
  3374. else:
  3375. stri += ", added to %s on "%list
  3376. stri += time.ctime() + "\n"
  3377. list_path = self.get_list(list)
  3378. with open(list_path,"r") as l_file:
  3379. lines = l_file.readlines()
  3380. l_file.close()
  3381. with open(list_path,"w") as l_file:
  3382. l_file.write("#%s\n"%list)
  3383. l_file.write(stri)
  3384. counter = 0
  3385. # Truncating is useful in case we open a new branch
  3386. # after a few back in history
  3387. to_truncate = truncate_lines
  3388. for l in lines:
  3389. if not l.startswith("#"):
  3390. if to_truncate > 0:
  3391. to_truncate -= 1
  3392. elif limit == 0 or counter < limit:
  3393. l_file.write(l)
  3394. counter += 1
  3395. l_file.close()
  3396. # remove an url from a list.
  3397. # return True if the URL was removed
  3398. # return False if the URL was not found
  3399. def list_rm_url(self,url,list):
  3400. return self.list_has_url(url,list,deletion=True)
  3401. # deletion and has_url are so similar, I made them the same method
  3402. def list_has_url(self,url,list,deletion=False):
  3403. list_path = self.list_path(list)
  3404. if list_path:
  3405. to_return = False
  3406. with open(list_path,"r") as lf:
  3407. lines = lf.readlines()
  3408. lf.close()
  3409. to_write = []
  3410. # let’s remove the mode
  3411. url = url.split("##offpunk_mode=")[0]
  3412. for l in lines:
  3413. # we separate components of the line
  3414. # to ensure we identify a complete URL, not a part of it
  3415. splitted = l.split()
  3416. if url not in splitted and len(splitted) > 1:
  3417. current = splitted[1].split("##offpunk_mode=")[0]
  3418. #sometimes, we must remove the ending "/"
  3419. if url == current:
  3420. to_return = True
  3421. elif url.endswith("/") and url[:-1] == current:
  3422. to_return = True
  3423. else:
  3424. to_write.append(l)
  3425. else:
  3426. to_return = True
  3427. if deletion :
  3428. with open(list_path,"w") as lf:
  3429. for l in to_write:
  3430. lf.write(l)
  3431. lf.close()
  3432. return to_return
  3433. else:
  3434. return False
  3435. def list_get_links(self,list):
  3436. list_path = self.list_path(list)
  3437. if list_path:
  3438. gi = GeminiItem("list:///%s"%list)
  3439. return gi.get_links()
  3440. else:
  3441. return []
  3442. def list_go_to_line(self,line,list):
  3443. list_path = self.list_path(list)
  3444. if not list_path:
  3445. print("List %s does not exist. Create it with ""list create %s"""%(list,list))
  3446. elif not line.isnumeric():
  3447. print("go_to_line requires a number as parameter")
  3448. else:
  3449. gi = GeminiItem("list:///%s"%list)
  3450. gi = gi.get_link(int(line))
  3451. display = not self.sync_only
  3452. if gi:
  3453. self._go_to_gi(gi,handle=display)
  3454. return gi.url_mode()
  3455. def list_show(self,list):
  3456. list_path = self.list_path(list)
  3457. if not list_path:
  3458. print("List %s does not exist. Create it with ""list create %s"""%(list,list))
  3459. else:
  3460. gi = GeminiItem("list:///%s"%list)
  3461. display = not self.sync_only
  3462. self._go_to_gi(gi,handle=display)
  3463. #return the path of the list file if list exists.
  3464. #return None if the list doesn’t exist.
  3465. def list_path(self,list):
  3466. listdir = os.path.join(_DATA_DIR,"lists")
  3467. list_path = os.path.join(listdir, "%s.gmi"%list)
  3468. if os.path.exists(list_path):
  3469. return list_path
  3470. else:
  3471. return None
  3472. def list_create(self,list,title=None):
  3473. list_path = self.list_path(list)
  3474. if list in ["create","edit","delete","help"]:
  3475. print("%s is not allowed as a name for a list"%list)
  3476. elif not list_path:
  3477. listdir = os.path.join(_DATA_DIR,"lists")
  3478. os.makedirs(listdir,exist_ok=True)
  3479. list_path = os.path.join(listdir, "%s.gmi"%list)
  3480. with open(list_path,"a") as lfile:
  3481. if title:
  3482. lfile.write("# %s\n"%title)
  3483. else:
  3484. lfile.write("# %s\n"%list)
  3485. lfile.close()
  3486. print("list created. Display with `list %s`"%list)
  3487. else:
  3488. print("list %s already exists" %list)
  3489. def do_move(self,arg):
  3490. """move LIST will add the current page to the list LIST.
  3491. With a major twist: current page will be removed from all other lists.
  3492. If current page was not in a list, this command is similar to `add LIST`."""
  3493. if not arg:
  3494. print("LIST argument is required as the target for your move")
  3495. elif arg[0] == "archives":
  3496. self.do_archive()
  3497. else:
  3498. args = arg.split()
  3499. list_path = self.list_path(args[0])
  3500. if not list_path:
  3501. print("%s is not a list, aborting the move" %args[0])
  3502. else:
  3503. lists = self.list_lists()
  3504. for l in lists:
  3505. if l != args[0] and l not in ["archives", "history"]:
  3506. isremoved = self.list_rm_url(self.gi.url_mode(),l)
  3507. if isremoved:
  3508. print("Removed from %s"%l)
  3509. self.list_add_line(args[0])
  3510. def list_lists(self):
  3511. listdir = os.path.join(_DATA_DIR,"lists")
  3512. to_return = []
  3513. if os.path.exists(listdir):
  3514. lists = os.listdir(listdir)
  3515. if len(lists) > 0:
  3516. for l in lists:
  3517. #removing the .gmi at the end of the name
  3518. to_return.append(l[:-4])
  3519. return to_return
  3520. def list_has_status(self,list,status):
  3521. path = self.list_path(list)
  3522. toreturn = False
  3523. if path:
  3524. with open(path) as f:
  3525. line = f.readline().strip()
  3526. f.close()
  3527. if line.startswith("#") and status in line:
  3528. toreturn = True
  3529. return toreturn
  3530. def list_is_subscribed(self,list):
  3531. return self.list_has_status(list,"#subscribed")
  3532. def list_is_frozen(self,list):
  3533. return self.list_has_status(list,"#frozen")
  3534. def list_is_system(self,list):
  3535. return list in ["history","to_fetch","archives","tour"]
  3536. # This modify the status of a list to one of :
  3537. # normal, frozen, subscribed
  3538. # action is either #frozen, #subscribed or None
  3539. def list_modify(self,list,action=None):
  3540. path = self.list_path(list)
  3541. with open(path) as f:
  3542. lines = f.readlines()
  3543. f.close()
  3544. if lines[0].strip().startswith("#"):
  3545. first_line = lines.pop(0).strip("\n")
  3546. else:
  3547. first_line = "# %s "%list
  3548. first_line = first_line.replace("#subscribed","").replace("#frozen","")
  3549. if action:
  3550. first_line += " " + action
  3551. print("List %s has been marked as %s"%(list,action))
  3552. else:
  3553. print("List %s is now a normal list" %list)
  3554. first_line += "\n"
  3555. lines.insert(0,first_line)
  3556. with open(path,"w") as f:
  3557. for line in lines:
  3558. f.write(line)
  3559. f.close()
  3560. def do_list(self,arg):
  3561. """Manage list of bookmarked pages.
  3562. - list : display available lists
  3563. - list $LIST : display pages in $LIST
  3564. - list create $NEWLIST : create a new list
  3565. - list edit $LIST : edit the list
  3566. - list subscribe $LIST : during sync, add new links found in listed pages to tour
  3567. - list freeze $LIST : don’t update pages in list during sync if a cache already exists
  3568. - list normal $LIST : update pages in list during sync but don’t add anything to tour
  3569. - list delete $LIST : delete a list permanently (a confirmation is required)
  3570. - list help : print this help
  3571. See also :
  3572. - add $LIST (to add current page to $LIST or, by default, to bookmarks)
  3573. - move $LIST (to add current page to list while removing from all others)
  3574. - archive (to remove current page from all lists while adding to archives)
  3575. Note: There’s no "delete" on purpose. The use of "archive" is recommended."""
  3576. listdir = os.path.join(_DATA_DIR,"lists")
  3577. os.makedirs(listdir,exist_ok=True)
  3578. if not arg:
  3579. lists = self.list_lists()
  3580. if len(lists) > 0:
  3581. lgi = GeminiItem("list:///")
  3582. self._go_to_gi(lgi)
  3583. else:
  3584. print("No lists yet. Use `list create`")
  3585. else:
  3586. args = arg.split()
  3587. if args[0] == "create":
  3588. if len(args) > 2:
  3589. name = " ".join(args[2:])
  3590. self.list_create(args[1].lower(),title=name)
  3591. elif len(args) == 2:
  3592. self.list_create(args[1].lower())
  3593. else:
  3594. print("A name is required to create a new list. Use `list create NAME`")
  3595. elif args[0] == "edit":
  3596. editor = None
  3597. if "editor" in self.options and self.options["editor"]:
  3598. editor = self.options["editor"]
  3599. elif os.environ.get("VISUAL"):
  3600. editor = os.environ.get("VISUAL")
  3601. elif os.environ.get("EDITOR"):
  3602. editor = os.environ.get("EDITOR")
  3603. if editor:
  3604. if len(args) > 1 and args[1] in self.list_lists():
  3605. path = os.path.join(listdir,args[1]+".gmi")
  3606. try:
  3607. run("%s \"%s\""%(editor,path),direct_output=True)
  3608. except Exception as err:
  3609. print(err)
  3610. print("Please set a valid editor with \"set editor\"")
  3611. else:
  3612. print("A valid list name is required to edit a list")
  3613. else:
  3614. print("No valid editor has been found.")
  3615. print("You can use the following command to set your favourite editor:")
  3616. print("set editor EDITOR")
  3617. print("or use the $VISUAL or $EDITOR environment variables.")
  3618. elif args[0] == "delete":
  3619. if len(args) > 1:
  3620. if self.list_is_system(args[1]):
  3621. print("%s is a system list which cannot be deleted"%args[1])
  3622. elif args[1] in self.list_lists():
  3623. size = len(self.list_get_links(args[1]))
  3624. stri = "Are you sure you want to delete %s ?\n"%args[1]
  3625. confirm = "YES"
  3626. if size > 0:
  3627. stri += "! %s items in the list will be lost !\n"%size
  3628. confirm = "YES DELETE %s" %size
  3629. else :
  3630. stri += "The list is empty, it should be safe to delete it.\n"
  3631. stri += "Type \"%s\" (in capital, without quotes) to confirm :"%confirm
  3632. answer = input(stri)
  3633. if answer == confirm:
  3634. path = os.path.join(listdir,args[1]+".gmi")
  3635. os.remove(path)
  3636. print("* * * %s has been deleted" %args[1])
  3637. else:
  3638. print("A valid list name is required to be deleted")
  3639. else:
  3640. print("A valid list name is required to be deleted")
  3641. elif args[0] in ["subscribe","freeze","normal"]:
  3642. if len(args) > 1:
  3643. if self.list_is_system(args[1]):
  3644. print("You cannot modify %s which is a system list"%args[1])
  3645. elif args[1] in self.list_lists():
  3646. if args[0] == "subscribe":
  3647. action = "#subscribed"
  3648. elif args[0] == "freeze":
  3649. action = "#frozen"
  3650. else:
  3651. action = None
  3652. self.list_modify(args[1],action=action)
  3653. else:
  3654. print("A valid list name is required after %s" %args[0])
  3655. elif args[0] == "help":
  3656. self.onecmd("help list")
  3657. elif len(args) == 1:
  3658. self.list_show(args[0].lower())
  3659. else:
  3660. self.list_go_to_line(args[1],args[0].lower())
  3661. def completedefault(self,index,line,begidx,endidx):
  3662. print("completeing %s + %s" %index,line)
  3663. return ["bépo","auc"]
  3664. def do_help(self, arg):
  3665. """ALARM! Recursion detected! ALARM! Prepare to eject!"""
  3666. if arg == "!":
  3667. print("! is an alias for 'shell'")
  3668. elif arg == "?":
  3669. print("? is an alias for 'help'")
  3670. elif arg in _ABBREVS:
  3671. full_cmd = _ABBREVS[arg]
  3672. print("%s is an alias for '%s'" %(arg,full_cmd))
  3673. print("See the list of aliases with 'abbrevs'")
  3674. print("'help %s':"%full_cmd)
  3675. cmd.Cmd.do_help(self, full_cmd)
  3676. else:
  3677. cmd.Cmd.do_help(self, arg)
  3678. ### Flight recorder
  3679. def do_blackbox(self, *args):
  3680. """Display contents of flight recorder, showing statistics for the
  3681. current gemini browsing session."""
  3682. lines = []
  3683. # Compute flight time
  3684. now = time.time()
  3685. delta = now - self.log["start_time"]
  3686. hours, remainder = divmod(delta, 3600)
  3687. minutes, seconds = divmod(remainder, 60)
  3688. # Count hosts
  3689. ipv4_hosts = len([host for host in self.visited_hosts if host[0] == socket.AF_INET])
  3690. ipv6_hosts = len([host for host in self.visited_hosts if host[0] == socket.AF_INET6])
  3691. # Assemble lines
  3692. lines.append(("Patrol duration", "%02d:%02d:%02d" % (hours, minutes, seconds)))
  3693. lines.append(("Requests sent:", self.log["requests"]))
  3694. lines.append((" IPv4 requests:", self.log["ipv4_requests"]))
  3695. lines.append((" IPv6 requests:", self.log["ipv6_requests"]))
  3696. lines.append(("Bytes received:", self.log["bytes_recvd"]))
  3697. lines.append((" IPv4 bytes:", self.log["ipv4_bytes_recvd"]))
  3698. lines.append((" IPv6 bytes:", self.log["ipv6_bytes_recvd"]))
  3699. lines.append(("Unique hosts visited:", len(self.visited_hosts)))
  3700. lines.append((" IPv4 hosts:", ipv4_hosts))
  3701. lines.append((" IPv6 hosts:", ipv6_hosts))
  3702. lines.append(("DNS failures:", self.log["dns_failures"]))
  3703. lines.append(("Timeouts:", self.log["timeouts"]))
  3704. lines.append(("Refused connections:", self.log["refused_connections"]))
  3705. lines.append(("Reset connections:", self.log["reset_connections"]))
  3706. lines.append(("Cache hits:", self.log["cache_hits"]))
  3707. # Print
  3708. for key, value in lines:
  3709. print(key.ljust(24) + str(value).rjust(8))
  3710. def do_sync(self, line):
  3711. """Synchronize all bookmarks lists.
  3712. - New elements in pages in subscribed lists will be added to tour
  3713. - Elements in list to_fetch will be retrieved and added to tour
  3714. - Normal lists will be synchronized and updated
  3715. - Frozen lists will be fetched only if not present.
  3716. Argument : duration of cache validity (in seconds)."""
  3717. if self.offline_only:
  3718. print("Sync can only be achieved online. Change status with `online`.")
  3719. return
  3720. args = line.split()
  3721. if len(args) > 0:
  3722. if not args[0].isdigit():
  3723. print("sync argument should be the cache validity expressed in seconds")
  3724. return
  3725. else:
  3726. validity = int(args[0])
  3727. else:
  3728. validity = 0
  3729. self.call_sync(refresh_time=validity)
  3730. def call_sync(self,refresh_time=0,depth=1):
  3731. # fetch_gitem is the core of the sync algorithm.
  3732. # It takes as input :
  3733. # - a GeminiItem to be fetched
  3734. # - depth : the degree of recursion to build the cache (0 means no recursion)
  3735. # - validity : the age, in seconds, existing caches need to have before
  3736. # being refreshed (0 = never refreshed if it already exists)
  3737. # - savetotour : if True, newly cached items are added to tour
  3738. def add_to_tour(gitem):
  3739. if gitem.is_cache_valid():
  3740. toprint = " -> adding to tour: %s" %gitem.url
  3741. width = term_width() - 1
  3742. toprint = toprint[:width]
  3743. toprint += " "*(width-len(toprint))
  3744. print(toprint)
  3745. self.list_add_line("tour",gi=gitem,verbose=False)
  3746. return True
  3747. else:
  3748. return False
  3749. def fetch_gitem(gitem,depth=0,validity=0,savetotour=False,count=[0,0],strin=""):
  3750. #savetotour = True will save to tour newly cached content
  3751. # else, do not save to tour
  3752. #regardless of valitidy
  3753. if not gitem.is_cache_valid(validity=validity):
  3754. if strin != "":
  3755. endline = '\r'
  3756. else:
  3757. endline = None
  3758. #Did we already had a cache (even an old one) ?
  3759. isnew = not gitem.is_cache_valid()
  3760. toprint = "%s [%s/%s] Fetch "%(strin,count[0],count[1]) + gitem.url
  3761. width = term_width() - 1
  3762. toprint = toprint[:width]
  3763. toprint += " "*(width-len(toprint))
  3764. print(toprint,end=endline)
  3765. #If not saving to tour, then we should limit download size
  3766. limit = not savetotour
  3767. self._go_to_gi(gitem,update_hist=False,limit_size=limit)
  3768. if savetotour and isnew and gitem.is_cache_valid():
  3769. #we add to the next tour only if we managed to cache
  3770. #the ressource
  3771. add_to_tour(gitem)
  3772. #Now, recursive call, even if we didn’t refresh the cache
  3773. if depth > 0:
  3774. #we should only savetotour at the first level of recursion
  3775. # The code for this was removed so, currently, we savetotour
  3776. # at every level of recursion.
  3777. links = gitem.get_links()
  3778. subcount = [0,len(links)]
  3779. d = depth - 1
  3780. for k in links:
  3781. #recursive call (validity is always 0 in recursion)
  3782. substri = strin + " -->"
  3783. subcount[0] += 1
  3784. fetch_gitem(k,depth=d,validity=0,savetotour=savetotour,\
  3785. count=subcount,strin=substri)
  3786. def fetch_list(list,validity=0,depth=1,tourandremove=False,tourchildren=False):
  3787. links = self.list_get_links(list)
  3788. end = len(links)
  3789. counter = 0
  3790. print(" * * * %s to fetch in %s * * *" %(end,list))
  3791. for l in links:
  3792. counter += 1
  3793. fetch_gitem(l,depth=depth,validity=validity,savetotour=tourchildren,count=[counter,end])
  3794. if tourandremove:
  3795. if add_to_tour(l):
  3796. self.list_rm_url(l.url_mode(),list)
  3797. self.sync_only = True
  3798. lists = self.list_lists()
  3799. # We will fetch all the lists except "archives" and "history"
  3800. # We keep tour for the last round
  3801. subscriptions = []
  3802. normal_lists = []
  3803. fridge = []
  3804. for l in lists:
  3805. if not self.list_is_system(l):
  3806. if self.list_is_frozen(l):
  3807. fridge.append(l)
  3808. elif self.list_is_subscribed(l):
  3809. subscriptions.append(l)
  3810. else:
  3811. normal_lists.append(l)
  3812. # We start with the "subscribed" as we need to find new items
  3813. starttime = int(time.time())
  3814. for l in subscriptions:
  3815. fetch_list(l,validity=refresh_time,depth=depth,tourchildren=True)
  3816. #Then the fetch list (item are removed from the list after fetch)
  3817. # We fetch regarless of the refresh_time
  3818. if "to_fetch" in lists:
  3819. nowtime = int(time.time())
  3820. short_valid = nowtime - starttime
  3821. fetch_list("to_fetch",validity=short_valid,depth=depth,tourandremove=True)
  3822. #then we fetch all the rest (including bookmarks and tour)
  3823. for l in normal_lists:
  3824. fetch_list(l,validity=refresh_time,depth=depth)
  3825. for l in fridge:
  3826. fetch_list(l,validity=0,depth=depth)
  3827. #tour should be the last one as item my be added to it by others
  3828. fetch_list("tour",validity=refresh_time,depth=depth)
  3829. print("End of sync")
  3830. self.sync_only = False
  3831. ### The end!
  3832. def do_quit(self, *args):
  3833. """Exit Offpunk."""
  3834. def unlink(filename):
  3835. if filename and os.path.exists(filename):
  3836. os.unlink(filename)
  3837. # Close TOFU DB
  3838. self.db_conn.commit()
  3839. self.db_conn.close()
  3840. # Clean up after ourself
  3841. for cert in self.transient_certs_created:
  3842. for ext in (".crt", ".key"):
  3843. certfile = os.path.join(_CONFIG_DIR, "transient_certs", cert+ext)
  3844. if os.path.exists(certfile):
  3845. os.remove(certfile)
  3846. print("You can close your screen!")
  3847. sys.exit()
  3848. do_exit = do_quit
  3849. # Main function
  3850. def main():
  3851. # Parse args
  3852. parser = argparse.ArgumentParser(description='A command line gemini client.')
  3853. parser.add_argument('--bookmarks', action='store_true',
  3854. help='start with your list of bookmarks')
  3855. parser.add_argument('--tls-cert', metavar='FILE', help='TLS client certificate file')
  3856. parser.add_argument('--tls-key', metavar='FILE', help='TLS client certificate private key file')
  3857. parser.add_argument('--restricted', action="store_true", help='Disallow shell, add, and save commands')
  3858. parser.add_argument('--sync', action='store_true',
  3859. help='run non-interactively to build cache by exploring bookmarks')
  3860. parser.add_argument('--assume-yes', action='store_true',
  3861. help='assume-yes when asked questions about certificates/redirections during sync')
  3862. parser.add_argument('--disable-http',action='store_true',
  3863. help='do not try to get http(s) links (but already cached will be displayed)')
  3864. parser.add_argument('--fetch-later', action='store_true',
  3865. help='run non-interactively with an URL as argument to fetch it later')
  3866. parser.add_argument('--depth',
  3867. help='depth of the cache to build. Default is 1. More is crazy. Use at your own risks!')
  3868. parser.add_argument('--cache-validity',
  3869. help='duration for which a cache is valid before sync (seconds)')
  3870. parser.add_argument('--version', action='store_true',
  3871. help='display version information and quit')
  3872. parser.add_argument('--features', action='store_true',
  3873. help='display available features and dependancies then quit')
  3874. parser.add_argument('url', metavar='URL', nargs='*',
  3875. help='start with this URL')
  3876. args = parser.parse_args()
  3877. # Handle --version
  3878. if args.version:
  3879. print("Offpunk " + _VERSION)
  3880. sys.exit()
  3881. elif args.features:
  3882. GeminiClient.do_version(None,None)
  3883. sys.exit()
  3884. else:
  3885. for f in [_CONFIG_DIR, _CACHE_PATH, _DATA_DIR]:
  3886. if not os.path.exists(f):
  3887. print("Creating config directory {}".format(f))
  3888. os.makedirs(f)
  3889. # Instantiate client
  3890. gc = GeminiClient(restricted=args.restricted,synconly=args.sync)
  3891. torun_queue = []
  3892. # Act on args
  3893. if args.tls_cert:
  3894. # If tls_key is None, python will attempt to load the key from tls_cert.
  3895. gc._activate_client_cert(args.tls_cert, args.tls_key)
  3896. if args.bookmarks:
  3897. torun_queue.append("bookmarks")
  3898. elif args.url:
  3899. if len(args.url) == 1:
  3900. torun_queue.append("go %s" % args.url[0])
  3901. else:
  3902. for url in args.url:
  3903. torun_queue.append("tour %s" % url)
  3904. torun_queue.append("tour")
  3905. if args.disable_http:
  3906. gc.support_http = False
  3907. # Endless interpret loop (except while --sync or --fetch-later)
  3908. if args.fetch_later:
  3909. if args.url:
  3910. gc.sync_only = True
  3911. for u in args.url:
  3912. gi = GeminiItem(u)
  3913. if gi and gi.is_cache_valid():
  3914. gc.list_add_line("tour",gi)
  3915. else:
  3916. gc.list_add_line("to_fetch",gi)
  3917. else:
  3918. print("--fetch-later requires an URL (or a list of URLS) as argument")
  3919. elif args.sync:
  3920. if args.assume_yes:
  3921. gc.automatic_choice = "y"
  3922. if args.cache_validity:
  3923. refresh_time = int(args.cache_validity)
  3924. else:
  3925. # if no refresh time, a default of 0 is used (which means "infinite")
  3926. refresh_time = 0
  3927. if args.depth:
  3928. depth = int(args.depth)
  3929. else:
  3930. depth = 1
  3931. gc.call_sync(refresh_time=refresh_time,depth=depth)
  3932. gc.onecmd("blackbox")
  3933. else:
  3934. # We are in the normal mode. First process config file
  3935. rcfile = os.path.join(_CONFIG_DIR, "offpunkrc")
  3936. if os.path.exists(rcfile):
  3937. print("Using config %s" % rcfile)
  3938. with open(rcfile, "r") as fp:
  3939. for line in fp:
  3940. line = line.strip()
  3941. if ((args.bookmarks or args.url) and
  3942. any((line.startswith(x) for x in ("go", "g", "tour", "t")))
  3943. ):
  3944. if args.bookmarks:
  3945. print("Skipping rc command \"%s\" due to --bookmarks option." % line)
  3946. else:
  3947. print("Skipping rc command \"%s\" due to provided URLs." % line)
  3948. continue
  3949. torun_queue.append(line)
  3950. print("Welcome to Offpunk!")
  3951. if args.restricted:
  3952. print("Restricted mode engaged!")
  3953. print("Type `help` to get the list of available command.")
  3954. for line in torun_queue:
  3955. gc.onecmd(line)
  3956. while True:
  3957. try:
  3958. gc.cmdloop()
  3959. except KeyboardInterrupt:
  3960. print("")
  3961. if __name__ == '__main__':
  3962. main()