/ aether
aether
  1  #!/usr/bin/env python
  2  #   This is a component of aethertool, a command-line interface to aether
  3  #   Copyright 2005-2021 Jeff Epler <jepler@unpythonic.net>
  4  #
  5  #   This program is free software: you can redistribute it and/or modify it
  6  #   under the terms of the GNU General Public License version 3 as published by
  7  #   the Free Software Foundation.
  8  #
  9  #   This program is distributed in the hope that it will be useful, but WITHOUT
 10  #   ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 11  #   FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
 12  #   more details.
 13  #
 14  #   You should have received a copy of the GNU General Public License along
 15  #   with this program.  If not, see <http://www.gnu.org/licenses/>.
 16  
 17  import getpass, urllib.parse, urllib.request, urllib.error, tempfile, os, time, webbrowser, sys
 18  import getopt, cgi, re, disorient
 19  import shutil, atexit
 20  import urllib, html
 21  from sys import argv
 22  from html.entities import name2codepoint
 23  from PIL import Image
 24  import mechanize as ClientForm
 25  
 26  
 27  def execfile(fn):
 28      with open(fn) as f:
 29          content = f.read()
 30      print("exec", content)
 31      exec(content, globals(), globals())
 32  
 33  
 34  EDITOR = os.environ.get("EDITOR", "vim")
 35  
 36  thumb_geometry = (300, 300)
 37  medium_geometry = (900, 900)
 38  
 39  config = {}
 40  default_config = "default"
 41  browser_wait = 5
 42  
 43  
 44  def add_config(name, root, password=None, thumb_geometry=None, alternates=[]):
 45      config[name] = (root, password, thumb_geometry, alternates)
 46  
 47  
 48  def set_default(name):
 49      global default_config
 50      default_config = name
 51  
 52  
 53  def decode_geometry(s):
 54      if isinstance(s, str):
 55          s = tuple(map(int, s.split("x")))
 56      return s
 57  
 58  
 59  def load_config(name):
 60      c = config[name]
 61      global AETHER_TOP, PASS, thumb_geometry
 62      AETHER_TOP = c[0]
 63      PASS = c[1] or getpass.getpass()
 64      thumb_geometry = c[2] or thumb_geometry
 65  
 66  
 67  rcfile = os.path.join(os.environ.get("HOME", ""), ".aetherrc")
 68  if os.path.exists(rcfile):
 69      execfile(rcfile)
 70  else:
 71      print(
 72          "The configuration file %r does not exist.\n"
 73          "aethertool cannot run without it." % rcfile
 74      )
 75      raise SystemExit
 76  
 77  config_name = os.path.splitext(os.path.split(sys.argv[0])[1])[0]
 78  if len(config) == 1:
 79      default_config = config.keys()[0]
 80  if config_name not in config:
 81      config_name = None
 82  
 83  
 84  def quote_paranoid(text):
 85      """Convert utf-8 string to sequence of lower case English characters."""
 86  
 87      text = text.encode("utf-8")
 88  
 89      result = ""
 90      for char in text:
 91          result += chr(ord("a") + char // 16) + chr(ord("a") + char % 16)
 92  
 93      return result
 94  
 95  
 96  import mimetypes
 97  
 98  
 99  def post_multipart(url, fields, files):
100      """
101      Post fields and files to an http host as multipart/form-data.
102      fields is a sequence of (name, value) elements for regular form fields.
103      files is a sequence of (name, filename, value) elements for data to be uploaded as files
104      Return the server's response page.
105      """
106      content_type, data = encode_multipart_formdata(fields, files)
107      headers = {"Content-Type": content_type}
108      request = urllib.request.Request(url, data=data, headers=headers)
109      f = urllib.request.urlopen(request)
110      return f.read()
111  
112  
113  def encode_multipart_formdata(fields, files):
114      """
115      fields is a sequence of (name, value) elements for regular form fields.
116      files is a sequence of (name, filename, value) elements for data to be uploaded as files
117      Return (content_type, body) ready for httplib.HTTP instance
118      """
119      BOUNDARY = b"----------ThIs_Is_tHe_bouNdaRY_$"
120      CRLF = b"\r\n"
121      L = []
122      for (key, value) in fields:
123          L.append(b"--" + BOUNDARY)
124          L.append(b'Content-Disposition: form-data; name="%s"' % key.encode('utf-8'))
125          L.append(b"")
126          L.append(value.encode('utf-8'))
127      for (key, filename, value) in files:
128          L.append(b"--" + BOUNDARY)
129          L.append(
130              b'Content-Disposition: form-data; name="%s"; filename="%s"' % (key.encode('utf-8'), filename.encode('utf-8'))
131          )
132          L.append(b"Content-Type: %s" % get_content_type(filename).encode('utf-8'))
133          L.append(b"")
134          L.append(value)
135      L.append(b"--" + BOUNDARY + b"--")
136      L.append(b"")
137      body = CRLF.join(L)
138      content_type = (b"multipart/form-data; boundary=%s" % BOUNDARY)
139      return content_type, body
140  
141  
142  def get_content_type(filename):
143      return mimetypes.guess_type(filename)[0] or "application/octet-stream"
144  
145  
146  def posturl(url, fields, files):
147      urlparts = urllib.parse.urlsplit(url)
148      return post_multipart(url, fields, files)
149  
150  
151  def webbrowser_open(u):
152      os.spawnvp(os.P_NOWAIT, "firefox", ["firefox", u])
153      time.sleep(browser_wait)
154  
155  
156  def get_edit_url(page):
157      pw = urllib.parse.quote(PASS)
158      return AETHER_TOP + "?action=edit&password=%s&name=%s" % (pw, page)
159  
160  
161  def get_edit_form(page):
162      url = get_edit_url(page)
163  
164      forms = ClientForm.ParseResponse(urllib.request.urlopen(url))
165      for f in forms:
166          try:
167              action = f.get_value("action")
168          except ClientForm.ControlNotFoundError:
169              continue
170          if action == "edit":
171              return f
172      print("Required form not found.  AETHER_TOP or password may be incorrect.")
173  
174  
175  entity_re = re.compile("&(#[0-9]+|[a-zA-Z0-9]+);?")
176  
177  
178  def unescape_replace(m):
179      g = m.group(1)
180      if g.startswith("#"):
181          return chr(int(g[1:]))
182      codepoint = name2codepoint.get(g)
183      if codepoint is None:
184          return g
185      return chr(codepoint)
186  
187  
188  def unescape(s):
189      return entity_re.sub(unescape_replace, s)
190  
191  
192  current_text_pat = re.compile(
193      "<textarea[^>]*>" "([^<]*)" "</textarea>", re.I | re.M | re.DOTALL
194  )
195  
196  
197  def get_current_text(page):
198      url = get_edit_url(page)
199      page = urllib.request.urlopen(url).read().decode("utf-8", errors="replace")
200      m = current_text_pat.search(page).group(1)
201      m = unescape(m)
202      return m
203  
204  
205  def save_text(page, newtext):
206      f = get_edit_form(page)
207      f["text"] = newtext
208      return f.click("save")
209  
210  
211  def preview_text(page, newtext):
212      return """<HTML>
213      <META http-equiv="Content-Type" content="text/html; charset=UTF-8">
214      <BODY onload="document.forms[0].submit()">
215      <DIV style="display: none">
216      <FORM method=post accept-charset=\"UTF-8\" action=\"%s#buttonsandpreview\">
217          <input type=hidden name=action value=edit>
218          <input type=hidden name=password value=\"%s\">
219          <input name=newname value=\"%s\">
220          <input name=name value=\"%s\">
221          <input name=hasnewname value=yes>
222          <textarea name=text>%s</textarea>
223          <input type=submit name=preview>
224      </form>
225      </DIV>
226      Submitting updated text...
227      </body>""" % (
228          html.escape(AETHER_TOP, True),
229          html.escape(PASS, True),
230          html.escape(page, True),
231          html.escape(page, True),
232          html.escape(newtext, True),
233      )
234  
235  
236  def upload_file(page, local, remote, quiet=0):
237      if page.startswith("http"):
238          raise SystemExit("URL vs path confusion, page=%s" % page)
239      qPASS = quote_paranoid(PASS)
240      qpage = quote_paranoid(page)
241      url = AETHER_TOP
242      data = [
243          ("action", "attach"),
244          ("password", qPASS),
245          ("name", qpage),
246          ("nolisting", "1"),
247      ]
248      fdata = [("file", remote, open(local, "rb").read())]
249      posturl(url, data, fdata)
250      if not quiet:
251          print("Uploaded file is:")
252          print("   [page %s] [file %s]" % (page, remote))
253          if AETHER_TOP.endswith("/"):
254              print("   " + AETHER_TOP + "files/%s/%s" % (page, remote))
255          else:
256              print("   " + AETHER_TOP + "-files/%s/%s" % (page, remote))
257  
258  
259  def put_page(page, filename):
260      if hasattr(filename, "read"):
261          contents = filename.read()
262      elif filename is None:
263          contents = ""
264      else:
265          contents = open(filename).read()
266      print("size of new contents", len(contents))
267      u = save_text(page, contents)
268      try:
269          urllib.request.urlopen(u)
270      except urllib.error.HTTPError as detail:
271          if detail.code != 404 or contents:
272              raise
273  
274  
275  def edit_page(page):
276      open(os.path.expanduser("~/.aetherlast"), "w").write(
277          "%s\n%s\n" % (config_name, page)
278      )
279  
280      t = get_current_text(page)
281  
282      name = os.path.join(tempdir, (page.replace("/", "_") or "index") + ".ae")
283      fd = open(name, "w")
284      fd.write(t)
285      fd.close()
286  
287      hname = os.path.join(tempdir, "submit.html")
288  
289      pid = os.spawnvp(os.P_NOWAIT, EDITOR, [EDITOR, name])
290  
291      ost = os.stat(name)
292      while 1:
293          nst = os.stat(name)
294          if ost.st_mtime != nst.st_mtime:
295              ost = nst
296              new_text = open(name).read()
297              u = preview_text(page, new_text)
298              hfd = open(hname, "w")
299              hfd.write(u)
300              hfd.close()
301              webbrowser_open(hname)
302          sts = os.waitpid(0, os.P_NOWAIT)
303          if sts[0] == pid:
304              break
305          time.sleep(1)
306      os.unlink(name)
307  
308  
309  def isimage(f):
310      ext = os.path.splitext(f)[1].lower()
311      if ext in [".jpg", ".jpeg", ".png", ".gif"]:
312          return True
313      return False
314  
315  
316  def get_orient(f):
317      return disorient.exif_orientation(f) or 1
318  
319  
320  def optimize(f):
321      ext = os.path.splitext(f)[1].lower()
322      t = os.path.join(tempdir, "optimized" + ext)
323      if ext in [".jpg", ".jpeg"]:
324          command = [
325              "jpegtran",
326              "-optimize",
327              "-progressive",
328              "-copy",
329              "all",
330              "-outfile",
331              t,
332          ]
333          o = get_orient(f)
334          if o == 8:
335              command.extend(["-rotate", "270"])
336          elif o == 6:
337              command.extend(["-rotate", "90"])
338          command.append(f)
339          os.spawnvp(os.P_WAIT, command[0], command)
340          if o in (6, 8):
341              disorient.clear_exif_orientation(t)
342          return t
343      if ext in [".png"]:
344          os.spawnvp(os.P_WAIT, "pngcrush", ["pngcrush", "-q", "-reduce", f, t])
345          return t
346      # XXX convert gif to png?
347      return f
348  
349  
350  def resize(geometry, tag, src):
351      print("resize", geometry, tag, src)
352      geometry = decode_geometry(geometry)
353      ext = os.path.splitext(src)[1]
354      local = os.path.join(tempdir, "thumb" + ext)
355      localjpg = os.path.join(tempdir, "thumb" + ".jpg")
356  
357      i = Image.open(src)
358      if i.size[0] < geometry[0] and i.size[1] < geometry[1]:
359          print("size fail", i.size, geometry)
360          return None
361  
362      if i.mode == "P":
363          i = i.convert("RGB")
364      i.thumbnail(geometry, Image.ANTIALIAS)
365  
366      i.save(local)
367      local = optimize(local)
368      if localjpg != local and allow_jpg:
369          i.save(localjpg)
370          localjpg = optimize(localjpg)
371  
372          print(os.stat(localjpg).st_size, os.stat(local).st_size)
373          if os.stat(localjpg).st_size < 0.9 * os.stat(local).st_size:
374              local = localjpg
375      return local
376  
377  
378  tempdir = tempfile.mkdtemp()
379  atexit.register(shutil.rmtree, tempdir)
380  
381  
382  def usage(exitcode=1):
383      print(
384          """\
385  Usage:
386      %(name)s [-e] page
387          Edit 'page'.  Use '/' (forward slash) to edit the front page.  To
388          preview the changes in your browser, save the file.  Use the browser's
389          save button in the browser to save the changes.
390      %(name)s -p page < contents
391          Put new contents to 'page' from standard input.
392      %(name)s -n [-k suffix] blog
393          Create a new blog entry on 'blog', optonally with 'suffix' added
394          to the name of the created page.
395      %(name)s -u [-t] [-g WxH] page file[=localfile] file...
396      %(name)s -u [-t] [-g WxH] -l file[=localfile] file...
397          Upload files to 'page' or the last page edited (-l).  Create thumbnails
398          unless -t is specified.  Use -g to specify the maximum thumbnail size,
399          currently %(thumbsize)s.
400      %(name)s -d page
401          Delete 'page'
402      %(name)s -c configname [other usage from above]
403          Use the configuration 'configname' instead of the default
404          For help on the syntax of configuration files, use "-c help".
405  """
406          % {"name": os.path.basename(sys.argv[0]), "thumbsize": "%dx%d" % thumb_geometry}
407      )
408      raise SystemExit(exitcode)
409  
410  
411  def help_config():
412      print(
413          """\
414  The configuration file ~/.aetherrc is a python script.  A typical one might 
415  look like this (without the indentation):
416      add_config('configname', 'http://www.example.com/index.cgi', 'password')
417      set_default('configname')
418  The configuration is guessed from the script's name, otherwise the value
419  given by set_default is used.  If only one call to add_configuration is
420  present, then that configuration is the default.
421  
422  If 'password' is not specified, then it is prompted when the script is run.
423  """
424      )
425      print("The following configurations are defined:")
426      for k in config:
427          print("\t" + k)
428      print()
429      print(
430          "When invoked as '%s', the default configuration is '%s'"
431          % (os.path.basename(sys.argv[0]), default_config)
432      )
433      raise SystemExit(0)
434  
435  
436  def parse_url(u):
437      best = ""
438      for n, c in config.items():
439          root = c[0]
440          if u.startswith(root) and len(root) > len(best):
441              best = root
442              bestname = n
443              bestpage = u[len(root) :]
444          for root in c[3]:
445              if u.startswith(root) and len(root) > len(best):
446                  best = root
447                  bestname = n
448                  bestpage = u[len(root) :]
449      if best:
450          return bestname, bestpage
451  
452  
453  try:
454      opts, args = getopt.getopt(argv[1:], "+c: denpul U tj m:k: g: c: h?")
455  except getopt.GetoptError as detail:
456      print(os.path.basename(sys.argv[0]), detail, file=sys.stderr)
457      usage()
458  
459  MODE_EDIT, MODE_NEW_ENTRY, MODE_PUT, MODE_UPLOAD, MODE_DELETE, MODE_URL = range(6)
460  
461  mode = MODE_EDIT
462  suffix = ""
463  do_thumbnail = True
464  allow_jpg = True
465  
466  for k, v in opts:
467      if k == "-c":
468          config_name = v
469  
470      if k == "-d":
471          mode = MODE_DELETE
472      if k == "-e":
473          mode = MODE_EDIT
474      if k == "-n":
475          mode = MODE_NEW_ENTRY
476      if k == "-p":
477          mode = MODE_PUT
478      if k == "-u":
479          mode = MODE_UPLOAD
480  
481      if k == "-U":
482          mode = MODE_URL
483  
484      if k == "-t":
485          do_thumbnail = not do_thumbnail
486      if k == "-j":
487          allow_jpg = not allow_jpg
488      if k == "-k":
489          suffix = "-" + v.replace(" ", "-")
490      if k == "-g":
491          thumb_geometry = v
492      if k == "-m":
493          medium_geometry = v
494      if k == "-?" or k == "-h":
495          usage(0)
496  
497      if k == "-l":
498          config_name, page = open(os.path.expanduser("~/.aetherlast")).read().split()
499          args.insert(0, page)
500  
501  if config_name is None and args:
502      parsed = parse_url(args[0])
503      if parsed is not None:
504          config_name, args[0] = parsed
505  elif args:
506      parsed = parse_url(args[0])
507      if parsed is not None:
508          parsed_config_name, args[0] = parsed
509          if parsed_config_name != config_name:
510              raise SystemExit(
511                  "URL points to site %s, but you asked for %s"
512                  % (parsed_config_name, config_name)
513              )
514  if config_name is None:
515      config_name = default_config
516  
517  if config_name == "help":
518      help_config()
519  
520  load_config(config_name)
521  
522  if mode == MODE_URL:
523      print(get_edit_url(""))
524  elif mode == MODE_UPLOAD:
525      page = args[0]
526      for filename in args[1:]:
527          if "=" in filename:
528              remote, local = filename.split("=", 1)
529          else:
530              remote = os.path.basename(filename)
531              if not re.search("[a-z]", remote):
532                  remote = remote.lower()
533              local = filename
534          if isimage(remote):
535              local = optimize(local)
536          upload_file(page, local, remote)
537          if do_thumbnail and isimage(remote):
538              base, ext = os.path.splitext(remote)
539  
540              localmed = resize(medium_geometry, "medium", local)
541              if localmed:
542                  med = base + "-medium" + os.path.splitext(localmed)[1]
543                  upload_file(page, localmed, med)
544  
545              localthumb = resize(thumb_geometry, "small", local)
546              if localthumb:
547                  thumb = base + "-small" + os.path.splitext(localthumb)[1]
548                  upload_file(page, localthumb, thumb)
549  
550  elif mode == MODE_NEW_ENTRY:
551      if args:
552          page = args[0] + "/0" + str(int(time.time())) + suffix
553      else:
554          page = "0" + str(int(time.time())) + suffix
555      edit_page(page)
556  elif mode == MODE_EDIT:
557      if args:
558          page = args[0]
559      else:
560          page = "sandbox"
561      page = page.strip("/")
562      edit_page(page)
563  elif mode == MODE_PUT:
564      page = args[0].strip("/")
565      put_page(page, sys.stdin)
566  elif mode == MODE_DELETE:
567      page = args[0].strip("/")
568      put_page(page, None)
569  
570  # vim:sw=4:sts=4:et