From 9bed6157351eea6fdbccff69aba5cc967a0b2a56 Mon Sep 17 00:00:00 2001 From: Feuerfuchs Date: Sat, 16 Nov 2019 00:55:25 +0100 Subject: Initial Gemini support --- README.md | 5 +- assets/main.js | 2 +- assets/style.css | 2 +- cmd/gopherproxy/main.go | 2 +- css/main.scss | 1 + gopherproxy.go | 261 ++++++++++++++++++++++++++++++++++++++++-------- js/main.ts | 12 ++- libgemini.go | 153 ++++++++++++++++++++++++++++ template.go | 28 +++--- 9 files changed, 402 insertions(+), 64 deletions(-) create mode 100644 libgemini.go diff --git a/README.md b/README.md index f4d5cc6..e4f5d97 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Gopher (RFC 1436) Web Proxy -gopherproxy is a Gopher (RFC 1436) Web Proxy that acts as a gateway into Gopherspace -by proxying standard Web HTTP requests to Gopher requests of the target server. +gopherproxy is a Gopher (RFC 1436) and Gemini (gopher://zaibatsu.circumlunar.space/1/~solderpunk/gemini/docs/) Web Proxy that acts as a gateway into Gopherspace/Geminispace by proxying standard Web HTTP requests to Gopher/Gemini requests of the target server. gopherproxy is a fork of [https://github.com/prologic/gopherproxy](https://github.com/prologic/gopherproxy). -Demo: https://gopher.vulpes.one/ +Demo: https://proxy.vulpes.one/ ## Requirements diff --git a/assets/main.js b/assets/main.js index e853804..eee70d7 100644 --- a/assets/main.js +++ b/assets/main.js @@ -1 +1 @@ -"use strict";var KeyValueStore=function(){function e(e){this.data=e;for(var t=0,n=Object.keys(e);t=n.valueRange.length?l=0:l<0&&(l=n.valueRange.length-1),a=n.value=n.valueRange[l]}else{if("number"!=typeof a)throw new Error("Can't cycle \""+e+'"');a+=t,n.value=a}return n.callbacks&&n.callbacks.forEach((function(e){e(a)})),a},e.prototype.addCallback=function(e,t){var n=this.data[e];n.callbacks||(n.callbacks=[]),n.callbacks.push(t)},e}();function ensureSetting(e,t){var n=localStorage.getItem(e);return null===n&&(n=t,localStorage.setItem(e,n)),n}var settings=new KeyValueStore({wordWrap:{value:"1"===ensureSetting("word-wrap","1"),callbacks:[function(e){localStorage.setItem("word-wrap",e?"1":"0")}],valueRange:[!1,!0]},monospaceFont:{value:"1"===ensureSetting("monospace-font","1"),callbacks:[function(e){localStorage.setItem("monospace-font",e?"1":"0")}],valueRange:[!1,!0]},imagePreviews:{value:"1"===ensureSetting("image-previews","1"),callbacks:[function(e){localStorage.setItem("image-previews",e?"1":"0")}],valueRange:[!1,!0]},clickablePlainLinks:{value:"1"===ensureSetting("clickable-plain-links","1"),callbacks:[function(e){localStorage.setItem("clickable-plain-links",e?"1":"0")}],valueRange:[!1,!0]}});function generateImageThumbnails(){for(var e=document.querySelectorAll(".link--IMG, .link--GIF"),t=e.length,n=function(){var n=e[t],a=n.href.replace(/^(.*?)\/I/,"$1/T"),l=document.createTextNode("\n"),s=document.createElement("span");s.classList.add("type-annotation"),s.textContent=" -> ";var i=document.createElement("img");i.src=a,i.addEventListener("load",(function(e){i.classList.remove("faded")}));var r=document.createElement("a");r.classList.add("img-preview"),r.href=n.href,r.addEventListener("click",(function(e){return e.preventDefault(),i.classList.add("faded"),i.classList.contains("expanded")?(i.classList.remove("expanded"),i.src=a):(i.classList.add("expanded"),i.src=r.href),!1})),r.append(i),n.parentNode.insertBefore(r,n.nextSibling),n.parentNode.insertBefore(s,r),n.parentNode.insertBefore(l,s)};t--;)n()}function removeImageThumbnails(){for(var e=document.querySelectorAll(".link--IMG, .link--GIF"),t=e.length;t--;)for(var n=e[t],a=3;a--&&n.nextSibling;)n.nextSibling.remove()}function generateMarkupForPlainLinks(){if(document.body.classList.contains("is-plain")){var e=document.getElementsByClassName("content")[0];e.innerHTML=e.innerHTML.replace(/\b[a-z]*:\/\/[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,}\b[-a-zA-Z0-9@:%_\+.,~#?&//=]*/g,(function(e){var t=e;return 0===t.indexOf("gopher://")&&(t=t.replace(/^gopher:\/\/(.*)$/,location.origin+"/$1")),''+e+""})),e.innerHTML=e.innerHTML.replace(/\bmailto:[-a-zA-Z0-9@:%._\+~#=]+@(?:[-a-zA-Z0-9@:%._\+~#=]+\.)+[a-z]{2,}\b/g,(function(e){return''+e+""}))}}function removeMarkupForPlainLinks(){if(document.body.classList.contains("is-plain"))for(var e=document.getElementsByClassName("content")[0],t=e.getElementsByTagName("a"),n=t.length;n--;){var a=t[n],l=document.createTextNode(a.textContent);e.replaceChild(l,a)}}!function(){for(var e=document.getElementsByClassName("link--QRY"),t=e.length;t--;)e[t].addEventListener("click",(function(e){e.preventDefault();var t=prompt("Please enter required input: ","");return null!==t&&""!==t&&(window.location.href=e.target.href+"?"+t),!1}))}(),function(){for(var e=document.getElementsByClassName("location__prefix"),t=e.length;t--;)e[t].addEventListener("click",(function(e){e.preventDefault();var t=prompt("Please enter new location: ","");return null!==t&&""!==t.trim()&&(0===(t=t.trim()).indexOf("gopher://")&&(t=t.substring(9)),window.location.href=window.location.origin+"/"+t),!1}))}(),function(){var e=document.getElementsByClassName("wrap")[0],t=e.getElementsByClassName("content")[0],n=document.getElementsByClassName("setting--image-previews")[0].getElementsByClassName("setting__value")[0],a=function(e,t){void 0===t&&(t=!1),e?generateImageThumbnails():t||removeImageThumbnails(),n.textContent=e?"[yes]":"[no]"};n.addEventListener("click",(function(e){return e.preventDefault(),settings.cycleValue("imagePreviews"),!1})),a(settings.getValue("imagePreviews"),!0),settings.addCallback("imagePreviews",a);var l=document.getElementsByClassName("setting--monospace-font")[0].getElementsByClassName("setting__value")[0],s=function(e){e?t.classList.add("content--has-monospace-font"):t.classList.remove("content--has-monospace-font"),l.textContent=e?"[yes]":"[no]"};l.addEventListener("click",(function(e){return e.preventDefault(),settings.cycleValue("monospaceFont"),!1})),s(settings.getValue("monospaceFont")),settings.addCallback("monospaceFont",s);var i=document.getElementsByClassName("setting--word-wrap")[0].getElementsByClassName("setting__value")[0],r=function(t){t?e.classList.add("wrap--word-wrap"):e.classList.remove("wrap--word-wrap"),i.textContent=t?"[yes]":"[no]"};i.addEventListener("click",(function(e){return e.preventDefault(),settings.cycleValue("wordWrap"),!1})),r(settings.getValue("wordWrap")),settings.addCallback("wordWrap",r);var o=document.getElementsByClassName("setting--clickable-plain-links")[0].getElementsByClassName("setting__value")[0],c=function(e){e?generateMarkupForPlainLinks():removeMarkupForPlainLinks(),o.textContent=e?"[yes]":"[no]"};o.addEventListener("click",(function(e){return e.preventDefault(),settings.cycleValue("clickablePlainLinks"),!1})),c(settings.getValue("clickablePlainLinks")),settings.addCallback("clickablePlainLinks",c)}(),function(){for(var e=document.getElementsByClassName("modal"),t=e.length,n=function(){var n=e[t],a=n.getElementsByClassName("modal__content")[0],l=n.getElementsByClassName("modal__close-btn")[0];document.addEventListener("click",(function(e){n.classList.contains("modal--visible")&&(e.target===a||a.contains(e.target)||(n.classList.remove("modal--visible"),e.preventDefault(),e.stopPropagation()))}),!0),document.addEventListener("keydown",(function(e){n.classList.contains("modal--visible")&&27===e.keyCode&&n.classList.remove("modal--visible")})),l.addEventListener("click",(function(e){return e.preventDefault(),n.classList.remove("modal--visible"),!1}))};t--;)n();var a=document.getElementsByClassName("settings-btn")[0],l=document.getElementsByClassName("modal--settings")[0];a.addEventListener("click",(function(e){return e.preventDefault(),l.classList.add("modal--visible"),!1}))}(); \ No newline at end of file +"use strict";var KeyValueStore=function(){function e(e){this.data=e;for(var t=0,n=Object.keys(e);t=n.valueRange.length?l=0:l<0&&(l=n.valueRange.length-1),a=n.value=n.valueRange[l]}else{if("number"!=typeof a)throw new Error("Can't cycle \""+e+'"');a+=t,n.value=a}return n.callbacks&&n.callbacks.forEach((function(e){e(a)})),a},e.prototype.addCallback=function(e,t){var n=this.data[e];n.callbacks||(n.callbacks=[]),n.callbacks.push(t)},e}();function ensureSetting(e,t){var n=localStorage.getItem(e);return null===n&&(n=t,localStorage.setItem(e,n)),n}var settings=new KeyValueStore({wordWrap:{value:"1"===ensureSetting("word-wrap","1"),callbacks:[function(e){localStorage.setItem("word-wrap",e?"1":"0")}],valueRange:[!1,!0]},monospaceFont:{value:"1"===ensureSetting("monospace-font","1"),callbacks:[function(e){localStorage.setItem("monospace-font",e?"1":"0")}],valueRange:[!1,!0]},imagePreviews:{value:"1"===ensureSetting("image-previews","1"),callbacks:[function(e){localStorage.setItem("image-previews",e?"1":"0")}],valueRange:[!1,!0]},clickablePlainLinks:{value:"1"===ensureSetting("clickable-plain-links","1"),callbacks:[function(e){localStorage.setItem("clickable-plain-links",e?"1":"0")}],valueRange:[!1,!0]}});function generateImageThumbnails(){for(var e=document.querySelectorAll(".link--IMG, .link--GIF"),t=e.length,n=function(){var n=e[t],a=n.href.replace(/^(.*?)\/I/,"$1/T"),l=document.createTextNode("\n"),i=document.createElement("span");i.classList.add("type-annotation"),i.textContent=" -> ";var s=document.createElement("img");s.src=a,s.addEventListener("load",(function(e){s.classList.remove("faded")}));var r=document.createElement("a");r.classList.add("img-preview"),r.href=n.href,r.addEventListener("click",(function(e){return e.preventDefault(),s.classList.add("faded"),s.classList.contains("expanded")?(s.classList.remove("expanded"),s.src=a):(s.classList.add("expanded"),s.src=r.href),!1})),r.append(s),n.parentNode.insertBefore(r,n.nextSibling),n.parentNode.insertBefore(i,r),n.parentNode.insertBefore(l,i)};t--;)n()}function removeImageThumbnails(){for(var e=document.querySelectorAll(".link--IMG, .link--GIF"),t=e.length;t--;)for(var n=e[t],a=3;a--&&n.nextSibling;)n.nextSibling.remove()}function generateMarkupForPlainLinks(){if(document.body.classList.contains("is-plain")){var e=document.getElementsByClassName("content")[0];e.innerHTML=e.innerHTML.replace(/\b[a-z]*:\/\/[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,}\b[-a-zA-Z0-9@:%_\+.,~#?&//=]*/g,(function(e){var t=e;return 0===t.indexOf("gopher://")?t=t.replace(/^gopher:\/\/(.*)$/,location.origin+"/gopher/$1"):0===t.indexOf("gemini://")&&(t=t.replace(/^gemini:\/\/(.*)$/,location.origin+"/gemini/$1")),''+e+""})),e.innerHTML=e.innerHTML.replace(/\bmailto:[-a-zA-Z0-9@:%._\+~#=]+@(?:[-a-zA-Z0-9@:%._\+~#=]+\.)+[a-z]{2,}\b/g,(function(e){return''+e+""}))}}function removeMarkupForPlainLinks(){if(document.body.classList.contains("is-plain"))for(var e=document.getElementsByClassName("content")[0],t=e.getElementsByTagName("a"),n=t.length;n--;){var a=t[n],l=document.createTextNode(a.textContent);e.replaceChild(l,a)}}!function(){for(var e=document.getElementsByClassName("link--QRY"),t=e.length;t--;)e[t].addEventListener("click",(function(e){e.preventDefault();var t=prompt("Please enter required input: ","");return null!==t&&""!==t&&(window.location.href=e.target.href+"?"+t),!1}))}(),function(){for(var e=document.getElementsByClassName("location__prefix"),t=e.length;t--;)e[t].addEventListener("click",(function(e){e.preventDefault();var t=prompt("Please enter new location (gopher://... or gemini://...):","");return null!==t&&""!==t.trim()&&(t=0===(t=t.trim()).indexOf("gopher://")?"gopher/"+t.substring(9):0===t.indexOf("gemini://")?"gemini/"+t.substring(9):"gopher/"+t,window.location.href=window.location.origin+"/"+t),!1}))}(),function(){var e=document.getElementsByClassName("wrap")[0],t=e.getElementsByClassName("content")[0],n=document.getElementsByClassName("setting--image-previews")[0].getElementsByClassName("setting__value")[0],a=function(e,t){void 0===t&&(t=!1),e?generateImageThumbnails():t||removeImageThumbnails(),n.textContent=e?"[yes]":"[no]"};n.addEventListener("click",(function(e){return e.preventDefault(),settings.cycleValue("imagePreviews"),!1})),a(settings.getValue("imagePreviews"),!0),settings.addCallback("imagePreviews",a);var l=document.getElementsByClassName("setting--monospace-font")[0].getElementsByClassName("setting__value")[0],i=function(e){e?t.classList.add("content--has-monospace-font"):t.classList.remove("content--has-monospace-font"),l.textContent=e?"[yes]":"[no]"};l.addEventListener("click",(function(e){return e.preventDefault(),settings.cycleValue("monospaceFont"),!1})),i(settings.getValue("monospaceFont")),settings.addCallback("monospaceFont",i);var s=document.getElementsByClassName("setting--word-wrap")[0].getElementsByClassName("setting__value")[0],r=function(t){t?e.classList.add("wrap--word-wrap"):e.classList.remove("wrap--word-wrap"),s.textContent=t?"[yes]":"[no]"};s.addEventListener("click",(function(e){return e.preventDefault(),settings.cycleValue("wordWrap"),!1})),r(settings.getValue("wordWrap")),settings.addCallback("wordWrap",r);var o=document.getElementsByClassName("setting--clickable-plain-links")[0].getElementsByClassName("setting__value")[0],c=function(e){e?generateMarkupForPlainLinks():removeMarkupForPlainLinks(),o.textContent=e?"[yes]":"[no]"};o.addEventListener("click",(function(e){return e.preventDefault(),settings.cycleValue("clickablePlainLinks"),!1})),c(settings.getValue("clickablePlainLinks")),settings.addCallback("clickablePlainLinks",c)}(),function(){for(var e=document.getElementsByClassName("modal"),t=e.length,n=function(){var n=e[t],a=n.getElementsByClassName("modal__content")[0],l=n.getElementsByClassName("modal__close-btn")[0];document.addEventListener("click",(function(e){n.classList.contains("modal--visible")&&(e.target===a||a.contains(e.target)||(n.classList.remove("modal--visible"),e.preventDefault(),e.stopPropagation()))}),!0),document.addEventListener("keydown",(function(e){n.classList.contains("modal--visible")&&27===e.keyCode&&n.classList.remove("modal--visible")})),l.addEventListener("click",(function(e){return e.preventDefault(),n.classList.remove("modal--visible"),!1}))};t--;)n();var a=document.getElementsByClassName("settings-btn")[0],l=document.getElementsByClassName("modal--settings")[0];a.addEventListener("click",(function(e){return e.preventDefault(),l.classList.add("modal--visible"),!1}))}(); \ No newline at end of file diff --git a/assets/style.css b/assets/style.css index c52c2ad..70f53a7 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1 +1 @@ -body{margin:0;padding:0;background-color:#14171a;color:#cad1d8;font-family:"Iosevka Term SS03","IBM Plex Mono","Fira Code","Fira Mono","Roboto Mono","Droid Sans Mono",Monaco,Consolas,Courier,monospace;font-size:1.0625em;line-height:1.5}h1,h2,h3,h4,h5,h6{font:inherit;color:#fff;margin:0}button{background:none;border:0;padding:0;color:#fff;font:inherit;text-decoration:underline;cursor:pointer}button:focus{outline:1px dotted currentColor}img{display:inline-block;vertical-align:top;max-width:8em;margin:.1em 0}img::selection{background-color:rgba(239,198,138,0.35)}img.expanded{max-width:40em;max-width:80ch}img.faded{opacity:.5}strong{font-weight:normal}::selection{color:#000;background-color:rgba(239,198,138,0.996)}:link{color:#fff}:visited{color:#cad1d8}:link:hover,:visited:hover{color:#fff}.header-base{display:flex;flex-direction:row;align-items:center;justify-content:space-between;border-bottom:1px solid #353a3f}.header{padding:.9em 1em;color:#929ba3}.location{flex:0 1 auto;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-overflow:ellipsis '|';margin-right:.5em}.location__prefix{margin-right:.3em;color:#929ba3}@media (hover: hover){.location__prefix:hover{color:#fff}}.location__prefix--mobile{display:none}.location__slash{margin:0 .3em}.location__uripart{color:#fff}.location__uripart+.location__slash+.location__uripart{color:#cad1d8}.location__uripart:link:hover,.location__uripart:visited:hover{color:#fff}.actions{flex:0 0 auto}.actions :visited{color:#fff}.action{display:inline}.action+.action::before{content:' | '}.wrap{padding:2em 1em;text-align:center}.wrap--word-wrap{max-width:50em;max-width:100ch;margin:0 auto}.wrap--word-wrap .content{white-space:pre-wrap;word-wrap:break-word}.content{box-sizing:border-box;display:inline-block;min-width:0;max-width:100%;margin:0;padding:0;text-align:left;font:inherit;font-family:"Iosevka Aile","Fira Sans","Roboto","Droid Sans",sans-serif}.content--has-type-annotations{padding-left:3em;padding-left:5ch}.content--has-monospace-font{font-family:"Iosevka Term SS03","IBM Plex Mono","Fira Code","Fira Mono","Roboto Mono","Droid Sans Mono",Monaco,Consolas,Courier,monospace}.type-annotation{margin-left:-3em;margin-left:-5ch;color:#929ba3;white-space:pre;font-family:"Iosevka Term SS03","IBM Plex Mono","Fira Code","Fira Mono","Roboto Mono","Droid Sans Mono",Monaco,Consolas,Courier,monospace}.modal{position:fixed;top:0;left:0;z-index:100;display:none;width:100%;height:100%;box-sizing:border-box;padding:2em;background-color:rgba(0,0,0,0.75)}.modal--visible{display:block}.modal__content{max-width:30em;padding:1.5em 1.8em;margin:0 auto;background-color:#14171a;box-shadow:0 .3em 2em #000;text-align:left}.modal__head{padding-bottom:.75em;margin-bottom:1.5em}.modal__title{padding-right:1em;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;text-transform:uppercase}.setting{display:flex;flex-direction:row;align-items:baseline;justify-content:space-between}.setting::after{order:2;flex:1 1 auto;display:block;height:0;margin:0 .5em;border-bottom:2px dotted #353a3f;content:''}.setting__label{order:1}.setting__value{order:3}@media screen and (max-width: 800px){body{font-size:1em}.modal{padding:1em}.modal__content{padding:1em 1.3em}}@media screen and (max-width: 500px){.location__prefix{display:none}.location__prefix--mobile{display:inline}.action{display:block}.action+.action::before{content:''}}@media screen and (max-width: 280px){.location__prefix--mobile{display:none}} +body{margin:0;padding:0;background-color:#14171a;color:#cad1d8;font-family:"Iosevka Term SS03","IBM Plex Mono","Fira Code","Fira Mono","Roboto Mono","Droid Sans Mono",Monaco,Consolas,Courier,monospace;font-size:1.0625em;line-height:1.5}h1,h2,h3,h4,h5,h6{font:inherit;color:#fff;margin:0}button{background:none;border:0;padding:0;color:#fff;font:inherit;text-decoration:underline;cursor:pointer}button:focus{outline:1px dotted currentColor}img{display:inline-block;vertical-align:top;max-width:8em;margin:.1em 0}img::selection{background-color:rgba(239,198,138,0.35)}img.expanded{max-width:40em;max-width:80ch}img.faded{opacity:.5}strong{font-weight:normal}::selection{color:#000;background-color:rgba(239,198,138,0.996)}:link{color:#fff}:visited{color:#cad1d8}:link:hover,:visited:hover{color:#fff}.header-base{display:flex;flex-direction:row;align-items:center;justify-content:space-between;border-bottom:1px solid #353a3f}.header{padding:.9em 1em;color:#929ba3}.location{flex:0 1 auto;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;text-overflow:ellipsis '|';margin-right:.5em}.location__prefix{margin-right:.3em;color:#929ba3;cursor:pointer}@media (hover: hover){.location__prefix:hover{color:#fff}}.location__prefix--mobile{display:none}.location__slash{margin:0 .3em}.location__uripart{color:#fff}.location__uripart+.location__slash+.location__uripart{color:#cad1d8}.location__uripart:link:hover,.location__uripart:visited:hover{color:#fff}.actions{flex:0 0 auto}.actions :visited{color:#fff}.action{display:inline}.action+.action::before{content:' | '}.wrap{padding:2em 1em;text-align:center}.wrap--word-wrap{max-width:50em;max-width:100ch;margin:0 auto}.wrap--word-wrap .content{white-space:pre-wrap;word-wrap:break-word}.content{box-sizing:border-box;display:inline-block;min-width:0;max-width:100%;margin:0;padding:0;text-align:left;font:inherit;font-family:"Iosevka Aile","Fira Sans","Roboto","Droid Sans",sans-serif}.content--has-type-annotations{padding-left:3em;padding-left:5ch}.content--has-monospace-font{font-family:"Iosevka Term SS03","IBM Plex Mono","Fira Code","Fira Mono","Roboto Mono","Droid Sans Mono",Monaco,Consolas,Courier,monospace}.type-annotation{margin-left:-3em;margin-left:-5ch;color:#929ba3;white-space:pre;font-family:"Iosevka Term SS03","IBM Plex Mono","Fira Code","Fira Mono","Roboto Mono","Droid Sans Mono",Monaco,Consolas,Courier,monospace}.modal{position:fixed;top:0;left:0;z-index:100;display:none;width:100%;height:100%;box-sizing:border-box;padding:2em;background-color:rgba(0,0,0,0.75)}.modal--visible{display:block}.modal__content{max-width:30em;padding:1.5em 1.8em;margin:0 auto;background-color:#14171a;box-shadow:0 .3em 2em #000;text-align:left}.modal__head{padding-bottom:.75em;margin-bottom:1.5em}.modal__title{padding-right:1em;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;text-transform:uppercase}.setting{display:flex;flex-direction:row;align-items:baseline;justify-content:space-between}.setting::after{order:2;flex:1 1 auto;display:block;height:0;margin:0 .5em;border-bottom:2px dotted #353a3f;content:''}.setting__label{order:1}.setting__value{order:3}@media screen and (max-width: 800px){body{font-size:1em}.modal{padding:1em}.modal__content{padding:1em 1.3em}}@media screen and (max-width: 500px){.location__prefix{display:none}.location__prefix--mobile{display:inline}.action{display:block}.action+.action::before{content:''}}@media screen and (max-width: 280px){.location__prefix--mobile{display:none}} diff --git a/cmd/gopherproxy/main.go b/cmd/gopherproxy/main.go index e699d14..66248ac 100644 --- a/cmd/gopherproxy/main.go +++ b/cmd/gopherproxy/main.go @@ -13,7 +13,7 @@ var ( bind = flag.String("bind", "0.0.0.0:8000", "[int]:port to bind to") robotsfile = flag.String("robots-file", "robots.txt", "robots.txt file") robotsdebug = flag.Bool("robots-debug", false, "print output about ignored robots.txt") - uri = flag.String("uri", "floodgap.com", ":[port] to proxy to") + uri = flag.String("uri", "gopher/floodgap.com", "/:[port] to proxy to") vipsconcurrency = flag.Int("vips-concurrency", 1, "Concurrency level of libvips") ) diff --git a/css/main.scss b/css/main.scss index bed3b24..42b0b28 100644 --- a/css/main.scss +++ b/css/main.scss @@ -129,6 +129,7 @@ strong { &__prefix { margin-right: .3em; color: $text-minus; + cursor: pointer; @media (hover: hover) { &:hover { diff --git a/gopherproxy.go b/gopherproxy.go index 87a1ad0..9a60507 100644 --- a/gopherproxy.go +++ b/gopherproxy.go @@ -1,6 +1,7 @@ package gopherproxy import ( + "bufio" "bytes" "crypto/md5" "fmt" @@ -25,6 +26,11 @@ import ( "github.com/NYTimes/gziphandler" ) +const ( + ITEM_TYPE_GEMINI_LINE = "" + ITEM_TYPE_GEMINI_LINK = " =>" +) + type Item struct { Link template.URL Type string @@ -40,7 +46,7 @@ type AssetList struct { PropFontW2 string } -func renderDirectory(w http.ResponseWriter, tpl *template.Template, assetList AssetList, uri string, hostport string, d gopher.Directory) error { +func renderGopherDirectory(w http.ResponseWriter, tpl *template.Template, assetList AssetList, uri string, hostport string, d gopher.Directory) error { var title string out := make([]Item, len(d.Items)) @@ -76,7 +82,7 @@ func renderDirectory(w http.ResponseWriter, tpl *template.Template, assetList As path = strings.Replace(path, "%2F", "/", -1) tr.Link = template.URL( fmt.Sprintf( - "/%s/%s%s", + "/gopher/%s/%s%s", hostport, string(byte(x.Type)), path, @@ -92,13 +98,81 @@ func renderDirectory(w http.ResponseWriter, tpl *template.Template, assetList As } return tpl.Execute(w, struct { - Title string - URI string - Assets AssetList - Lines []Item - RawText string - Error bool - }{title, fmt.Sprintf("%s/%s", hostport, uri), assetList, out, "", false}) + Title string + URI string + Assets AssetList + Lines []Item + RawText string + Error bool + Protocol string + }{title, fmt.Sprintf("%s/%s", hostport, uri), assetList, out, "", false, "gopher"}) +} + +func parseGeminiDocument(response *GeminiResponse, uri string, hostport string) (items []Item) { + scanner := bufio.NewScanner(response.Body) + scanner.Split(bufio.ScanLines) + + baseUrl, err := url.Parse(fmt.Sprintf( + "gemini://%s/%s", + hostport, + uri, + )) + if err != nil { + return []Item{} + } + + for scanner.Scan() { + line := strings.Trim(scanner.Text(), "\r\n") + + item := Item{ + Type: ITEM_TYPE_GEMINI_LINE, + Text: line, + } + + linkMatch := GeminiLinkPattern.FindStringSubmatch(line) + if len(linkMatch) != 0 && linkMatch[0] != "" { + link := linkMatch[1] + + if strings.HasPrefix(link, "//") { + link = "/gemini/" + strings.TrimPrefix(link, "//") + } else if strings.HasPrefix(link, "gemini://") { + link = "/gemini/" + strings.TrimPrefix(link, "gemini://") + } else if strings.HasPrefix(link, "gopher://") { + link = "/gopher/" + strings.TrimPrefix(link, "gopher://") + } else { + linkUrl, err := url.Parse(link) + if err != nil { + continue + } + adjustedUrl := baseUrl.ResolveReference(linkUrl) + if adjustedUrl.Scheme == "gemini" { + link = "/gemini/" + adjustedUrl.Host + adjustedUrl.Path + } else if adjustedUrl.Scheme == "gopher" { + link = "/gopher/" + adjustedUrl.Host + adjustedUrl.Path + } else { + link = adjustedUrl.String() + } + } + + item.Type = ITEM_TYPE_GEMINI_LINK + item.Link = template.URL(link) + if linkMatch[2] != "" { + item.Text = linkMatch[2] + } else { + item.Text = linkMatch[1] + } + } + + items = append(items, item) + } + + return +} + +func DefaultHandler(tpl *template.Template, uri string) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + http.Redirect(w, req, "/"+uri, http.StatusFound) + } } // GopherHandler returns a Handler that proxies requests @@ -109,7 +183,7 @@ func renderDirectory(w http.ResponseWriter, tpl *template.Template, assetList As func GopherHandler(tpl *template.Template, robotsdata *robotstxt.RobotsData, assetList AssetList, robotsdebug bool, uri string) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { agent := req.UserAgent() - path := strings.TrimPrefix(req.URL.Path, "/") + path := strings.TrimPrefix(req.URL.Path, "/gopher/") if robotsdata != nil && robotsdebug && !robotsdata.TestAgent(path, agent) { log.Printf("UserAgent %s ignored robots.txt", agent) @@ -132,13 +206,14 @@ func GopherHandler(tpl *template.Template, robotsdata *robotstxt.RobotsData, ass uri, err := url.QueryUnescape(strings.Join(parts[1:], "/")) if err != nil { tpl.Execute(w, struct { - Title string - URI string - Assets AssetList - RawText string - Lines []Item - Error bool - }{"", hostport, assetList, fmt.Sprintf("Error: %s", err), nil, true}) + Title string + URI string + Assets AssetList + RawText string + Lines []Item + Error bool + Protocol string + }{"", hostport, assetList, fmt.Sprintf("Error: %s", err), nil, true, "gopher"}) return } @@ -153,13 +228,14 @@ func GopherHandler(tpl *template.Template, robotsdata *robotstxt.RobotsData, ass if err != nil { tpl.Execute(w, struct { - Title string - URI string - Assets AssetList - RawText string - Lines []Item - Error bool - }{uri, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error: %s", err), nil, true}) + Title string + URI string + Assets AssetList + RawText string + Lines []Item + Error bool + Protocol string + }{uri, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error: %s", err), nil, true, "gopher"}) return } @@ -171,13 +247,14 @@ func GopherHandler(tpl *template.Template, robotsdata *robotstxt.RobotsData, ass buf := new(bytes.Buffer) buf.ReadFrom(res.Body) tpl.Execute(w, struct { - Title string - URI string - Assets AssetList - RawText string - Lines []Item - Error bool - }{uri, fmt.Sprintf("%s/%s", hostport, uri), assetList, buf.String(), nil, false}) + Title string + URI string + Assets AssetList + RawText string + Lines []Item + Error bool + Protocol string + }{uri, fmt.Sprintf("%s/%s", hostport, uri), assetList, buf.String(), nil, false, "gopher"}) } else if strings.HasPrefix(parts[1], "T") { _, _, err = vips.NewTransform(). Load(res.Body). @@ -190,21 +267,123 @@ func GopherHandler(tpl *template.Template, robotsdata *robotstxt.RobotsData, ass io.Copy(w, res.Body) } } else { - if err := renderDirectory(w, tpl, assetList, uri, hostport, res.Dir); err != nil { + if err := renderGopherDirectory(w, tpl, assetList, uri, hostport, res.Dir); err != nil { tpl.Execute(w, struct { - Title string - URI string - Assets AssetList - RawText string - Lines []Item - Error bool - }{uri, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error: %s", err), nil, true}) + Title string + URI string + Assets AssetList + RawText string + Lines []Item + Error bool + Protocol string + }{uri, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error: %s", err), nil, true, "gopher"}) return } } } } +func GeminiHandler(tpl *template.Template, robotsdata *robotstxt.RobotsData, assetList AssetList, robotsdebug bool, uri string) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + agent := req.UserAgent() + path := strings.TrimPrefix(req.URL.Path, "/gemini/") + + if robotsdata != nil && robotsdebug && !robotsdata.TestAgent(path, agent) { + log.Printf("UserAgent %s ignored robots.txt", agent) + } + + parts := strings.Split(path, "/") + hostport := parts[0] + + if len(hostport) == 0 { + http.Redirect(w, req, "/"+uri, http.StatusFound) + return + } + + var qs string + + if req.URL.RawQuery != "" { + qs = fmt.Sprintf("?%s", url.QueryEscape(req.URL.RawQuery)) + } + + uri, err := url.QueryUnescape(strings.Join(parts[1:], "/")) + if err != nil { + tpl.Execute(w, struct { + Title string + URI string + Assets AssetList + RawText string + Lines []Item + Error bool + Protocol string + }{"", hostport, assetList, fmt.Sprintf("Error: %s", err), nil, true, "gemini"}) + return + } + + res, err := GeminiGet( + fmt.Sprintf( + "gemini://%s/%s%s", + hostport, + uri, + qs, + ), + ) + + if err != nil { + tpl.Execute(w, struct { + Title string + URI string + Assets AssetList + RawText string + Lines []Item + Error bool + Protocol string + }{uri, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error: %s", err), nil, true, "gemini"}) + return + } + + if int(res.Header.Status/10) != 2 { + tpl.Execute(w, struct { + Title string + URI string + Assets AssetList + RawText string + Lines []Item + Error bool + Protocol string + }{uri, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error %d: %s", res.Header.Status, res.Header.Meta), nil, true, "gemini"}) + return + } + + if strings.HasPrefix(res.Header.Meta, MIME_GEMINI) { + items := parseGeminiDocument(res, uri, hostport) + tpl.Execute(w, struct { + Title string + URI string + Assets AssetList + RawText string + Lines []Item + Error bool + Protocol string + }{uri, fmt.Sprintf("%s/%s", hostport, uri), assetList, "", items, false, "gemini"}) + } else if strings.HasPrefix(res.Header.Meta, "text/") { + buf := new(bytes.Buffer) + buf.ReadFrom(res.Body) + tpl.Execute(w, struct { + Title string + URI string + Assets AssetList + RawText string + Lines []Item + Error bool + Protocol string + }{uri, fmt.Sprintf("%s/%s", hostport, uri), assetList, buf.String(), nil, false, "gemini"}) + } else { + io.Copy(w, res.Body) + } + } +} + // RobotsTxtHandler returns the contents of the robots.txt file // if configured and valid. func RobotsTxtHandler(robotstxtdata []byte) http.HandlerFunc { @@ -386,7 +565,9 @@ func ListenAndServe(bind, robotsfile string, robotsdebug bool, vipsconcurrency i ConcurrencyLevel: vipsconcurrency, }) - http.Handle("/", gziphandler.GzipHandler(GopherHandler(tpl, robotsdata, AssetList{styleAsset, jsAsset, fontwAsset, fontw2Asset, propfontwAsset, propfontw2Asset}, robotsdebug, uri))) + http.Handle("/", gziphandler.GzipHandler(DefaultHandler(tpl, uri))) + http.Handle("/gopher/", gziphandler.GzipHandler(GopherHandler(tpl, robotsdata, AssetList{styleAsset, jsAsset, fontwAsset, fontw2Asset, propfontwAsset, propfontw2Asset}, robotsdebug, uri))) + http.Handle("/gemini/", gziphandler.GzipHandler(GeminiHandler(tpl, robotsdata, AssetList{styleAsset, jsAsset, fontwAsset, fontw2Asset, propfontwAsset, propfontw2Asset}, robotsdebug, uri))) http.Handle("/robots.txt", gziphandler.GzipHandler(RobotsTxtHandler(robotstxtdata))) http.Handle("/favicon.ico", gziphandler.GzipHandler(FaviconHandler(favicondata))) http.Handle(styleAsset, gziphandler.GzipHandler(StyleHandler(styledata))) diff --git a/js/main.ts b/js/main.ts index 7e0c8a1..2d11ea4 100644 --- a/js/main.ts +++ b/js/main.ts @@ -76,11 +76,15 @@ const settings = new KeyValueStore({ locationPrefixEls[i].addEventListener('click', e => { e.preventDefault(); - let resp = prompt('Please enter new location: ', ''); + let resp = prompt('Please enter new location (gopher://... or gemini://...):', ''); if ((resp !== null) && (resp.trim() !== "")) { resp = resp.trim(); if (resp.indexOf('gopher://') === 0) { - resp = resp.substring(9); + resp = 'gopher/' + resp.substring(9); + } else if (resp.indexOf('gemini://') === 0) { + resp = 'gemini/' + resp.substring(9); + } else { + resp = 'gopher/' + resp; } window.location.href = window.location.origin + '/' + resp; @@ -305,7 +309,9 @@ function generateMarkupForPlainLinks() { contentEl.innerHTML = contentEl.innerHTML.replace(urlRegex, match => { let href: string = match; if (href.indexOf('gopher://') === 0) { - href = href.replace(/^gopher:\/\/(.*)$/, location.origin + '/$1'); + href = href.replace(/^gopher:\/\/(.*)$/, location.origin + '/gopher/$1'); + } else if (href.indexOf('gemini://') === 0) { + href = href.replace(/^gemini:\/\/(.*)$/, location.origin + '/gemini/$1'); } return `${match}`; }); diff --git a/libgemini.go b/libgemini.go new file mode 100644 index 0000000..05321ef --- /dev/null +++ b/libgemini.go @@ -0,0 +1,153 @@ +package gopherproxy + +import ( + "bufio" + "crypto/tls" + "errors" + "io" + "net/url" + "regexp" + "strconv" + "strings" +) + +const ( + CRLF = "\r\n" +) + +const ( + STATUS_INPUT = 10 + STATUS_SUCCESS = 20 + STATUS_SUCCESS_CERT = 21 + STATUS_REDIRECT_TEMP = 30 + STATUS_REDIRECT_PERM = 31 + STATUS_TEMP_FAILURE = 40 + STATUS_SERVER_UNAVAILABLE = 41 + STATUS_CGI_ERROR = 42 + STATUS_PROXY_ERROR = 43 + STATUS_SLOW_DOWN = 44 + STATUS_PERM_FAILURE = 50 + STATUS_NOT_FOUND = 51 + STATUS_GONE = 52 + STATUS_PROXY_REFUSED = 53 + STATUS_BAD_REQUEST = 59 + STATUS_CLIENT_CERT_EXPIRED = 60 + STATUS_TRANSIENT_CERT_REQUEST = 61 + STATUS_AUTH_CERT_REQUIRED = 62 + STATUS_CERT_REJECTED = 63 + STATUS_FUTURE_CERT_REJECTED = 64 + STATUS_EXPIRED_CERT_REJECTED = 65 +) + +const ( + MIME_GEMINI = "text/gemini" + DEFAULT_MIME = MIME_GEMINI + DEFAULT_CHARSET = "utf-8" +) + +var ( + HeaderPattern = regexp.MustCompile("^(\\d\\d)[ \\t]+(.*)$") + MimeTypePattern = regexp.MustCompile("^[-\\w.]+/[-\\w.]+") + MimeCharsetPattern = regexp.MustCompile("charset=([^ ;]+)") + GeminiLinkPattern = regexp.MustCompile("^=>[ \\t]*([^ ]+)(?:[ \\t]+(.*))?$") +) + +type GeminiHeader struct { + Status int + Meta string +} + +type GeminiResponse struct { + Header *GeminiHeader + Body io.Reader +} + +func GeminiGet(uri string) (*GeminiResponse, error) { + u, err := url.Parse(uri) + if err != nil { + return nil, err + } + + if u.Scheme != "gemini" { + return nil, errors.New("invalid scheme for uri") + } + + var ( + host string + port int + ) + + hostport := strings.Split(u.Host, ":") + if len(hostport) == 2 { + host = hostport[0] + n, err := strconv.ParseInt(hostport[1], 10, 32) + if err != nil { + return nil, err + } + port = int(n) + } else { + host, port = hostport[0], 1965 + } + + conn, err := tls.Dial("tcp", host+":"+strconv.Itoa(port), &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, + }) + if err != nil { + return nil, err + } + + _, err = conn.Write([]byte(u.String() + CRLF)) + if err != nil { + conn.Close() + return nil, err + } + + reader := bufio.NewReader(conn) + + line, _, err := reader.ReadLine() + if err != nil { + conn.Close() + return nil, err + } + + header, err := ParseGeminiHeader(string(line)) + if err != nil { + conn.Close() + return nil, err + } + + return &GeminiResponse{ + Header: header, + Body: reader, + }, nil +} + +func ParseGeminiHeader(line string) (header *GeminiHeader, err error) { + matches := HeaderPattern.FindStringSubmatch(line) + + status, err := strconv.Atoi(matches[1]) + if err != nil { + return nil, err + } + + meta := matches[2] + + if int(status/10) == 2 { + if meta == "" { + meta = DEFAULT_MIME + ";charset=" + DEFAULT_CHARSET + } + + mimeType := MimeTypePattern.FindString(meta) + if strings.HasPrefix(mimeType, "text/") && MimeCharsetPattern.FindString(meta) == "" { + meta += ";charset=" + DEFAULT_CHARSET + } + } + + header = &GeminiHeader{ + Status: status, + Meta: meta, + } + + return +} diff --git a/template.go b/template.go index 54aa53a..b7d1b1f 100644 --- a/template.go +++ b/template.go @@ -27,6 +27,7 @@ var tpltext = `
+ {{- $page := . -}} {{- $href := "" -}} {{- $uriParts := split .URI "/" -}} {{- $uriLast := $uriParts | last -}} @@ -35,35 +36,32 @@ var tpltext = ` {{- $uriLast = $uriParts | last -}} {{- $uriParts = $uriParts | pop -}} {{- end -}} - {{- if eq (len $uriParts) 1 -}} - {{- $uriLast = $uriParts | last -}} - {{- $uriParts = $uriParts | pop -}} - {{- end -}} - + {{ .Protocol }}://:// + {{- $href = printf "%s/%s" $href .Protocol -}} {{- range $i, $part := $uriParts -}} - {{- if ne $i 1 -}} - {{- $href = printf "%s/%s" $href . -}} - {{- if ne $i 0 -}} - / - {{- end -}} - {{ . }} - {{- else -}} + {{- if and (eq $page.Protocol "gopher") (eq $i 1) -}} {{- $href = printf "%s/1" $href -}} {{- $part = $part | trimLeftChar -}} {{- if not (eq $part "") -}} {{- $href = printf "%s/%s" $href $part -}} - /{{ $part }} + /{{ $part }} + {{- end -}} + {{- else -}} + {{- $href = printf "%s/%s" $href . -}} + {{- if ne $i 0 -}} + / {{- end -}} + {{ . }} {{- end -}} {{- end -}} {{- if ne (len $uriParts) 0 -}} / {{- end -}} - {{ $uriLast -}} + {{ $uriLast }}
- {{- if and (not .Lines) (not .Error) -}} + {{- if and (not .Lines) (not .Error) (eq .Protocol "gopher") -}} {{- end -}}
-- cgit v1.2.3-54-g00ecf