krz/skunky-art
Alternative privacy frontend for DeviantArt.
clone: git clone https://gitbay.org/krz/skunky-art.git
1537da9b16dfda1ecb2730b5d1de3b9005397618
unsigned
author: lost+skunk <skunky@ebloid.ru> · 2024-08-13T12:59:52Z
README.md | 8 +- SETUP-RU.md | 2 +- SETUP.md | 2 +- TODO.md | 18 +++-- app/cache.go | 132 +++++++++++++++++++++++++++++++ app/cli.go | 63 +++++++-------- app/config.go | 16 ++-- app/parsers.go | 8 +- app/router.go | 33 ++++++-- app/stat-freebsd.go | 13 +++ app/stat.go | 13 +++ app/util.go | 136 ++++++++------------------------ app/wrapper.go | 40 ++++++---- config.example.json | 6 +- html/daily.htm | 28 ------- html/search.htm | 30 ------- main.go | 3 +- {css => static/css}/skunky.css | 13 ++- {html => static/html}/about.htm | 19 +---- static/html/daily.htm | 13 +++ {html => static/html}/deviantion.htm | 20 +---- {html => static/html}/gruser.htm | 16 +--- static/html/head.htm | 24 ++++++ static/html/header.htm | 14 ++++ {html => static/html}/index.htm | 8 +- static/html/search.htm | 16 ++++ {misc => static/images}/logo.png | Bin static/templates-noembed.go | 148 +++++++++++++++++++++++++++++++++++ static/templates.go | 16 ++++ 29 files changed, 555 insertions(+), 303 deletions(-) @@ -1,15 +1,15 @@ -<img src="https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/misc/logo.png" alt="SkunkyArt" title="SkunkyArt Logo" width="20%" loading="lazy"/> +<img src="/skunky/SkunkyArt/raw/branch/master/misc/logo.png" alt="SkunkyArt" title="SkunkyArt Logo" width="20%" loading="lazy"/> [](https://go.kde.org/matrix/#/#skunkyart:ebloid.ru) -Instances: [`INSTANCES.md`](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/INSTANCES.md) +Instances: [`INSTANCES.md`](/skunky/SkunkyArt/src/branch/master/INSTANCES.md) # EN 🇺🇸 ## Description SkunkyArt 🦨 — alternative frontend for DevianArt, which works without JS. ## Setup The sample config is in the `config.example.json` file. For custom config, use `--config` option. -See the [`SETUP.md`](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/SETUP.md) file for more info about directives. +See the [`SETUP.md`](/skunky/SkunkyArt/src/branch/master/SETUP.md) file for more info about directives. ## Adding instance to the list To do this, you must either make a PR by adding your instance to the `instances.json` and `INSTANCES.md` files (you can use `--add-instance` cli-argument to automatically add the instance to these files), or create an Issue, or report it to the room in Matrix. Keep in mind that your instance must comply with the following rules: 1. the Instance must not use Cloudflare. @@ -23,7 +23,7 @@ To do this, you must either make a PR by adding your instance to the `instances. SkunkyArt 🦨 — альтернативный фронтенд к DeviantArt, который полностью работает без JS (JavaScript). ## Настройка Пример конфига находится в файле `config.example.json`. Чтобы указать свой конфиг, используйте cli-аргумент `--config`. -См. [`SETUP-RU.md`](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/SETUP-RU.md) для информации о настройки фронтенда. +См. [`SETUP-RU.md`](/skunky/SkunkyArt/src/branch/master/SETUP-RU.md) для информации о настройки фронтенда. ## Добавление инстанса в список Чтобы это сделать, вы должны либо сделать PR, добавив в файлы `instances.json` и `INSTANCES.md` свой инстанс (можете воспользоваться cli-аргументом `--add-instance`, который автоматически это сделает), либо создать Issue, или сообщить о нём в комнате в Matrix. Учтите, что ваш инстанс должен соблюсти следущие правила: 1. Инстанс не должен использовать Cloudflare итп. @@ -1,4 +1,4 @@ -[English version 🇬🇧](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/SETUP.md) +[English version 🇬🇧](/skunky/SkunkyArt/src/branch/master/SETUP.md) # Единицы измерения Размер файла в кеше измеряется в мегабайтах.<br> @@ -1,4 +1,4 @@ -[Версия на русском языке 🇷🇺](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/SETUP-RU.md) +[Версия на русском языке 🇷🇺](/skunky/SkunkyArt/src/branch/master/SETUP-RU.md) # Units Maximum file size in megabytes, requires numeric value.<br> @@ -1,19 +1,21 @@ # v1.3.x * Почистить говнокод +* ~~Сделать порт под FreeBSD~~ ✔️ * **Доделать парсинг описания** -* Избавиться от хардкода под Linux -* ~~Реализовать стрипы в ежедневных артах~~ +* ~~Реализовать стрипы в ежедневных артах~~ ✔️ +* ~~Исправить баг с навигацией по страницам~~ ✔️ * Сделать нормальное отображение ошибок -* ~~Исправить баг с навигацией по страницам~~ -* ~~Сделать единицы в конфиге более понятными~~ -* Добавить возможность включить темплейты в бинарник -* ~~Реализовать миниатюры и оптимизировать CSS под маленькие экраны~~ +* ~~Сделать единицы в конфиге более понятными~~ ✔️ +* Добавить просмотр понравившихся артов пользователю +* Добавить возможность включить темплейты в бинарник [P] +* ~~Реализовать миниатюры и оптимизировать CSS под маленькие экраны~~ ✔️ * Написать Makefile и скрипт для автоматического развёртывания инстанса * **Реализовать отображение контента, отличного от картинок (видео, аудио, etc)** * Исправить баг с эмоджи, когда некоторые кастомные эмоции могут не отображаться -* Добавить флаг сборки, который позволит собрать бинарник со встроенными темплейтами -* Улучшить систему кеширования: добавить рейтинг для удаления и копирование изображений в ОЗУ +* ~~Добавить аргумент &filename, который будет выдавать файл с нормально выглядещем именем~~ ✔️ +* ~~Улучшить систему кеширования: добавить рейтинг для удаления и копирование изображений в ОЗУ~~ # v1.4 * Реализовать API * Реализовать темы +* Перейти на арены в кеше * Реализовать многоязычный интерфейс \ No newline at end of file new file mode 100644 @@ -0,0 +1,132 @@ +// TODO: реализовать кеширование JSON и почистить код +package app + +import ( + "crypto/sha1" + "encoding/hex" + "io" + "os" + "strings" + "sync" + "syscall" + "time" +) + +type file struct { + Score int + Content []byte +} + +var tempFS = make(map[[20]byte]*file) +var mx = &sync.RWMutex{} + +func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { + var url strings.Builder + url.WriteString("https://images-wixmp-") + url.WriteString(subdomain) + url.WriteString(".wixmp.com/") + url.WriteString(path) + url.WriteString("?token=") + url.WriteString(s.Args.Get("token")) + + var response []byte + + switch { + case CFG.Cache.Enabled: + fileName := sha1.Sum([]byte(subdomain + path)) + filePath := CFG.Cache.Path + "/" + hex.EncodeToString(fileName[:]) + + mx.Lock() + if tempFS[fileName] == nil { + tempFS[fileName] = &file{} + } + f := *tempFS[fileName] + mx.Unlock() + + if f.Content != nil { + f.Score += 2 + } else { + file, err := os.Open(filePath) + if err != nil { + if dwnld := Download(url.String()); dwnld.Status == 200 && dwnld.Headers["Content-Type"][0][:5] == "image" { + f.Content = dwnld.Body + try(os.WriteFile(filePath, f.Content, 0700)) + } else { + s.ReturnHTTPError(dwnld.Status) + return + } + } else { + file, e := io.ReadAll(file) + try(e) + f.Content = file + } + + go func() { + defer restore() + for { + time.Sleep(1 * time.Minute) + + mx.Lock() + if tempFS[fileName].Score <= 0 { + delete(tempFS, fileName) + mx.Unlock() + return + } + tempFS[fileName].Score-- + mx.Unlock() + } + }() + } + + mx.Lock() + tempFS[fileName] = &f + mx.Unlock() + response = f.Content + case CFG.Proxy: + dwnld := Download(url.String()) + if dwnld.Status != 200 { + s.ReturnHTTPError(dwnld.Status) + return + } + response = dwnld.Body + default: + s.Writer.WriteHeader(403) + response = []byte("Sorry, butt proxy on this instance are disabled.") + } + + s.Writer.Write(response) +} + +func InitCacheSystem() { + c := &CFG.Cache + os.Mkdir(c.Path, 0700) + for { + dir, e := os.Open(c.Path) + try(e) + stat, e := dir.Stat() + try(e) + + dirnames, e := dir.Readdirnames(-1) + try(e) + for _, a := range dirnames { + a = c.Path + "/" + a + if c.Lifetime != "" { + now := time.Now().UnixMilli() + + f, _ := os.Stat(a) + stat := f.Sys().(*syscall.Stat_t) + time := statTime(stat) + + if time+lifetimeParsed <= now { + try(os.RemoveAll(a)) + } + } + if c.MaxSize != 0 && stat.Size() > c.MaxSize { + try(os.RemoveAll(a)) + } + } + + dir.Close() + time.Sleep(time.Second * time.Duration(c.UpdateInterval)) + } +} @@ -78,16 +78,16 @@ func addInstance() { try(err) defer instancesJson.Close() - instances, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + instancesFile, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) try(err) - defer instances.Close() + defer instancesFile.Close() for { - if Templates["instances.json"] == "" { + if string(instances) == "" { print("\rDownloading instance list...") } else { println("\r\033[2KDownloaded!") - try(json.Unmarshal([]byte(Templates["instances.json"]), &settingsVar)) + try(json.Unmarshal(instances, &settingsVar)) settingsVar.Instances = append(settingsVar.Instances, settings{ Title: prompt("Title", true), @@ -113,51 +113,46 @@ func addInstance() { settingsVar := &settingsVar.Instances[len(settingsVar.Instances)-1] var mdstr bytes.Buffer - mdstr.WriteString("\n|") - if settingsVar.Urls.Clearnet != "" { - mdstr.WriteString("[") - mdstr.WriteString(settingsVar.Title) - mdstr.WriteString("](") - mdstr.WriteString(settingsVar.Urls.Clearnet) - mdstr.WriteString(")") - } else { - mdstr.WriteString(settingsVar.Title) + mdbuilder := func(yes bool, link string, title string) { + switch { + case yes && (title != "" && link != ""): + mdstr.WriteString("[") + mdstr.WriteString(title) + mdstr.WriteString("](") + mdstr.WriteString(link) + mdstr.WriteString(")") + case yes && link != "": + mdstr.WriteString("[Yes](") + mdstr.WriteString(link) + mdstr.WriteString(")") + case yes: + mdstr.WriteString("Yes") + default: + mdstr.WriteString("No") + } + mdstr.WriteString("|") } - mdstr.WriteString("|") + + mdstr.WriteString("\n|") + mdbuilder(settingsVar.Urls.Clearnet != "", settingsVar.Urls.Clearnet, settingsVar.Title) urls := []string{settingsVar.Urls.Ygg, settingsVar.Urls.I2P, settingsVar.Urls.Tor} for i, l := 0, len(urls); i < l; i++ { url := urls[i] - if url != "" { - mdstr.WriteString("[Yes](") - mdstr.WriteString(url) - mdstr.WriteString(")|") - } else { - mdstr.WriteString("No|") - } + mdbuilder(url != "", url, "") } settings := []bool{settingsVar.Settings.Nsfw, settingsVar.Settings.Proxy} for i, l := 0, len(settings); i < l; i++ { - if settings[i] { - mdstr.WriteString("Yes|") - } else { - mdstr.WriteString("No|") - } + mdbuilder(settings[i], "", "") } - if settingsVar.ModifiedSrc != "" { - mdstr.WriteString("[Yes](") - mdstr.WriteString(settingsVar.ModifiedSrc) - mdstr.WriteString(")|") - } else { - mdstr.WriteString("No|") - } + mdbuilder(settingsVar.ModifiedSrc != "", settingsVar.ModifiedSrc, "") mdstr.WriteString(settingsVar.Country) mdstr.WriteString("|") - instances.Write(mdstr.Bytes()) + instancesFile.Write(mdstr.Bytes()) break } time.Sleep(500 * time.Millisecond) @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "regexp" + "skunkyart/static" "strconv" "time" @@ -24,9 +25,9 @@ type config struct { URI string `json:"uri"` Cache cache_config Proxy, Nsfw bool - UserAgent string `json:"user-agent"` - DownloadProxy string `json:"download-proxy"` - Dirs []string `json:"dirs-to-memory"` + UserAgent string `json:"user-agent"` + DownloadProxy string `json:"download-proxy"` + StaticPath string `json:"static-path"` } var CFG = config{ @@ -38,10 +39,10 @@ var CFG = config{ Path: "cache", UpdateInterval: 1, }, - Dirs: []string{"html", "css", "misc"}, - UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - Proxy: true, - Nsfw: true, + StaticPath: "static", + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + Proxy: true, + Nsfw: true, } var lifetimeParsed int64 @@ -56,6 +57,7 @@ func ExecuteConfig() { exit("Incompatible settings detected: cannot use caching media content without proxy", 1) } + static.StaticPath = CFG.StaticPath if CFG.Cache.Enabled { if CFG.Cache.Lifetime != "" { var duration int64 @@ -41,7 +41,9 @@ func (s skunkyart) ParseComments(c devianter.Comments) string { cmmts.WriteString("</b></a> ") if x.Parent > 0 { - cmmts.WriteString(` In reply to <a href="#`) + cmmts.WriteString(` In reply to <a href="`) + cmmts.WriteString(Path) + cmmts.WriteString("#") cmmts.WriteString(strconv.Itoa(x.Parent)) cmmts.WriteString(`">`) if replied[x.Parent] == "" { @@ -80,7 +82,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con for i, l := 0, len(devs); i < l; i++ { data := &devs[i] - if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !(data.NSFW && !CFG.Nsfw) { + if preview, fullview := ParseMedia(data.Media, data.Title, 320), ParseMedia(data.Media, data.Title); !(data.NSFW && !CFG.Nsfw) { if allowAtom && s.Atom { id := strconv.Itoa(data.ID) listContent.WriteString(`<entry><author><name>`) @@ -284,7 +286,7 @@ func ParseDescription(dscr devianter.Text) string { parsedDescription.WriteString(`<a href="`) parsedDescription.WriteString(ConvertDeviantArtUrlToSkunkyArt(d.Url)) parsedDescription.WriteString(`"><img width="50%" src="`) - parsedDescription.WriteString(ParseMedia(d.Media)) + parsedDescription.WriteString(ParseMedia(d.Media, d.Title)) parsedDescription.WriteString(`" title="`) parsedDescription.WriteString(d.Author.Username) parsedDescription.WriteString(" - ") @@ -1,13 +1,15 @@ package app import ( + "io" "net/http" u "net/url" + "skunkyart/static" "strconv" "strings" ) -var Host string +var Host, Path string func Router() { parsepath := func(path string) map[int]string { @@ -41,6 +43,15 @@ func Router() { return } + open := func(name string) []byte { + file, err := static.Templates.Open(name) + try(err) + fileReaded, err := io.ReadAll(file) + try(err) + + return fileReaded + } + // функция, что управляет всем handle := func(w http.ResponseWriter, r *http.Request) { if h := r.Header["X-Forwarded-Proto"]; len(h) != 0 && h[0] == "https" { @@ -49,7 +60,8 @@ func Router() { Host = "http://" + r.Host } - path := parsepath(r.URL.Path) + Path = r.URL.Path + path := parsepath(Path) // структура с функциями var skunky skunkyart skunky.Writer = w @@ -70,12 +82,14 @@ func Router() { skunky.Atom = true } + skunky.Endpoint = path[1] + // пути - switch path[1] { + switch skunky.Endpoint { default: skunky.ReturnHTTPError(404) case "": - skunky.ExecuteTemplate("index.htm", &CFG.URI) + w.Write(open("html/index.htm")) case "post": skunky.Deviation(path[2], path[3]) case "search": @@ -88,6 +102,13 @@ func Router() { case "media": switch path[2] { case "file": + if a := arg("filename"); a != "" { + var filename strings.Builder + filename.WriteString(`filename="`) + filename.WriteString(a) + filename.WriteString(`"`) + w.Header().Add("Content-Disposition", filename.String()) + } skunky.DownloadAndSendMedia(path[3], next(path, 4)) case "emojitar": skunky.Emojitar(path[3]) @@ -96,9 +117,9 @@ func Router() { skunky.About() case "stylesheet": w.Header().Add("content-type", "text/css") - wr(w, Templates["skunky.css"]) + w.Write(open("css/skunky.css")) case "favicon.ico": - wr(w, Templates["logo.png"]) + w.Write(open("images/logo.png")) } } new file mode 100644 @@ -0,0 +1,13 @@ +//go:build freebsd +// +build freebsd + +package app + +import ( + "syscall" + "time" +) + +func statTime(stat *syscall.Stat_t) int64 { + return time.Unix(stat.Ctimespec.Unix()).UnixMilli() +} new file mode 100644 @@ -0,0 +1,13 @@ +//go:build !freebsd +// +build !freebsd + +package app + +import ( + "syscall" + "time" +) + +func statTime(stat *syscall.Stat_t) int64 { + return time.Unix(stat.Ctim.Unix()).UnixMilli() +} @@ -1,14 +1,13 @@ package app import ( - "encoding/base64" "io" "net/http" - u "net/url" + "net/url" "os" + "skunkyart/static" "strconv" "strings" - "syscall" "text/template" "time" @@ -32,26 +31,34 @@ func tryWithExitStatus(err error, code int) { } } +func restore() { + if r := recover(); r != nil { + recover() + } +} + +var instances []byte + func RefreshInstances() { for { func() { - defer func() { - if r := recover(); r != nil { - recover() - } - }() - Templates["instances.json"] = string(Download("https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/instances.json").Body) + defer restore() + instances = Download("https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/instances.json").Body }() time.Sleep(1 * time.Hour) } } // some crap for frontend -func (s skunkyart) ExecuteTemplate(file string, data any) { +func (s skunkyart) ExecuteTemplate(file, dir string, data any) { var buf strings.Builder tmp := template.New(file) - tmp, e := tmp.Parse(Templates[file]) - try(e) + tmp, err := tmp.ParseFS(static.Templates, dir+"/*") + if err != nil { + s.Writer.WriteHeader(500) + wr(s.Writer, err.Error()) + return + } try(tmp.Execute(&buf, &data)) wr(s.Writer, buf.String()) } @@ -91,15 +98,15 @@ type Downloaded struct { Body []byte } -func Download(url string) (d Downloaded) { +func Download(urlString string) (d Downloaded) { cli := &http.Client{} if CFG.DownloadProxy != "" { - u, e := u.Parse(CFG.DownloadProxy) + u, e := url.Parse(CFG.DownloadProxy) try(e) cli.Transport = &http.Transport{Proxy: http.ProxyURL(u)} } - req, e := http.NewRequest("GET", url, nil) + req, e := http.NewRequest("GET", urlString, nil) try(e) req.Header.Set("User-Agent", CFG.UserAgent) @@ -115,97 +122,16 @@ func Download(url string) (d Downloaded) { return } -// caching -func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { - var url strings.Builder - url.WriteString("https://images-wixmp-") - url.WriteString(subdomain) - url.WriteString(".wixmp.com/") - url.WriteString(path) - url.WriteString("?token=") - url.WriteString(s.Args.Get("token")) - - if CFG.Cache.Enabled { - fname := CFG.Cache.Path + "/" + base64.StdEncoding.EncodeToString([]byte(subdomain+path)) - file, e := os.Open(fname) - - if e != nil { - dwnld := Download(url.String()) - if dwnld.Status == 200 && dwnld.Headers["Content-Type"][0][:5] == "image" { - try(os.WriteFile(fname, dwnld.Body, 0700)) - s.Writer.Write(dwnld.Body) - } - } else { - file, e := io.ReadAll(file) - try(e) - s.Writer.Write(file) - } - } else if CFG.Proxy { - dwnld := Download(url.String()) - s.Writer.Write(dwnld.Body) - } else { - s.Writer.WriteHeader(403) - s.Writer.Write([]byte("Sorry, butt proxy on this instance are disabled.")) - } -} - -func InitCacheSystem() { - c := &CFG.Cache - os.Mkdir(CFG.Cache.Path, 0700) - for { - dir, e := os.Open(c.Path) - try(e) - stat, e := dir.Stat() - try(e) - - dirnames, e := dir.Readdirnames(-1) - try(e) - for _, a := range dirnames { - a = c.Path + "/" + a - if c.Lifetime != "" { - now := time.Now().UnixMilli() - - f, _ := os.Stat(a) - stat := f.Sys().(*syscall.Stat_t) - time := time.Unix(stat.Ctim.Unix()).UnixMilli() - - if time+lifetimeParsed <= now { - try(os.RemoveAll(a)) - } - } - if c.MaxSize != 0 && stat.Size() > c.MaxSize { - try(os.RemoveAll(a)) - } - } - - dir.Close() - time.Sleep(time.Second * time.Duration(CFG.Cache.UpdateInterval)) - } -} - -func CopyTemplatesToMemory() { - for _, dirname := range CFG.Dirs { - dir, e := os.ReadDir(dirname) - tryWithExitStatus(e, 1) - - for _, x := range dir { - file, e := os.ReadFile(dirname + "/" + x.Name()) - tryWithExitStatus(e, 1) - Templates[x.Name()] = string(file) - } - } -} - /* PARSING HELPERS */ -func ParseMedia(media devianter.Media, thumb ...int) string { - url := devianter.UrlFromMedia(media, thumb...) - if len(url) != 0 && CFG.Proxy { - url = url[21:] - dot := strings.Index(url, ".") +func ParseMedia(media devianter.Media, filename string, thumb ...int) string { + mediaUrl := devianter.UrlFromMedia(media, thumb...) + if len(mediaUrl) != 0 && CFG.Proxy { + mediaUrl = mediaUrl[21:] + dot := strings.Index(mediaUrl, ".") - return UrlBuilder("media", "file", url[:dot], url[dot+11:]) + return UrlBuilder("media", "file", mediaUrl[:dot], mediaUrl[dot+11:], "&filename=", url.QueryEscape(filename)) } - return url + return mediaUrl } func ConvertDeviantArtUrlToSkunkyArt(url string) (output string) { @@ -255,7 +181,9 @@ func (s skunkyart) NavBase(c DeviationList) string { list.WriteString("<br>") prevrev := func(msg string, page int, onpage bool) { if !onpage { - list.WriteString(`<a href="?p=`) + list.WriteString(`<a href="`) + list.WriteString(Path) + list.WriteString(`?p=`) list.WriteString(strconv.Itoa(page)) if s.Type != 0 { list.WriteString("&type=") @@ -15,17 +15,17 @@ import ( ) var wr = io.WriteString -var Templates = make(map[string]string) type skunkyart struct { Writer http.ResponseWriter - Args url.Values - BasePath string - Type rune - Query, QueryRaw string - Page int - Atom bool + Args url.Values + Page int + Type rune + Atom bool + + BasePath, Endpoint string + Query, QueryRaw string Templates struct { About struct { @@ -136,7 +136,7 @@ func (s skunkyart) GRUser() { case "cover_deviation": group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation group.About.BGMeta.Url = ConvertDeviantArtUrlToSkunkyArt(group.About.BGMeta.Url) - group.About.BG = ParseMedia(group.About.BGMeta.Media) + group.About.BG = ParseMedia(group.About.BGMeta.Media, group.About.BGMeta.Title) case "group_admins": var htm strings.Builder for _, z := range x.ModuleData.GroupAdmins.Results { @@ -171,7 +171,7 @@ func (s skunkyart) GRUser() { folders.WriteString(`<a href="`) folders.WriteString(ConvertDeviantArtUrlToSkunkyArt(x.Thumb.Url)) folders.WriteString(`"><img loading="lazy" src="`) - folders.WriteString(ParseMedia(x.Thumb.Media)) + folders.WriteString(ParseMedia(x.Thumb.Media, x.Thumb.Title)) folders.WriteString(`" title="`) folders.WriteString(x.Thumb.Title) folders.WriteString(`"></a>`) @@ -180,7 +180,7 @@ func (s skunkyart) GRUser() { } folders.WriteString("<br>") - folders.WriteString(`<a href="?folder=`) + folders.WriteString(`<a href="group_user?folder=`) folders.WriteString(strconv.Itoa(x.FolderId)) folders.WriteString("&q=") folders.WriteString(s.Query) @@ -209,7 +209,7 @@ func (s skunkyart) GRUser() { } if !s.Atom { - s.ExecuteTemplate("gruser.htm", &s) + s.ExecuteTemplate("gruser.htm", "html", &s) } } @@ -233,7 +233,7 @@ func (s skunkyart) Deviation(author, postname string) { } // время публикации post.StringTime = post.Post.Deviation.PublishedTime.UTC().String() - post.Post.IMG = ParseMedia(post.Post.Deviation.Media) + post.Post.IMG = ParseMedia(post.Post.Deviation.Media, post.Post.Deviation.Title) for _, x := range post.Post.Deviation.Extended.RelatedContent { if len(x.Deviations) != 0 { post.Related += s.DeviationList(x.Deviations, false) @@ -258,7 +258,7 @@ func (s skunkyart) Deviation(author, postname string) { post.Comments = s.ParseComments(devianter.GetComments(id, post.Post.Comments.Cursor, s.Page, 1)) - s.ExecuteTemplate("deviantion.htm", &s) + s.ExecuteTemplate("deviantion.htm", "html", &s) } func (s skunkyart) DD() { @@ -281,11 +281,16 @@ func (s skunkyart) DD() { More: dd.HasMore, }) if !s.Atom { - s.ExecuteTemplate("daily.htm", &s) + s.ExecuteTemplate("daily.htm", "html", &s) } } func (s skunkyart) Search() { + if s.Query == "" { + s.ReturnHTTPError(400) + return + } + var err error ss := &s.Templates.Search switch s.Type { @@ -338,6 +343,7 @@ func (s skunkyart) Search() { } default: s.ReturnHTTPError(400) + return } try(err) @@ -348,7 +354,7 @@ func (s skunkyart) Search() { }) } - s.ExecuteTemplate("search.htm", &s) + s.ExecuteTemplate("search.htm", "html", &s) } func (s skunkyart) Emojitar(name string) { @@ -367,6 +373,6 @@ func (s skunkyart) Emojitar(name string) { func (s skunkyart) About() { s.Templates.About.Nsfw = CFG.Nsfw s.Templates.About.Proxy = CFG.Proxy - try(json.Unmarshal([]byte(Templates["instances.json"]), &s.Templates.About)) - s.ExecuteTemplate("about.htm", &s) + try(json.Unmarshal(instances, &s.Templates.About)) + s.ExecuteTemplate("about.htm", "html", &s) } @@ -8,11 +8,7 @@ "max-size": 1024, "update-interval": 5 }, - "dirs-to-memory": [ - "html", - "css", - "misc" - ], + "static-path": "static", "download-proxy": "http://127.0.0.1:8080", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", "proxy": true, deleted file mode 100644 @@ -1,28 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <title>SkunkyArt | Daily Deviations</title> - <link rel="stylesheet" href="{{.BasePath}}stylesheet"> - <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico"> - </head> - <main> - <header> - <h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a> | <a href="?atom=true">RSS</a></h1> - <form method="get" action="{{.BasePath}}search"> - <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false"> - <select name="type"> - <option value="all">All</option> - <option value="tag">Tag</option> - <option value="r">Groups</option> - </select> - <button type="submit">Search!</button> - </form> - </header> - {{if ne .Templates.DDStrips ""}} - <h2 id="strips"><a href="#strips">#</a> Strips</h2> - {{.Templates.DDStrips}} - {{end}} - <h2 id="content"><a href="#content">#</a> Content</h2> - {{.Templates.SomeList}} - </main> -</html> \ No newline at end of file deleted file mode 100644 @@ -1,30 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <title>SkunkyArt | Search "{{.QueryRaw}}"</title> - <link rel="stylesheet" href="{{.BasePath}}stylesheet"> - <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico"> - </head> - <main> - <header> - <h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a></h1> - <form method="get" action="search"> - <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false"> - <select name="type"> - <option value="all">All</option> - <option value="tag">Tag</option> - <option value="r">Groups</option> - </select> - <button type="submit">Search!</button> - </form> - </header> - {{if ne .Templates.Search.List ""}} - {{if ne .Templates.Search.Content.Total 0}} - <h1>Results by request '{{.QueryRaw}}': {{.Templates.Search.Content.Total}}</h1> - {{end}} - {{.Templates.Search.List}} - {{else}} - <p>No results :(</p> - {{end}} - </main> -</html> \ No newline at end of file @@ -2,6 +2,7 @@ package main import ( "skunkyart/app" + "skunkyart/static" "time" "git.macaw.me/skunky/devianter" @@ -12,7 +13,7 @@ func main() { app.ExecuteCommandLineArguments() app.ExecuteConfig() - app.CopyTemplatesToMemory() + static.CopyTemplatesToMemory() go func() { for { similarity index 94% rename from css/skunky.css rename to static/css/skunky.css @@ -22,12 +22,19 @@ header form { header { display: flex; } +form { + font-size: 0; + border: solid #164e3e 1px; + max-width: fit-content; +} form input, button, select { background-color: #134134; padding: 5px; color: whitesmoke; - border: 0px; - border-radius: 1px; + border: 0; +} +input:focus { + outline: none; } /* BLOCKS */ @@ -146,6 +153,8 @@ form input, button, select { header form { font-size: 60%; + max-width: unset; + border: 0; } header, center { text-align: center; similarity index 74% rename from html/about.htm rename to static/html/about.htm @@ -1,23 +1,8 @@ <!DOCTYPE html> <html> - <head> - <title>SkunkyArt</title> - <link rel="stylesheet" href="{{.BasePath}}stylesheet"> - <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico"> - </head> + {{template "head" .}} <main> - <header> - <h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a></h1> - <form method="get" action="{{.BasePath}}search"> - <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false"> - <select name="type"> - <option value="all">All</option> - <option value="tag">Tag</option> - <option value="r">Groups</option> - </select> - <button type="submit">Search!</button> - </form> - </header> + {{template "header" .}} <p> SkunkyArt is an alternative frontend for deviantart.com, written in Go. </p> new file mode 100644 @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> + {{template "head" . }} + <main> + {{template "header" . }} + {{if ne .Templates.DDStrips ""}} + <h2 id="strips"><a href="#strips">#</a> Strips</h2> + {{.Templates.DDStrips}} + {{end}} + <h2 id="content"><a href="#content">#</a> Content</h2> + {{.Templates.SomeList}} + </main> +</html> \ No newline at end of file similarity index 72% rename from html/deviantion.htm rename to static/html/deviantion.htm @@ -1,24 +1,8 @@ <!DOCTYPE html> <html> - <head> - <title>SkunkyArt | {{.Templates.Deviation.Post.Deviation.Author.Username}} - {{.Templates.Deviation.Post.Deviation.Title}}</title> - <link rel="stylesheet" href="{{.BasePath}}stylesheet"> - <meta name="referrer" content="no-referrer" /> - <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico"> - </head> + {{template "head" . }} <main> - <header> - <h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a></h1> - <form method="get" action="{{.BasePath}}search"> - <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false"> - <select name="type"> - <option value="all">All</option> - <option value="tag">Tag</option> - <option value="r">Groups</option> - </select> - <button type="submit">Search!</button> - </form> - </header> + {{template "header" . }} <figure> <img src="{{.BasePath}}media/emojitar/{{.Templates.Deviation.Post.Deviation.Author.Username}}?type=a" width="30px"> <span><strong><a href="{{.BasePath}}group_user?type=about&q={{.Templates.Deviation.Post.Deviation.Author.Username}}">{{.Templates.Deviation.Post.Deviation.Author.Username}}</a></strong> — {{if (.Templates.Deviation.Post.Deviation.DD)}} similarity index 87% rename from html/gruser.htm rename to static/html/gruser.htm @@ -1,21 +1,11 @@ <!DOCTYPE html> <html> - <head> - <title>SkunkyArt | - {{if eq .Type 'a'}} - {{.Templates.GroupUser.GR.Owner.Username}} - {{else}} - gallery of {{.Templates.GroupUser.GR.Owner.Username}} - {{end}} - </title> - <link rel="stylesheet" href="{{.BasePath}}stylesheet"> - <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico"> - </head> + {{template "head" . }} <main> <header> <h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a> - | <a href="?q={{.Templates.GroupUser.GR.Owner.Username}}&type={{if eq .Type 'a'}}gallery">Gallery{{else}}about">About{{end}}</a> - | <a href="?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery&atom=true">RSS</a></h1> + | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type={{if eq .Type 'a'}}gallery">Gallery{{else}}about">About{{end}}</a> + | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery&atom=true">RSS</a></h1> <form method="get" action="{{.BasePath}}search"> <input type="gallery" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false"> <input type="hidden" name="usr" value="{{.Templates.GroupUser.GR.Owner.Username}}"> new file mode 100644 @@ -0,0 +1,24 @@ +{{define "head"}} +<head> + <title>SkunkyArt | + {{if eq .Endpoint "search"}} + "{{.QueryRaw}}" + {{else if eq .Endpoint "post"}} + {{.Templates.Deviation.Post.Deviation.Author.Username}} — {{.Templates.Deviation.Post.Deviation.Title}} + {{else if eq .Type 'a'}} + {{if .Templates.GroupUser.GR.Owner.Username}} + {{.Templates.GroupUser.GR.Owner.Username}} + {{else}} + gallery of {{.Templates.GroupUser.GR.Owner.Username}} + {{end}} + {{else}} + {{.Endpoint}} + {{end}} + </title> + + <base href="{{.BasePath}}"> + <meta name="referrer" content="no-referrer" /> + <link rel="stylesheet" href="{{.BasePath}}stylesheet"> + <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico"> +</head> +{{end}} \ No newline at end of file new file mode 100644 @@ -0,0 +1,14 @@ +{{define "header"}} +<header> + <h1><a href="">HOME</a> | <a href="dd">DD</a> {{if eq .Endpoint "dd"}}| <a href="{{.Endpoint}}?atom=true">RSS</a>{{end}}</h1> + <form method="get" action="search"> + <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false" value="{{.QueryRaw}}"> + <select name="type"> + <option value="all">All</option> + <option value="tag">Tag</option> + <option value="r">Groups</option> + </select> + <button type="submit">Search!</button> + </form> +</header> +{{end}} \ No newline at end of file similarity index 61% rename from html/index.htm rename to static/html/index.htm @@ -2,12 +2,12 @@ <html> <head> <title>SkunkyArt</title> - <link rel="stylesheet" href="{{.}}stylesheet"/> - <link rel="icon" type="image/x-icon" href="{{.}}favicon.ico"> + <link rel="stylesheet" href="stylesheet"/> + <link rel="icon" type="image/x-icon" href="favicon.ico"> </head> <main> <center> - <form method="get" action="{{.}}search"> + <form method="get" action="search"> <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false"> <select name="type"> <option value="all">All</option> @@ -16,7 +16,7 @@ </select> <button type="submit">Search!</button> </form> - <h1><a href="{{.}}dd">Daily Deviations</a> | <a href="{{.}}about">About</a> | <a href="https://git.macaw.me/skunky/SkunkyArt" target="_blank">Source Code</a></h1> + <h1><a href="dd">Daily Deviations</a> | <a href="about">About</a> | <a href="https://git.macaw.me/skunky/SkunkyArt" target="_blank">Source Code</a></h1> </center> </main> </html> \ No newline at end of file new file mode 100644 @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + {{template "head" . }} + <main> + {{template "header" . }} + + {{if ne .Templates.Search.List ""}} + {{if ne .Templates.Search.Content.Total 0}} + <h1>Results by request '{{.QueryRaw}}': {{.Templates.Search.Content.Total}}</h1> + {{end}} + {{.Templates.Search.List}} + {{else}} + <p>No results :(</p> + {{end}} + </main> +</html> \ No newline at end of file similarity index 100% rename from misc/logo.png rename to static/images/logo.png new file mode 100644 @@ -0,0 +1,148 @@ +//go:build !embed +// +build !embed + +package static + +import ( + "bytes" + "io/fs" + "os" + "strings" + "time" +) + +var Templates FS + +type file struct { + path string + name string + content []byte +} + +var templateNames = []string{} +var templates = make(map[string][]file) +var StaticPath string + +func CopyTemplatesToMemory() { + baseDir, err := os.ReadDir(StaticPath) + try(err) + + for _, c := range baseDir { + if c.IsDir() { + templateNames = append(templateNames, c.Name()) + + var filePath strings.Builder + filePath.WriteString(StaticPath) + filePath.WriteString("/") + filePath.WriteString(c.Name()) + + dir, err := os.ReadDir(filePath.String()) + try(err) + + filePath.WriteString("/") + for _, cd := range dir { + f, err := os.ReadFile(filePath.String() + cd.Name()) + try(err) + templates[c.Name()] = append(templates[c.Name()], file{ + content: f, + name: cd.Name(), + path: c.Name() + "/" + cd.Name(), + }) + } + } + } +} + +type FS struct{} + +func (FS) Open(name string) (fs.File, error) { + for i, l := 0, len(templateNames); i < l; i++ { + for _, x := range templates[templateNames[i]] { + if x.content != nil && name == x.path { + return &File{ + name: x.path, + content: bytes.NewBuffer(x.content), + }, nil + } + } + } + return nil, &fs.PathError{} +} + +func (FS) Glob(pattern string) ([]string, error) { + trimmed := strings.Split(pattern, "/") + var matches = []string{} + for x, s := range templates { + for i, l := 0, len(s); i < l && trimmed[0] == x; i++ { + s := s[i] + matches = append(matches, s.path) + } + } + if len(matches) != 0 { + return matches, nil + } + return nil, &fs.PathError{} +} + +func try(err error) { + if err != nil { + println(err.Error()) + os.Exit(1) + } +} + +/* сделано на основе https://github.com/psanford/memfs; требуется для корректной работы templates.ParseFS */ +type fileInfo struct { + name string +} + +func (fi fileInfo) Name() string { + return fi.name +} + +func (fi fileInfo) Size() int64 { + return 4096 +} + +func (fileInfo) Mode() fs.FileMode { + return 0 +} + +func (fileInfo) ModTime() time.Time { + return time.Time{} +} + +func (fileInfo) IsDir() bool { + return false +} + +func (fileInfo) Sys() interface{} { + return nil +} + +type File struct { + name string + content *bytes.Buffer + closed bool +} + +func (f *File) Stat() (fs.FileInfo, error) { + return fileInfo{ + name: f.name, + }, nil +} + +func (f *File) Read(b []byte) (int, error) { + if f.closed { + return 0, fs.ErrClosed + } + return f.content.Read(b) +} + +func (f *File) Close() error { + if f.closed { + return fs.ErrClosed + } + f.closed = true + return nil +} new file mode 100644 @@ -0,0 +1,16 @@ +//go:build embed +// +build embed + +package static + +import "embed" + +//go:embed * +var Templates embed.FS +var Enabled bool = true + +var StaticPath string + +func CopyTemplatesToMemory() { + _ = StaticPath +}