diff --git a/README.md b/README.md index 54723946..389f3f46 100644 --- a/README.md +++ b/README.md @@ -1039,6 +1039,27 @@ url parameters: * `a` = filesize * uppercase = reverse-sort; `M` = oldest file first +# opds feeds + +browse and download files from your e-book reader + +enabled with the `opds` volflag or `--opds` global option + +add `?opds` to the end of the url you would like to browse, then input that in your opds client. +for example: `https://copyparty.example/books/?opds`. + +to log in with a password, enter it into either of the username or password fields in your client. + +- if you've enabled `--usernames`, then you need to enter both username and password . + +note: some clients (e.g. Moon+ Reader) will not send the password when downloading cover images, which will +cause your ip to be banned by copyparty. to work around this, you can grant the [`g` permission](#accounts-and-volumes) +to unauthenticated requests and enable [filekeys](#filekeys) to prevent guessing filenames. for example: +`-vbooks:books:r,ed:g:c,fk,opds` + +by default, not all file types will be listed in opds feeds. to change this, add the extension to +`--opds-allowed` (volflag: `opds_allowed`), or empty the list to list everything + ## recent uploads diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 1605d55b..35121e97 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1436,6 +1436,10 @@ def add_smb(ap): ap2.add_argument("--smbvv", action="store_true", help="verboser") ap2.add_argument("--smbvvv", action="store_true", help="verbosest") +def add_opds(ap): + ap2 = ap.add_argument_group("OPDS options") + ap2.add_argument("--opds", action="store_true", help="enable opds -- allows e-book readers to browse and download files (volflag=opds)") + ap2.add_argument("--opds-allowed", metavar="T,T", type=u, default="epub,cbz,pdf", help="file formats to list in OPDS feeds; leave empty to show everything (volflag=opds_allowed)") def add_handlers(ap): ap2 = ap.add_argument_group("handlers (see --help-handlers)") @@ -1864,6 +1868,7 @@ def run_argparse( add_webdav(ap) add_tftp(ap) add_smb(ap) + add_opds(ap) add_safety(ap) add_salt(ap, fk_salt, dk_salt, ah_salt) add_optouts(ap) diff --git a/copyparty/cfg.py b/copyparty/cfg.py index bfeda5bf..ed8bd468 100644 --- a/copyparty/cfg.py +++ b/copyparty/cfg.py @@ -52,6 +52,7 @@ def vf_bmap() -> dict[str, str]: "og", "og_no_head", "og_s_title", + "opds", "rand", "reflink", "rmagic", @@ -145,6 +146,7 @@ def vf_cmap() -> dict[str, str]: "mte", "mth", "mtp", + "opds_allowed", "xac", "xad", "xar", @@ -331,6 +333,10 @@ flagcats = { "og_no_head": "you want to add tags manually with og_tpl", "og_ua": "if defined: only send OG html if useragent matches this regex", }, + "opds": { + "opds": "enable OPDS", + "opds_allowed": "file formats to list in OPDS feeds; leave empty to show everything" + }, "textfiles": { "md_no_br": "newline only on double-newline or two tailing spaces", "md_hist": "where to put markdown backups; s=subfolder, v=volHist, n=nope", diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 6b48457f..6eb42cad 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -4027,6 +4027,10 @@ class HttpCli(object): raise Pebkac(403, t) return "" + def _can_opds(self, volflags: dict[str, Any]) -> str: + # TODO: Permissions + return "" + def tx_res(self, req_path: str) -> bool: status = 200 logmsg = "{:4} {} ".format("", self.req) @@ -4229,7 +4233,7 @@ class HttpCli(object): # # force download - if "dl" in self.ouparam: + if "dl" in self.ouparam or "opds" in self.uparam: cdis = gen_content_disposition(os.path.basename(req_path)) self.out_headers["Content-Disposition"] = cdis @@ -6234,7 +6238,7 @@ class HttpCli(object): add_og = "og" in vn.flags if add_og: - if "th" in self.uparam or "raw" in self.uparam: + if "th" in self.uparam or "raw" in self.uparam or "opds" in self.uparam: add_og = False elif vn.flags["og_ua"]: add_og = vn.flags["og_ua"].search(self.ua) @@ -6446,9 +6450,19 @@ class HttpCli(object): is_ls = True tpl = "browser" + is_opds = False if "b" in self.uparam: tpl = "browser2" is_js = False + elif "opds" in self.uparam: + # Display directory listing as OPDS v1.2 catalog feed + if not (self.args.opds or "opds" in self.vn.flags): + raise Pebkac(405, "OPDS is disabled in server config") + if not self.can_read: + raise Pebkac(401, "OPDS requires read permission") + is_opds = True + tpl = "opds" + is_js = False vf = vn.flags ls_ret = { @@ -6575,6 +6589,12 @@ class HttpCli(object): no_zip = bool(self._can_zip(vf)) + volflag_opds_allowed = vf.get("opds_allowed") + if volflag_opds_allowed is not None: + opds_no_filter = len(volflag_opds_allowed) == 0 + else: + opds_no_filter = len(self.args.opds_allowed) == 0 + dirs = [] files = [] ptn_hr = RE_HR @@ -6638,6 +6658,8 @@ class HttpCli(object): ext = ptn_hr.sub("@", fn.rsplit(".", 1)[1]) if len(ext) > 16: ext = ext[:16] + if is_opds and not opds_no_filter and ext not in self.args.opds_allowed: + continue else: ext = "%" @@ -6660,6 +6682,43 @@ class HttpCli(object): else: href = quotep(href) + mime = None + if is_opds: + href += "&" if "?" in href else "?" + href += "opds" + if not is_dir: + if "rmagic" in self.vn.flags: + mime = guess_mime(fn, fspath) + else: + mime = guess_mime(fn) + # Make sure we can actually generate JPEG thumbnails + if ( + self.args.th_no_jpg + or not self.thumbcli + or "dthumb" in dbv.flags + or "dithumb" in dbv.flags + ): + jpeg_thumb_href = None + jpeg_thumb_href_hires = None + else: + jpeg_thumb_href = href + "&th=jf" + jpeg_thumb_href_hires = jpeg_thumb_href + "3" + + iso8601 = "%04d-%02d-%02dT%02d:%02d:%02dZ" % ( + zd.year, + zd.month, + zd.day, + zd.hour, + zd.minute, + zd.second, + ) + + else: + mime = None + iso8601 = None + jpeg_thumb_href = None + jpeg_thumb_href_hires = None + item = { "lead": margin, "href": href, @@ -6667,7 +6726,11 @@ class HttpCli(object): "sz": sz, "ext": ext, "dt": dt, + "iso8601": iso8601, "ts": int(linf.st_mtime), + "mime": mime, + "jpeg_thumb_href": jpeg_thumb_href, + "jpeg_thumb_href_hires": jpeg_thumb_href_hires, } if is_dir: dirs.append(item) @@ -6856,6 +6919,9 @@ class HttpCli(object): "taglist": taglist, } j2a["files"] = [] + elif is_opds: + j2a["files"] = files + j2a["dirs"] = dirs else: j2a["files"] = dirs + files @@ -7006,5 +7072,9 @@ class HttpCli(object): self.html_head = zs.replace("\n\n", "\n") html = self.j2s(tpl, **j2a) - self.reply(html.encode("utf-8", "replace")) + if is_opds: + mime = "application/atom+xml;profile=opds-catalog" + else: + mime = None + self.reply(html.encode("utf-8", "replace"), mime=mime) return True diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index 8e4e4a4e..da41fbca 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -187,6 +187,7 @@ class HttpSrv(object): "svcs", ] self.j2 = {x: env.get_template(x + ".html") for x in jn} + self.j2["opds"] = env.get_template("opds.xml") self.prism = has_resource(self.E, "web/deps/prism.js.gz") if self.args.ipu: diff --git a/copyparty/web/opds.xml b/copyparty/web/opds.xml new file mode 100644 index 00000000..fb22371c --- /dev/null +++ b/copyparty/web/opds.xml @@ -0,0 +1,31 @@ + + + {%- for d in dirs %} + + {{ d.name }} + + {{ d.iso8601 }} + + {%- endfor %} + {%- for f in files %} + + {{ f.name }} + {{ f.iso8601 }} + + {%- if f.jpeg_thumb_href != None %} + + {%- endif %} + {%- if f.jpeg_thumb_href_hires != None %} + + {%- endif %} + + {%- endfor %} + \ No newline at end of file