commit 883b4880e5438e00f54681657b7311f73d79f600 Author: Skunky <> Date: Thu Apr 4 21:24:47 2024 +0300 First commit! diff --git a/config.json b/config.json new file mode 100644 index 0000000..ce29bac --- /dev/null +++ b/config.json @@ -0,0 +1,11 @@ +{ + "listen": "0.0.0.0:3003", + "cache": { + "enabled": true, + "path": "cache", + "lifetime": null, + "max_size": 10000 + }, + "proxy": false, + "nsfw": false +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f2b1082 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module skunkyart + +go 1.18 + +require golang.org/x/net v0.21.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dab1c7f --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= diff --git a/main.go b/main.go new file mode 100644 index 0000000..91520b4 --- /dev/null +++ b/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + "net/http" + "net/url" + "os" + "runtime" + "strconv" + "strings" + "time" + + "skunkyart/misc" + "skunkyart/post" + "skunkyart/user" + "skunkyart/util" +) + +const Compiler = "gccgo" + +func main() { + util.ParseConfig() + print("Getting CSRF Token..\033[B\r") + util.CSRFToken() + print("CSRF Token has been getted.\033[B\r\n") + + go func() { + for { + time.Sleep(30 * time.Minute) + util.CSRFToken() + println("CSRF token has been updated.") + } + }() + + println("SkunkyArt listening on http://" + util.Conf.Listen) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + e, p := endpoint(r.URL.Path) + c := make(chan string, 100) + + switch e { + case "image": + fmt.Fprintln(w, misc.Getimage(p, url.QueryEscape(r.URL.Query().Get("t")))) + + case "avatar": + if p == "" { + w.WriteHeader(400) + fmt.Fprint(w, "Missing username.") + } else { + fmt.Fprintln(w, misc.Misc(strings.ToLower(p), false)) + } + + case "emoji": + fmt.Fprintln(w, misc.Misc(strings.ToLower(p), true)) + + case "group": + fmt.Fprintln(w, "Groups is not implemented yet :(") + + case "user": + fmt.Fprintln(w, user.Get(p, r.URL.Query().Get("a"), r.URL.Query().Get("p"), url.QueryEscape(r.URL.Query().Get("q")))) + + case "post": + intt, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 32) + + go post.Get(p, int(intt)+1, c) + fmt.Fprintln(w, <-c) + + case "dd": + intt, _ := strconv.ParseInt(r.URL.Query().Get("p"), 10, 32) + + go misc.DD(int(intt), c) + fmt.Fprintln(w, <-c) + + case "search": + go misc.Search(url.QueryEscape(r.URL.Query().Get("q")), r.URL.Query().Get("p"), r.URL.Query().Get("scope"), c) + fmt.Fprintln(w, <-c) + + case "about": + fmt.Fprintln(w, "Its an alternative frontend for deviantart.com. Compiled with Go:", runtime.Version()) + + case "static": + file, err := os.ReadFile("static/" + p) + if err != nil { + fmt.Fprintln(w, "No such file") + } else { + w.Header().Add("Content-Type", "text/css") + fmt.Fprintln(w, string(file)) + } + + case "": + f, _ := os.ReadFile("templates/home.htm") + fmt.Fprintln(w, string(f)) + + default: + w.WriteHeader(404) + fmt.Fprintln(w, "404 - Not found.") + } + }) + http.ListenAndServe(util.Conf.Listen, nil) +} + +func endpoint(url string) (string, string) { + end := strings.Index(url[1:], "/") + if end == -1 { + return url[1:], "" + } + return url[1 : end+1], url[end+2:] +} diff --git a/misc/dailydeviations.go b/misc/dailydeviations.go new file mode 100644 index 0000000..2a29580 --- /dev/null +++ b/misc/dailydeviations.go @@ -0,0 +1,42 @@ +package misc + +import ( + "bytes" + "encoding/json" + t "html/template" + "skunkyart/util" + "strconv" +) + +func DD(page int, out chan string) { + var buf bytes.Buffer + { + var ( + j struct { + Deviations []util.Deviation + } + d, p string + ) + + if page == 0 { + page++ + } + + rawjson := util.Puppy("dabrowse/networkbar/rfy/deviations?page=" + strconv.Itoa(page)) + json.Unmarshal([]byte(rawjson), &j) + + for _, a := range j.Deviations { + d += util.Images(a, a.Media, false) + } + + if page > 1 { + p += "<-- Back " + } + p += "Next -->" + + tmp, _ := t.ParseFiles("templates/dd.htm") + tmp.Execute(&buf, t.HTML(d)) + tmp.ExecuteTemplate(&buf, "P", t.HTML(p)) + } + out <- buf.String() +} diff --git a/misc/media.go b/misc/media.go new file mode 100644 index 0000000..ecbaf4f --- /dev/null +++ b/misc/media.go @@ -0,0 +1,46 @@ +package misc + +import ( + "skunkyart/util" + "strings" +) + +var Extensions = [3]string{ + ".jpg", + ".png", + ".gif", +} + +func get(scope, name string) string { + var uri string + switch scope { + case "avatar": + uri = "https://a.deviantart.net/avatars-big" + "/" + string(name[0:1]) + "/" + string(name[1:2]) + "/" + name + case "emoji": + uri = "https://e.deviantart.net/emoticons/" + name[0:1] + "/" + name + } + + for x := 0; x < len(Extensions); x++ { + image := util.Cache(uri + Extensions[x]) + if image != "Failed to get image." { + return image + } + } + return "" +} + +func Misc(name string, emoji bool) string { + if emoji { + return get("emoji", name) + } + return get("avatar", name) +} + +func Getimage(img string, token string) string { + var imguri string + if len(img) > 0 { + imguri = "https://images-wixmp-" + img[0:strings.Index(img, "/")] + ".wixmp.com" + img[strings.Index(img, "/"):] + "?token=" + token + return util.Cache(imguri) + } + return "Invalid uri." +} diff --git a/misc/search.go b/misc/search.go new file mode 100644 index 0000000..9a16eaf --- /dev/null +++ b/misc/search.go @@ -0,0 +1,72 @@ +package misc + +import ( + "bytes" + "encoding/json" + "html/template" + "math" + "os" + "skunkyart/util" + "strconv" +) + +func Search(q, p, scope string, output chan string) { + var buf bytes.Buffer + if q != "" { + var j struct { + EstTotal int + Deviations []util.Deviation + } + + var rawjson string + switch scope { + case "", "all": + rawjson = util.Puppy("dabrowse/search/all?q=" + q + "&page=" + p) + case "tag": + rawjson = util.Puppy("dabrowse/networkbar/tag/deviations?tag=" + q + "&page=" + p) + default: + output <- "Unknown scope." + } + + json.Unmarshal([]byte(rawjson), &j) + + var tmp struct { + Results, Pages string + Total int + } + for _, a := range j.Deviations { + tmp.Results += util.Images(a, a.Media, false) + } + tmp.Total = j.EstTotal + + switch scope { + case "all": + for x := 0; x < int(math.Round(float64(tmp.Total/25))); x++ { + if x == 417 { + break + } + tmp.Pages += "" + strconv.Itoa(x+1) + " " + } + case "tag": + if p == "" { + p = "1" + } + next, _ := strconv.ParseInt(p, 10, 32) + if int(next) > 1 { + tmp.Pages += "<-- Back " + } + tmp.Pages += "Next -->" + default: + output <- "Missing or invalid scope." + } + + tmpl, _ := template.ParseFiles("templates/search/search.htm") + tmpl.Execute(&buf, tmp.Total) + tmpl.ExecuteTemplate(&buf, "R", template.HTML(tmp.Results)) + tmpl.ExecuteTemplate(&buf, "P", template.HTML(tmp.Pages)) + } else { + doc, _ := os.ReadFile("templates/search/search-base.htm") + output <- string(doc) + } + output <- buf.String() +} diff --git a/post/comments.go b/post/comments.go new file mode 100644 index 0000000..b9cb334 --- /dev/null +++ b/post/comments.go @@ -0,0 +1,137 @@ +package post + +import ( + "encoding/json" + "fmt" + "skunkyart/util" + "strconv" + "strings" +) + +type Comments struct { + Total int + Thread []struct { + CommentId, ParentId, Replies, Likes int + Posted util.Time + IsAuthorHighlited bool + + TextContent struct { + Html struct { + Markup string + } + } + + User struct { + Username string + IsBanned bool + } + } +} + +// json +func js(c string, cc int, id []string) (*string, bool, bool) { + var result string + + var j struct { + Cursor string + PrevOffset int + HasMore, HasLess bool + } + + for x := 0; x < cc; x++ { + p := "dashared/comments/thread?typeid=1&itemid=" + id[len(id)-1] + "&maxdepth=1000&order=newest" + result = util.Puppy(p + "&limit=50&cursor=" + strings.ReplaceAll(c, "+", `%2B`)) + + json.Unmarshal([]byte(result), &j) + if !j.HasMore { + cc = 0 + } + c = j.Cursor + } + + return &result, j.HasMore, j.HasLess +} + +// comments!! +func comments(uri string, user string, page int, cursor string, id []string) *string { + var comments Comments + rawjson, next, prev := js(cursor, page, id) + + err := json.Unmarshal([]byte(*rawjson), &comments) + if err != nil { + fmt.Println(err) + } + + // обработка жирсона + var htm string + for _, b := range comments.Thread { + var msgbody string + // если свич находит фигурную скобку, то парсит "по-новому", не находит - "по-старому" (в сообщении нет жсона). + switch b.TextContent.Html.Markup[0:1] { + case "{": + var comments struct { + Blocks []struct { + Text string + // EntityMap []struct { + // Data struct { + // Src, Title, Type string + // } + // } + } + } + + err := json.Unmarshal([]byte(b.TextContent.Html.Markup), &comments) + if err != nil { + fmt.Println(err) + } + + for _, a := range comments.Blocks { + msgbody = a.Text + // for _, a := range a.EntityMap { + // fmt.Println(a) + // println("" + a.Data.Title + "") + // msgbody += "" + a.Data.Title + "" + // } + } + default: + msgbody = b.TextContent.Html.Markup + } + + // удаляем говно + msgbody = strings.ReplaceAll(msgbody, "https://www.deviantart.com/users/outgoing?", "") + + // генерируем HTML-куски + var ( + isauthor string + reply string + ) + if b.IsAuthorHighlited { + isauthor = "author" + } + var space string + if b.ParentId != 0 { + space = "margin-left: 2%" + reply = "In reply to message:" + } + + msgbody = util.Parse(msgbody) + + htm += fmt.Sprintf("

%[1]s %[4]s %[9]s

%[3]s

👍: %[7]d ⏩: %[8]d

\n", b.User.Username, b.CommentId, msgbody, b.Posted.UTC().String(), b.User.IsBanned, isauthor, b.Likes, b.Replies, reply, space) + } + + // если комментариев больше, чем 0, то отображаем их, иначе пишем, что комментов нет. + if comments.Total > 0 { + htm = "

Comments (" + strconv.Itoa(comments.Total) + ")

" + htm + uri = uri[strings.LastIndex(uri, "/")+1:] + if prev { + htm += "<-- Back " + } + if next { + htm += "Next -->" + } + } else { + htm = "

There is no comments.

" + } + + return &htm +} diff --git a/post/main.go b/post/main.go new file mode 100644 index 0000000..235a89d --- /dev/null +++ b/post/main.go @@ -0,0 +1,141 @@ +package post + +import ( + "bytes" + "encoding/json" + "html/template" + "regexp" + "skunkyart/util" + "strconv" + "strings" +) + +type Post struct { + Deviation util.Deviation + Comments struct { + Total, Cursor int + } +} + +var tmp struct { + Author, Postname, Img, Description, Time, Tags, Comments, License, Recomendations string + Views, FavsCount, Downloads int + Nsfw, Ai, DD bool +} + +func _post(id []string, author string, url string, page int) string { + var j Post + + if !strings.Contains(url, "journal/") { + url = "art/" + url + } + + // парсинг + rawjson := util.Puppy("dadeviation/init?deviationid=" + id[len(id)-1] + "&username=" + author + "&type=art&include_session=false&expand=deviation.related&preload=true") + if strings.Contains(rawjson, "{") { + json.Unmarshal([]byte(rawjson), &j) + } else { + println("Something went wrong. Maybe, rate limit?") + } + + tmp.Nsfw = j.Deviation.IsMature + if !util.Conf.Nsfw && j.Deviation.IsMature { + return "NSFW content has been disabled on this instance." + } + + tmp.Author = j.Deviation.Author.Username + tmp.Postname = j.Deviation.Title + + tmp.Img = util.Images(j.Deviation, j.Deviation.Media, true) + + tmp.Time = j.Deviation.PublishedTime.UTC().String() + tmp.Views = j.Deviation.Stats.Views + tmp.FavsCount = j.Deviation.Stats.Favourites + + if j.Deviation.Extended.DescriptionText.Html.Markup != "" { + tmp.Description = j.Deviation.Extended.DescriptionText.Html.Markup + } else { + tmp.Description = j.Deviation.TextContent.Html.Markup + } + + if len(tmp.Description) > 0 { + if tmp.Description[0:1] == "{" { + var j2 struct { + Blocks []struct { + Text string + } + } + + json.Unmarshal([]byte(tmp.Description), &j2) + + tmp.Description = "" + for _, a := range j2.Blocks { + if j.Deviation.TextContent.Html.Type == "draft" || j.Deviation.Extended.DescriptionText.Html.Type == "draft" { + tmp.Description += a.Text + "
" + } else { + tmp.Description = a.Text + } + } + } + tmp.Description = util.Parse(tmp.Description) + } + + tmp.Downloads = j.Deviation.Stats.Downloads + tmp.Ai = j.Deviation.IsAiGenerated + tmp.License = j.Deviation.License + tmp.DD = j.Deviation.IsDailyDeviation + + tmp.Recomendations = "" + for _, a := range j.Deviation.Extended.RelatedContent { + for _, a := range a.Deviations { + tmp.Recomendations += util.Images(a, a.Media, false) + } + } + + tmp.Tags = "" + for _, a := range j.Deviation.Extended.Tags { + tmp.Tags += "#" + a.Name + " " + } + + tmp.Comments = "" + if j.Comments.Total > 0 { + tmp.Comments = *comments(url, tmp.Author, page, strconv.Itoa(j.Comments.Cursor), id) + } + return "" +} + +func Get(uri string, page int, output chan string) { + var url string + + switch { + case strings.Contains(uri, "journal/"): + url = "https://www.deviantart.com/" + uri + default: + url = "https://www.deviantart.com/art/" + uri + } + + id := regexp.MustCompile("[0-9]+").FindAllString(uri, -1) + + var buffer bytes.Buffer + body, status, _, _ := util.Request(url, "") + if status == 200 { + body = body[strings.Index(body, " by ")+4:] + err := _post(id, body[0:strings.Index(body, " ")], uri, page) + + if err != "" { + output <- err + } + + // темплейт + templ, _ := template.ParseFiles("templates/post.htm") + templ.Execute(&buffer, tmp) + templ.ExecuteTemplate(&buffer, "T", template.HTML(tmp.Tags)) + templ.ExecuteTemplate(&buffer, "D", template.HTML(tmp.Description)) + templ.ExecuteTemplate(&buffer, "R", template.HTML(tmp.Recomendations)) + templ.ExecuteTemplate(&buffer, "C", template.HTML(string(tmp.Comments))) + + output <- buffer.String() + } else { + output <- "Something went wrong. Status: " + strconv.Itoa(int(status)) + } +} diff --git a/static/base.css b/static/base.css new file mode 100644 index 0000000..a6df499 --- /dev/null +++ b/static/base.css @@ -0,0 +1,66 @@ +html { + font-family: ubuntu; + background-color:black; + color: whitesmoke; +} +a { + text-decoration: none; + color: cadetblue; +} +form input, button, select { + background-color: #134134; + padding: 5px; + color: whitesmoke; + border: 0px; + border-radius: 2px; +} +.nsfw, .true { + color: red; +} +.author { + color: seagreen; +} +.msg { + background-color: #091f19; + color: whitesmoke; + width: fit-content; + max-width: 90%; + padding: 4px; + border-radius: 2px; + margin-top: 6px; + text-wrap: pretty; + transition: 350ms; +} +.msg:hover { + background-color: #134134; +} +.dd { + color: rgb(160, 0, 147); +} +.content { + display: flex; + flex-wrap: wrap; + justify-content: center; +} +.block { + max-width: 20%; + height: 0%; + padding: 4px; + border-radius: 2px; + border: 3px solid #091f19; + word-break: break-all; + background-color: #091f19; + margin-left: 5px; + margin-top: 5px; + text-align: center; +} +.block:hover { + border: 3px solid #4d27d6; + transition: 400ms; +} +.block img { + width: 100%; +} +.block p { + word-break: break-all; +} diff --git a/templates/dd.htm b/templates/dd.htm new file mode 100644 index 0000000..174c476 --- /dev/null +++ b/templates/dd.htm @@ -0,0 +1,26 @@ + + + + + Daily Deviations | SkunkyArt + + + +
+ + + +
+

Daily Deviations

+
+ {{.}} +
+ {{define "P"}} +
+ {{.}} + + +{{end}} \ No newline at end of file diff --git a/templates/home.htm b/templates/home.htm new file mode 100644 index 0000000..e8e7c8f --- /dev/null +++ b/templates/home.htm @@ -0,0 +1,19 @@ + + + + + SkunkyArt + + + +
+ + + +
+

Daily Deviations About

+ + \ No newline at end of file diff --git a/templates/post.htm b/templates/post.htm new file mode 100644 index 0000000..a740489 --- /dev/null +++ b/templates/post.htm @@ -0,0 +1,53 @@ + + + + + {{.Author}} — {{.Postname}} | SkunkyArt + + + +
+ + + +
+
+ + {{.Author}} — {{if (.DD)}} + {{.Postname}} + {{else}}{{.Postname}}{{end}} + {{if (ne .License "none")}}({{.License}}){{end}} {{if (.Ai)}}[🤖]{{end}} + {{if (.Nsfw)}}[NSFW]{{end}} + +
+ {{if (ne .Img "/image/")}} + +
+ {{end}} + Published: {{.Time}}; Views: {{.Views}}; Favourites: {{.FavsCount}}; Downloads: {{.Downloads}} + {{define "T"}} + {{if (ne . "")}} +
+ Tags: {{.}} + {{end}} + {{end}} + {{define "R"}} +

See also:

+
+ {{.}} +
+ {{end}} +
+
+ {{define "D"}} +
{{.}}
+ {{end}} + {{define "C"}} + {{.}} +
+ + +{{end}} \ No newline at end of file diff --git a/templates/search/search-base.htm b/templates/search/search-base.htm new file mode 100644 index 0000000..eefb0bc --- /dev/null +++ b/templates/search/search-base.htm @@ -0,0 +1,18 @@ + + + + + Search | SkunkyArt + + + +
+ + + +
+ + \ No newline at end of file diff --git a/templates/search/search.htm b/templates/search/search.htm new file mode 100644 index 0000000..5da35d3 --- /dev/null +++ b/templates/search/search.htm @@ -0,0 +1,31 @@ + + + + + Search | SkunkyArt + + + +
+ + + +
+
+ {{if (ne . 0)}} +

Total resuls: {{.}}

+ {{end}} + {{define "R"}} +
+ {{.}} +
+ {{end}} + {{define "P"}} +
+ {{.}} + + +{{end}} \ No newline at end of file diff --git a/templates/user/gallery.htm b/templates/user/gallery.htm new file mode 100644 index 0000000..00423cb --- /dev/null +++ b/templates/user/gallery.htm @@ -0,0 +1,20 @@ + + + + {{.Name}}'s Gallery | SkunkyArt + + + +

Gallery of {{.Name}}

+

Home | Search

+ {{define "G"}} +
+ {{.}} +
+ {{end}} + {{define "P"}} +
+

Pages: {{.}}

+ + +{{end}} \ No newline at end of file diff --git a/templates/user/info.htm b/templates/user/info.htm new file mode 100644 index 0000000..615bc79 --- /dev/null +++ b/templates/user/info.htm @@ -0,0 +1,36 @@ + + + + {{.Name}} | SkunkyArt + + + + {{if (ne .Bg "/image/")}} + + {{end}} +

{{.Name}} {{.Gender}}

+

Gallery

+

Watchers: {{.Watchers}}; Watching: {{.Watching}}; Comments on Profile: {{.CMMRCPF}}; Posts: {{.Posts}}; Pageviews: {{.Pageviews}}; Comments: {{.CM}}; + Favourites: {{.Fav}}; Friends: {{.Friends}} +

+

Registration Date: {{.RegDate}} +

From {{.Country}}.

+ {{.SiteTitle}} +

+

{{.Status}}

+ {{define "S"}} +

Social Links

+

+ {{.}} +

+ {{end}} + {{define "I"}} +

Interests

+

{{.}}

+ {{end}} + {{define "D"}} +

About me

+

{{.}}

+ + +{{end}} \ No newline at end of file diff --git a/templates/user/search-base.htm b/templates/user/search-base.htm new file mode 100644 index 0000000..50de613 --- /dev/null +++ b/templates/user/search-base.htm @@ -0,0 +1,16 @@ + + + + Search {{.}}'s Gallery | SkunkyArt + + + +

Search gallery of {{.}}.

+

Home | Gallery

+
+ + + +
+ + \ No newline at end of file diff --git a/templates/user/search.htm b/templates/user/search.htm new file mode 100644 index 0000000..8c9a78e --- /dev/null +++ b/templates/user/search.htm @@ -0,0 +1,25 @@ + + + + Search {{.Name}}'s Gallery | SkunkyArt + + + +

Search gallery of {{.Name}}. Total Deviations: {{.Total}}

+

Home | Gallery

+
+ + + +
+ {{define "R"}} +
+ {{.}} +
+ {{end}} + {{define "P"}} +
+

Pages: {{.}}

+ + +{{end}} \ No newline at end of file diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..45ddb55 --- /dev/null +++ b/todo.md @@ -0,0 +1,12 @@ +Нужно сделать: +- провести оптимизацию кода +- кеширование жсона +- ~~добавить устаревание кеша и его максимальный вес~~ +- ~~проксирование и кеширование гифок~~ +- картинки в комментариях +- улучшить парсинг HTML +- улучшить парсинг описания профиля пользователя +- реализовать RSS-ленты пользователей +- ~~добавить возможность включать/выключать разрешение просматривать NSFW-контент в конфиге~~ +- реализовать систему аккаунтов +- написать несколько тем, в том числе, легковесных \ No newline at end of file diff --git a/user/about.go b/user/about.go new file mode 100644 index 0000000..27d676b --- /dev/null +++ b/user/about.go @@ -0,0 +1,128 @@ +package user + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "skunkyart/util" + "time" +) + +type Tmp struct { + Watchers, Posts, Watching, Pageviews, CMMRCPF, CM, Fav, Friends int + Name, Description, Bg, Bgname, Country, RegDate, Gender, Site, SiteTitle, Social, Interests, Status string +} + +func about(name *string, output chan string) { + ums := time.Now().Unix() + + var buf bytes.Buffer + { + var tmp Tmp + var j ab + + // парсинг + rawjson := util.Puppy("dauserprofile/init/about?username=" + *name) + err := json.Unmarshal([]byte(rawjson), &j) + if err != nil { + fmt.Println(err) + if rawjson == "Something went wrong. Status: 403" { + output <- "Rate limit from Cloudfront." + } else { + output <- "Something went wrong." + } + } else { + // ошибки + switch j.ErrorDescription { + case "User deactivated.": + output <- "User deactivated their account or has been banned." + case "user_not_found.": + output <- "Not exists user." + } + j.ErrorDescription = "" + + for _, a := range j.Gruser.Page.Modules { + if a.ModuleData.About.Country != "" { + tmp.Country = a.ModuleData.About.Country + } + + switch a.Name { + case "about": + var j struct { + Blocks []struct { + Text string + // InlineStyleRanges []struct { + // Offset, Length int + // Style string + // EntityMap struct { + + // } + // } + } + } + + json.Unmarshal([]byte(a.ModuleData.About.TextContent.Html.Markup), &j) + + for _, a := range j.Blocks { + tmp.Description += a.Text + "

" + } + case "cover_deviation": + tmp.Bg = util.Images(a.ModuleData.CoverDeviation.CoverDeviation, a.ModuleData.CoverDeviation.CoverDeviation.Media, true) + tmp.Bgname = a.ModuleData.CoverDeviation.CoverDeviation.Title + } + + if a.ModuleData.About.DeviantFor > 0 { + tmp.RegDate = time.Unix(ums-a.ModuleData.About.DeviantFor, 0).UTC().String() + } + + switch a.ModuleData.About.Gender { + case "female": + tmp.Gender = "♀️" + case "male": + tmp.Gender = "♂️" + } + + if a.ModuleData.About.Website != "" { + tmp.Site = a.ModuleData.About.Website + if a.ModuleData.About.WebsiteLabel != "" { + tmp.SiteTitle = a.ModuleData.About.WebsiteLabel + } else { + tmp.SiteTitle = a.ModuleData.About.Website + } + } + + for _, a := range a.ModuleData.About.SocialLinks { + tmp.Social += "" + a.Value + "

" + } + + for _, a := range a.ModuleData.About.Interests { + tmp.Interests += "" + a.Label + ": " + a.Value + "

" + } + + if a.ModuleData.About.Tagline != "" { + tmp.Status = a.ModuleData.About.Tagline + } + + // говно + tmp.Watchers = j.PageExtraData.Stats.Watchers + tmp.Posts = j.PageExtraData.Stats.Deviations + tmp.Watching = j.PageExtraData.Stats.Watching + tmp.CMMRCPF = j.PageExtraData.Stats.CommentsReceivedProfile + tmp.CM = j.PageExtraData.Stats.CommentsMade + tmp.Fav = j.PageExtraData.Stats.Favourites + tmp.Friends = j.PageExtraData.Stats.Friends + tmp.Name = j.Owner.Username + } + + // темплейты + tmpl, _ := template.ParseFiles("templates/user/info.htm") + tmpl.Execute(&buf, tmp) + tmpl.ExecuteTemplate(&buf, "S", template.HTML(tmp.Social)) + tmpl.ExecuteTemplate(&buf, "I", template.HTML(tmp.Interests)) + tmpl.ExecuteTemplate(&buf, "D", template.HTML(tmp.Description)) + } + } + + output <- buf.String() +} diff --git a/user/gallery.go b/user/gallery.go new file mode 100644 index 0000000..c87f479 --- /dev/null +++ b/user/gallery.go @@ -0,0 +1,72 @@ +package user + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "skunkyart/util" + "strconv" +) + +func gallery(name *string, output chan string, page string) { + var buf bytes.Buffer + { + var tmp struct { + Posts, Name, Nav string + TP int + } + var j struct { + Gruser struct { + Page struct { + Modules []struct { + Name string + ModuleData struct { + FolderDeviations struct { + Username string + TotalPageCount int + Deviations []util.Deviation + } + } + } + } + } + } + + rawjson := util.Puppy("dauserprofile/init/gallery?username=" + *name + "&page=" + page + "&deviations_limit=50&with_subfolders=false") + err := json.Unmarshal([]byte(rawjson), &j) + if err != nil { + fmt.Println(err) + if rawjson == "Something went wrong. Status: 403" { + output <- "Rate limit from CloudFront." + } else { + output <- "Something went wrong." + } + } else { + for _, a := range j.Gruser.Page.Modules { + if a.ModuleData.FolderDeviations.Username != "" { + tmp.Name = a.ModuleData.FolderDeviations.Username + } + if a.ModuleData.FolderDeviations.TotalPageCount != 0 { + tmp.TP = a.ModuleData.FolderDeviations.TotalPageCount + } + + for _, a := range a.ModuleData.FolderDeviations.Deviations { + tmp.Posts += util.Images(a, a.Media, false) + } + + tmp.Nav = "" + for x := 0; x < tmp.TP; x++ { + tmp.Nav += "" + strconv.Itoa(x+1) + " " + } + } + } + + tmpl, _ := template.ParseFiles("templates/user/gallery.htm") + tmpl.Execute(&buf, tmp) + tmpl.ExecuteTemplate(&buf, "G", template.HTML(tmp.Posts)) + tmpl.ExecuteTemplate(&buf, "P", template.HTML(tmp.Nav)) + } + + output <- buf.String() +} diff --git a/user/main.go b/user/main.go new file mode 100644 index 0000000..2518176 --- /dev/null +++ b/user/main.go @@ -0,0 +1,22 @@ +package user + +import "strconv" + +func Get(name, action, page, query string) string { + c := make(chan string, 100) + + switch action { + case "": + go about(&name, c) + case "gallery": + if page == "" { + page = "1" + } + + go gallery(&name, c, page) + case "search": + p, _ := strconv.ParseInt(page, 10, 32) + go search(&name, &query, int(p), c) + } + return <-c +} diff --git a/user/misc.go b/user/misc.go new file mode 100644 index 0000000..c424ad9 --- /dev/null +++ b/user/misc.go @@ -0,0 +1,46 @@ +package user + +import ( + "skunkyart/util" +) + +type ab struct { + ErrorDescription string + Owner struct { + Username string + } + Gruser struct { + Page struct { + Modules []struct { + Name string + ModuleData struct { + About struct { + Country, Website, WebsiteLabel, Gender, Tagline string + DeviantFor int64 + SocialLinks []struct { + Value string + } + TextContent struct { + Excerpt string + Html struct { + Markup string + } + } + Interests []struct { + Label, Value string + } + } + CoverDeviation struct { + CoverDeviation util.Deviation + } + } + } + } + } + PageExtraData struct { + GruserTagline string + Stats struct { + Deviations, Watchers, Watching, Pageviews, CommentsReceivedProfile, CommentsMade, Favourites, Friends int + } + } +} diff --git a/user/search.go b/user/search.go new file mode 100644 index 0000000..f555409 --- /dev/null +++ b/user/search.go @@ -0,0 +1,56 @@ +package user + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "math" + "skunkyart/util" + "strconv" +) + +func search(user, query *string, page int, output chan string) { + var buf bytes.Buffer + { + var tmp struct { + Total int + Results, Pages, Name string + } + + var q struct { + EstTotal int + Results []util.Deviation + } + + if *query != "" { + rawjson := util.Puppy("dashared/gallection/search?username=" + *user + "&q=" + *query + "&offset=" + strconv.Itoa(50*page) + "&type=gallery&order=most-recent&init=true&limit=50") + err := json.Unmarshal([]byte(rawjson), &q) + if err != nil { + fmt.Println(err) + } else { + if q.EstTotal != 0 { + tmp.Total = q.EstTotal + } + + for _, a := range q.Results { + tmp.Results += util.Images(a, a.Media, false) + } + + tmp.Pages = "" + tmp.Name = *user + for x := 0; x < int(math.Round(float64(q.EstTotal/50))); x++ { + tmp.Pages += "" + strconv.Itoa(x+1) + " " + } + } + tmpl, _ := template.ParseFiles("templates/user/search.htm") + tmpl.Execute(&buf, tmp) + tmpl.ExecuteTemplate(&buf, "R", template.HTML(tmp.Results)) + tmpl.ExecuteTemplate(&buf, "P", template.HTML(tmp.Pages)) + } else { + tmpl, _ := template.ParseFiles("templates/user/search-base.htm") + tmpl.Execute(&buf, *user) + } + } + output <- buf.String() +} diff --git a/util/cache.go b/util/cache.go new file mode 100644 index 0000000..6adfeee --- /dev/null +++ b/util/cache.go @@ -0,0 +1,99 @@ +package util + +import ( + "crypto/sha1" + "fmt" + "io" + "os" + "strings" + "syscall" + "time" +) + +func check(what ...string) { + if Conf.Cache.Max_size != 0 || Conf.Cache.Lifetime != 0 { + for { + dir, err := os.Open(Conf.Cache.Path) + if err != nil { + err = os.Mkdir(Conf.Cache.Path, 0700) + if err != nil { + panic(fmt.Sprintln(err)) + } + } + stat, _ := dir.Stat() + + c, _ := dir.Readdirnames(-1) + for _, a := range c { + for _, b := range what { + switch b { + case "lifetime": + if Conf.Cache.Lifetime != 0 { + now := time.Now().UnixMilli() + + f, _ := os.Stat(Conf.Cache.Path + "/" + a) + stat := f.Sys().(*syscall.Stat_t) + time := time.Unix(stat.Ctim.Unix()).UnixMilli() + + if time+Conf.Cache.Lifetime <= now { + os.RemoveAll(Conf.Cache.Path + "/" + a) + } + } + case "maxsize": + if Conf.Cache.Max_size != 0 && stat.Size() > Conf.Cache.Max_size { + os.RemoveAll(Conf.Cache.Path + "/" + a) + } + } + } + } + + dir.Close() + + time.Sleep(time.Second) + } + } +} + +func Cache(img string) string { + if Conf.Cache.Enabled { + os.Mkdir(Conf.Cache.Path, 0700) + + sum := sha1.Sum([]byte(img)) + encsum := fmt.Sprintf("%X", sum[:]) + file, err := os.Open(Conf.Cache.Path + "/" + encsum) + + if err != nil { + body, status, _, h := Request(img, "") + + if status == 200 { + for b, a := range h { + if b == "Content-Type" { + for _, a := range a { + a = a[0:strings.Index(a, "/")] + if a == "image" || a == "video" { + err := os.WriteFile(Conf.Cache.Path+"/"+encsum, []byte(body), 0700) + if err != nil { + fmt.Println(err) + } + + return string(body) + } + } + } + } + } else { + return "Failed to get image." + } + } else { + file, err := io.ReadAll(file) + if err != nil { + println(err) + } + return string(file) + } + file.Close() + } else { + body, _, _, _ := Request(img, "") + return string(body) + } + return "" +} diff --git a/util/config.go b/util/config.go new file mode 100644 index 0000000..ed06010 --- /dev/null +++ b/util/config.go @@ -0,0 +1,76 @@ +package util + +import ( + "encoding/json" + "fmt" + "log" + "os" +) + +type cconf struct { + Enabled bool + Path string + Max_size, Lifetime int64 +} + +type c struct { + Listen string + Cache cconf + Proxy, Nsfw bool +} + +var Conf = c{ + Listen: "127.0.0.1:3003", + Cache: cconf{ + Enabled: true, + Path: "cache", + // Max_size: 4000000, + // Lifetime: 10000, + }, + Proxy: true, + Nsfw: true, +} + +func ParseConfig() { + cfile := "config.json" + + for b, a := range os.Args { + if len(a) > 2 && a[0:2] == "--" { + a = a[2:] + switch a { + case "help": + println(`SkunkyArt v1.0 Beta +Usage: + - config: specify config file path; + - help: prints this message. +Example: + ./skunkyart --config /services/skunkyart/config.json + +lost+skunk, 2024. Source Code: https://git.macaw.me/skunky/skunkyart`) + os.Exit(0) + case "config": + file, err := os.ReadFile(os.Args[b+1]) + if err != nil { + log.Fatalln(fmt.Sprint(err)) + } + + if len(file) > 0 { + if string(file)[0:1] == "{" { + cfile = os.Args[b+1] + } else { + log.Fatalln("Invalid config file.") + } + } + } + } + } + + file, err := os.ReadFile(cfile) + if err != nil { + panic(fmt.Sprintln(err)) + } + + json.Unmarshal(file, &Conf) + + go check("lifetime", "maxsize") +} diff --git a/util/dtype.go b/util/dtype.go new file mode 100644 index 0000000..94397a9 --- /dev/null +++ b/util/dtype.go @@ -0,0 +1,115 @@ +package util + +import ( + "strconv" + "strings" + "time" +) + +type Deviation struct { + Title, Url, License string + PublishedTime Time + IsMature, IsAiGenerated, IsDailyDeviation bool + Author struct { + Username string + } + Stats struct { + Favourites, Views, Downloads int + } + Media Media + Extended struct { + Tags []struct { + Name string + } + DescriptionText struct { + Html HTML + } + RelatedContent []struct { + Deviations []Deviation + } + } + TextContent struct { + Html HTML + } +} + +type Media struct { + BaseUri string + Token []string + Types []struct { + T string + H, W int + } +} + +type HTML struct { + Markup, Type string +} + +type Time struct { + time.Time +} + +func (t *Time) UnmarshalJSON(b []byte) (err error) { + if b[0] == '"' && b[len(b)-1] == '"' { + b = b[1 : len(b)-1] + } + t.Time, err = time.Parse("2006-01-02T15:04:05-0700", string(b)) + return +} + +// обработка изображения(ий) +func Images(deviation Deviation, media Media, nfmt bool) string { + var uri string + if len(media.BaseUri) > 0 { + uri = strings.Replace(media.BaseUri[21:], ".wixmp.com", "", 1) + } + + var image string + for _, a := range media.Types { + if a.T == "fullview" { + image = uri + if len(media.Token) > 0 { + if media.BaseUri[len(media.BaseUri)-3:] == "gif" { + image = uri + "?t=" + media.Token[0] + } else { + image = uri + "/v1/fill/w_" + strconv.Itoa(a.W) + ",h_" + strconv.Itoa(a.H) + "/" + ".gif" + "?t=" + media.Token[0] + } + } + } + } + + if nfmt { + return "/image/" + image + } + + var link string + if deviation.Url != "" { + Url := deviation.Url[27:] + link = strings.ReplaceAll(Url, Url[0:strings.Index(Url, "/")], "") + } + + if deviation.Author.Username != "" { + deviation.Author.Username = deviation.Author.Username + " — " + } + + if image != "" { + image = "" + } else { + image = "

[ TEXT ]

" + } + + switch { + case deviation.IsAiGenerated: + deviation.Title += " [🤖]" + case deviation.IsDailyDeviation: + deviation.Title += " [DD]" + case deviation.IsMature: + if !Conf.Nsfw { + return "" + } + deviation.Title += " [NSFW]" + } + + return "
" + image + "
" + deviation.Author.Username + deviation.Title + "
" +} diff --git a/util/misc.go b/util/misc.go new file mode 100644 index 0000000..8f3356b --- /dev/null +++ b/util/misc.go @@ -0,0 +1,37 @@ +package util + +import ( + "bytes" + "fmt" + "io" + "net/http" + + "golang.org/x/net/html" +) + +func Request(url string, cookie string) (string, int, []*http.Cookie, http.Header) { + // println(url) + cli := &http.Client{} + request, err := http.NewRequest("GET", url, nil) + request.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0.0") + request.Header.Set("Cookie", cookie) + if err != nil { + fmt.Println(err) + } + response, err := cli.Do(request) + if err != nil { + fmt.Println(err) + } + body, err := io.ReadAll(response.Body) + if err != nil { + fmt.Println(err) + } + response.Body.Close() + return string(body), response.StatusCode, response.Cookies(), response.Header +} + +func Render(n *html.Node) string { + var buffer bytes.Buffer + html.Render(io.Writer(&buffer), n) + return buffer.String() +} diff --git a/util/parsetags.go b/util/parsetags.go new file mode 100644 index 0000000..fd5c24a --- /dev/null +++ b/util/parsetags.go @@ -0,0 +1,72 @@ +package util + +import ( + "bytes" + "strings" + + "golang.org/x/net/html" +) + +func Parse(n string) string { + var buf bytes.Buffer + tt := html.NewTokenizer(strings.NewReader(n)) + for { + t := tt.Next() + switch t { + case html.ErrorToken: + return buf.String() + case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken: + token := tt.Token() + switch token.Data { + case "a": + for _, a := range token.Attr { + if a.Key == "href" { + url := strings.ReplaceAll(a.Val, "https://www.deviantart.com/users/outgoing?", "") + if strings.Contains(url, "deviantart") { + url = strings.ReplaceAll(url, "https://www.deviantart.com/", "") + url = strings.ReplaceAll(url, url[0:strings.Index(url, "/")+1], "") + } + buf.WriteString("" + tagval(tt) + " ") + } + } + case "img": + var ( + uri, title string + ) + for b, a := range token.Attr { + switch a.Key { + case "src": + if a.Val[8:9] == "e" { + uri = "/emoji/" + a.Val[37:len(a.Val)-4] + } + case "title": + title = a.Val + } + if title != "" { + for x := -1; x < b; x++ { + buf.WriteString("") + } + } + } + case "br", "li", "ul", "p", "b": + buf.WriteString(token.String()) + case "div": + buf.WriteString("

") + } + case html.TextToken: + buf.Write(tt.Text()) + } + } +} + +func tagval(t *html.Tokenizer) string { + for { + tt := t.Next() + switch tt { + case html.ErrorToken: + return "" + case html.TextToken: + return string(t.Text()) + } + } +} diff --git a/util/puppy.go b/util/puppy.go new file mode 100644 index 0000000..dff7bd2 --- /dev/null +++ b/util/puppy.go @@ -0,0 +1,48 @@ +package util + +import ( + "fmt" + "net/http" + "strconv" + "strings" +) + +var cookie []*http.Cookie +var zond, token string + +func CSRFToken() { + // получаем отслеживающий куки, без которого не удастся получить жсон в будущем + if cookie == nil || zond == "" { + _, _, _c, _ := Request("https://www.deviantart.com/_puppy/", "") + cookie = _c + + for _, a := range cookie { + zond = a.Raw + } + } + + body, status, _, _ := Request("https://www.deviantart.com/", zond) + if status != 200 { + token = "Something went wrong. Status: " + strconv.Itoa(status) + } + token = body[strings.Index(body, "window.__CSRF_TOKEN__ = '")+25 : strings.Index(body, "window.__XHR_LOCAL__")-3] +} + +func Puppy(data string) string { + // получаем жсон + if zond != "" { + if token[0:21] == "Something went wrong." { + return token + } + + jsonbody, status, _, _ := Request(fmt.Sprintf("https://www.deviantart.com/_puppy/%s&csrf_token=%s&da_minor_version=20230710", data, token), zond) + if status == 400 && jsonbody == `{"error":"invalid_request","errorDescription":"Invalid or expired form submission","errorDetails":{"csrf":"invalid"},"status":"error"}` { + cookie = nil + zond = "" + CSRFToken() + return jsonbody + } + return jsonbody + } + return "" +}