Commit Diff


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)))