// // mod.go // post moderation // package srnd import ( "bytes" "fmt" "io" "log" "net/http" "os" "strings" ) // regenerate pages function type RegenFunc func(newsgroup, msgid, root string, page int) // does an action for the administrator // takes in json type AdminFunc func(param map[string]interface{}) (interface{}, error) // interface for moderation ui type ModUI interface { // check if this key is allowed to access // return true if it can otherwise false CheckKey(privkey, scope string) (bool, error) // serve the base page ServeModPage(wr http.ResponseWriter, r *http.Request) // handle a login POST request HandleLogin(wr http.ResponseWriter, r *http.Request) // handle a delete article request HandleDeletePost(wr http.ResponseWriter, r *http.Request) // handle a ban address request HandleBanAddress(wr http.ResponseWriter, r *http.Request) // handle an unban address request HandleUnbanAddress(wr http.ResponseWriter, r *http.Request) // handle add a pubkey HandleAddPubkey(wr http.ResponseWriter, r *http.Request) // handle removing a pubkey HandleDelPubkey(wr http.ResponseWriter, r *http.Request) // handle key generation HandleKeyGen(wr http.ResponseWriter, r *http.Request) // handle admin command HandleAdminCommand(wr http.ResponseWriter, r *http.Request) // handle mark a post as spam HandlePostSpam(wr http.ResponseWriter, r *http.Request) // get outbound message channel MessageChan() chan NNTPMessage } type ModAction string const ModInetBan = ModAction("overchan-inet-ban") const ModDelete = ModAction("delete") const ModRemove = ModAction("remove") const ModRemoveAttachment = ModAction("overchan-del-attachment") const ModStick = ModAction("overchan-stick") const ModLock = ModAction("overchan-lock") const ModHide = ModAction("overchan-hide") const ModSage = ModAction("overchan-sage") const ModSpam = ModAction("spam") const ModHam = ModAction("ham") const ModDeleteAlt = ModAction("delete") type ModEvent interface { // turn it into a string for putting into an article String() string // what type of mod event Action() ModAction // what reason for the event Reason() string // what is the event acting on Target() string // scope of the event, regex of newsgroup Scope() string // when this mod event expires, unix nano Expires() int64 } type simpleModEvent string func (self simpleModEvent) String() string { return string(self) } func (self simpleModEvent) Action() ModAction { switch strings.ToLower(strings.Split(string(self), " ")[0]) { case "remove": return ModRemove case "delete": return ModDelete case "overchan-inet-ban": return ModInetBan case "spam": return ModSpam case "ham": return ModHam } return "" } func (self simpleModEvent) Reason() string { return "" } func (self simpleModEvent) Target() string { parts := strings.Split(string(self), " ") if len(parts) > 1 { return parts[1] } return "" } func (self simpleModEvent) Scope() string { // TODO: hard coded return "overchan.*" } func (self simpleModEvent) Expires() int64 { // no expiration return -1 } // create an overchan-delete mod event func overchanDelete(msgid string) ModEvent { return simpleModEvent(fmt.Sprintf("delete %s", msgid)) } // create an overchan-inet-ban mod event func overchanInetBan(encAddr, key string, expire int64) ModEvent { return simpleModEvent(fmt.Sprintf("overchan-inet-ban %s:%s:%d", encAddr, key, expire)) } // create a mark as spam event func modMarkSpam(msgid string) ModEvent { return simpleModEvent(fmt.Sprintf("spam %s", msgid)) } // moderation message // wraps multiple mod events // is turned into an NNTPMessage later type ModMessage []ModEvent // write this mod message's body func (self ModMessage) WriteTo(wr io.Writer, delim []byte) (err error) { // write body for _, ev := range self { _, err = io.WriteString(wr, ev.String()) _, err = wr.Write(delim) } return } func ParseModEvent(line string) ModEvent { return simpleModEvent(line) } // wrap mod message in an nntp message // does not sign func wrapModMessage(mm ModMessage) NNTPMessage { pathname := "nntpchan.censor" nntp := &nntpArticle{ headers: make(ArticleHeaders), } nntp.headers.Set("Newsgroups", "ctl") nntp.headers.Set("Content-Type", "text/plain; charset=UTF-8") nntp.headers.Set("Message-ID", genMessageID(pathname)) nntp.headers.Set("Date", timeNowStr()) nntp.headers.Set("Path", pathname) // todo: set these maybe? nntp.headers.Set("From", "anon ") nntp.headers.Set("Subject", "censor") var buff bytes.Buffer // crlf delimited _ = mm.WriteTo(&buff, []byte{10}) // create plaintext attachment, cut off last 2 bytes nntp.message = buff.String() buff.Reset() return nntp } type ModEngine interface { // load and handle a mod message from ctl after it's verified HandleMessage(msgid string) // delete post of a poster DeletePost(msgid string) error // mark message as spam MarkSpam(msgid string) error // ban a cidr BanAddress(cidr string) error // do we allow this public key to delete this message-id ? AllowDelete(pubkey, msgid string) bool // do we allow this public key to do inet-ban? AllowBan(pubkey string) bool // allow janitor AllowJanitor(pubkey string) bool // load a mod message LoadMessage(msgid string) NNTPMessage // execute 1 mod action line by a mod with pubkey Execute(ev ModEvent, pubkey string) // do a mod event unconditionally Do(ev ModEvent) } type modEngine struct { database Database store ArticleStore spam *SpamFilter regen RegenFunc } func (self *modEngine) MarkSpam(msgid string) (err error) { if self.spam == nil { err = self.store.MarkSpam(msgid) } else { var f io.ReadCloser f, err = self.store.OpenMessage(msgid) if err == nil { err = self.spam.MarkSpam(f) f.Close() } } return } func (self *modEngine) LoadMessage(msgid string) NNTPMessage { return self.store.GetMessage(msgid) } func (self *modEngine) BanAddress(cidr string) (err error) { return self.database.BanAddr(cidr) } func (self *modEngine) DeletePost(msgid string) (err error) { hdr := self.store.GetHeaders(msgid) var delposts []string var page int64 var ref, group string rootmsgid := "" if hdr == nil { log.Println("failed to get headers for article", msgid, err) } else { ref = hdr.Get("References", "") group = hdr.Get("Newsgroups", "") if ref == "" || ref == msgid { // is root post // delete replies too repls := self.database.GetThreadReplies(msgid, 0, 0) if repls == nil { log.Println("cannot get thread replies for", msgid) } else { delposts = append(delposts, repls...) } _, page, err = self.database.GetPageForRootMessage(msgid) ref = msgid rootmsgid = msgid } else { _, page, err = self.database.GetPageForRootMessage(ref) } } delposts = append(delposts, msgid) // get list of files to delete var delfiles []string for _, delmsg := range delposts { article := self.store.GetFilename(delmsg) delfiles = append(delfiles, article) // get attachments for post atts := self.database.GetPostAttachments(delmsg) if atts != nil { for _, att := range atts { img := self.store.AttachmentFilepath(att) thm := self.store.ThumbnailFilepath(att) delfiles = append(delfiles, img, thm) } } } // delete all files for _, f := range delfiles { log.Printf("delete file: %s", f) os.Remove(f) } for _, delmsg := range delposts { // delete article from post database err = self.database.DeleteArticle(delmsg) if err != nil { log.Println(err) } // ban article self.database.BanArticle(delmsg, "deleted by moderator") self.store.Remove(delmsg) } if rootmsgid != "" { self.database.DeleteThread(rootmsgid) } self.regen(group, msgid, ref, int(page)) return nil } func (self *modEngine) AllowBan(pubkey string) bool { is_admin, _ := self.database.CheckAdminPubkey(pubkey) if is_admin { // admins can do whatever return true } return self.database.CheckModPubkeyGlobal(pubkey) } func (self *modEngine) AllowJanitor(pubkey string) bool { is_admin, _ := self.database.CheckAdminPubkey(pubkey) if is_admin { return true } if self.database.CheckModPubkeyGlobal(pubkey) { return true } // TODO: more checks return false } func (self *modEngine) AllowDelete(pubkey, msgid string) (allow bool) { is_admin, _ := self.database.CheckAdminPubkey(pubkey) if is_admin { // admins can do whatever return true } if self.database.CheckModPubkeyGlobal(pubkey) { // globals can delete as they wish return true } // check for scoped permissions _, group, _, err := self.database.GetInfoForMessage(msgid) if err == nil && newsgroupValidFormat(group) { allow = self.database.CheckModPubkeyCanModGroup(pubkey, group) } else if err != nil { log.Println("db error in mod engine while checking permissions", err) } return } func (mod *modEngine) HandleMessage(msgid string) { nntp := mod.store.GetMessage(msgid) if nntp == nil { log.Println("failed to load", msgid, "in mod engine, missing message") return } // sanity check if nntp.Newsgroup() == "ctl" { pubkey := nntp.Pubkey() for _, line := range strings.Split(nntp.Message(), "\n") { line = strings.Trim(line, "\r\t\n ") if len(line) > 0 { ev := ParseModEvent(line) mod.Execute(ev, pubkey) } } } } func (mod *modEngine) Do(ev ModEvent) { action := ev.Action() target := ev.Target() if action == ModDelete || action == ModDeleteAlt { msgid := target if !ValidMessageID(msgid) { // invalid message-id log.Println("invalid message-id", msgid) return } err := mod.DeletePost(msgid) if err != nil { log.Println(msgid, err) } else { log.Println("deleted", msgid) } } else if action == ModInetBan { // ban action if target[0] == '[' { err := mod.BanAddress(target) if err != nil { log.Println("failed to do literal ipv6 range ban on", target, err) } else { log.Println("banned", target) } return } parts := strings.Split(target, ":") if len(parts) == 3 { // encrypted ip encaddr, key := parts[0], parts[1] cidr := decAddr(encaddr, key) if cidr == "" { log.Println("failed to decrypt inet ban") } else { err := mod.BanAddress(cidr) if err != nil { log.Println("failed to do range ban on", cidr, err) } else { log.Println("banned", cidr) } } } else if len(parts) == 2 { // x-encrypted-ip ban without pad err := mod.database.BanEncAddr(parts[0]) if err != nil { log.Println("failed to ban encrypted ip", err) } else { log.Println("banned poster", parts[0]) } } else if len(parts) == 1 { // literal cidr cidr := parts[0] err := mod.BanAddress(cidr) if err != nil { log.Println("failed to do literal range ban on", cidr, err) } else { log.Println("banned cidr", cidr) } } else { log.Printf("invalid overchan-inet-ban: target=%s", target) } } else if action == ModSpam { if ValidMessageID(target) { mod.MarkSpam(target) } } else if action == ModHide { // TODO: implement } else if action == ModLock { // TODO: implement } else if action == ModSage { // TODO: implement } else if action == ModStick { // TODP: implement } else if action == ModRemove { if ValidMessageID(target) { err := mod.database.DeleteArticle(target) if err == nil { err = mod.store.Remove(target) } if err == nil { log.Println("removed", target) } else { log.Println(action, target, "failed:", err) } } } else if action == ModRemoveAttachment { var delfiles []string atts := mod.database.GetPostAttachments(target) if atts != nil { for _, att := range atts { delfiles = append(delfiles, mod.store.AttachmentFilepath(att)) delfiles = append(delfiles, mod.store.ThumbnailFilepath(att)) } } for _, f := range delfiles { log.Println("remove file", f) os.Remove(f) } } else { log.Println("invalid mod action", action) } } func (mod *modEngine) Execute(ev ModEvent, pubkey string) { action := ev.Action() target := ev.Target() switch action { case ModDelete: if mod.AllowDelete(pubkey, target) { mod.Do(ev) } return case ModInetBan: if mod.AllowBan(pubkey) { mod.Do(ev) } return case ModSpam: if mod.AllowJanitor(pubkey) { mod.Do(ev) } return case ModHide: case ModLock: case ModSage: case ModStick: case ModRemoveAttachment: if mod.AllowJanitor(pubkey) { mod.Do(ev) } return default: // invalid action } }