/ 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