Skip to content

Instantly share code, notes, and snippets.

@teramako
Created July 15, 2009 23:23
Show Gist options
  • Save teramako/148054 to your computer and use it in GitHub Desktop.
Save teramako/148054 to your computer and use it in GitHub Desktop.
//#!/usr/lib/xulrunner/xpcshell -f
/*
const Cc = Components.classes;
const Ci = Components.interfaces;
*/
liberator.plugins.telnetd = (function(){
const cs = Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService);
function log(msg){
cs.logStringMessage(msg);
}
// --------------------------------------------------------
// sessions
// -----------------------------------------------------{{{
let sessions = (function(){
let list = [];
let self = {
add: function(session){
list.push(session);
},
get: function(index){
return list[index];
},
get count(){
return list.length;
},
remove: function(s){
if (typeof s == "number"){
if (s in list){
list[s].quit();
list.splice(s, 1);
} else {
throw new ReferenceError();
}
} else if (s instanceof TelnetdSession){
list = list.filter(function(session, i){
if (session === s){
s.quit();
return false;
}
return true;
});
return !list.length;
} else if (typeof index == "undefined"){
list = list.filter(function(session, i){
if (session.isOpen) return true;
return !session.quit();
});
return !list.length;
} else {
throw new TypeError();
}
},
};
return self;
})();
// }}}
// --------------------------------------------------------
// daemon
// -----------------------------------------------------{{{
let daemon = {
/*
QueryInterface: function(iid){
if (iid.equals(Ci.nsIObserver) || iid.equals(Ci.nsIServerSocketListener) || iid.equals(Ci.nsISupports))
return this;
Components.returnCode = Components.results.NS_ERROR_NO_INTERFACE;
return null;
},
*/
socket: null,
start: function(port){
if (this.socket){
throw Components.results.NS_ERROR_ALREADY_INITIALIZED;
}
var socket = Cc["@mozilla.org/network/server-socket;1"].createInstance(Ci.nsIServerSocket);
socket.init(port, true, 5);
log(">>> listenin on port " + socket.port + "\n");
socket.asyncListen(this);
this.socket = socket;
},
stop: function(){
if (!this.socket)
return;
if (sessions.count > 0){
let res = sessions.remove();
log(">>> close all sessions ... " + res);
}
log(">>> stopping listening on port " + this.socket.port);
this.socket.close();
this.socket = null;
},
onSocketAccepted: function D_onSocketAccepted(serverSocket, clientSocket){
log(">>> accepted connection on " + clientSocket.host + ":" + clientSocket.port);
let oStream = clientSocket.openOutputStream(Ci.nsITransport.OPEN_BLOCKING, 0, 0);
let iStream = clientSocket.openInputStream(Ci.nsITransport.OPEN_UNBUFFERED, 0, 0);
let session = new TelnetdSession(iStream, oStream);
sessions.add(session);
},
onStopListening: function(serverSocket, status){
log(">>> shutting down server socket");
},
}; // }}}
// --------------------------------------------------------
// TelnetSession
// -----------------------------------------------------{{{
function TelnetdSession(is, os){ this.init.apply(this, arguments); };
TelnetdSession.prototype = {
init: function TS_init(is, os){ /// {{{
let self = this;
// Setup input steram
let iStream = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(Ci.nsIConverterInputStream);
iStream.init(is, "UTF-8", 1024, Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER);
//iStream.init(is, "UTF-8", 1024, 0);
let binInStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(Ci.nsIBinaryInputStream);
binInStream.setInputStream(is);
let pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance(Ci.nsIInputStreamPump);
// iStream, stremPos, stremLen, segSize, segCount, closeWhenDone
pump.init(is , -1, -1, 0, 0, true);
pump.asyncRead({
onStartRequest: function(request, context) { },
onStopRequest: function(request, context, status){ self.quit(); },
onDataAvailable: function onPumpDataAvailable(request, context, inputStream, offset, count){
let bytes = binInStream.readByteArray(count);
//log("read binay as: " + bytes + " size:" + bytes.length);
let strBytes = telnetOption.parse(bytes, self);
self.onInput.call(self, strBytes);
}
}, null);
this.inputStream = iStream;
this.binIn = binInStream;
function bytesToUTF8(bytes){
let storage = Cc["@mozilla.org/storagestream;1"].createInstance(Ci.nsIStorageStream);
storage.init();
}
// Setup output steram
let oStream = Cc["@mozilla.org/intl/converter-output-stream;1"].createInstance(Ci.nsIConverterOutputStream);
oStream.init(os, "UTF-8", 1024, "?".charCodeAt(0));
this.outputStream = oStream;
let binOutStream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance(Ci.nsIBinaryOutputStream);
binOutStream.setOutputStream(os);
this.binOut = binOutStream;
this.shell = new Shell(this);
this.buffer = new Buffer();
this.isOpen = true;
telnetOption.create(this, "WILL","SGA").send();
telnetOption.create(this, "WILL","ECHO").send();
telnetOption.create(this, "DONT","ECHO").send();
telnetOption.create(this, "DO","NAWS").send();
telnetOption.create(this, "DO","TERMINAL_TYPE").send();
telnetOption.create(this, "SB","TERMINAL_TYPE", [0x01]).send();
telnetOption.create(this, "DO","LINEMODE").send();
}, /// }}}
option: {
ECHO: false,
SGA: false
},
code: [
"NUL","SOH","STX","ETX","EOT","ENQ","ACK","BEL","BS", "HT",
"LF", "VT", "NP", "CR", "SO", "SI", "DLE","DC1","DC2","DC3",
"DC4","NAK","SYN","ETB","CAN","EM", "SUB","ESC","FS", "GS",
"RS", "US"
],
onInput :function TS_onInput(bytes){
let index = this.buffer.str.length;
let mode = 0;
let buf = [];
for (let i=0, len=bytes.length; i < len; i++){
let byte = bytes[i];
if (byte < 32){
if (buf.length > 0){
this.buffer.add(buf.splice(0));
}
switch(this.code[byte]){
case "ETX": // 0x03 ^C
case "EOT": // 0x04 ^D
case "HT": // 0x09 Tab
if (this.shell.onKey(this.code[byte])) return;
break;
case "CR": // 0x0D
if (bytes[i+1] == 0x00 || bytes[i+1] == 0x0A){
i++;
this.print("\r\n");
if (this.buffer.str.length > 0){
this.shell.exec(this.buffer.str);
}
this.prompt();
this.buffer.clear();
}
break;
case "ESC":
if (bytes[i+1]== 0x5B /* [ */){
i++;
mode = 1;
}
break;
case "BS":
let delLen = this.buffer.back();
if (delLen){
this.buffer.delete();
if (this.option.ECHO){
this.print(this.VT100.delLine + "\r")
this.prompt();
this.print(this.buffer.str);
}
}
break;
}
} else if (mode){
let char = String.fromCharCode(byte);
switch(char){
case "A": //up
this.shell.onKey("KEY_UP");
break;
case "B": //down
this.shell.onKey("KEY_DOWN");
break;
case "C": //right
this.print(this.VT100.right(this.buffer.forward()));
break;
case "D": //left
this.print(this.VT100.left(this.buffer.back()));
break;
case "3":
if (bytes[i+1] == 0x7E /* ~ */){
i++;
this.print(this.VT100.delChar);
}
}
mode = 0;
} else if (byte == 127){ //DEL
if (buf.length > 0){
this.buffer.add(buf.splice(0));
}
let delLen = this.buffer.back();
if (delLen){
this.buffer.delete();
if (this.option.ECHO){
this.print(this.VT100.delLine + "\r");
this.prompt();
this.print(this.buffer.str);
}
}
} else {
buf.push(byte);
}
}
if (buf.length > 0){
this.buffer.add(buf);
}
if (this.buffer.str.length > index && this.option.ECHO){
if (this.buffer.str.length == this.buffer.cursorPosition)
this.print(this.buffer.str.slice(index));
else {
let len = this.buffer.splitStrsLength[1];
this.print(this.VT100.delLine + "\r");
this.prompt();
this.print(this.buffer.str + this.VT100.left(len));
}
}
},
binPrint: function TS_binaryPrint(bytes){
if (!(bytes instanceof Array)) return;
try {
this.binOut.writeByteArray(bytes, bytes.length);
} catch(e){ }
},
print: function TS_out(str){
try {
this.outputStream.writeString(str);
} catch(e){}
},
prompt:function TS_prompt(){
this.shell.prompt();
},
quit: function TS_quit(){
if (!this.isOpen) return;
log("session quiting...");
this.binIn.close();
this.inputStream.close();
this.binOut.close();
this.outputStream.close();
this.isOpen = false;
sessions.remove(this);
return true;
},
VT100: {
saveCursor: "\0337",
restorCursor: "\0338",
delChar: "\033[P",
delLine: "\033[M",
clear: "\033[H\033[2J",
newScreen: "\0337\033[?47h",
exitScreen: "\033[?47l\0338",
color: function(color, bgColor){ // {{{
let c, bg;
let str = "\033[";
switch(color){
case "black": c = "30"; break;
case "red": c = "31"; break;
case "green": c = "32"; break;
case "yellow": c = "33"; break;
case "blue": c = "34"; break;
case "magenta": c = "35"; break;
case "cyan": c = "36"; break;
case "white": c = "37"; break;
case "reset": c = "00"; break;
}
switch (bgColor){
case "black": bg = "40"; break;
case "red": bg = "41"; break;
case "green": bg = "42"; break;
case "yellow": bg = "43"; break;
case "blue": bg = "44"; break;
case "magenta": bg = "45"; break;
case "cyan": bg = "46"; break;
case "white": bg = "47"; break;
case "reset": bg = "00"; break;
}
if (c && bg){
str += c + ";" + bg;
} else if (c){
str += c;
} else if (bg){
str += bg;
}
return str + "m";
}, // }}}
cursor: function(x, y){
return "\033[" + y + ";" + x + "H";
},
cursorX: function(x){
return "\033[" + x + "G";
},
left: function(num){
if (num < 1) return "";
return "\033[" + num + "D";
},
right: function(num){
if (num < 1) return "";
return "\033[" + num + "C";
},
},
clear: function TS_claer(){
this.print("\033[H\033[2J");
},
}; // }}}
// --------------------------------------------------------
// Buffer
// -----------------------------------------------------{{{
let Buffer = (function(){
function bytesToString(bytes){
return decodeURIComponent(bytes.map(function(byte){
return "%" + ("00" + byte.toString(16)).slice(-2);
}).join(""));
}
function displayLength(str){
return Array.slice(str).reduce(function(sum, char){
let code = char.charCodeAt(0);
return sum + (code < 0x80 ? 1 : ((code > 0xFF60 && code < 0xFFA0) ? 1 : 2));
}, 0);
}
function B(){
this.str = "";
this.cursorPosition = 0;
}
B.prototype = {
clear: function(){
this.str="";
this.cursorPosition=0;
},
get splitStrs(){
return [this.str.slice(0, this.cursorPosition), this.str.slice(this.cursorPosition)];
},
get splitStrsLength(){
return [displayLength(str) for each(str in this.splitStrs)];
},
add: function(bytes){
var str = bytesToString(bytes);
var [pre, pos] = this.splitStrs;
this.cursorPosition += str.length;
this.str = pre + str + pos;
},
delete: function(){
var [pre,pos] = this.splitStrs;
this.str = pre + pos.slice(1);
return pos[0];
},
back: function(){
if (this.cursorPosition > 0){
return (this.str.charCodeAt(--this.cursorPosition) > 127 ? 2 :1);
}
return 0;
},
forward: function(){
if (this.cursorPosition < this.str.length){
return (this.str.charCodeAt(this.cursorPosition++) > 127 ? 2 :1);
}
return 0;
},
toString: function(){
return this.str;
}
};
return B;
})()
// }}}
// --------------------------------------------------------
// TelnetOption
// -----------------------------------------------------{{{
let telnetOption = (function(){
const TC = { // {{{
SE: {code: 0xF0, name: "SubnegotiationEnd"},
NOP: {code: 0xF1, name: "NOP"},
DM: {code: 0xF2, name: "DataMark"},
BREAK: {code: 0xF3, name: "BREAK"},
IP: {code: 0xF4, name: "InterruptProcess"},
AO: {code: 0xF5, name: "AbortOutput"},
AYT: {code: 0xF6, name: "AreYouThere", receive: function(s){
s.print("[YES]\r\n");
}},
EC: {code: 0xF7, name: "EraseCharacter"},
EL: {code: 0xF8, name: "EraseLine"},
GA: {code: 0xF9, name: "GoAhead"},
SB: {code: 0xFA, name: "SubnegotiationBegin"},
WILL: {code: 0xFB, name: "WILL"},
WONT: {code: 0xFC, name: "WON'T"},
DO: {code: 0xFD, name: "DO"},
DONT: {code: 0xFE, name: "DON'T"},
IAC: {code: 0xFF, name: "IAC"},
}; // }}}
const TA = { // {{{
BIN: {code: 0x00, name: "Binary"},
ECHO: {
code: 0x01,
name: "ECHO",
send: function(s, cmd){
switch(cmd.code){
case TC.WILL.code:
s.option.ECHO = true;
break;
case TC.WONT.code:
s.option.ECHO = false;
break;
}
},
receive: function(s, cmd){
switch(cmd.code){
case TC.DO.code:
s.option.ECHO = true;
break;
case TC.DONT.code:
s.option.ECHO = false;
break;
}
}
},
SGA: {code: 0x03, name: "SuppressGoAhead"},
STATUS: {code: 0x05, name: "Status"},
TIMING_MARK: {code: 0x06, name: "TimingMark"},
TERMINAL_TYPE: {
code: 0x18,
name: "TerminalType",
receive: function(s, cmd, option){
if (!option || option[0] != 0x00) return;
option.shift();
let term = option.map(function(byte){return String.fromCharCode(byte);}).join("");
s.shell.env.TERM = term;
}
},
END_OF_RECODE: {code: 0x19, name: "EndOfRecode"},
NAWS: { // http://www.faqs.org/rfcs/rfc1073.html
code: 0x1F,
name: "NegotiateAboutWindowSize",
receive: function(s, cmd, option){
if (!option) return;
s.shell.env.COLUMNS = option.shift() * 256 + option.shift();
s.shell.env.LINES = option.shift() * 256 + option.shift();
}
},
LFLOW: {code: 0x21, name: "ToggleFlowControl"},
LINEMODE: {code: 0x22, name: "LineMode"}, // http://www.faqs.org/rfcs/rfc1184.html
ENVIRON: {code: 0x24, name: "Envirion"},
NEW_ENVIRON: {code: 0x27, name: "NewEnviron"},
EXOPL: {code: 0xFF, name: "ExtendedOoptionsList"},
}; // }}}
function getFromTable(code, table){
for (var key in table){
if (table[key].code == code)
return table[key];
}
return null;
}
function getCmd(code){
return getFromTable(code, TC);
}
function getAct(code){
return getFromTable(code, TA);
}
function TelnetOption(){ this.init.apply(this, arguments); }
TelnetOption.prototype = { // {{{
init: function(session, cmd, act, option){
this.session = session;
this.cmd = null;
this.act = null;
this.option = null;
if (typeof cmd == "number"){
this.cmd = getCmd(cmd);
} else if (typeof cmd == "string" && cmd in TC){
this.cmd = TC[cmd];
}
if (typeof act == "number"){
this.act = getAct(act);
} else if (typeof act == "string" && act in TA){
this.act = TA[act];
}
if (this.cmd.code == TC.SB.code && option){
this.option = option;
}
},
send: function(){
log("SEND: " + this.toString());
this.session.binPrint(this.toByteArray());
if (this.act && this.act.send)
this.act.send(this.session, this.cmd, this.option);
else if (this.cmd && this.cmd.send)
this.cmd.send(this.session);
},
receive: function(){
log("RCVD: " + this.toString());
let resBytes;
if (this.act && this.act.receive)
resBytes = this.act.receive(this.session, this.cmd, this.option);
else if (this.cmd && this.cmd.receive)
resBytes = this.cmd.receive(this.session);
return resBytes;
},
toByteArray: function(){
var bytes = [TC.IAC.code];
bytes.push(this.cmd.code);
if (this.act){
bytes.push(this.act.code);
// SubnegitionBeginだった場合はoptionとSubnegtiationEndを付加
if (this.cmd.code == TC.SB.code && this.option){
bytes = bytes.concat(this.option, [TC.IAC.code, TC.SE.code]);
}
}
return bytes;
},
toString: function(){
var str = ["IAC"];
str.push(this.cmd.name);
if (this.act){
str.push(this.act.name);
if (this.cmd.code == TC.SB.code && this.option){
str.push(this.option.join(" "));
str.push("IAC");
str.push(TC.SE.name);
}
}
return str.join(" ");
},
}; // }}}
let self = {
parse: function TO_parse(bytes, session){ // {{{
var strBytes = [], telnetOptions = [];
var mode = 0;
var cmd = null, act = null, subnegoBytes = [];
var byte;
var i = 0, len = bytes.length;
for (; i < len; i++){
byte = bytes[i];
switch (mode){
case 1: // after IAC
cmd = byte;
switch (byte){
case 0xFA: //TC.SB:
mode = 3
break;
case 0xFB: //WILL:
case 0xFC: //WONT:
case 0xFD: //DO:
case 0xFE: //DONT:
mode = 2;
break;
case 0xF2:
case 0xF3:
case 0xF4:
case 0xF5:
case 0xF6:
case 0xF7:
case 0xF8:
case 0xF9:
opt = new TelnetOption(session, cmd);
opt.receive();
mode = 0;
break;
case 0xF1: //NOP
defalt:
mode = 0;
}
break;
case 2: // after TELNET CMD
opt = new TelnetOption(session, cmd, byte);
opt.receive();
//telnetOptions.push(opt);
mode = 0;
break;
case 3: // Subnegotiation ACT
act = byte;
mode = 4;
break;
case 4: // Subnetotiation DATA
if (byte == 0xFF && (bytes[i+1] && bytes[i+1] == 0xF0)){
i++;
opt = new TelnetOption(session, cmd, act, subnegoBytes);
opt.receive();
//telnetOptions.push(opt);
mode = 0;
subnegoBytes = [];
} else {
subnegoBytes.push(byte);
}
break;
case 0:
default:
if (byte == 0xFF){
mode = 1;
continue;
}
strBytes.push(byte);
}
}
//return [strBytes, telnetOptions];
return strBytes;
}, // }}}
create: function(session, cmd, act, opt){
return new TelnetOption(session, cmd,act,opt);
}
};
return self;
})(); // }}}
// --------------------------------------------------------
// Shell
// -----------------------------------------------------{{{
let Shell = (function(){
function ENV(){}
ENV.prototype = {
PWD: "document.defaultView",
PS1: "$ ",
PS2: "> ",
COLUMNS: 80,
LINES: 40,
option: {
ignore_eof: false,
}
};
function History(){} // {{{
History.prototype = {
list: [],
index: 0,
max: 20,
add: function(line){
if (this.list.length == this.max){
this.list.shift();
}
this.index = this.list.push(line);
},
getPrev: function(){
this.index = this.index > 1 ? this.index-1 : 0;
return this.list[this.index];
},
getNext: function(){
this.index = this.index < this.max ? this.index+1 : this.max;
return this.list[this.index];
}
}; // }}}
let cmds = { // {{{
echo: function(args){
return args.toString() + "\r\n";
},
env: function(args){
let env = this.env;
let res = [];
for (let key in env){
if (typeof env[key] != "object"){
res.push(key + "=" + env[key]);
}
}
return res.join("\r\n") + "\r\n";
},
pwd: function(){
this.print(this.env.PWD + "\r\n");
},
cd: function(args){
if (args && args[0]){
let path = args[0];
let cdir = this.env.PWD;
let obj = window.eval("document.defaultView." +path);
if (typeof obj != "object"){
obj = window.eval(cdir + "." + path);
if (typeof obj != "object"){
this.print("No such a object: " + path);
}
this.env.PWD = cdir + "." + path;
return;
}
this.env.PWD = "document.defaultView." + path;
return;
}
},
ls: function(args){
if (args && args.length > 0){
let objs = args.map(function(path){ return window.eval(path); })
//TODO
}
},
exit: function(args){
this.quit();
},
}; // }}}
function Statements(){ // {{{
this.list = [];
this.list.isPipe = false;
}
Statements.prototype = {
get length(){ return this.list.length; },
new: function(){
return this.list.push([]);
},
get isPipe(){
return this.list.isPipe;
},
set isPipe(value){
return this.list.isPipe = !!value;
},
push: function(expr){
let i = this.length - 1;
return this.list[i].push(expr);
},
append: function(expr){
var i = this.length - 1;
if (!expr && this.list[i].length == 0){
return;
}
return this.list[i].push(expr);
},
getStatement: function(i){
let args = this.list[i];
if (args.length > 0){
let cmd = args.shift();
return [cmd, args];
} else {
return [null, null];
}
}
}; // }}}
function Command(){ this.init.apply(this, arguments); } // {{{
Command.prototype = {
init: function(name, action, extra){
this.name = name;
this.action = action;
this.options = extra.options || [];
this.completer = extra.completer || null;
},
}; // }}}
function Commands(){ // {{{
this.cmds = [];
}
Commands.prototype = {
parseLine: function(line){
let i = -1, len = line.length;
let dq = false, sq = false, sp = false;
let c = "";
let buf = "";
let sts = new Statements();
let bufstr = "";
sts.new();
while(i++ < len){
c = line.charAt(i);
switch(c){
case " ":
if (dq || sq){
break;
} else if (!sp){
sts.push(bufstr);
bufstr = "";
sp = true;
}
continue;
case ";":
if (dq || sq){
break;
}
sts.push(bufstr);
sts.new();
bufstr = "";
continue;
case "|":
if (dq || sq) break;
sts.isPipe = true;
sts.push(bufstr);
sts.new();
break;
case "\\":
buf = line.charAt(++i);
if (buf == "r"){
c = "\r";
} else if (buf = "n"){
c = "\r\n";
} else {
c = buf;
}
break;
case "\"":
if (sq) break;
dq = !dq;
continue;
case "'":
if (dq) break;
sq = !sq;
continue;
}
sp = false;
bufstr += c;
}
if (bufstr) sts.push(bufstr);
//if (sq || dq) print("ERROR: didn't quoted");
return sts;
},
addCommend: function(name, action, extra){
},
parse: function(line){
line = line.replace(/^\s*|\s*$/g, "");
let matches = line.match(/^([$_a-zA-Z][\w]*)(?:\s+(.*$))?/);
if (matches){
return [matches[1], matches[2]];
}
return [null, null];
},
get: function(cmd){
if (cmd in cmds){
return cmds[cmd];
}
return null;
}
}; // }}}
function SH(){ this.init.apply(this, arguments); }
SH.prototype = {
init: function(session){
this.session = session;
this.env = new ENV();
this.history = new History();
this.cmd = new Commands();
this.prompt();
},
exec: function(str){
this.history.add(str);
let sts = this.cmd.parseLine(str);
for (let i=0, len=sts.length; i<len; i++){
let [cmd, args] = sts.getStatement(i);
if (!cmd) continue;
let command = this.cmd.get(cmd);
if (command){
let res = command.call(this, args);
if (res){
this.print(res.toString());
}
} else {
this.print("No such a command: " + cmd + "\r\n");
}
}
},
onKey: function(evtName, buffer){
switch(evtName){
case "ETX":
this.print("\r\n^C\r\n");
this.session.buffer.clear();
this.prompt();
return true;
case "EOT":
this.print("\r\n^D\r\n");
this.quit();
return true;
case "HT":
this.print("^I");
break;
case "KEY_UP":
let hist = this.history.getPrev();
this.print("\r" + this.session.VT100.delLine);
this.prompt();
this.print(hist);
break;
case "KEY_DOWN":
let hist = this.history.getNext();
this.print("\r" + this.session.VT100.delLine);
this.prompt();
this.print(hist);
break;
}
return false;
},
prompt: function(){
this.print(this.env.PS1);
},
quit: function(){
this.print("\r\nbye ...\r\n");
this.session.quit();
},
print: function(str){
this.session.print(str);
}
};
return SH;
})();
// }}}
let self = {
start: function(port){
if (!port) port = 4444;
daemon.start(port);
},
stop: function(){
daemon.stop();
},
get daemon(){
return daemon;
},
get sessions(){
return sessions;
}
};
return self;
})();
function onUnload(){
liberator.plugins.telnetd.stop();
}
liberator.plugins.telnetd.start();
// vim: sw=2 ts=2 et fdm=marker:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment