commit 1d126b158f872e4cad8db4a9bd5ac8a9d9cb6355 from: Omar Polo date: Wed Oct 13 13:38:30 2021 UTC initial commit commit - /dev/null commit + 1d126b158f872e4cad8db4a9bd5ac8a9d9cb6355 blob - /dev/null blob + 619eb117f132cfa1718799bbee7f2413bc095dc9 (mode 644) --- /dev/null +++ .gitignore @@ -0,0 +1,4 @@ +.cpcache +.nrepl-port +target + blob - /dev/null blob + 3267c8bbfaa4c9343ea1a2383dfd3b8c3bbc776b (mode 644) --- /dev/null +++ README.md @@ -0,0 +1,52 @@ +# Gemini library for clojure + +`gemini.core` is a clojure library to make Gemini requests. + + +## Usage + +```clojure +user=> (require '[gemini.core :as gemini]) +``` + +#### fetch + +`fetch` makes a Gemini request and returns a map with `:request`, +`:meta`, `:code` and `:body` as keys, or `:error` if an error occur. + +The request needs to be closed afterwards using `close`, or calling +the `.close` method on the `:request` object. + +```clojure +user=> (gemini/fetch "gemini://gemini.circumlunar.space/") +{:request + #object[com.omarpolo.gemini.Request 0x3b270767 "com.omarpolo.gemini.Request@3b270767"], + :meta "gemini://gemini.circumlunar.space/", + :code 31, + :body + #object[java.io.BufferedReader 0x49358b66 "java.io.BufferedReader@49358b66"]} +``` + +#### body-as-string! + +Read all the response into a string and returns it. It also closes +the request automatically. + +```clojure +user=> (-> (gemini/fetch "gemini://gemini.circumlunar.space/") + gemini/body-as-string!) +"# Project Gemini\n\n## Overview\n\nGemini is a new internet protocol which..." +``` + +#### close + +Closes a request. + +#### with-request + +Like `with-open`, but specifically for the requests: + +```clojure +user=> (with-request [req (fetch "gemini://gemini.circumlunar.space/")] + ,,,) +``` blob - /dev/null blob + 0fa37ac8be5938dd0fbe741f493f3776fbb3e8a8 (mode 644) --- /dev/null +++ build.clj @@ -0,0 +1,34 @@ +(ns build + (:require [clojure.tools.build.api :as b])) + +(def lib 'com.omarpolo/gemini) +(def version (format "0.1.0")) +(def class-dir "target/classes") +(def basis (b/create-basis {:project "deps.edn"})) +(def jar-file (format "target/%s-%s.jar" (name lib) version)) + +(defn clean [_] + (b/delete {:path "target"})) + +(defn compile [_] + (b/javac {:src-dirs ["src"] + :class-dir class-dir + :basis basis + :javac-opts ["-source" "11" "-target" "11"]})) + +(defn jar [_] + (compile nil) + (let [repo "github.com/omar-polo/gemini.git"] + (b/write-pom {:class-dir class-dir + :lib lib + :version version + :basis basis + :src-dirs ["src"] + :scm {:connection (str "scm:git:git://" repo) + :developerConnection (str "scm:git:ssh://git@" repo) + :tag version + :url (str "https://" repo)}})) + (b/copy-dir {:src-dirs ["src"] + :target-dir class-dir}) + (b/jar {:class-dir class-dir + :jar-file jar-file})) blob - /dev/null blob + cd7784ac5aa074018fba1b8f2d8b0715713e41c0 (mode 644) --- /dev/null +++ deps.edn @@ -0,0 +1,6 @@ +{:paths ["src" "target/classes"] + + :aliases + {:build {:deps {io.github.clojure/tools.build {:tag "v0.6.2" + :sha "7ef409b370312c8819c587aa648177331602fa44"}} + :ns-default build}}} blob - /dev/null blob + e06d244a4b22ece7e1cd9cbb01acc35872a7716f (mode 644) --- /dev/null +++ src/com/omarpolo/gemini/Request.java @@ -0,0 +1,146 @@ +package com.omarpolo.gemini; + +import javax.net.ssl.*; +import java.io.*; +import java.net.*; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.NoSuchElementException; +import java.util.Scanner; + +public class Request implements AutoCloseable { + + private final BufferedReader in; + private final PrintWriter out; + private final SSLSocket sock; + + private final int code; + private final String meta; + + public static class DummyManager extends X509ExtendedTrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) { + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) { + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return null; + } + } + + public static class MalformedResponse extends Exception {} + + public Request(String uri) throws IOException, MalformedResponse, URISyntaxException { + this(new URI(uri)); + } + + public Request(URL url) throws IOException, MalformedResponse { + this(url.getHost(), url.getPort(), url.toString()); + } + + public Request(URI uri) throws IOException, MalformedResponse { + this(uri.getHost(), uri.getPort(), uri.toString()); + } + + public Request(String host, int port, URL req) throws IOException, MalformedResponse { + this(host, port, req.toString()); + } + + public Request(String host, int port, URI req) throws IOException, MalformedResponse { + this(host, port, req.toString()); + } + + public Request(String host, int port, String req) throws IOException, MalformedResponse { + if (port == -1) { + port = 1965; + } + + sock = connect(host, port); + + var outStream = sock.getOutputStream(); + out = new PrintWriter( + new BufferedWriter(new OutputStreamWriter(outStream))); + + out.print(req); + out.print("\r\n"); + out.flush(); + + var inStream = sock.getInputStream(); + in = new BufferedReader(new InputStreamReader(inStream)); + + var reply = in.readLine(); + + if (reply.length() > 1027) { + throw new MalformedResponse(); + } + + var s = new Scanner(new StringReader(reply)); + try { + code = s.nextInt(); + s.skip(" "); + meta = s.nextLine(); + } catch (NoSuchElementException e) { + throw new MalformedResponse(); + } + } + + public SSLSocket connect(String host, int port) throws IOException { + try { + var params = new SSLParameters(); + params.setServerNames(Collections.singletonList(new SNIHostName(host))); + + var ctx = SSLContext.getInstance("TLS"); + ctx.init(null, new DummyManager[]{new DummyManager()}, new SecureRandom()); + var factory = (SSLSocketFactory) ctx.getSocketFactory(); + + var socket = (SSLSocket) factory.createSocket(host, port); + socket.setSSLParameters(params); + socket.startHandshake(); + return socket; + } catch (NoSuchAlgorithmException | KeyManagementException e) { + throw new RuntimeException("Unexpected failure", e); + } + } + + public int getCode() { + return code; + } + + public String getMeta() { + return meta; + } + + public BufferedReader body() { + return in; + } + + public void close() throws IOException { + in.close(); + out.close(); + sock.close(); + } +} blob - /dev/null blob + 665dc45d1769bd4eca626027f74307b72cc68486 (mode 644) --- /dev/null +++ src/gemini/core.clj @@ -0,0 +1,83 @@ +(ns gemini.core + (:require + [clojure.java.io :as io]) + (:import (com.omarpolo.gemini Request))) + +(defmacro ^:private request->map [& args] + `(try + (let [req# (Request. ~@args)] + {:request req# + :code (.getCode req#) + :meta (.getMeta req#) + :body (.body req#)}) + (catch Throwable e# + {:error e#}))) + +(defn fetch + "Make a gemini request. `uri` may be a URI, URL or string, and + represent the request to perform. `host` and `port` are extracted + from the given `uri` in not given, and port defaults to 1965. The + returned request needs to be closed when done, or use the + `with-request` macro." + ([uri] + (request->map uri)) + ([host uri] + (fetch host 1965 uri)) + ([host port uri] + (request->map host port uri))) + +(defn body-as-string! [{r :request}] + "Read all the response into a strings and returns it. The request + will be closed." + (let [sw (java.io.StringWriter.)] + (with-open [r r] + (io/copy (.body r) sw) + (.toString sw)))) + +(defn close [{r :request}] + (.close r)) + +(defmacro with-request [[var req] & body] + "Make a request, eval `body` when it succeed and automatically close + the request, or throw an exception if the request fails." + `(let [,var ,req] + (when-let [e# (:error ,var)] + (throw e#)) + (with-open [req# (:request ,var)] + ~@body))) + +(comment + (with-request [req (fetch ,,,)] + ,,,) + ) + + +;; helpers + +(def code-description + "Human description for every response code." + {10 "input" + 11 "sensitive input" + 20 "success" + 30 "temporary redirect" + 31 "permanent redirect" + 40 "temporary failure" + 41 "server unavailable" + 42 "CGI error" + 43 "proxy error" + 44 "slow down" + 50 "permanent failure" + 51 "not found" + 52 "gone" + 53 "proxy request refused" + 59 "bad request" + 60 "client certificate required" + 61 "certificate not authorized" + 62 "certificate not valid"}) + +(defn is-input? [{c :code}] (= 1 (/ c 10))) +(defn is-success? [{c :code}] (= 2 (/ c 10))) +(defn is-redirect? [{c :code}] (= 3 (/ c 10))) +(defn is-temporary-failure? [{c :code}] (= 4 (/ c 10))) +(defn is-permanent-failure? [{c :code}] (= 5 (/ c 10))) +(defn is-client-cert-required? [{c :code}] (= 6 (/ c 10)))