- BREAKING CHANGE from v0.9.35 and eariler: Move accounts into subfolder of SessionStore - viewergen and viewer updates from @GraspYonOx - new homepage theme inspired by @GraspYonOx - connect setup also mostly by @GraspYonOx - numerous bug fixes - nntp-server-zefie is now on npmjs - implement proper nodejs debugging - will start to phase out using console.log for actual debugging - existing "debug" (verbose) messages will stay as is - future code debugging will use debug() to debug WIP, therefore not showing to most users if it is accidently left in
532 lines
24 KiB
JavaScript
532 lines
24 KiB
JavaScript
class WTVNews {
|
|
|
|
minisrv_config = null;
|
|
newsie = require('newsie').default;
|
|
strftime = require('strftime');
|
|
wtvshared = null;
|
|
service_name = null;
|
|
client = null;
|
|
username = null;
|
|
password = null;
|
|
posting_allowed = true;
|
|
debug = null;
|
|
|
|
constructor(minisrv_config, service_name) {
|
|
this.minisrv_config = minisrv_config;
|
|
this.service_name = service_name;
|
|
const { WTVShared } = require("./WTVShared.js");
|
|
this.wtvshared = new WTVShared(minisrv_config);
|
|
this.debug = require('debug')('WTVNews');
|
|
}
|
|
|
|
initializeUsenet(host, port = 119, tls_options = null, username = null, password = null) {
|
|
// use local self-signed cert for local server
|
|
var newsie_options = {
|
|
host: host,
|
|
port: port,
|
|
tlsPort: (tls_options !== null) ? true : false,
|
|
}
|
|
if (newsie_options.tlsPort) newsie_options.tlsOptions = tls_options;
|
|
this.client = new this.newsie(newsie_options);
|
|
if (username && password) {
|
|
this.username = username;
|
|
this.password = password;
|
|
}
|
|
}
|
|
|
|
connectUsenet() {
|
|
return new Promise((resolve, reject) => {
|
|
this.client.connect().then((response) => {
|
|
if (response.code == 200 || response.code == 201) {
|
|
if (response.code == 201) this.posting_allowed = false;
|
|
if (this.username && this.password) {
|
|
this.client.authInfoUser(this.username).then((res) => {
|
|
if (res.code == "381") {
|
|
res.authInfoPass(this.password).then((res) => {
|
|
if (res.code == 281) resolve(true);
|
|
else reject(res.description);
|
|
}).catch((e) => {
|
|
console.error(" * WTVNews Error:", "Command: connect", e);
|
|
reject("Could not connect to upstream usenet server");
|
|
});
|
|
} else {
|
|
reject(res.description)
|
|
}
|
|
}).catch((e) => {
|
|
console.error(" * WTVNews Error:", "Command: connect", e);
|
|
reject("Could not connect to upstream usenet server");
|
|
});
|
|
} else {
|
|
resolve(true);
|
|
}
|
|
}
|
|
}).catch((e) => {
|
|
console.error(" * WTVNews Error:", "Command: connect", e);
|
|
reject("Could not connect to upstream usenet server");
|
|
});
|
|
});
|
|
}
|
|
|
|
listGroup(group, page = 0, limit = 100, raw_range = null) {
|
|
// list of articles from group
|
|
return new Promise((resolve, reject) => {
|
|
this.selectGroup(group).then((res) => {
|
|
if (!raw_range) {
|
|
var range = {
|
|
start: (limit * page) + res.group.low,
|
|
}
|
|
range.end = range.start + limit;
|
|
if (page) range.start++;
|
|
if (range.end > res.high) delete range.group.end;
|
|
} else {
|
|
range = raw_range;
|
|
}
|
|
this.client.listGroup(group, range).then((data) => {
|
|
resolve(data);
|
|
}).catch((e) => {
|
|
console.error(" * WTVNews Error:", "Command: listGroup", e);
|
|
reject(`No such group <b>${group}</b>`);
|
|
});
|
|
}).catch((e) => {
|
|
console.error(" * WTVNews Error:", "Command: selectGroup", e);
|
|
});
|
|
})
|
|
}
|
|
|
|
processGroupList(list) {
|
|
if (list) return list.newsgroups;
|
|
else return null;
|
|
}
|
|
|
|
listGroups(search = null) {
|
|
// list of groups on the server
|
|
return new Promise((resolve, reject) => {
|
|
if (!search) {
|
|
this.client.list().then((data) => {
|
|
console.log('listGroups data', data)
|
|
resolve(this.processGroupList(data));
|
|
}).catch((e) => {
|
|
console.error(" * WTVNews Error:", "Command: listGroups (all)", e);
|
|
reject(e);
|
|
});
|
|
} else {
|
|
this.client.listNewsgroups((search === '*') ? '*' : '*' + search + '*').then((data) => {
|
|
resolve(this.processGroupList(data));
|
|
}).catch((e) => {
|
|
console.error(" * WTVNews Error:", "Command: listGroups (search)", search, e);
|
|
reject(e);
|
|
});
|
|
}
|
|
})
|
|
}
|
|
|
|
selectGroup(group) {
|
|
return new Promise((resolve, reject) => {
|
|
this.client.group(group).then((response) => {
|
|
if (response.code == 211) resolve(response);
|
|
else reject(`No such group <b>${group}</b>`);
|
|
}).catch((e) => {
|
|
console.error(" * WTVNews Error:", "Command: selectGroup", e);
|
|
reject(`Error selecting group <b>${group}</b>`);
|
|
});
|
|
});
|
|
}
|
|
|
|
getArticle(articleID, get_next_last = true) {
|
|
var articleID = parseInt(articleID);
|
|
return new Promise((resolve, reject) => {
|
|
var promises = [];
|
|
this.client.article(articleID).then((data) => {
|
|
if (get_next_last) {
|
|
// ask server for next article
|
|
promises.push(new Promise((resolve, reject) => {
|
|
this.client.next().then((res) => {
|
|
data.next_article = res.article.articleNumber;
|
|
resolve(data.next_article);
|
|
}).catch((e) => {
|
|
data.next_article = null;
|
|
resolve(data.next_article);
|
|
})
|
|
}));
|
|
|
|
// ask server for previous article
|
|
promises.push(new Promise((resolve, reject) => {
|
|
this.client.last().then((res) => {
|
|
data.prev_article = res.article.articleNumber;
|
|
if (data.prev_article === articleID) {
|
|
// do it again, needed this for CodoSoft NNTPd?
|
|
this.client.article(data.prev_article).then(() => {
|
|
this.client.last().then((res) => {
|
|
data.prev_article = res.article.articleNumber;
|
|
resolve(data.prev_article);
|
|
}).catch(() => {
|
|
data.prev_article = null;
|
|
resolve(data.prev_article);
|
|
});
|
|
}).catch(() => {
|
|
data.prev_article = null;
|
|
resolve(data.prev_article);
|
|
});
|
|
} else {
|
|
resolve(data.prev_article);
|
|
}
|
|
}).catch(() => {
|
|
data.prev_article = null;
|
|
resolve(data.prev_article);
|
|
})
|
|
}));
|
|
|
|
Promise.all(promises).then(() => {
|
|
var self = this;
|
|
if (data.article.headers) Object.keys(data.article.headers).forEach((k) => {
|
|
data.article.headers[k] = self.decodeCharset(data.article.headers[k])
|
|
});
|
|
resolve(data);
|
|
});
|
|
} else {
|
|
var self = this;
|
|
if (data.article.headers) Object.keys(data.article.headers).forEach((k) => {
|
|
data.article.headers[k] = self.decodeCharset(data.article.headers[k])
|
|
});
|
|
resolve(data);
|
|
}
|
|
}).catch((e) => {
|
|
reject(`Error reading article ID ${articleID}`);
|
|
console.error(" * WTVNews Error:", "Command: article", "args:", articleID, "Error:", e);
|
|
});
|
|
});
|
|
}
|
|
|
|
decodeCharset(string) {
|
|
var regex = /=\?{1}(.+)\?{1}([B|Q])\?{1}(.+)\?{1}=/;
|
|
var decoded = null;
|
|
var check = string.match(regex);
|
|
if (check) {
|
|
var match = check[0];
|
|
var charset = check[1];
|
|
var encoding = check[2];
|
|
var encoded_text = check[3];
|
|
switch (encoding) {
|
|
case "B":
|
|
var buffer = new Buffer.from(encoded_text, 'base64')
|
|
decoded = buffer.toString(charset).replace(/[^\x00-\x7F]/g, "");;
|
|
break;
|
|
|
|
case "Q":
|
|
// unimplemented
|
|
return string;
|
|
}
|
|
if (decoded) return string.replace(match, decoded);
|
|
}
|
|
return string;
|
|
}
|
|
|
|
getHeader(articleID) {
|
|
return new Promise((resolve, reject) => {
|
|
this.client.head(articleID).then((data) => {
|
|
var self = this;
|
|
if (data.article.headers) Object.keys(data.article.headers).forEach((k) => {
|
|
data.article.headers[k] = self.decodeCharset(data.article.headers[k])
|
|
});
|
|
resolve(data);
|
|
}).catch((e) => {
|
|
reject(`Error getting header for article ID ${articleID}`);
|
|
console.error(" * WTVNews Error:", "Command: head -", "Article ID: " + articleID, e);
|
|
});
|
|
});
|
|
}
|
|
|
|
getHeaderFromMessage(message, header) {
|
|
var response = null;
|
|
if (message.article.headers) {
|
|
Object.keys(message.article.headers).forEach((k) => {
|
|
if (k.toLowerCase() == header.toLowerCase()) {
|
|
response = message.article.headers[k];
|
|
return false;
|
|
}
|
|
})
|
|
}
|
|
return response;
|
|
}
|
|
|
|
quitUsenet() {
|
|
return new Promise((resolve, reject) => {
|
|
this.client.quit().then((response) => {
|
|
if (response.code == 205) resolve(true);
|
|
else {
|
|
console.error(" * WTVNews Error:", "Command: quit", e);
|
|
reject(`Unexpected response code ${response.code}`);
|
|
}
|
|
}).catch((e) => {
|
|
console.error(" * WTVNews Error:", "Command: quit", e);
|
|
reject("Error quitting usenet session");
|
|
})
|
|
});
|
|
}
|
|
|
|
postToGroup(group, from_addr, msg_subject, msg_body, article = null, headers = null) {
|
|
return new Promise((resolve, reject) => {
|
|
var promises = [];
|
|
var messageid = null;
|
|
this.connectUsenet()
|
|
.then(() => {
|
|
if (article) {
|
|
promises.push(new Promise((resolve, reject) => {
|
|
this.selectGroup(group).then((res) => {
|
|
this.getArticleMessageID(article).then((data) => {
|
|
messageid = data;
|
|
resolve(data);
|
|
}).catch((e) => {
|
|
console.log('Error getting articleID',article, e)
|
|
reject(e)
|
|
});
|
|
}).catch((e) => {
|
|
console.log('Error selecting group', e)
|
|
reject(e)
|
|
});
|
|
}));
|
|
}
|
|
Promise.all(promises).then(() => {
|
|
this.client.post()
|
|
.then((response) => {
|
|
if (response.code == 340) {
|
|
var articleData = {};
|
|
articleData.headers = {
|
|
'Relay-Version': "version zefie_wtvp_minisrv " + this.minisrv_config.version + "; site " + this.minisrv_config.config.domain_name,
|
|
'Posting-Version': "version zefie_wtvp_minisrv " + this.minisrv_config.version + "; site " + this.minisrv_config.config.domain_name,
|
|
'Path': "@" + this.minisrv_config.config.domain_name,
|
|
'From': from_addr,
|
|
'Newsgroups': group,
|
|
'Subject': msg_subject || "(No subject)",
|
|
'Message-ID': "<" + this.wtvshared.generateString(16) + "@" + this.minisrv_config.config.domain_name + ">",
|
|
'Date': this.strftime('%a, %-d %b %Y %H:%M:%S %z', new Date())
|
|
}
|
|
if (headers) {
|
|
Object.keys(headers).forEach((k) => {
|
|
articleData.headers[k] = headers[k];
|
|
});
|
|
}
|
|
if (messageid) {
|
|
articleData.headers.References = messageid;
|
|
articleData.headers['In-Reply-To'] = messageid;
|
|
}
|
|
if (msg_body) articleData.body = msg_body.split("\n");
|
|
else articleData.body = [];
|
|
|
|
response.send(articleData).then((response) => {
|
|
this.client.quit();
|
|
if (response.code !== 240) {
|
|
reject("Could not send post. Server returned error " + response.code);
|
|
} else {
|
|
resolve(true);
|
|
}
|
|
}).catch((e) => {
|
|
console.error(e);
|
|
this.client.quit();
|
|
reject("Could not send post. Server returned error " + response.code);
|
|
});
|
|
} else {
|
|
this.client.quit();
|
|
console.error('usenet upstream uncaught error', e);
|
|
reject("Could not send post. Server returned unknown error");
|
|
};
|
|
}).catch((e) => {
|
|
console.error('could not connect to server', e);
|
|
reject("could not connect to server");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
getArticleMessageID(articleID) {
|
|
return new Promise((resolve, reject) => {
|
|
this.client.article(articleID).then((data) => {
|
|
resolve(data.article.messageId);
|
|
}).catch((e) => {
|
|
console.error("error getting messageID from article", articleID, e)
|
|
reject(e);
|
|
});
|
|
});
|
|
}
|
|
|
|
getHeaderObj(NGArticles) {
|
|
return new Promise((resolve, reject) => {
|
|
var messages = [];
|
|
var promises = [];
|
|
for (var article in NGArticles) {
|
|
if (article == "getCaseInsensitiveKey") continue;
|
|
promises.push(new Promise((resolve, reject) => {
|
|
this.getHeader(NGArticles[article]).then((data) => {
|
|
if (data.article) messages.push(data.article)
|
|
resolve();
|
|
}).catch((e) => {
|
|
reject(e);
|
|
});
|
|
}));
|
|
}
|
|
if (promises.length > 0) {
|
|
Promise.all(promises).then(() => {
|
|
if (messages.length > 0) resolve(messages);
|
|
}).catch((e) => {
|
|
reject("Could not read message list", e);
|
|
});
|
|
} else {
|
|
resolve(messages);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
parseAttachments(message) {
|
|
var contype = this.getHeaderFromMessage(message, 'Content-Type');
|
|
if (contype) {
|
|
var regex = /multipart\/mixed\; boundary=\"(.+)\"/i;
|
|
var match = contype.match(regex);
|
|
if (match) {
|
|
var boundary = "--" + match[1];
|
|
var body = message.article.body.join("\n").split(boundary);
|
|
var attachments = [];
|
|
var i = 0;
|
|
var message_body = '';
|
|
var message_type = 'text/plain';
|
|
body.forEach((element) => {
|
|
var section_type = null;
|
|
var section = element.split("\n");
|
|
attachments[i] = {};
|
|
section.forEach((line) => {
|
|
this.debug('section_type', section_type, 'line', line);
|
|
var section_header_match = line.match(/^Content\-/i)
|
|
if (section_header_match) {
|
|
var section_match = line.match(/^Content\-Type\: (.+)\;/i)
|
|
if (section_match) {
|
|
this.debug('section_match', section_match)
|
|
section_type = section_match[1];
|
|
if (section_match[1].match("text/plain")) {
|
|
message_type = section_type;
|
|
} else {
|
|
section_type = section_match[1];
|
|
attachments[i].content_type = section_match[1]
|
|
}
|
|
this.debug('section_type', section_type)
|
|
}
|
|
section_match = line.match(/^Content\-Disposition\: (.+)\;/i)
|
|
if (section_match) {
|
|
section_match = line.match(/^Content\-Disposition\: (.+)\; filename=\"(.+)\"/i)
|
|
if (section_match) attachments[i].filename = section_match[2];
|
|
}
|
|
section_match = line.match(/^Content-Transfer-Encoding: (.+)/i)
|
|
if (section_match) attachments[i].content_encoding = section_match[1];
|
|
} else {
|
|
if (section_type != null) {
|
|
if (section_type.match("text/plain")) message_body += line;
|
|
else {
|
|
if (attachments[i].data) attachments[i].data += line;
|
|
else attachments[i].data = line;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
if (attachments[i].content_type) i++;
|
|
})
|
|
attachments.pop();
|
|
return {
|
|
text: message_body,
|
|
text_type: message_type || "text/plain",
|
|
attachments: attachments
|
|
}
|
|
} else {
|
|
var message_body = '';
|
|
if (message.article.body) message_body = message.article.body.join("\n")
|
|
|
|
return { text: message_body }
|
|
}
|
|
} else {
|
|
var message_body = '';
|
|
if (message.article.body) message_body = message.article.body.join("\n")
|
|
|
|
return { text: message_body }
|
|
}
|
|
|
|
}
|
|
|
|
sortByResponse(messages) {
|
|
var sorted = [];
|
|
var message_id_roots = [];
|
|
var message_relations = [];
|
|
Object.keys(messages).forEach((k) => {
|
|
var messageId = messages[k].messageId;
|
|
var ref = messages[k].headers.REFERENCES;
|
|
if (ref) {
|
|
var res = message_id_roots.find(e => e.messageId == ref);
|
|
if (res) {
|
|
// see if its attached to a root post
|
|
if (message_relations[res.messageId]) message_relations[res.messageId].push({ "messageId": messageId, "index": k });
|
|
else message_relations[res.messageId] = [{ "messageId": messageId, "index": k }];
|
|
} else {
|
|
// see if its related to a relation
|
|
if (Object.keys(message_relations).length > 0) {
|
|
var found = false;
|
|
Object.keys(message_relations).forEach((j) => {
|
|
if (message_relations[j].length > 0) {
|
|
Object.keys(message_relations[j]).forEach((h) => {
|
|
if (found) return;
|
|
if (message_relations[j][h].messageId == ref) {
|
|
var searchref = messages[message_relations[j][h].index].headers.REFERENCES || null;
|
|
var mainref = null;
|
|
while (searchref !== null) {
|
|
var searchart = messages.find(e => e.messageId == searchref);
|
|
var searchref = searchart.headers.REFERENCES || null;
|
|
}
|
|
mainref = searchart.messageId;
|
|
message_relations[mainref].push({ "messageId": messageId, "index": k });
|
|
found = true;
|
|
} else {
|
|
// no relation, missing reference, add as root
|
|
message_id_roots.push({ "messageId": messageId, "index": k });
|
|
}
|
|
});
|
|
}
|
|
})
|
|
} else {
|
|
message_id_roots.push({ "messageId": messageId, "index": k });
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
message_id_roots.push({ "messageId": messageId, "index": k });
|
|
}
|
|
});
|
|
|
|
// sort the relations, putting root articles first, followed by their relations
|
|
var message_roots_sorted = [];
|
|
Object.keys(message_id_roots).forEach((k) => {
|
|
// sort relations by date
|
|
var article = messages[message_id_roots[k].index];
|
|
var article_date = Date.parse(article.headers.DATE);
|
|
message_roots_sorted.push({ "article": article, "relation": null, "date": article_date });
|
|
});
|
|
message_roots_sorted.sort((a, b) => { return (a.date - b.date) });
|
|
Object.keys(message_roots_sorted).forEach((k) => {
|
|
sorted.push(message_roots_sorted[k]);
|
|
|
|
if (message_relations[message_id_roots[k].messageId]) {
|
|
var relations = [];
|
|
Object.keys(message_relations[message_id_roots[k].messageId]).forEach((j) => {
|
|
// sort relations by date
|
|
var article = messages[message_relations[message_id_roots[k].messageId][j].index];
|
|
var article_date = Date.parse(article.headers.DATE);
|
|
relations.push({ "article": article, "relation": message_id_roots[k].messageId || null, "date": article_date })
|
|
});
|
|
relations.sort((a, b) => { return (a.date - b.date) });
|
|
Object.keys(relations).forEach((j) => {
|
|
sorted.push(relations[j]);
|
|
});
|
|
}
|
|
})
|
|
return sorted;
|
|
}
|
|
|
|
}
|
|
|
|
module.exports = WTVNews; |