From 57bd1e0eb5bf512b29267d0c2a26c82b8ebb6a94 Mon Sep 17 00:00:00 2001 From: ChronosX88 Date: Sat, 5 Feb 2022 13:51:50 +0300 Subject: [PATCH] Implement ARTICLE command --- README.md | 2 +- internal/backend/sqlite/sqlite.go | 10 +- internal/backend/storage_backend.go | 1 + internal/protocol/constants.go | 1 + internal/server/handler.go | 76 +++- internal/server/session.go | 5 +- internal/utils/message.go | 635 ++++++++++++++++++++++++++++ 7 files changed, 722 insertions(+), 8 deletions(-) create mode 100644 internal/utils/message.go diff --git a/README.md b/README.md index cdf0dbb..ea9c22f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - :heavy_check_mark: `LIST NEWSGROUPS` - :heavy_check_mark: `MODE READER` - :heavy_check_mark: `QUIT` -- :x: `ARTICLE` +- :heavy_check_mark: `ARTICLE` - :x: `BODY` - :heavy_check_mark: `GROUP` - :x: `HDR` diff --git a/internal/backend/sqlite/sqlite.go b/internal/backend/sqlite/sqlite.go index e076af1..1f624dd 100644 --- a/internal/backend/sqlite/sqlite.go +++ b/internal/backend/sqlite/sqlite.go @@ -129,7 +129,15 @@ func (sb *SQLiteBackend) SaveArticle(a models.Article, groups []string) error { func (sb *SQLiteBackend) GetArticle(messageID string) (models.Article, error) { var a models.Article - if err := sb.db.Get(&a, "SELECT * FROM articles WHERE json_extract(articles.header, '$.Message-ID[0]') = ?", messageID); err != nil { + if err := sb.db.Get(&a, "SELECT * FROM articles WHERE json_extract(articles.header, '$.Message-Id[0]') = ?", messageID); err != nil { + return a, err + } + return a, json.Unmarshal([]byte(a.HeaderRaw), &a.Header) +} + +func (sb *SQLiteBackend) GetArticleByNumber(g *models.Group, num int) (models.Article, error) { + var a models.Article + if err := sb.db.Get(&a, "SELECT articles.* FROM articles INNER JOIN articles_to_groups atg on atg.article_id = articles.id WHERE atg.article_number = ? AND atg.group_id = ?", num, g.ID); err != nil { return a, err } return a, json.Unmarshal([]byte(a.HeaderRaw), &a.Header) diff --git a/internal/backend/storage_backend.go b/internal/backend/storage_backend.go index c4b02bd..008188a 100644 --- a/internal/backend/storage_backend.go +++ b/internal/backend/storage_backend.go @@ -16,5 +16,6 @@ type StorageBackend interface { GetGroupHighWaterMark(g *models.Group) (int, error) SaveArticle(article models.Article, groups []string) error GetArticle(messageID string) (models.Article, error) + GetArticleByNumber(g *models.Group, num int) (models.Article, error) GetArticleNumbers(g *models.Group, low, high int64) ([]int64, error) } diff --git a/internal/protocol/constants.go b/internal/protocol/constants.go index 86d9ea5..a1c0a6d 100644 --- a/internal/protocol/constants.go +++ b/internal/protocol/constants.go @@ -56,6 +56,7 @@ const ( CommandNewGroups = "NEWGROUPS" CommandPost = "POST" CommandListGroup = "LISTGROUP" + CommandArticle = "ARTICLE" ) const ( diff --git a/internal/server/handler.go b/internal/server/handler.go index fb30de6..b203f90 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -35,6 +35,7 @@ func NewHandler(b backend.StorageBackend, serverDomain string) *Handler { protocol.CommandNewGroups: h.handleNewGroups, protocol.CommandPost: h.handlePost, protocol.CommandListGroup: h.handleListgroup, + protocol.CommandArticle: h.handleArticle, } h.serverDomain = serverDomain return h @@ -274,13 +275,10 @@ func (h *Handler) handlePost(s *Session, arguments []string, id uint) error { if err != nil { return err } - if err != nil { - return err - } // generate message id messageID := fmt.Sprintf("<%s@%s>", uuid.New().String(), h.serverDomain) - headers["Message-ID"] = []string{messageID} + headers.Set("Message-ID", messageID) headerJson, err := json.Marshal(headers) if err != nil { @@ -387,6 +385,76 @@ func (h *Handler) handleListgroup(s *Session, arguments []string, id uint) error return dw.Close() } +func (h *Handler) handleArticle(s *Session, arguments []string, id uint) error { + s.tconn.StartResponse(id) + defer s.tconn.EndResponse(id) + + if len(arguments) == 0 && s.currentArticle == nil { + return s.tconn.PrintfLine(protocol.NNTPResponse{Code: 420, Message: "No current article selected"}.String()) + } + + if len(arguments) > 1 { + return s.tconn.PrintfLine(protocol.ErrSyntaxError.String()) + } + + getByArticleNum := true + num, err := strconv.Atoi(arguments[0]) + if err != nil { + getByArticleNum = false + } + + if getByArticleNum && s.currentGroup == nil { + return s.tconn.PrintfLine(protocol.NNTPResponse{Code: 412, Message: "No newsgroup selected"}.String()) + } + + var a models.Article + + if getByArticleNum { + a, err = h.backend.GetArticleByNumber(s.currentGroup, num) + if err != nil { + if err == sql.ErrNoRows { + return s.tconn.PrintfLine(protocol.NNTPResponse{Code: 423, Message: "No article with that number"}.String()) + } else { + return err + } + } + } else { + a, err = h.backend.GetArticle(arguments[0]) + if err != nil { + if err == sql.ErrNoRows { + return s.tconn.PrintfLine(protocol.NNTPResponse{Code: 430, Message: "No Such Article Found"}.String()) + } else { + return err + } + } + } + + s.currentArticle = &a + + err = json.Unmarshal([]byte(a.HeaderRaw), &a.Header) + if err != nil { + return err + } + + m := utils.NewMessage() + for k, v := range a.Header { + m.SetHeader(k, v...) + } + + m.SetBody("text/plain", a.Body) // FIXME currently only plain text is supported + dw := s.tconn.DotWriter() + _, err = dw.Write([]byte(protocol.NNTPResponse{Code: 220, Message: fmt.Sprintf("%d %s", num, a.Header.Get("Message-ID"))}.String() + protocol.CRLF)) + if err != nil { + return err + } + _, err = m.WriteTo(dw) + if err != nil { + return err + } + + return dw.Close() +} + func (h *Handler) Handle(s *Session, message string, id uint) error { splittedMessage := strings.Split(message, " ") for i, v := range splittedMessage { diff --git a/internal/server/session.go b/internal/server/session.go index 73213d5..5d2ca9b 100644 --- a/internal/server/session.go +++ b/internal/server/session.go @@ -27,8 +27,9 @@ type Session struct { closed chan<- bool h *Handler - currentGroup *models.Group - mode SessionMode + currentGroup *models.Group + currentArticle *models.Article + mode SessionMode } func NewSession(ctx context.Context, conn net.Conn, caps protocol.Capabilities, id string, closed chan<- bool, handler *Handler) (*Session, error) { diff --git a/internal/utils/message.go b/internal/utils/message.go new file mode 100644 index 0000000..5a62c9c --- /dev/null +++ b/internal/utils/message.go @@ -0,0 +1,635 @@ +package utils + +// this package has been kindly taken from https://github.com/go-gomail/gomail +// licensed under MIT license + +// Copyright (c) 2014 Alexandre Cesaro + +import ( + "bytes" + "encoding/base64" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "os" + "path/filepath" + "strings" + "time" +) + +var newQPWriter = quotedprintable.NewWriter + +type mimeEncoder struct { + mime.WordEncoder +} + +var ( + bEncoding = mimeEncoder{mime.BEncoding} + qEncoding = mimeEncoder{mime.QEncoding} + lastIndexByte = strings.LastIndexByte +) + +// Message represents an email. +type Message struct { + header header + parts []*part + attachments []*file + embedded []*file + charset string + encoding Encoding + hEncoder mimeEncoder + buf bytes.Buffer +} + +type header map[string][]string + +type part struct { + contentType string + copier func(io.Writer) error + encoding Encoding +} + +// NewMessage creates a new message. It uses UTF-8 and quoted-printable encoding +// by default. +func NewMessage(settings ...MessageSetting) *Message { + m := &Message{ + header: make(header), + charset: "UTF-8", + encoding: QuotedPrintable, + } + + m.applySettings(settings) + + if m.encoding == Base64 { + m.hEncoder = bEncoding + } else { + m.hEncoder = qEncoding + } + + return m +} + +// Reset resets the message so it can be reused. The message keeps its previous +// settings so it is in the same state that after a call to NewMessage. +func (m *Message) Reset() { + for k := range m.header { + delete(m.header, k) + } + m.parts = nil + m.attachments = nil + m.embedded = nil +} + +func (m *Message) applySettings(settings []MessageSetting) { + for _, s := range settings { + s(m) + } +} + +// A MessageSetting can be used as an argument in NewMessage to configure an +// email. +type MessageSetting func(m *Message) + +// SetCharset is a message setting to set the charset of the email. +func SetCharset(charset string) MessageSetting { + return func(m *Message) { + m.charset = charset + } +} + +// SetEncoding is a message setting to set the encoding of the email. +func SetEncoding(enc Encoding) MessageSetting { + return func(m *Message) { + m.encoding = enc + } +} + +// Encoding represents a MIME encoding scheme like quoted-printable or base64. +type Encoding string + +const ( + // QuotedPrintable represents the quoted-printable encoding as defined in + // RFC 2045. + QuotedPrintable Encoding = "quoted-printable" + // Base64 represents the base64 encoding as defined in RFC 2045. + Base64 Encoding = "base64" + // Unencoded can be used to avoid encoding the body of an email. The headers + // will still be encoded using quoted-printable encoding. + Unencoded Encoding = "8bit" +) + +// SetHeader sets a value to the given header field. +func (m *Message) SetHeader(field string, value ...string) { + m.encodeHeader(value) + m.header[field] = value +} + +func (m *Message) encodeHeader(values []string) { + for i := range values { + values[i] = m.encodeString(values[i]) + } +} + +func (m *Message) encodeString(value string) string { + return m.hEncoder.Encode(m.charset, value) +} + +// SetHeaders sets the message headers. +func (m *Message) SetHeaders(h map[string][]string) { + for k, v := range h { + m.SetHeader(k, v...) + } +} + +// SetAddressHeader sets an address to the given header field. +func (m *Message) SetAddressHeader(field, address, name string) { + m.header[field] = []string{m.FormatAddress(address, name)} +} + +// FormatAddress formats an address and a name as a valid RFC 5322 address. +func (m *Message) FormatAddress(address, name string) string { + if name == "" { + return address + } + + enc := m.encodeString(name) + if enc == name { + m.buf.WriteByte('"') + for i := 0; i < len(name); i++ { + b := name[i] + if b == '\\' || b == '"' { + m.buf.WriteByte('\\') + } + m.buf.WriteByte(b) + } + m.buf.WriteByte('"') + } else if hasSpecials(name) { + m.buf.WriteString(bEncoding.Encode(m.charset, name)) + } else { + m.buf.WriteString(enc) + } + m.buf.WriteString(" <") + m.buf.WriteString(address) + m.buf.WriteByte('>') + + addr := m.buf.String() + m.buf.Reset() + return addr +} + +func hasSpecials(text string) bool { + for i := 0; i < len(text); i++ { + switch c := text[i]; c { + case '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"': + return true + } + } + + return false +} + +// SetDateHeader sets a date to the given header field. +func (m *Message) SetDateHeader(field string, date time.Time) { + m.header[field] = []string{m.FormatDate(date)} +} + +// FormatDate formats a date as a valid RFC 5322 date. +func (m *Message) FormatDate(date time.Time) string { + return date.Format(time.RFC1123Z) +} + +// GetHeader gets a header field. +func (m *Message) GetHeader(field string) []string { + return m.header[field] +} + +// SetBody sets the body of the message. It replaces any content previously set +// by SetBody, AddAlternative or AddAlternativeWriter. +func (m *Message) SetBody(contentType, body string, settings ...PartSetting) { + m.parts = []*part{m.newPart(contentType, newCopier(body), settings)} +} + +// AddAlternative adds an alternative part to the message. +// +// It is commonly used to send HTML emails that default to the plain text +// version for backward compatibility. AddAlternative appends the new part to +// the end of the message. So the plain text part should be added before the +// HTML part. See http://en.wikipedia.org/wiki/MIME#Alternative +func (m *Message) AddAlternative(contentType, body string, settings ...PartSetting) { + m.AddAlternativeWriter(contentType, newCopier(body), settings...) +} + +func newCopier(s string) func(io.Writer) error { + return func(w io.Writer) error { + _, err := io.WriteString(w, s) + return err + } +} + +// AddAlternativeWriter adds an alternative part to the message. It can be +// useful with the text/template or html/template packages. +func (m *Message) AddAlternativeWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) { + m.parts = append(m.parts, m.newPart(contentType, f, settings)) +} + +func (m *Message) newPart(contentType string, f func(io.Writer) error, settings []PartSetting) *part { + p := &part{ + contentType: contentType, + copier: f, + encoding: m.encoding, + } + + for _, s := range settings { + s(p) + } + + return p +} + +// A PartSetting can be used as an argument in Message.SetBody, +// Message.AddAlternative or Message.AddAlternativeWriter to configure the part +// added to a message. +type PartSetting func(*part) + +// SetPartEncoding sets the encoding of the part added to the message. By +// default, parts use the same encoding than the message. +func SetPartEncoding(e Encoding) PartSetting { + return PartSetting(func(p *part) { + p.encoding = e + }) +} + +type file struct { + Name string + Header map[string][]string + CopyFunc func(w io.Writer) error +} + +func (f *file) setHeader(field, value string) { + f.Header[field] = []string{value} +} + +// A FileSetting can be used as an argument in Message.Attach or Message.Embed. +type FileSetting func(*file) + +// SetHeader is a file setting to set the MIME header of the message part that +// contains the file content. +// +// Mandatory headers are automatically added if they are not set when sending +// the email. +func SetHeader(h map[string][]string) FileSetting { + return func(f *file) { + for k, v := range h { + f.Header[k] = v + } + } +} + +// Rename is a file setting to set the name of the attachment if the name is +// different than the filename on disk. +func Rename(name string) FileSetting { + return func(f *file) { + f.Name = name + } +} + +// SetCopyFunc is a file setting to replace the function that runs when the +// message is sent. It should copy the content of the file to the io.Writer. +// +// The default copy function opens the file with the given filename, and copy +// its content to the io.Writer. +func SetCopyFunc(f func(io.Writer) error) FileSetting { + return func(fi *file) { + fi.CopyFunc = f + } +} + +func (m *Message) appendFile(list []*file, name string, settings []FileSetting) []*file { + f := &file{ + Name: filepath.Base(name), + Header: make(map[string][]string), + CopyFunc: func(w io.Writer) error { + h, err := os.Open(name) + if err != nil { + return err + } + if _, err := io.Copy(w, h); err != nil { + h.Close() + return err + } + return h.Close() + }, + } + + for _, s := range settings { + s(f) + } + + if list == nil { + return []*file{f} + } + + return append(list, f) +} + +// Attach attaches the files to the email. +func (m *Message) Attach(filename string, settings ...FileSetting) { + m.attachments = m.appendFile(m.attachments, filename, settings) +} + +// Embed embeds the images to the email. +func (m *Message) Embed(filename string, settings ...FileSetting) { + m.embedded = m.appendFile(m.embedded, filename, settings) +} + +// WriteTo implements io.WriterTo. It dumps the whole message into w. +func (m *Message) WriteTo(w io.Writer) (int64, error) { + mw := &messageWriter{w: w} + mw.writeMessage(m) + return mw.n, mw.err +} + +func (w *messageWriter) writeMessage(m *Message) { + if _, ok := m.header["Mime-Version"]; !ok { + w.writeString("Mime-Version: 1.0\r\n") + } + if _, ok := m.header["Date"]; !ok { + w.writeHeader("Date", m.FormatDate(time.Now())) + } + w.writeHeaders(m.header) + + if m.hasMixedPart() { + w.openMultipart("mixed") + } + + if m.hasRelatedPart() { + w.openMultipart("related") + } + + if m.hasAlternativePart() { + w.openMultipart("alternative") + } + for _, part := range m.parts { + w.writePart(part, m.charset) + } + if m.hasAlternativePart() { + w.closeMultipart() + } + + w.addFiles(m.embedded, false) + if m.hasRelatedPart() { + w.closeMultipart() + } + + w.addFiles(m.attachments, true) + if m.hasMixedPart() { + w.closeMultipart() + } +} + +func (m *Message) hasMixedPart() bool { + return (len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1 +} + +func (m *Message) hasRelatedPart() bool { + return (len(m.parts) > 0 && len(m.embedded) > 0) || len(m.embedded) > 1 +} + +func (m *Message) hasAlternativePart() bool { + return len(m.parts) > 1 +} + +type messageWriter struct { + w io.Writer + n int64 + writers [3]*multipart.Writer + partWriter io.Writer + depth uint8 + err error +} + +func (w *messageWriter) openMultipart(mimeType string) { + mw := multipart.NewWriter(w) + contentType := "multipart/" + mimeType + ";\r\n boundary=" + mw.Boundary() + w.writers[w.depth] = mw + + if w.depth == 0 { + w.writeHeader("Content-Type", contentType) + w.writeString("\r\n") + } else { + w.createPart(map[string][]string{ + "Content-Type": {contentType}, + }) + } + w.depth++ +} + +func (w *messageWriter) createPart(h map[string][]string) { + w.partWriter, w.err = w.writers[w.depth-1].CreatePart(h) +} + +func (w *messageWriter) closeMultipart() { + if w.depth > 0 { + w.writers[w.depth-1].Close() + w.depth-- + } +} + +func (w *messageWriter) writePart(p *part, charset string) { + w.writeHeaders(map[string][]string{ + "Content-Type": {p.contentType + "; charset=" + charset}, + "Content-Transfer-Encoding": {string(p.encoding)}, + }) + w.writeBody(p.copier, p.encoding) +} + +func (w *messageWriter) addFiles(files []*file, isAttachment bool) { + for _, f := range files { + if _, ok := f.Header["Content-Type"]; !ok { + mediaType := mime.TypeByExtension(filepath.Ext(f.Name)) + if mediaType == "" { + mediaType = "application/octet-stream" + } + f.setHeader("Content-Type", mediaType+`; name="`+f.Name+`"`) + } + + if _, ok := f.Header["Content-Transfer-Encoding"]; !ok { + f.setHeader("Content-Transfer-Encoding", string(Base64)) + } + + if _, ok := f.Header["Content-Disposition"]; !ok { + var disp string + if isAttachment { + disp = "attachment" + } else { + disp = "inline" + } + f.setHeader("Content-Disposition", disp+`; filename="`+f.Name+`"`) + } + + if !isAttachment { + if _, ok := f.Header["Content-ID"]; !ok { + f.setHeader("Content-ID", "<"+f.Name+">") + } + } + w.writeHeaders(f.Header) + w.writeBody(f.CopyFunc, Base64) + } +} + +func (w *messageWriter) Write(p []byte) (int, error) { + if w.err != nil { + return 0, w.err + } + + var n int + n, w.err = w.w.Write(p) + w.n += int64(n) + return n, w.err +} + +func (w *messageWriter) writeString(s string) { + n, _ := io.WriteString(w.w, s) + w.n += int64(n) +} + +func (w *messageWriter) writeHeader(k string, v ...string) { + w.writeString(k) + if len(v) == 0 { + w.writeString(":\r\n") + return + } + w.writeString(": ") + + // Max header line length is 78 characters in RFC 5322 and 76 characters + // in RFC 2047. So for the sake of simplicity we use the 76 characters + // limit. + charsLeft := 76 - len(k) - len(": ") + + for i, s := range v { + // If the line is already too long, insert a newline right away. + if charsLeft < 1 { + if i == 0 { + w.writeString("\r\n ") + } else { + w.writeString(",\r\n ") + } + charsLeft = 75 + } else if i != 0 { + w.writeString(", ") + charsLeft -= 2 + } + + // While the header content is too long, fold it by inserting a newline. + for len(s) > charsLeft { + s = w.writeLine(s, charsLeft) + charsLeft = 75 + } + w.writeString(s) + if i := lastIndexByte(s, '\n'); i != -1 { + charsLeft = 75 - (len(s) - i - 1) + } else { + charsLeft -= len(s) + } + } + w.writeString("\r\n") +} + +func (w *messageWriter) writeLine(s string, charsLeft int) string { + // If there is already a newline before the limit. Write the line. + if i := strings.IndexByte(s, '\n'); i != -1 && i < charsLeft { + w.writeString(s[:i+1]) + return s[i+1:] + } + + for i := charsLeft - 1; i >= 0; i-- { + if s[i] == ' ' { + w.writeString(s[:i]) + w.writeString("\r\n ") + return s[i+1:] + } + } + + // We could not insert a newline cleanly so look for a space or a newline + // even if it is after the limit. + for i := 75; i < len(s); i++ { + if s[i] == ' ' { + w.writeString(s[:i]) + w.writeString("\r\n ") + return s[i+1:] + } + if s[i] == '\n' { + w.writeString(s[:i+1]) + return s[i+1:] + } + } + + // Too bad, no space or newline in the whole string. Just write everything. + w.writeString(s) + return "" +} + +func (w *messageWriter) writeHeaders(h map[string][]string) { + if w.depth == 0 { + for k, v := range h { + if k != "Bcc" { + w.writeHeader(k, v...) + } + } + } else { + w.createPart(h) + } +} + +func (w *messageWriter) writeBody(f func(io.Writer) error, enc Encoding) { + var subWriter io.Writer + if w.depth == 0 { + w.writeString("\r\n") + subWriter = w.w + } else { + subWriter = w.partWriter + } + + if enc == Base64 { + wc := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(subWriter)) + w.err = f(wc) + wc.Close() + } else if enc == Unencoded { + w.err = f(subWriter) + } else { + wc := newQPWriter(subWriter) + w.err = f(wc) + wc.Close() + } +} + +// As required by RFC 2045, 6.7. (page 21) for quoted-printable, and +// RFC 2045, 6.8. (page 25) for base64. +const maxLineLen = 76 + +// base64LineWriter limits text encoded in base64 to 76 characters per line +type base64LineWriter struct { + w io.Writer + lineLen int +} + +func newBase64LineWriter(w io.Writer) *base64LineWriter { + return &base64LineWriter{w: w} +} + +func (w *base64LineWriter) Write(p []byte) (int, error) { + n := 0 + for len(p)+w.lineLen > maxLineLen { + w.w.Write(p[:maxLineLen-w.lineLen]) + w.w.Write([]byte("\r\n")) + p = p[maxLineLen-w.lineLen:] + n += maxLineLen - w.lineLen + w.lineLen = 0 + } + + w.w.Write(p) + w.lineLen += len(p) + + return n + len(p), nil +}