First commit!

This commit is contained in:
Skunky 2024-04-04 21:24:47 +03:00
commit 883b4880e5
31 changed files with 1657 additions and 0 deletions

11
config.json Normal file
View File

@ -0,0 +1,11 @@
{
"listen": "0.0.0.0:3003",
"cache": {
"enabled": true,
"path": "cache",
"lifetime": null,
"max_size": 10000
},
"proxy": false,
"nsfw": false
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module skunkyart
go 1.18
require golang.org/x/net v0.21.0

2
go.sum Normal file
View File

@ -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=

108
main.go Normal file
View File

@ -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:]
}

42
misc/dailydeviations.go Normal file
View File

@ -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 += "<a href=\"/dd?p=" + strconv.Itoa(int(page)-1) + "\"><-- Back</a> "
}
p += "<a href=\"/dd?p=" + strconv.Itoa(int(page)+1) + "\">Next --></a>"
tmp, _ := t.ParseFiles("templates/dd.htm")
tmp.Execute(&buf, t.HTML(d))
tmp.ExecuteTemplate(&buf, "P", t.HTML(p))
}
out <- buf.String()
}

46
misc/media.go Normal file
View File

@ -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."
}

72
misc/search.go Normal file
View File

@ -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 += "<a href=\"/search?p=" + strconv.Itoa(x+1) + "&scope=all&q=" + q + "\">" + strconv.Itoa(x+1) + "</a> "
}
case "tag":
if p == "" {
p = "1"
}
next, _ := strconv.ParseInt(p, 10, 32)
if int(next) > 1 {
tmp.Pages += "<a href=\"/search?p=" + strconv.Itoa(int(next)-1) + "&scope=tag&q=" + q + "\"><-- Back</a> "
}
tmp.Pages += "<a href=\"/search?p=" + strconv.Itoa(int(next)+1) + "&scope=tag&q=" + q + "\">Next --></a>"
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()
}

137
post/comments.go Normal file
View File

@ -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("<img src=\"" + a.Data.Src + "\">" + a.Data.Title + "</img>")
// msgbody += "<img src=\"" + a.Data.Src + "\">" + a.Data.Title + "</img>"
// }
}
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 = "<b>In reply to <a href=\"#" + strconv.Itoa(b.ParentId) + "\">message</a></b>:"
}
msgbody = util.Parse(msgbody)
htm += fmt.Sprintf("<div class=\"msg\" style=\"%[10]s\"><p id=\"%[2]d\"><img src=\"/avatar/%[1]s\" width=\"30px\" height=\"30px\"/> <a href=\"/user/%[1]s\"><b class=\"%[6]s %[5]t\">%[1]s</b></a> %[4]s %[9]s<p>%[3]s<p>👍: %[7]d ⏩: %[8]d</p></div>\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 = "<h2 id=\"comments\">Comments (" + strconv.Itoa(comments.Total) + ")</h2>" + htm
uri = uri[strings.LastIndex(uri, "/")+1:]
if prev {
htm += "<a href=\"" + uri + "?page=" + strconv.Itoa(page-2) + "#comments\"><-- Back</a> "
}
if next {
htm += "<a href=\"" + uri + "?page=" + strconv.Itoa(page) + "#comments\">Next --></a>"
}
} else {
htm = "<p>There is no comments.</p>"
}
return &htm
}

141
post/main.go Normal file
View File

@ -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 + "<br>"
} 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 href=\"/search?q=" + a.Name + "&scope=tag\">#" + a.Name + "</a> "
}
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))
}
}

66
static/base.css Normal file
View File

@ -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;
}

26
templates/dd.htm Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta charset="UTF-8">
<title>Daily Deviations | SkunkyArt</title>
<link rel="stylesheet" href="/static/base.css">
</head>
<body>
<form method="get" action="/search">
<input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
<select name="scope">
<option value="all">All</option>
<option value="tag">Tag</option>
</select>
<button type="submit">Search!</button>
</form>
<h1>Daily Deviations</h1>
<div class="content">
{{.}}
</div>
{{define "P"}}
<hr>
{{.}}
</body>
</html>
{{end}}

19
templates/home.htm Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta charset="UTF-8">
<title>SkunkyArt</title>
<link rel="stylesheet" href="/static/base.css">
</head>
<body>
<form method="get" action="/search">
<input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
<select name="scope">
<option value="all">All</option>
<option value="tag">Tag</option>
</select>
<button type="submit">Search!</button>
</form>
<h1><a href="/dd">Daily Deviations</a> <a href="/about">About</a></h1>
</body>
</html>

53
templates/post.htm Normal file
View File

@ -0,0 +1,53 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta charset="UTF-8">
<title>{{.Author}} — {{.Postname}} | SkunkyArt</title>
<link rel="stylesheet" href="/static/base.css">
</head>
<body>
<form method="get" action="/search">
<input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
<select name="scope">
<option value="all">All</option>
<option value="tag">Tag</option>
</select>
<button type="submit">Search!</button>
</form>
<figure>
<img src="/avatar/{{.Author}}" width="30px">
<span><strong><a href="/user/{{.Author}}">{{.Author}}</a></strong> — {{if (.DD)}}
<span class="dd" title="Daily Deviation!"><b>{{.Postname}}</b></span>
{{else}}{{.Postname}}{{end}}
{{if (ne .License "none")}}({{.License}}){{end}} {{if (.Ai)}}[🤖]{{end}}
{{if (.Nsfw)}}[<span class="nsfw">NSFW</span>]{{end}}
</span>
<br>
{{if (ne .Img "/image/")}}
<img src="{{.Img}}" width="50%">
<br>
{{end}}
<span>Published: <strong>{{.Time}}</strong>; Views: <strong>{{.Views}}</strong>; Favourites: <strong>{{.FavsCount}}</strong>; Downloads: <strong>{{.Downloads}}</strong></span>
{{define "T"}}
{{if (ne . "")}}
<br>
<span>Tags: <strong>{{.}}</strong></span>
{{end}}
{{end}}
{{define "R"}}
<h2>See also:</h2>
<div class="content">
{{.}}
</div>
{{end}}
<br>
<br>
{{define "D"}}
<figcaption>{{.}}</figcaption>
{{end}}
{{define "C"}}
{{.}}
</figure>
</body>
</html>
{{end}}

View File

@ -0,0 +1,18 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta charset="UTF-8">
<title>Search | SkunkyArt</title>
<link rel="stylesheet" href="/static/base.css">
</head>
<body>
<form method="get" action="/search">
<input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
<select name="scope">
<option value="all">All</option>
<option value="tag">Tag</option>
</select>
<button type="submit">Search!</button>
</form>
</body>
</html>

View File

@ -0,0 +1,31 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta charset="UTF-8">
<title>Search | SkunkyArt</title>
<link rel="stylesheet" href="/static/base.css">
</head>
<body>
<form method="get" action="/search">
<input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
<select name="scope">
<option value="all">All</option>
<option value="tag">Tag</option>
</select>
<button type="submit">Search!</button>
</form>
<hr>
{{if (ne . 0)}}
<h1>Total resuls: {{.}}</h1>
{{end}}
{{define "R"}}
<div class="content">
{{.}}
</div>
{{end}}
{{define "P"}}
<hr>
{{.}}
</body>
</html>
{{end}}

View File

@ -0,0 +1,20 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<title>{{.Name}}'s Gallery | SkunkyArt</title>
<link rel="stylesheet" href="/static/base.css">
</head>
<body>
<img src="/avatar/{{.Name}}" width="60px"><h1>Gallery of {{.Name}}</h1></img>
<h2><a href="/user/{{.Name}}">Home</a> | <a href="/user/{{.Name}}?a=search">Search</a></h2>
{{define "G"}}
<div class="content">
{{.}}
</div>
{{end}}
{{define "P"}}
<hr>
<p><b>Pages: {{.}}</b></p>
</body>
</html>
{{end}}

36
templates/user/info.htm Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<title>{{.Name}} | SkunkyArt</title>
<link rel="stylesheet" href="/static/base.css">
</head>
<body>
{{if (ne .Bg "/image/")}}
<img src="{{.Bg}}" width="30%" title="{{.Bgname}}">
{{end}}
<h1><img src="/avatar/{{.Name}}" width="60px">{{.Name}} {{.Gender}}</h1>
<h2><a href="/user/{{.Name}}?a=gallery">Gallery</a></h2>
<p>Watchers: {{.Watchers}}; Watching: {{.Watching}}; Comments on Profile: {{.CMMRCPF}}; Posts: {{.Posts}}; Pageviews: {{.Pageviews}}; Comments: {{.CM}};
Favourites: {{.Fav}}; Friends: {{.Friends}}
</p>
<p>Registration Date: <b>{{.RegDate}}</b>
<p>From <b>{{.Country}}</b>.<p>
<a href="https://{{.Site}}" target="_blank">{{.SiteTitle}}</a>
</p>
<p><i title="User status">{{.Status}}</i></p>
{{define "S"}}
<h2>Social Links</h2>
<p>
{{.}}
</p>
{{end}}
{{define "I"}}
<h2>Interests</h2>
<p>{{.}}</p>
{{end}}
{{define "D"}}
<h2>About me</h2>
<p>{{.}}</p>
</body>
</html>
{{end}}

View File

@ -0,0 +1,16 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<title>Search {{.}}'s Gallery | SkunkyArt</title>
<link rel="stylesheet" href="/static/base.css">
</head>
<body>
<img src="/avatar/{{.}}" width="60px"><h1>Search gallery of {{.}}.</h1></img>
<h2><a href="/user/{{.}}">Home</a> | <a href="/user/{{.}}?a=gallery">Gallery</a></h2>
<form method="get" action="/user/{{.}}">
<input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
<input type="hidden" name="a" value="search">
<button type="submit">Search!</button>
</form>
</body>
</html>

25
templates/user/search.htm Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<title>Search {{.Name}}'s Gallery | SkunkyArt</title>
<link rel="stylesheet" href="/static/base.css">
</head>
<body>
<img src="/avatar/{{.Name}}" width="60px"><h1>Search gallery of {{.Name}}. Total Deviations: {{.Total}}</h1></img>
<h2><a href="/user/{{.Name}}">Home</a> | <a href="/user/{{.Name}}?a=gallery">Gallery</a></h2>
<form method="get" action="/user/{{.Name}}">
<input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
<input type="hidden" name="a" value="search">
<button type="submit">Search!</button>
</form>
{{define "R"}}
<div class="content">
{{.}}
</div>
{{end}}
{{define "P"}}
<hr>
<p><b>Pages: {{.}}</b></p>
</body>
</html>
{{end}}

12
todo.md Normal file
View File

@ -0,0 +1,12 @@
Нужно сделать:
- провести оптимизацию кода
- кеширование жсона
- ~~добавить устаревание кеша и его максимальный вес~~
- ~~проксирование и кеширование гифок~~
- картинки в комментариях
- улучшить парсинг HTML
- улучшить парсинг описания профиля пользователя
- реализовать RSS-ленты пользователей
- ~~добавить возможность включать/выключать разрешение просматривать NSFW-контент в конфиге~~
- реализовать систему аккаунтов
- написать несколько тем, в том числе, легковесных

128
user/about.go Normal file
View File

@ -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 + "<p>"
}
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 href=\"" + a.Value + "\" target=\"_blank\">" + a.Value + "</a><p>"
}
for _, a := range a.ModuleData.About.Interests {
tmp.Interests += "<b>" + a.Label + "</b>: " + a.Value + "<p>"
}
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()
}

72
user/gallery.go Normal file
View File

@ -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 += "<a href=\"/user/" + tmp.Name + "?a=gallery&p=" + strconv.Itoa(x+1) + "\">" + strconv.Itoa(x+1) + "</a> "
}
}
}
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()
}

22
user/main.go Normal file
View File

@ -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
}

46
user/misc.go Normal file
View File

@ -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
}
}
}

56
user/search.go Normal file
View File

@ -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 += "<a href=\"/user/" + tmp.Name + "?a=search&p=" + strconv.Itoa(x+1) + "&q=" + *query + "\">" + strconv.Itoa(x+1) + "</a> "
}
}
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()
}

99
util/cache.go Normal file
View File

@ -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 ""
}

76
util/config.go Normal file
View File

@ -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")
}

115
util/dtype.go Normal file
View File

@ -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 = "<img src=\"/image/" + image + "\" width=\"15%\">"
} else {
image = "<h1>[ TEXT ]</h1>"
}
switch {
case deviation.IsAiGenerated:
deviation.Title += " [🤖]"
case deviation.IsDailyDeviation:
deviation.Title += " [<span class=\"dd\">DD</span>]"
case deviation.IsMature:
if !Conf.Nsfw {
return ""
}
deviation.Title += " [<span class=\"nsfw\">NSFW</span>]"
}
return "<div class=\"block\">" + image + "<br><a href=\"/post" + link + "\">" + deviation.Author.Username + deviation.Title + "</a></div>"
}

37
util/misc.go Normal file
View File

@ -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()
}

72
util/parsetags.go Normal file
View File

@ -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("<a target=\"_blank\" href=\"" + url + "\">" + tagval(tt) + "</a> ")
}
}
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("<img src=\"" + uri + "\" title=\"" + title + "\">")
}
}
}
case "br", "li", "ul", "p", "b":
buf.WriteString(token.String())
case "div":
buf.WriteString("<p> ")
}
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())
}
}
}

48
util/puppy.go Normal file
View File

@ -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 ""
}