From 3f9bc232282de3028c812c4842a32aa3e27a231a Mon Sep 17 00:00:00 2001 From: FChannel <> Date: Fri, 19 Mar 2021 22:55:52 -0700 Subject: removed word filter --- #main.go# | 1971 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Database.go | 10 +- 2 files changed, 1976 insertions(+), 5 deletions(-) create mode 100644 #main.go# diff --git a/#main.go# b/#main.go# new file mode 100644 index 0000000..7f97ee3 --- /dev/null +++ b/#main.go# @@ -0,0 +1,1971 @@ +package main + +import "fmt" +import "strings" +import "strconv" +import "net/http" +import "database/sql" +import _ "github.com/lib/pq" +import "math/rand" +import "html/template" +import "time" +import "regexp" +import "os/exec" +import "bytes" +import "encoding/json" +import "io/ioutil" +import "mime/multipart" +import "os" +import "bufio" +import "io" +import "github.com/gofrs/uuid" + +var Port = ":" + GetConfigValue("instanceport") +var TP = GetConfigValue("instancetp") +var Domain = TP + "" + GetConfigValue("instance") + +var authReq = []string{"captcha","email","passphrase"} + +var supportedFiles = []string{"image/gif","image/jpeg","image/png","image/svg+xml","image/webp","image/avif","image/apng","video/mp4","video/ogg","video/webm","audio/mpeg","audio/ogg","audio/wav", "audio/wave", "audio/x-wav"} + +var SiteEmail = GetConfigValue("emailaddress") //contact@fchan.xyz +var SiteEmailPassword = GetConfigValue("emailpass") +var SiteEmailServer = GetConfigValue("emailserver") //mail.fchan.xyz +var SiteEmailPort = GetConfigValue("emailport") //587 + +var ldjson = "application/ld+json" +var activitystreams = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" + +func main() { + + if _, err := os.Stat("./public"); os.IsNotExist(err) { + os.Mkdir("./public", 0755) + } + + InitCache() + + db := ConnectDB(); + + db. + + defer db.Close() + + go MakeCaptchas(db, 100) + + *Key = CreateClientKey() + + FollowingBoards = GetActorFollowingDB(db, Domain) + + Boards = GetBoardCollection(db) + + // root actor is used to follow remote feeds that are not local + //name, prefname, summary, auth requirements, restricted + if GetConfigValue("instancename") != "" { + CreateNewBoardDB(db, *CreateNewActor("", GetConfigValue("instancename"), GetConfigValue("instancesummary"), authReq, false)) + } + + // Allow access to public media folder + fileServer := http.FileServer(http.Dir("./public")) + http.Handle("/public/", http.StripPrefix("/public", neuter(fileServer))) + + javascriptFiles := http.FileServer(http.Dir("./static")) + http.Handle("/static/", http.StripPrefix("/static", neuter(javascriptFiles))) + + // main routing + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request){ + path := r.URL.Path + + // remove trailing slash + if path != "/" { + re := regexp.MustCompile(`/$`) + path = re.ReplaceAllString(path, "") + } + + var mainActor bool + var mainInbox bool + var mainOutbox bool + var mainFollowing bool + var mainFollowers bool + + var actorMain bool + var actorInbox bool + var actorCatalog bool + var actorOutbox bool + var actorPost bool + var actorFollowing bool + var actorFollowers bool + var actorReported bool + var actorVerification bool + var actorMainPage bool + + var accept = r.Header.Get("Accept") + + var method = r.Method + + var actor = GetActorFromPath(db, path, "/") + + if actor.Name == "main" { + mainActor = (path == "/") + mainInbox = (path == "/inbox") + mainOutbox = (path == "/outbox") + mainFollowing = (path == "/following") + mainFollowers = (path == "/followers") + } else { + actorMain = (path == "/" + actor.Name) + actorInbox = (path == "/" + actor.Name + "/inbox") + actorCatalog = (path == "/" + actor.Name + "/catalog") + actorOutbox = (path == "/" + actor.Name + "/outbox") + actorFollowing = (path == "/" + actor.Name + "/following") + actorFollowers = (path == "/" + actor.Name + "/followers") + actorReported = (path == "/" + actor.Name + "/reported") + actorVerification = (path == "/" + actor.Name + "/verification") + + escapedActorName := strings.Replace(actor.Name, "*", "\\*", -1) + escapedActorName = strings.Replace(escapedActorName, "^", "\\^", -1) + escapedActorName = strings.Replace(escapedActorName, "$", "\\$", -1) + escapedActorName = strings.Replace(escapedActorName, "?", "\\?", -1) + escapedActorName = strings.Replace(escapedActorName, "+", "\\+", -1) + escapedActorName = strings.Replace(escapedActorName, ".", "\\.", -1) + + re := regexp.MustCompile("/" + escapedActorName + "/[0-9]{1,2}$") + + actorMainPage = re.MatchString(path) + + re = regexp.MustCompile("/" + escapedActorName + "/\\w+") + + actorPost = re.MatchString(path) + } + + if mainActor { + if accept == activitystreams || accept == ldjson { + GetActorInfo(w, db, Domain) + return + } + + IndexGet(w, r, db) + + return + } + + if mainInbox { + if method == "POST" { + + } else { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("404 no path")) + } + return + } + + if mainOutbox { + if method == "GET" { + GetActorOutbox(w, r, db) + } else if method == "POST" { + ParseOutboxRequest(w, r, db) + } else { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("404 no path")) + } + return + } + + if mainFollowing { + GetActorFollowing(w, db, Domain) + return + } + + if mainFollowers { + GetActorFollowers(w, db, Domain) + return + } + + if actorMain || actorMainPage { + if accept == activitystreams || accept == ldjson { + GetActorInfo(w, db, actor.Id) + return + } + + postNum := strings.Replace(r.URL.EscapedPath(), "/" + actor.Name + "/", "", 1) + + page, _ := strconv.Atoi(postNum) + collection, valid := WantToServePage(db, actor.Name, page) + if valid { + OutboxGet(w, r, db, collection) + } + + return + } + + if actorFollowing { + GetActorFollowing(w, db, actor.Id) + return + } + + if actorFollowers { + GetActorFollowers(w, db, actor.Id) + return + } + + if actorInbox { + if method == "POST" { + ParseInboxRequest(w, r, db) + } else { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("404 no path")) + } + return + } + + if actorCatalog { + collection, valid := WantToServeCatalog(db, actor.Name) + if valid { + CatalogGet(w, r, db, collection) + } + return + } + + if actorOutbox { + if method == "GET" { + GetActorOutbox(w, r, db) + } else if method == "POST" { + ParseOutboxRequest(w, r, db) + } else { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("404 no path")) + } + return + } + + if actorReported { + GetActorReported(w, r, db, actor.Id) + return + } + + if actorVerification { + r.ParseForm() + + code := r.FormValue("code") + + var verify Verify + + verify.Board = actor.Id + verify.Identifier = "post" + + verify = GetVerificationCode(db, verify) + + auth := CreateTripCode(verify.Code) + auth = CreateTripCode(auth) + + if CreateTripCode(auth) == code { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusUnauthorized) + } + + w.Write([]byte("")) + } + + //catch all + if actorPost { + if accept == activitystreams || accept == ldjson { + GetActorPost(w, db, path) + return + } + + PostGet(w, r, db) + return + } + + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("404 no path")) + }) + + http.HandleFunc("/post", func(w http.ResponseWriter, r *http.Request){ + + r.ParseMultipartForm(10 << 20) + + file, header, _ := r.FormFile("file") + + if(file != nil && header.Size > (7 << 20)){ + w.Write([]byte("7MB max file size")) + return + } + + if(r.FormValue("inReplyTo") == "" || file == nil) { + if(r.FormValue("comment") == "" && r.FormValue("subject") == ""){ + w.Write([]byte("Comment or Subject required")) + return + } + } + + if(len(r.FormValue("comment")) > 2000) { + w.Write([]byte("Comment limit 2000 characters")) + return + } + + if(len(r.FormValue("subject")) > 100 || len(r.FormValue("name")) > 100 || len(r.FormValue("options")) > 100) { + w.Write([]byte("Name, Subject or Options limit 100 characters")) + return + } + + if(r.FormValue("captcha") == "") { + w.Write([]byte("Incorrect Captcha")) + return + } + + b := bytes.Buffer{} + we := multipart.NewWriter(&b) + + if(file != nil){ + var fw io.Writer + + fw, err := we.CreateFormFile("file", header.Filename) + + CheckError(err, "error with form file create") + + _, err = io.Copy(fw, file) + + CheckError(err, "error with form file copy") + } + + reply := ParseCommentForReply(r.FormValue("comment")) + + for key, r0 := range r.Form { + if(key == "captcha") { + err := we.WriteField(key, r.FormValue("captchaCode") + ":" + r.FormValue("captcha")) + CheckError(err, "error with writing field") + }else{ + err := we.WriteField(key, r0[0]) + CheckError(err, "error with writing field") + } + } + + if(r.FormValue("inReplyTo") == "" && reply != ""){ + err := we.WriteField("inReplyTo", reply) + CheckError(err, "error with writing inReplyTo field") + } + + we.Close() + + req, err := http.NewRequest("POST", r.FormValue("sendTo"), &b) + + CheckError(err, "error with post form req") + + req.Header.Set("Content-Type", we.FormDataContentType()) + req.Header.Set("Authorization", "Basic " + *Key) + + resp, err := http.DefaultClient.Do(req) + + CheckError(err, "error with post form resp") + + defer resp.Body.Close() + + if(resp.StatusCode == 200){ + + body, _ := ioutil.ReadAll(resp.Body) + + var obj ObjectBase + + obj = ParseOptions(r, obj) + for _, e := range obj.Option { + if(e == "noko" || e == "nokosage"){ + http.Redirect(w, r, Domain + "/" + r.FormValue("boardName") + "/" + shortURL(r.FormValue("sendTo"), string(body)) , http.StatusMovedPermanently) + return + } + } + + http.Redirect(w, r, Domain + "/" + r.FormValue("boardName"), http.StatusMovedPermanently) + return + } + + if(resp.StatusCode == 403){ + w.Write([]byte("Incorrect Captcha")) + return + } + + http.Redirect(w, r, Domain + "/" + r.FormValue("boardName"), http.StatusMovedPermanently) + }) + + http.HandleFunc("/" + *Key + "/", func(w http.ResponseWriter, r *http.Request) { + + id, _ := GetPasswordFromSession(r) + + actor := GetActorFromPath(db, r.URL.Path, "/" + *Key + "/") + + if actor.Id == "" { + actor = GetActorFromDB(db, Domain) + } + + if id == "" || (id != actor.Id && id != Domain) { + t := template.Must(template.ParseFiles("./static/verify.html")) + t.Execute(w, "") + return + } + + re := regexp.MustCompile("/" + *Key + "/" + actor.Name + "/follow") + follow := re.MatchString(r.URL.Path) + + re = regexp.MustCompile("/" + *Key + "/" + actor.Name) + manage := re.MatchString(r.URL.Path) + + re = regexp.MustCompile("/" + *Key ) + admin := re.MatchString(r.URL.Path) + + re = regexp.MustCompile("/" + *Key + "/follow" ) + adminFollow := re.MatchString(r.URL.Path) + + if follow || adminFollow { + r.ParseForm() + + + var followActivity Activity + + followActivity.AtContext.Context = "https://www.w3.org/ns/activitystreams" + followActivity.Type = "Follow" + + var nactor Actor + var obj ObjectBase + followActivity.Actor = &nactor + followActivity.Object = &obj + followActivity.Actor.Id = r.FormValue("actor") + + var mactor Actor + followActivity.Object.Actor = &mactor + followActivity.Object.Actor.Id = r.FormValue("follow") + followActivity.To = append(followActivity.To, r.FormValue("follow")) + + if followActivity.Actor.Id == Domain && !IsActorLocal(db, followActivity.Object.Actor.Id) { + w.Write([]byte("main board can only follow local boards. Create a new board and then follow outside boards from it.")) + return + } + + enc, _ := json.Marshal(followActivity) + + req, err := http.NewRequest("POST", actor.Outbox, bytes.NewBuffer(enc)) + + CheckError(err, "error with follow req") + + _, pass := GetPasswordFromSession(r) + + pass = CreateTripCode(pass) + pass = CreateTripCode(pass) + + req.Header.Set("Authorization", "Basic " + pass) + + req.Header.Set("Content-Type", activitystreams) + + _, err = http.DefaultClient.Do(req) + + CheckError(err, "error with add board follow resp") + + FollowingBoards = GetActorFollowingDB(db, Domain) + + Boards = GetBoardCollection(db) + + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + + } else if manage && actor.Name != "" { + t := template.Must(template.ParseFiles("./static/main.html", "./static/manage.html")) + + follow := GetActorCollection(actor.Following) + follower := GetActorCollection(actor.Followers) + reported := GetActorCollectionReq(r, actor.Id + "/reported") + + var following []string + var followers []string + var reports []Report + + for _, e := range follow.Items { + following = append(following, e.Id) + } + + for _, e := range follower.Items { + followers = append(followers, e.Id) + } + + for _, e := range reported.Items { + var r Report + r.Count = int(e.Size) + r.ID = e.Id + reports = append(reports, r) + } + + localReports := GetLocalReportDB(db, actor.Name) + + for _, e := range localReports { + var r Report + r.Count = e.Count + r.ID = e.ID + reports = append(reports, r) + } + + var adminData AdminPage + adminData.Following = following + adminData.Followers = followers + adminData.Reported = reports + adminData.Domain = Domain + adminData.IsLocal = IsActorLocal(db, actor.Id) + + adminData.Title = "Manage /" + actor.Name + "/" + adminData.Boards = Boards + adminData.Board.Name = actor.Name + adminData.Board.Actor = actor + adminData.Key = *Key + adminData.Board.TP = TP + + adminData.Board.Post.Actor = &actor + + t.ExecuteTemplate(w, "layout", adminData) + + } else if admin || actor.Id == Domain { + + t := template.Must(template.ParseFiles("./static/main.html", "./static/nadmin.html")) + + actor := GetActor(Domain) + follow := GetActorCollection(actor.Following).Items + follower := GetActorCollection(actor.Followers).Items + + var following []string + var followers []string + + for _, e := range follow { + following = append(following, e.Id) + } + + for _, e := range follower { + followers = append(followers, e.Id) + } + + var adminData AdminPage + adminData.Following = following + adminData.Followers = followers + adminData.Actor = actor.Id + adminData.Key = *Key + adminData.Domain = Domain + adminData.Board.ModCred,_ = GetPasswordFromSession(r) + + adminData.Boards = Boards + + adminData.Board.Post.Actor = &actor + + t.ExecuteTemplate(w, "layout", adminData) + } + }) + + http.HandleFunc("/" + *Key + "/addboard", func(w http.ResponseWriter, r *http.Request) { + + var newActorActivity Activity + var board Actor + r.ParseForm() + + actor := GetActorFromDB(db, Domain) + + var restrict bool + if r.FormValue("restricted") == "True" { + restrict = true + } else { + restrict = false + } + + board.Name = r.FormValue("name") + board.PreferredUsername = r.FormValue("prefname") + board.Summary = r.FormValue("summary") + board.Restricted = restrict + + newActorActivity.AtContext.Context = "https://www.w3.org/ns/activitystreams" + newActorActivity.Type = "New" + var nactor Actor + var nobj ObjectBase + newActorActivity.Actor = &nactor + newActorActivity.Object = &nobj + newActorActivity.Actor.Id = actor.Id + newActorActivity.Object.Actor = &board + + + enc, _ := json.Marshal(newActorActivity) + + req, err := http.NewRequest("POST", actor.Outbox, bytes.NewBuffer(enc)) + + CheckError(err, "error with add board follow req") + + _, pass := GetPasswordFromSession(r) + + pass = CreateTripCode(pass) + pass = CreateTripCode(pass) + + req.Header.Set("Authorization", "Basic " + pass) + req.Header.Set("Content-Type", activitystreams) + + resp, err := http.DefaultClient.Do(req) + + CheckError(err, "error with add board follow resp") + + defer resp.Body.Close() + + body, _ := ioutil.ReadAll(resp.Body) + + var respActor Actor + + err = json.Unmarshal(body, &respActor) + + CheckError(err, "error getting actor from body in new board") + + //update board list with new instances following + if resp.StatusCode == 200 { + var board []ObjectBase + var item ObjectBase + var removed bool = false + + item.Id = respActor.Id + for _, e := range FollowingBoards { + if e.Id != item.Id { + board = append(board, e) + } else { + removed = true + } + } + + if !removed { + board = append(board, item) + } + + FollowingBoards = board + + Boards = GetBoardCollection(db) + } + + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + }) + + http.HandleFunc("/verify", func(w http.ResponseWriter, r *http.Request){ + if(r.Method == "POST") { + r.ParseForm() + identifier := r.FormValue("id") + code := r.FormValue("code") + + var verify Verify + verify.Identifier = identifier + verify.Code = code + + j, _ := json.Marshal(&verify) + + req, err := http.NewRequest("POST", Domain + "/auth", bytes.NewBuffer(j)) + + CheckError(err, "error making verify req") + + req.Header.Set("Content-Type", activitystreams) + + resp, err := http.DefaultClient.Do(req) + + CheckError(err, "error getting verify resp") + + defer resp.Body.Close() + + rBody, _ := ioutil.ReadAll(resp.Body) + + body := string(rBody) + + if(resp.StatusCode != 200) { + t := template.Must(template.ParseFiles("./static/verify.html")) + t.Execute(w, "wrong password " + verify.Code) + } else { + + sessionToken, _ := uuid.NewV4() + + _, err := cache.Do("SETEX", sessionToken, "86400", body + "|" + verify.Code) + if err != nil { + t := template.Must(template.ParseFiles("./static/verify.html")) + t.Execute(w, "") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: sessionToken.String(), + Expires: time.Now().Add(60 * 60 * 48 * time.Second), + }) + + http.Redirect(w, r, "/", http.StatusSeeOther) + } + } else { + t := template.Must(template.ParseFiles("./static/verify.html")) + t.Execute(w, "") + } + }) + + http.HandleFunc("/delete", func(w http.ResponseWriter, r *http.Request){ + id := r.URL.Query().Get("id") + board := r.URL.Query().Get("board") + actor := GetActorFromPath(db, id, "/") + _, auth := GetPasswordFromSession(r) + + if id == "" || auth == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + return + } + + if !HasAuth(db, auth, actor.Id) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + return + } + + if !IsIDLocal(db, id) { + CreateLocalDeleteDB(db, id, "post") + CloseLocalReportDB(db, id, board) + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + return + } + + + var obj ObjectBase + obj.Id = id + obj.Actor = &actor + + isOP := CheckIfObjectOP(db, obj.Id) + + if !isOP { + DeleteObjectRequest(db, id) + DeleteObject(db, obj.Id) + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + return + } else { + DeleteObjectAndRepliesRequest(db, id) + DeleteObjectAndReplies(db, obj.Id) + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + return + } + + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + }) + + http.HandleFunc("/deleteattach", func(w http.ResponseWriter, r *http.Request){ + + id := r.URL.Query().Get("id") + + _, auth := GetPasswordFromSession(r) + + if id == "" || auth == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + return + } + + actor := GetActorFromPath(db, id, "/") + + if !HasAuth(db, auth, actor.Id) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + return + } + + if !IsIDLocal(db, id) { + CreateLocalDeleteDB(db, id, "attachment") + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + return + } + + DeleteAttachmentFromFile(db, id) + DeletePreviewFromFile(db, id) + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + }) + + http.HandleFunc("/report", func(w http.ResponseWriter, r *http.Request){ + + r.ParseForm() + + id := r.FormValue("id") + board := r.FormValue("board") + reason := r.FormValue("comment") + close := r.FormValue("close") + + actor := GetActorFromPath(db, id, "/") + _, auth := GetPasswordFromSession(r) + + var captcha = r.FormValue("captchaCode") + ":" + r.FormValue("captcha") + + if len(reason) > 100 { + w.Write([]byte("Report comment limit 100 characters")) + return + } + + if(!CheckCaptcha(db, captcha)) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("captcha required")) + return + } + + if close == "1" { + if !HasAuth(db, auth, actor.Id) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + return + } + + if !IsIDLocal(db, id) { + CloseLocalReportDB(db, id, board) + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + return + } + + reported := DeleteReportActivity(db, id) + if reported { + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + return + } + + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + return + } + + if !IsIDLocal(db, id) { + CreateLocalReportDB(db, id, board, reason) + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + return + } + + reported := ReportActivity(db, id, reason) + if reported { + http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) + return + } + + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + }) + + http.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request){ + var verify Verify + defer r.Body.Close() + + body, _ := ioutil.ReadAll(r.Body) + + err := json.Unmarshal(body, &verify) + + CheckError(err, "error get verify from json") + + v := GetVerificationByCode(db, verify.Code) + + if v.Identifier == verify.Identifier { + w.Write([]byte(v.Board)) + return + } + + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + }) + + fmt.Println("Server for " + Domain + " running on port " + Port) + + fmt.Println("Mod key: " + *Key) + PrintAdminAuth(db) + + http.ListenAndServe(Port, nil) +} + +func CheckError(e error, m string) error{ + if e != nil { + fmt.Println() + fmt.Println(m) + fmt.Println() + panic(e) + } + + return e +} + +func ConnectDB() *sql.DB { + + host := GetConfigValue("dbhost") + port,_ := strconv.Atoi(GetConfigValue("dbport")) + user := GetConfigValue("dbuser") + password := GetConfigValue("dbpass") + dbname := GetConfigValue("dbname") + + psqlInfo := fmt.Sprintf("host=%s port=%d user=%s password=%s " + + "dbname=%s sslmode=disable", host, port, user, password, dbname) + + db, err := sql.Open("postgres", psqlInfo) + CheckError(err, "error with db connection") + + err = db.Ping() + + CheckError(err, "error with db ping") + + fmt.Println("Successfully connected DB") + return db +} + +func CreateKey(len int) string { + var key string + str := (CreateTripCode(RandomID(len))) + for i := 0; i < len; i++ { + key += fmt.Sprintf("%c", str[i]) + } + return key +} + +func neuter(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/") { + http.NotFound(w, r) + return + } + + next.ServeHTTP(w, r) + }) +} + +func CreateTripCode(input string) string { + cmd := exec.Command("sha512sum") + cmd.Stdin = strings.NewReader(input) + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + + CheckError(err, "error with create trip code") + + code := strings.Split(out.String(), " ") + + return code[0] +} + +func CreateNameTripCode(input string) string { + re := regexp.MustCompile("#.+") + chunck := re.FindString(input) + hash := CreateTripCode(chunck) + return re.ReplaceAllString(input, "!" + hash[42:50]) +} + +func GetActorFromPath(db *sql.DB, location string, prefix string) Actor { + pattern := fmt.Sprintf("%s([^/\n]+)(/.+)?", prefix) + re := regexp.MustCompile(pattern) + match := re.FindStringSubmatch(location) + + var actor string + + if(len(match) < 1 ) { + actor = "/" + } else { + actor = strings.Replace(match[1], "/", "", -1) + } + + if actor == "/" || actor == "outbox" || actor == "inbox" || actor == "following" || actor == "followers" { + actor = "main" + } + + var nActor Actor + + nActor = GetActorByNameFromDB(db, actor) + + if nActor.Id == "" { + nActor = GetActorByName(db, actor) + } + + return nActor +} + +func GetContentType(location string) string { + elements := strings.Split(location, ";") + if len(elements) > 0 { + return elements[0] + } else { + return location + } +} + +func RandomID(size int) string { + rand.Seed(time.Now().UnixNano()) + domain := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + rng := size + newID := "" + for i := 0; i < rng; i++ { + newID += string(domain[rand.Intn(len(domain))]) + } + + return newID +} + +func CreateUniqueID(db *sql.DB, actor string) string { + var newID string + isUnique := false + for !isUnique { + newID = RandomID(8) + + query := fmt.Sprintf("select id from activitystream where id='%s/%s/%s'", Domain, actor, newID) + + rows, err := db.Query(query) + + CheckError(err, "error with unique id query") + + defer rows.Close() + + var count int = 0 + for rows.Next(){ + count += 1 + } + + if count < 1 { + isUnique = true + } + } + + return newID +} + +func CreateNewActor(board string, prefName string, summary string, authReq []string, restricted bool) *Actor{ + actor := new(Actor) + + var path string + if board == "" { + path = Domain + actor.Name = "main" + } else { + path = Domain + "/" + board + actor.Name = board + } + + actor.Type = "Service" + actor.Id = fmt.Sprintf("%s", path) + actor.Following = fmt.Sprintf("%s/following", actor.Id) + actor.Followers = fmt.Sprintf("%s/followers", actor.Id) + actor.Inbox = fmt.Sprintf("%s/inbox", actor.Id) + actor.Outbox = fmt.Sprintf("%s/outbox", actor.Id) + actor.PreferredUsername = prefName + actor.Restricted = restricted + actor.Summary = summary + actor.AuthRequirement = authReq + + return actor +} + +func GetActorInfo(w http.ResponseWriter, db *sql.DB, id string) { + actor := GetActorFromDB(db, id) + enc, _ := json.MarshalIndent(actor, "", "\t") + w.Header().Set("Content-Type", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") + w.Write(enc) +} + +func GetActorPost(w http.ResponseWriter, db *sql.DB, path string) { + collection := GetCollectionFromPath(db, Domain + "" + path) + if len(collection.OrderedItems) > 0 { + enc, _ := json.MarshalIndent(collection, "", "\t") + w.Header().Set("Content-Type", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") + w.Write(enc) + } +} + +func CreateObject(objType string) ObjectBase { + var nObj ObjectBase + + nObj.Type = objType + nObj.Published = time.Now().Format(time.RFC3339) + nObj.Updated = time.Now().Format(time.RFC3339) + + return nObj +} + +func AddFollowersToActivity(db *sql.DB, activity Activity) Activity{ + + if len(activity.To) < 1 { + activity.To = append(activity.To, activity.Actor.Id) + } + + for _, e := range activity.To { + aFollowers := GetActorCollection(e + "/followers") + for _, k := range aFollowers.Items { + activity.To = append(activity.To, k.Id) + } + } + + var nActivity Activity + + for _, e := range activity.To { + var alreadyTo = false + for _, k := range nActivity.To { + if e == k || e == activity.Actor.Id { + alreadyTo = true + } + } + + if !alreadyTo { + nActivity.To = append(nActivity.To, e) + } + } + + activity.To = nActivity.To + + return activity +} + +func CreateActivity(activityType string, obj ObjectBase) Activity { + var newActivity Activity + + newActivity.AtContext.Context = "https://www.w3.org/ns/activitystreams" + newActivity.Type = activityType + newActivity.Published = obj.Published + newActivity.Actor = obj.Actor + newActivity.Object = &obj + + for _, e := range obj.To { + if obj.Actor.Id != e { + newActivity.To = append(newActivity.To, e) + } + } + + for _, e := range obj.Cc { + if obj.Actor.Id != e { + newActivity.Cc = append(newActivity.Cc, e) + } + } + + return newActivity +} + +func ProcessActivity(db *sql.DB, activity Activity) { + activityType := activity.Type + + if activityType == "Create" { + for _, e := range activity.To { + if GetActorFromDB(db, e).Id != "" { + fmt.Println("actor is in the database") + } else { + fmt.Println("actor is NOT in the database") + } + } + } else if activityType == "Follow" { + + } else if activityType == "Delete" { + + } +} + +func CreatePreviewObject(obj ObjectBase) *NestedObjectBase { + + re := regexp.MustCompile(`/.+$`) + + mimetype := re.ReplaceAllString(obj.MediaType, "") + + var nPreview NestedObjectBase + + if mimetype != "image" { + return &nPreview + } + + re = regexp.MustCompile(`.+/`) + + file := re.ReplaceAllString(obj.MediaType, "") + + href := GetUniqueFilename(file) + + nPreview.Type = "Preview" + nPreview.Name = obj.Name + nPreview.Href = Domain + "" + href + nPreview.MediaType = obj.MediaType + nPreview.Size = obj.Size + nPreview.Published = obj.Published + + re = regexp.MustCompile(`/public/.+`) + + objFile := re.FindString(obj.Href) + + cmd := exec.Command("convert", "." + objFile ,"-resize", "250x250>", "." + href) + + err := cmd.Run() + + if CheckError(err, "error with resize attachment preview") != nil { + var preview NestedObjectBase + return &preview + } + + return &nPreview +} + +func CreateAttachmentObject(file multipart.File, header *multipart.FileHeader) ([]ObjectBase, *os.File) { + contentType, _ := GetFileContentType(file) + filename := header.Filename + size := header.Size + + re := regexp.MustCompile(`.+/`) + + fileType := re.ReplaceAllString(contentType, "") + + tempFile, _ := ioutil.TempFile("./public", "*." + fileType) + + var nAttachment []ObjectBase + var image ObjectBase + + image.Type = "Attachment" + image.Name = filename + image.Href = Domain + "/" + tempFile.Name() + image.MediaType = contentType + image.Size = size + image.Published = time.Now().Format(time.RFC3339) + + nAttachment = append(nAttachment, image) + + return nAttachment, tempFile +} + +func ParseCommentForReplies(comment string) []ObjectBase { + + re := regexp.MustCompile("(>>)(https://|http://)?(www\\.)?.+\\/\\w+") + match := re.FindAllStringSubmatch(comment, -1) + + var links []string + + for i:= 0; i < len(match); i++ { + str := strings.Replace(match[i][0], ">>", "", 1) + str = strings.Replace(str, "www.", "", 1) + str = strings.Replace(str, "http://", "", 1) + str = strings.Replace(str, "https://", "", 1) + str = TP + "" + str + if !IsInStringArray(links, str) { + links = append(links, str) + } + } + + var validLinks []ObjectBase + for i:= 0; i < len(links); i++ { + _, isValid := CheckValidActivity(links[i]) + if(isValid) { + var reply = new(ObjectBase) + reply.Id = links[i] + reply.Published = time.Now().Format(time.RFC3339) + validLinks = append(validLinks, *reply) + } + } + + return validLinks +} + +func CheckValidActivity(id string) (Collection, bool) { + + req, err := http.NewRequest("GET", id, nil) + + if err != nil { + fmt.Println("error with request") + panic(err) + } + + req.Header.Set("Accept", activitystreams) + + resp, err := http.DefaultClient.Do(req) + + if err != nil { + fmt.Println("error with response") + panic(err) + } + + defer resp.Body.Close() + + body, _ := ioutil.ReadAll(resp.Body) + + var respCollection Collection + + err = json.Unmarshal(body, &respCollection) + + if err != nil { + panic(err) + } + + if respCollection.AtContext.Context == "https://www.w3.org/ns/activitystreams" && respCollection.OrderedItems[0].Id != "" { + return respCollection, true; + } + + return respCollection, false; +} + +func GetActor(id string) Actor { + + var respActor Actor + + if id == "" { + return respActor + } + + req, err := http.NewRequest("GET", id, nil) + + CheckError(err, "error with getting actor req") + + req.Header.Set("Accept", activitystreams) + + resp, err := http.DefaultClient.Do(req) + + if err != nil { + fmt.Println("error with getting actor resp " + id) + return respActor + } + + defer resp.Body.Close() + + body, _ := ioutil.ReadAll(resp.Body) + + err = json.Unmarshal(body, &respActor) + + CheckError(err, "error getting actor from body") + + return respActor +} + +func GetActorCollection(collection string) Collection { + var nCollection Collection + + if collection == "" { + return nCollection + } + + req, err := http.NewRequest("GET", collection, nil) + + CheckError(err, "error with getting actor collection req " + collection) + + req.Header.Set("Accept", activitystreams) + + resp, err := http.DefaultClient.Do(req) + + if err != nil { + fmt.Println("error with getting actor collection resp " + collection) + return nCollection + } + + + defer resp.Body.Close() + + if resp.StatusCode == 200 { + body, _ := ioutil.ReadAll(resp.Body) + + if len(body) > 0 { + err = json.Unmarshal(body, &nCollection) + + CheckError(err, "error getting actor collection from body " + collection) + } + } + + return nCollection +} + +func IsValidActor(id string) (Actor, bool) { + var respCollection Actor + req, err := http.NewRequest("GET", id, nil) + + CheckError(err, "error with valid actor request") + + req.Header.Set("Accept", activitystreams) + + resp, err := http.DefaultClient.Do(req) + + CheckError(err, "error with valid actor response") + + defer resp.Body.Close() + + if resp.StatusCode == 403 { + return respCollection, false; + } + + body, _ := ioutil.ReadAll(resp.Body) + + err = json.Unmarshal(body, &respCollection) + + if err != nil { + panic(err) + } + + if respCollection.Id != "" && respCollection.Inbox != "" && respCollection.Outbox != "" { + return respCollection, true; + } + + return respCollection, false; +} + +func IsActivityLocal(db *sql.DB, activity Activity) bool { + for _, e := range activity.To { + if GetActorFromDB(db, e).Id != "" { + return true + } + } + + for _, e := range activity.Cc { + if GetActorFromDB(db, e).Id != "" { + return true + } + } + + if activity.Actor != nil && GetActorFromDB(db, activity.Actor.Id).Id != "" { + return true + } + + return false +} + +func IsIDLocal(db *sql.DB, id string) bool { + activity := GetActivityFromDB(db, id) + return len(activity.OrderedItems) > 0 +} + +func IsActorLocal(db *sql.DB, id string) bool { + actor := GetActorFromDB(db, id) + + if actor.Id != "" { + return true + } + + return false +} + +func IsObjectLocal(db *sql.DB, id string) bool { + + query := `select id from activitystream where id=$1` + + rows, _ := db.Query(query, id) + + var nID string + defer rows.Close() + rows.Next() + rows.Scan(&nID) + + if nID == "" { + return false + } + + return true +} + +func IsObjectCached(db *sql.DB, id string) bool { + + query := `select id from cacheactivitystream where id=$1` + rows, _ := db.Query(query, id) + + var nID string + defer rows.Close() + rows.Next() + rows.Scan(&nID) + + if nID == "" { + return false + } + + return true +} + +func GetObjectFromActivity(activity Activity) ObjectBase { + return *activity.Object +} + +func MakeCaptchas(db *sql.DB, total int) { + difference := total - GetCaptchaTotal(db) + + for i := 0; i < difference; i++ { + CreateNewCaptcha(db) + } +} + +func GetFileContentType(out multipart.File) (string, error) { + + buffer := make([]byte, 512) + + _, err := out.Read(buffer) + if err != nil { + return "", err + } + + out.Seek(0, 0) + + contentType := http.DetectContentType(buffer) + + return contentType, nil +} + +func IsReplyInThread(db *sql.DB, inReplyTo string, id string) bool { + obj, _ := CheckValidActivity(inReplyTo) + + for _, e := range obj.OrderedItems[0].Replies.OrderedItems { + if e.Id == id { + return true + } + } + return false +} + +func SupportedMIMEType(mime string) bool { + for _, e := range supportedFiles { + if e == mime { + return true + } + } + + return false +} + +func DeleteReportActivity(db *sql.DB, id string) bool { + + query := `delete from reported where id=$1` + + _, err := db.Exec(query, id) + + if err != nil { + CheckError(err, "error closing reported activity") + return false + } + + return true +} + +func ReportActivity(db *sql.DB, id string, reason string) bool { + + if !IsIDLocal(db, id) { + return false + } + + actor := GetActivityFromDB(db, id) + + query := `select count from reported where id=$1` + + rows, err := db.Query(query, id) + + CheckError(err, "could not select count from reported") + + defer rows.Close() + var count int + for rows.Next() { + rows.Scan(&count) + } + + if count < 1 { + query = `insert into reported (id, count, board, reason) values ($1, $2, $3, $4)` + + _, err := db.Exec(query, id, 1, actor.Actor.Id, reason) + + if err != nil { + CheckError(err, "error inserting new reported activity") + return false + } + + } else { + count = count + 1 + query = `update reported set count=$1 where id=$2` + + _, err := db.Exec(query, count, id) + + if err != nil { + CheckError(err, "error updating reported activity") + return false + } + } + + return true +} + +func GetActorReported(w http.ResponseWriter, r *http.Request, db *sql.DB, id string) { + + auth := r.Header.Get("Authorization") + verification := strings.Split(auth, " ") + + if len(verification) < 2 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + return + } + + if !HasAuth(db, verification[1], id) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("")) + return + } + + var following Collection + + following.AtContext.Context = "https://www.w3.org/ns/activitystreams" + following.Type = "Collection" + following.TotalItems = GetActorReportedTotal(db, id) + following.Items = GetActorReportedDB(db, id) + + enc, _ := json.MarshalIndent(following, "", "\t") + w.Header().Set("Content-Type", activitystreams) + w.Write(enc) +} + +func MakeActivityRequest(db *sql.DB, activity Activity) { + + j, _ := json.MarshalIndent(activity, "", "\t") + + var verify Verify + + verify.Board = activity.Actor.Id + verify.Identifier = "post" + + verify = GetVerificationCode(db, verify) + + auth := CreateTripCode(verify.Code) + + auth = CreateTripCode(auth) + + for _, e := range activity.To { + + actor := GetActor(e) + + if actor.Inbox != "" { + req, err := http.NewRequest("POST", actor.Inbox, bytes.NewBuffer(j)) + + req.Header.Set("Content-Type", activitystreams) + + req.Header.Set("Authorization", "Basic " + auth) + + CheckError(err, "error with sending activity req to") + + _, err = http.DefaultClient.Do(req) + + CheckError(err, "error with sending activity resp to") + } + } +} + +func GetCollectionFromID(id string) Collection { + var nColl Collection + + req, err := http.NewRequest("GET", id, nil) + + CheckError(err, "could not get collection from id req") + + req.Header.Set("Accept", activitystreams) + + resp, err := http.DefaultClient.Do(req) + + if err != nil { + CheckError(err, "could not get collection from " + id) + return nColl + } + + if resp.StatusCode == 200 { + defer resp.Body.Close() + + body, _ := ioutil.ReadAll(resp.Body) + + err = json.Unmarshal(body, &nColl) + + CheckError(err, "error getting collection resp from json body") + + } + + return nColl +} + +func GetActorFromID(id string) Actor { + req, err := http.NewRequest("GET", id, nil) + + CheckError(err, "error getting actor from id req") + + req.Header.Set("Accept", activitystreams) + + resp, err := http.DefaultClient.Do(req) + + CheckError(err, "error getting actor from id resp") + + defer resp.Body.Close() + + body, _ := ioutil.ReadAll(resp.Body) + + var respCollection Collection + + err = json.Unmarshal(body, &respCollection) + + CheckError(err, "error getting actor resp from json body") + + return *respCollection.OrderedItems[0].Actor +} + +func GetConfigValue(value string) string{ + file, err := os.Open("config") + + CheckError(err, "there was an error opening the config file") + + defer file.Close() + + lines := bufio.NewScanner(file) + + for lines.Scan() { + line := strings.SplitN(lines.Text(), ":", 2) + if line[0] == value { + return line[1] + } + } + + return "" +} + +func PrintAdminAuth(db *sql.DB){ + query := fmt.Sprintf("select identifier, code from boardaccess where board='%s' and type='admin'", Domain) + + rows, err := db.Query(query) + + CheckError(err, "Error getting Domain auth") + + var code string + var identifier string + + rows.Next() + rows.Scan(&identifier, &code) + + fmt.Println("Admin Login: " + identifier + ", Code: " + code) +} + +func IsInStringArray(array []string, value string) bool { + for _, e := range array { + if e == value { + return true + } + } + return false +} + +func GetUniqueFilename(_type string) string { + id := RandomID(8) + file := "/public/" + id + "." + _type + + for true { + if _, err := os.Stat("." + file); err == nil { + id = RandomID(8) + file = "/public/" + id + "." + _type + }else{ + return "/public/" + id + "." + _type + } + } + + return "" +} + +func DeleteObjectRequest(db *sql.DB, id string) { + var nObj ObjectBase + var nActor Actor + nObj.Id = id + nObj.Actor = &nActor + + activity := CreateActivity("Delete", nObj) + + obj := GetObjectFromPath(db, id) + + activity.Actor.Id = obj.Actor.Id + + followers := GetActorFollowDB(db, obj.Actor.Id) + for _, e := range followers { + activity.To = append(activity.To, e.Id) + } + + following := GetActorFollowingDB(db, obj.Actor.Id) + for _, e := range following { + activity.To = append(activity.To, e.Id) + } + + MakeActivityRequest(db, activity) +} + +func DeleteObjectAndRepliesRequest(db *sql.DB, id string) { + var nObj ObjectBase + var nActor Actor + nObj.Id = id + nObj.Actor = &nActor + + activity := CreateActivity("Delete", nObj) + + obj := GetObjectByIDFromDB(db, id) + + activity.Actor.Id = obj.OrderedItems[0].Actor.Id + + activity.Object = &obj.OrderedItems[0] + + followers := GetActorFollowDB(db, obj.OrderedItems[0].Actor.Id) + for _, e := range followers { + activity.To = append(activity.To, e.Id) + } + + following := GetActorFollowingDB(db, obj.OrderedItems[0].Actor.Id) + for _, e := range following { + activity.To = append(activity.To, e.Id) + } + + MakeActivityRequest(db, activity) +} + +func ResizeAttachmentToPreview(db *sql.DB) { + query := `select id, href, mediatype, name, size, published from activitystream where id in (select attachment from activitystream where attachment!='' and preview='')` + + rows, err := db.Query(query) + + CheckError(err, "error getting attachments") + + + defer rows.Close() + for rows.Next() { + + var id string + var href string + var mediatype string + var name string + var size int + var published string + + rows.Scan(&id, &href, &mediatype, &name, &size, &published) + + re := regexp.MustCompile(`^\w+`) + + _type := re.FindString(mediatype) + + if _type == "image" { + + re = regexp.MustCompile(`.+/`) + + file := re.ReplaceAllString(mediatype, "") + + nHref := GetUniqueFilename(file) + + var nPreview NestedObjectBase + + re = regexp.MustCompile(`/\w+$`) + actor := re.ReplaceAllString(id, "") + + nPreview.Type = "Preview" + nPreview.Id = fmt.Sprintf("%s/%s", actor, CreateUniqueID(db, actor)) + nPreview.Name = name + nPreview.Href = Domain + "" + nHref + nPreview.MediaType = mediatype + nPreview.Size = int64(size) + nPreview.Published = published + nPreview.Updated = published + + re = regexp.MustCompile(`/public/.+`) + + objFile := re.FindString(href) + + if(id != "") { + cmd := exec.Command("convert", "." + objFile ,"-resize", "250x250>", "." + nHref) + + err := cmd.Run() + + CheckError(err, "error with resize attachment preview") + + if err == nil { + fmt.Println(objFile + " -> " + nHref) + WritePreviewToDB(db, nPreview) + UpdateObjectWithPreview(db, id, nPreview.Id) + } + } + } + } +} + +func UpdateObjectWithPreview(db *sql.DB, id string, preview string) { + query := `update activitystream set preview=$1 where attachment=$2` + + _, err := db.Exec(query, preview, id) + + CheckError(err, "could not update activity stream with preview") + +} + +func ParseCommentForReply(comment string) string { + + re := regexp.MustCompile("(>>)(https://|http://)?(www\\.)?.+\\/\\w+") + match := re.FindAllStringSubmatch(comment, -1) + + var links []string + + for i:= 0; i < len(match); i++ { + str := strings.Replace(match[i][0], ">>", "", 1) + links = append(links, str) + } + + if(len(links) > 0){ + _, isValid := CheckValidActivity(links[0]) + + if(isValid) { + return links[0] + } + } + + return "" +} + +func GetActorByName(db *sql.DB, name string) Actor { + var actor Actor + for _, e := range Boards { + if e.Actor.Name == name { + actor = e.Actor + } + } + + return actor +} + +func GetActorCollectionReq(r *http.Request, collection string) Collection { + var nCollection Collection + + req, err := http.NewRequest("GET", collection, nil) + + CheckError(err, "error with getting actor collection req " + collection) + + _, pass := GetPasswordFromSession(r) + + req.Header.Set("Accept", activitystreams) + + req.Header.Set("Authorization", "Basic " + pass) + + resp, err := http.DefaultClient.Do(req) + + CheckError(err, "error with getting actor collection resp " + collection) + + if resp.StatusCode == 200 { + + defer resp.Body.Close() + + body, _ := ioutil.ReadAll(resp.Body) + + err = json.Unmarshal(body, &nCollection) + + CheckError(err, "error getting actor collection from body " + collection) + } + + return nCollection +} + + +func shortURL(actorName string, url string) string { + + re := regexp.MustCompile(`outbox`) + + actor := re.ReplaceAllString(actorName, "") + + re = regexp.MustCompile(`\w+$`) + temp := re.ReplaceAllString(url, "") + + if(temp == actor){ + short := StripTransferProtocol(url) + + re := regexp.MustCompile(`\w+$`) + + id := re.FindString(short); + + return id; + }else{ + short := StripTransferProtocol(url) + + re := regexp.MustCompile(`\w+$`) + + id := re.FindString(short); + + re = regexp.MustCompile(`.+/.+/`) + + actorurl := re.FindString(short) + + re = regexp.MustCompile(`/.+/`) + + actorname := re.FindString(actorurl) + + actorname = strings.Replace(actorname, "/", "", -1) + + id = "f" + actorname + "-" + id + + return id; + } +} diff --git a/Database.go b/Database.go index 1b71e9d..f145f51 100644 --- a/Database.go +++ b/Database.go @@ -1160,11 +1160,11 @@ func DeleteCaptchaCodeDB(db *sql.DB, verify string) { } func EscapeString(text string) string { - re := regexp.MustCompile("(?i)(n)+(\\s+)?(i)+(\\s+)?(g)+(\\s+)?(e)+?(\\s+)?(r)+(\\s+)?") - text = re.ReplaceAllString(text, "I love black people") - re = regexp.MustCompile("(?i)(n)+(\\s+)?(i)+(\\s+)?(g)(\\s+)?(g)+(\\s+)?") - text = re.ReplaceAllString(text, "I love black people") - text = strings.Replace(text, "<", "<", -1) + // re := regexp.MustCompile("(?i)(n)+(\\s+)?(i)+(\\s+)?(g)+(\\s+)?(e)+?(\\s+)?(r)+(\\s+)?") + // text = re.ReplaceAllString(text, "I love black people") + // re = regexp.MustCompile("(?i)(n)+(\\s+)?(i)+(\\s+)?(g)(\\s+)?(g)+(\\s+)?") + // text = re.ReplaceAllString(text, "I love black people") + // text = strings.Replace(text, "<", "<", -1) return text } -- cgit v1.2.3