Commit 1537da9b16

1537da9b16dfda1ecb2730b5d1de3b9005397618

parent: 4db018fb7f

Unsigned

lost+skunk <skunky@ebloid.ru> · 2024-08-13 12:59 UTC

темплейты в бинарнике и улучшенная система кеша

Layout: unified · split

README.md +4 −4
@@ -1,15 +1,15 @@
1<img src="https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/misc/logo.png" alt="SkunkyArt" title="SkunkyArt Logo" width="20%" loading="lazy"/>
1<img src="/skunky/SkunkyArt/raw/branch/master/misc/logo.png" alt="SkunkyArt" title="SkunkyArt Logo" width="20%" loading="lazy"/>
22
33[![Matrix room](https://img.shields.io/badge/matrix-000000?style=for-the-badge&logo=Matrix&logoColor=white)](https://go.kde.org/matrix/#/#skunkyart:ebloid.ru)
44
5Instances: [`INSTANCES.md`](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/INSTANCES.md)
5Instances: [`INSTANCES.md`](/skunky/SkunkyArt/src/branch/master/INSTANCES.md)
66
77# EN 🇺🇸
88## Description
99SkunkyArt 🦨 — alternative frontend for DevianArt, which works without JS.
1010## Setup
1111The sample config is in the `config.example.json` file. For custom config, use `--config` option.
12See the [`SETUP.md`](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/SETUP.md) file for more info about directives.
12See the [`SETUP.md`](/skunky/SkunkyArt/src/branch/master/SETUP.md) file for more info about directives.
1313## Adding instance to the list
1414To 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:
15151. 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.
2323SkunkyArt 🦨 — альтернативный фронтенд к DeviantArt, который полностью работает без JS (JavaScript).
2424## Настройка
2525Пример конфига находится в файле `config.example.json`. Чтобы указать свой конфиг, используйте cli-аргумент `--config`.
26См. [`SETUP-RU.md`](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/SETUP-RU.md) для информации о настройки фронтенда.
26См. [`SETUP-RU.md`](/skunky/SkunkyArt/src/branch/master/SETUP-RU.md) для информации о настройки фронтенда.
2727## Добавление инстанса в список
2828Чтобы это сделать, вы должны либо сделать PR, добавив в файлы `instances.json` и `INSTANCES.md` свой инстанс (можете воспользоваться cli-аргументом `--add-instance`, который автоматически это сделает), либо создать Issue, или сообщить о нём в комнате в Matrix. Учтите, что ваш инстанс должен соблюсти следущие правила:
29291. Инстанс не должен использовать Cloudflare итп.
SETUP-RU.md +1 −1
@@ -1,4 +1,4 @@
1[English version 🇬🇧](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/SETUP.md)
1[English version 🇬🇧](/skunky/SkunkyArt/src/branch/master/SETUP.md)
22
33# Единицы измерения
44Размер файла в кеше измеряется в мегабайтах.<br>
SETUP.md +1 −1
@@ -1,4 +1,4 @@
1[Версия на русском языке 🇷🇺](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/SETUP-RU.md)
1[Версия на русском языке 🇷🇺](/skunky/SkunkyArt/src/branch/master/SETUP-RU.md)
22
33# Units
44Maximum file size in megabytes, requires numeric value.<br>
TODO.md +10 −8
@@ -1,19 +1,21 @@
11# v1.3.x
22* Почистить говнокод
3* ~~Сделать порт под FreeBSD~~ ✔️
34* **Доделать парсинг описания**
4* Избавиться от хардкода под Linux
5* ~~Реализовать стрипы в ежедневных артах~~
5* ~~Реализовать стрипы в ежедневных артах~~ ✔️
6* ~~Исправить баг с навигацией по страницам~~ ✔️
67* Сделать нормальное отображение ошибок
7* ~~Исправить баг с навигацией по страницам~~
8* ~~Сделать единицы в конфиге более понятными~~
9* Добавить возможность включить темплейты в бинарник
10* ~~Реализовать миниатюры и оптимизировать CSS под маленькие экраны~~
8* ~~Сделать единицы в конфиге более понятными~~ ✔️
9* Добавить просмотр понравившихся артов пользователю
10* Добавить возможность включить темплейты в бинарник [P]
11* ~~Реализовать миниатюры и оптимизировать CSS под маленькие экраны~~ ✔️
1112* Написать Makefile и скрипт для автоматического развёртывания инстанса
1213* **Реализовать отображение контента, отличного от картинок (видео, аудио, etc)**
1314* Исправить баг с эмоджи, когда некоторые кастомные эмоции могут не отображаться
14* Добавить флаг сборки, который позволит собрать бинарник со встроенными темплейтами
15* Улучшить систему кеширования: добавить рейтинг для удаления и копирование изображений в ОЗУ
15* ~~Добавить аргумент &filename, который будет выдавать файл с нормально выглядещем именем~~ ✔️
16* ~~Улучшить систему кеширования: добавить рейтинг для удаления и копирование изображений в ОЗУ~~
1617# v1.4
1718* Реализовать API
1819* Реализовать темы
20* Перейти на арены в кеше
1921* Реализовать многоязычный интерфейс
\ No newline at end of file
app/cache.go added +132
@@ -0,0 +1,132 @@
1// TODO: реализовать кеширование JSON и почистить код
2package app
3
4import (
5 "crypto/sha1"
6 "encoding/hex"
7 "io"
8 "os"
9 "strings"
10 "sync"
11 "syscall"
12 "time"
13)
14
15type file struct {
16 Score int
17 Content []byte
18}
19
20var tempFS = make(map[[20]byte]*file)
21var mx = &sync.RWMutex{}
22
23func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
24 var url strings.Builder
25 url.WriteString("https://images-wixmp-")
26 url.WriteString(subdomain)
27 url.WriteString(".wixmp.com/")
28 url.WriteString(path)
29 url.WriteString("?token=")
30 url.WriteString(s.Args.Get("token"))
31
32 var response []byte
33
34 switch {
35 case CFG.Cache.Enabled:
36 fileName := sha1.Sum([]byte(subdomain + path))
37 filePath := CFG.Cache.Path + "/" + hex.EncodeToString(fileName[:])
38
39 mx.Lock()
40 if tempFS[fileName] == nil {
41 tempFS[fileName] = &file{}
42 }
43 f := *tempFS[fileName]
44 mx.Unlock()
45
46 if f.Content != nil {
47 f.Score += 2
48 } else {
49 file, err := os.Open(filePath)
50 if err != nil {
51 if dwnld := Download(url.String()); dwnld.Status == 200 && dwnld.Headers["Content-Type"][0][:5] == "image" {
52 f.Content = dwnld.Body
53 try(os.WriteFile(filePath, f.Content, 0700))
54 } else {
55 s.ReturnHTTPError(dwnld.Status)
56 return
57 }
58 } else {
59 file, e := io.ReadAll(file)
60 try(e)
61 f.Content = file
62 }
63
64 go func() {
65 defer restore()
66 for {
67 time.Sleep(1 * time.Minute)
68
69 mx.Lock()
70 if tempFS[fileName].Score <= 0 {
71 delete(tempFS, fileName)
72 mx.Unlock()
73 return
74 }
75 tempFS[fileName].Score--
76 mx.Unlock()
77 }
78 }()
79 }
80
81 mx.Lock()
82 tempFS[fileName] = &f
83 mx.Unlock()
84 response = f.Content
85 case CFG.Proxy:
86 dwnld := Download(url.String())
87 if dwnld.Status != 200 {
88 s.ReturnHTTPError(dwnld.Status)
89 return
90 }
91 response = dwnld.Body
92 default:
93 s.Writer.WriteHeader(403)
94 response = []byte("Sorry, butt proxy on this instance are disabled.")
95 }
96
97 s.Writer.Write(response)
98}
99
100func InitCacheSystem() {
101 c := &CFG.Cache
102 os.Mkdir(c.Path, 0700)
103 for {
104 dir, e := os.Open(c.Path)
105 try(e)
106 stat, e := dir.Stat()
107 try(e)
108
109 dirnames, e := dir.Readdirnames(-1)
110 try(e)
111 for _, a := range dirnames {
112 a = c.Path + "/" + a
113 if c.Lifetime != "" {
114 now := time.Now().UnixMilli()
115
116 f, _ := os.Stat(a)
117 stat := f.Sys().(*syscall.Stat_t)
118 time := statTime(stat)
119
120 if time+lifetimeParsed <= now {
121 try(os.RemoveAll(a))
122 }
123 }
124 if c.MaxSize != 0 && stat.Size() > c.MaxSize {
125 try(os.RemoveAll(a))
126 }
127 }
128
129 dir.Close()
130 time.Sleep(time.Second * time.Duration(c.UpdateInterval))
131 }
132}
app/cli.go +29 −34
@@ -78,16 +78,16 @@ func addInstance() {
7878 try(err)
7979 defer instancesJson.Close()
8080
81 instances, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
81 instancesFile, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
8282 try(err)
83 defer instances.Close()
83 defer instancesFile.Close()
8484
8585 for {
86 if Templates["instances.json"] == "" {
86 if string(instances) == "" {
8787 print("\rDownloading instance list...")
8888 } else {
8989 println("\r\033[2KDownloaded!")
90 try(json.Unmarshal([]byte(Templates["instances.json"]), &settingsVar))
90 try(json.Unmarshal(instances, &settingsVar))
9191
9292 settingsVar.Instances = append(settingsVar.Instances, settings{
9393 Title: prompt("Title", true),
@@ -113,51 +113,46 @@ func addInstance() {
113113 settingsVar := &settingsVar.Instances[len(settingsVar.Instances)-1]
114114 var mdstr bytes.Buffer
115115
116 mdstr.WriteString("\n|")
117 if settingsVar.Urls.Clearnet != "" {
118 mdstr.WriteString("[")
119 mdstr.WriteString(settingsVar.Title)
120 mdstr.WriteString("](")
121 mdstr.WriteString(settingsVar.Urls.Clearnet)
122 mdstr.WriteString(")")
123 } else {
124 mdstr.WriteString(settingsVar.Title)
116 mdbuilder := func(yes bool, link string, title string) {
117 switch {
118 case yes && (title != "" && link != ""):
119 mdstr.WriteString("[")
120 mdstr.WriteString(title)
121 mdstr.WriteString("](")
122 mdstr.WriteString(link)
123 mdstr.WriteString(")")
124 case yes && link != "":
125 mdstr.WriteString("[Yes](")
126 mdstr.WriteString(link)
127 mdstr.WriteString(")")
128 case yes:
129 mdstr.WriteString("Yes")
130 default:
131 mdstr.WriteString("No")
132 }
133 mdstr.WriteString("|")
125134 }
126 mdstr.WriteString("|")
135
136 mdstr.WriteString("\n|")
137 mdbuilder(settingsVar.Urls.Clearnet != "", settingsVar.Urls.Clearnet, settingsVar.Title)
127138
128139 urls := []string{settingsVar.Urls.Ygg, settingsVar.Urls.I2P, settingsVar.Urls.Tor}
129140 for i, l := 0, len(urls); i < l; i++ {
130141 url := urls[i]
131 if url != "" {
132 mdstr.WriteString("[Yes](")
133 mdstr.WriteString(url)
134 mdstr.WriteString(")|")
135 } else {
136 mdstr.WriteString("No|")
137 }
142 mdbuilder(url != "", url, "")
138143 }
139144
140145 settings := []bool{settingsVar.Settings.Nsfw, settingsVar.Settings.Proxy}
141146 for i, l := 0, len(settings); i < l; i++ {
142 if settings[i] {
143 mdstr.WriteString("Yes|")
144 } else {
145 mdstr.WriteString("No|")
146 }
147 mdbuilder(settings[i], "", "")
147148 }
148149
149 if settingsVar.ModifiedSrc != "" {
150 mdstr.WriteString("[Yes](")
151 mdstr.WriteString(settingsVar.ModifiedSrc)
152 mdstr.WriteString(")|")
153 } else {
154 mdstr.WriteString("No|")
155 }
150 mdbuilder(settingsVar.ModifiedSrc != "", settingsVar.ModifiedSrc, "")
156151
157152 mdstr.WriteString(settingsVar.Country)
158153 mdstr.WriteString("|")
159154
160 instances.Write(mdstr.Bytes())
155 instancesFile.Write(mdstr.Bytes())
161156 break
162157 }
163158 time.Sleep(500 * time.Millisecond)
app/config.go +9 −7
@@ -4,6 +4,7 @@ import (
44 "encoding/json"
55 "os"
66 "regexp"
7 "skunkyart/static"
78 "strconv"
89 "time"
910
@@ -24,9 +25,9 @@ type config struct {
2425 URI string `json:"uri"`
2526 Cache cache_config
2627 Proxy, Nsfw bool
27 UserAgent string `json:"user-agent"`
28 DownloadProxy string `json:"download-proxy"`
29 Dirs []string `json:"dirs-to-memory"`
28 UserAgent string `json:"user-agent"`
29 DownloadProxy string `json:"download-proxy"`
30 StaticPath string `json:"static-path"`
3031}
3132
3233var CFG = config{
@@ -38,10 +39,10 @@ var CFG = config{
3839 Path: "cache",
3940 UpdateInterval: 1,
4041 },
41 Dirs: []string{"html", "css", "misc"},
42 UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
43 Proxy: true,
44 Nsfw: true,
42 StaticPath: "static",
43 UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
44 Proxy: true,
45 Nsfw: true,
4546}
4647
4748var lifetimeParsed int64
@@ -56,6 +57,7 @@ func ExecuteConfig() {
5657 exit("Incompatible settings detected: cannot use caching media content without proxy", 1)
5758 }
5859
60 static.StaticPath = CFG.StaticPath
5961 if CFG.Cache.Enabled {
6062 if CFG.Cache.Lifetime != "" {
6163 var duration int64
app/parsers.go +5 −3
@@ -41,7 +41,9 @@ func (s skunkyart) ParseComments(c devianter.Comments) string {
4141 cmmts.WriteString("</b></a> ")
4242
4343 if x.Parent > 0 {
44 cmmts.WriteString(` In reply to <a href="#`)
44 cmmts.WriteString(` In reply to <a href="`)
45 cmmts.WriteString(Path)
46 cmmts.WriteString("#")
4547 cmmts.WriteString(strconv.Itoa(x.Parent))
4648 cmmts.WriteString(`">`)
4749 if replied[x.Parent] == "" {
@@ -80,7 +82,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
8082
8183 for i, l := 0, len(devs); i < l; i++ {
8284 data := &devs[i]
83 if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !(data.NSFW && !CFG.Nsfw) {
85 if preview, fullview := ParseMedia(data.Media, data.Title, 320), ParseMedia(data.Media, data.Title); !(data.NSFW && !CFG.Nsfw) {
8486 if allowAtom && s.Atom {
8587 id := strconv.Itoa(data.ID)
8688 listContent.WriteString(`<entry><author><name>`)
@@ -284,7 +286,7 @@ func ParseDescription(dscr devianter.Text) string {
284286 parsedDescription.WriteString(`<a href="`)
285287 parsedDescription.WriteString(ConvertDeviantArtUrlToSkunkyArt(d.Url))
286288 parsedDescription.WriteString(`"><img width="50%" src="`)
287 parsedDescription.WriteString(ParseMedia(d.Media))
289 parsedDescription.WriteString(ParseMedia(d.Media, d.Title))
288290 parsedDescription.WriteString(`" title="`)
289291 parsedDescription.WriteString(d.Author.Username)
290292 parsedDescription.WriteString(" - ")
app/router.go +27 −6
@@ -1,13 +1,15 @@
11package app
22
33import (
4 "io"
45 "net/http"
56 u "net/url"
7 "skunkyart/static"
68 "strconv"
79 "strings"
810)
911
10var Host string
12var Host, Path string
1113
1214func Router() {
1315 parsepath := func(path string) map[int]string {
@@ -41,6 +43,15 @@ func Router() {
4143 return
4244 }
4345
46 open := func(name string) []byte {
47 file, err := static.Templates.Open(name)
48 try(err)
49 fileReaded, err := io.ReadAll(file)
50 try(err)
51
52 return fileReaded
53 }
54
4455 // функция, что управляет всем
4556 handle := func(w http.ResponseWriter, r *http.Request) {
4657 if h := r.Header["X-Forwarded-Proto"]; len(h) != 0 && h[0] == "https" {
@@ -49,7 +60,8 @@ func Router() {
4960 Host = "http://" + r.Host
5061 }
5162
52 path := parsepath(r.URL.Path)
63 Path = r.URL.Path
64 path := parsepath(Path)
5365 // структура с функциями
5466 var skunky skunkyart
5567 skunky.Writer = w
@@ -70,12 +82,14 @@ func Router() {
7082 skunky.Atom = true
7183 }
7284
85 skunky.Endpoint = path[1]
86
7387 // пути
74 switch path[1] {
88 switch skunky.Endpoint {
7589 default:
7690 skunky.ReturnHTTPError(404)
7791 case "":
78 skunky.ExecuteTemplate("index.htm", &CFG.URI)
92 w.Write(open("html/index.htm"))
7993 case "post":
8094 skunky.Deviation(path[2], path[3])
8195 case "search":
@@ -88,6 +102,13 @@ func Router() {
88102 case "media":
89103 switch path[2] {
90104 case "file":
105 if a := arg("filename"); a != "" {
106 var filename strings.Builder
107 filename.WriteString(`filename="`)
108 filename.WriteString(a)
109 filename.WriteString(`"`)
110 w.Header().Add("Content-Disposition", filename.String())
111 }
91112 skunky.DownloadAndSendMedia(path[3], next(path, 4))
92113 case "emojitar":
93114 skunky.Emojitar(path[3])
@@ -96,9 +117,9 @@ func Router() {
96117 skunky.About()
97118 case "stylesheet":
98119 w.Header().Add("content-type", "text/css")
99 wr(w, Templates["skunky.css"])
120 w.Write(open("css/skunky.css"))
100121 case "favicon.ico":
101 wr(w, Templates["logo.png"])
122 w.Write(open("images/logo.png"))
102123 }
103124 }
104125
app/stat-freebsd.go added +13
@@ -0,0 +1,13 @@
1//go:build freebsd
2// +build freebsd
3
4package app
5
6import (
7 "syscall"
8 "time"
9)
10
11func statTime(stat *syscall.Stat_t) int64 {
12 return time.Unix(stat.Ctimespec.Unix()).UnixMilli()
13}
app/stat.go added +13
@@ -0,0 +1,13 @@
1//go:build !freebsd
2// +build !freebsd
3
4package app
5
6import (
7 "syscall"
8 "time"
9)
10
11func statTime(stat *syscall.Stat_t) int64 {
12 return time.Unix(stat.Ctim.Unix()).UnixMilli()
13}
app/util.go +32 −104
@@ -1,14 +1,13 @@
11package app
22
33import (
4 "encoding/base64"
54 "io"
65 "net/http"
7 u "net/url"
6 "net/url"
87 "os"
8 "skunkyart/static"
99 "strconv"
1010 "strings"
11 "syscall"
1211 "text/template"
1312 "time"
1413
@@ -32,26 +31,34 @@ func tryWithExitStatus(err error, code int) {
3231 }
3332}
3433
34func restore() {
35 if r := recover(); r != nil {
36 recover()
37 }
38}
39
40var instances []byte
41
3542func RefreshInstances() {
3643 for {
3744 func() {
38 defer func() {
39 if r := recover(); r != nil {
40 recover()
41 }
42 }()
43 Templates["instances.json"] = string(Download("https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/instances.json").Body)
45 defer restore()
46 instances = Download("https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/instances.json").Body
4447 }()
4548 time.Sleep(1 * time.Hour)
4649 }
4750}
4851
4952// some crap for frontend
50func (s skunkyart) ExecuteTemplate(file string, data any) {
53func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
5154 var buf strings.Builder
5255 tmp := template.New(file)
53 tmp, e := tmp.Parse(Templates[file])
54 try(e)
56 tmp, err := tmp.ParseFS(static.Templates, dir+"/*")
57 if err != nil {
58 s.Writer.WriteHeader(500)
59 wr(s.Writer, err.Error())
60 return
61 }
5562 try(tmp.Execute(&buf, &data))
5663 wr(s.Writer, buf.String())
5764}
@@ -91,15 +98,15 @@ type Downloaded struct {
9198 Body []byte
9299}
93100
94func Download(url string) (d Downloaded) {
101func Download(urlString string) (d Downloaded) {
95102 cli := &http.Client{}
96103 if CFG.DownloadProxy != "" {
97 u, e := u.Parse(CFG.DownloadProxy)
104 u, e := url.Parse(CFG.DownloadProxy)
98105 try(e)
99106 cli.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
100107 }
101108
102 req, e := http.NewRequest("GET", url, nil)
109 req, e := http.NewRequest("GET", urlString, nil)
103110 try(e)
104111 req.Header.Set("User-Agent", CFG.UserAgent)
105112
@@ -115,97 +122,16 @@ func Download(url string) (d Downloaded) {
115122 return
116123}
117124
118// caching
119func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
120 var url strings.Builder
121 url.WriteString("https://images-wixmp-")
122 url.WriteString(subdomain)
123 url.WriteString(".wixmp.com/")
124 url.WriteString(path)
125 url.WriteString("?token=")
126 url.WriteString(s.Args.Get("token"))
127
128 if CFG.Cache.Enabled {
129 fname := CFG.Cache.Path + "/" + base64.StdEncoding.EncodeToString([]byte(subdomain+path))
130 file, e := os.Open(fname)
131
132 if e != nil {
133 dwnld := Download(url.String())
134 if dwnld.Status == 200 && dwnld.Headers["Content-Type"][0][:5] == "image" {
135 try(os.WriteFile(fname, dwnld.Body, 0700))
136 s.Writer.Write(dwnld.Body)
137 }
138 } else {
139 file, e := io.ReadAll(file)
140 try(e)
141 s.Writer.Write(file)
142 }
143 } else if CFG.Proxy {
144 dwnld := Download(url.String())
145 s.Writer.Write(dwnld.Body)
146 } else {
147 s.Writer.WriteHeader(403)
148 s.Writer.Write([]byte("Sorry, butt proxy on this instance are disabled."))
149 }
150}
151
152func InitCacheSystem() {
153 c := &CFG.Cache
154 os.Mkdir(CFG.Cache.Path, 0700)
155 for {
156 dir, e := os.Open(c.Path)
157 try(e)
158 stat, e := dir.Stat()
159 try(e)
160
161 dirnames, e := dir.Readdirnames(-1)
162 try(e)
163 for _, a := range dirnames {
164 a = c.Path + "/" + a
165 if c.Lifetime != "" {
166 now := time.Now().UnixMilli()
167
168 f, _ := os.Stat(a)
169 stat := f.Sys().(*syscall.Stat_t)
170 time := time.Unix(stat.Ctim.Unix()).UnixMilli()
171
172 if time+lifetimeParsed <= now {
173 try(os.RemoveAll(a))
174 }
175 }
176 if c.MaxSize != 0 && stat.Size() > c.MaxSize {
177 try(os.RemoveAll(a))
178 }
179 }
180
181 dir.Close()
182 time.Sleep(time.Second * time.Duration(CFG.Cache.UpdateInterval))
183 }
184}
185
186func CopyTemplatesToMemory() {
187 for _, dirname := range CFG.Dirs {
188 dir, e := os.ReadDir(dirname)
189 tryWithExitStatus(e, 1)
190
191 for _, x := range dir {
192 file, e := os.ReadFile(dirname + "/" + x.Name())
193 tryWithExitStatus(e, 1)
194 Templates[x.Name()] = string(file)
195 }
196 }
197}
198
199125/* PARSING HELPERS */
200func ParseMedia(media devianter.Media, thumb ...int) string {
201 url := devianter.UrlFromMedia(media, thumb...)
202 if len(url) != 0 && CFG.Proxy {
203 url = url[21:]
204 dot := strings.Index(url, ".")
126func ParseMedia(media devianter.Media, filename string, thumb ...int) string {
127 mediaUrl := devianter.UrlFromMedia(media, thumb...)
128 if len(mediaUrl) != 0 && CFG.Proxy {
129 mediaUrl = mediaUrl[21:]
130 dot := strings.Index(mediaUrl, ".")
205131
206 return UrlBuilder("media", "file", url[:dot], url[dot+11:])
132 return UrlBuilder("media", "file", mediaUrl[:dot], mediaUrl[dot+11:], "&filename=", url.QueryEscape(filename))
207133 }
208 return url
134 return mediaUrl
209135}
210136
211137func ConvertDeviantArtUrlToSkunkyArt(url string) (output string) {
@@ -255,7 +181,9 @@ func (s skunkyart) NavBase(c DeviationList) string {
255181 list.WriteString("<br>")
256182 prevrev := func(msg string, page int, onpage bool) {
257183 if !onpage {
258 list.WriteString(`<a href="?p=`)
184 list.WriteString(`<a href="`)
185 list.WriteString(Path)
186 list.WriteString(`?p=`)
259187 list.WriteString(strconv.Itoa(page))
260188 if s.Type != 0 {
261189 list.WriteString("&type=")
app/wrapper.go +23 −17
@@ -15,17 +15,17 @@ import (
1515)
1616
1717var wr = io.WriteString
18var Templates = make(map[string]string)
1918
2019type skunkyart struct {
2120 Writer http.ResponseWriter
2221
23 Args url.Values
24 BasePath string
25 Type rune
26 Query, QueryRaw string
27 Page int
28 Atom bool
22 Args url.Values
23 Page int
24 Type rune
25 Atom bool
26
27 BasePath, Endpoint string
28 Query, QueryRaw string
2929
3030 Templates struct {
3131 About struct {
@@ -136,7 +136,7 @@ func (s skunkyart) GRUser() {
136136 case "cover_deviation":
137137 group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation
138138 group.About.BGMeta.Url = ConvertDeviantArtUrlToSkunkyArt(group.About.BGMeta.Url)
139 group.About.BG = ParseMedia(group.About.BGMeta.Media)
139 group.About.BG = ParseMedia(group.About.BGMeta.Media, group.About.BGMeta.Title)
140140 case "group_admins":
141141 var htm strings.Builder
142142 for _, z := range x.ModuleData.GroupAdmins.Results {
@@ -171,7 +171,7 @@ func (s skunkyart) GRUser() {
171171 folders.WriteString(`<a href="`)
172172 folders.WriteString(ConvertDeviantArtUrlToSkunkyArt(x.Thumb.Url))
173173 folders.WriteString(`"><img loading="lazy" src="`)
174 folders.WriteString(ParseMedia(x.Thumb.Media))
174 folders.WriteString(ParseMedia(x.Thumb.Media, x.Thumb.Title))
175175 folders.WriteString(`" title="`)
176176 folders.WriteString(x.Thumb.Title)
177177 folders.WriteString(`"></a>`)
@@ -180,7 +180,7 @@ func (s skunkyart) GRUser() {
180180 }
181181 folders.WriteString("<br>")
182182
183 folders.WriteString(`<a href="?folder=`)
183 folders.WriteString(`<a href="group_user?folder=`)
184184 folders.WriteString(strconv.Itoa(x.FolderId))
185185 folders.WriteString("&q=")
186186 folders.WriteString(s.Query)
@@ -209,7 +209,7 @@ func (s skunkyart) GRUser() {
209209 }
210210
211211 if !s.Atom {
212 s.ExecuteTemplate("gruser.htm", &s)
212 s.ExecuteTemplate("gruser.htm", "html", &s)
213213 }
214214}
215215
@@ -233,7 +233,7 @@ func (s skunkyart) Deviation(author, postname string) {
233233 }
234234 // время публикации
235235 post.StringTime = post.Post.Deviation.PublishedTime.UTC().String()
236 post.Post.IMG = ParseMedia(post.Post.Deviation.Media)
236 post.Post.IMG = ParseMedia(post.Post.Deviation.Media, post.Post.Deviation.Title)
237237 for _, x := range post.Post.Deviation.Extended.RelatedContent {
238238 if len(x.Deviations) != 0 {
239239 post.Related += s.DeviationList(x.Deviations, false)
@@ -258,7 +258,7 @@ func (s skunkyart) Deviation(author, postname string) {
258258
259259 post.Comments = s.ParseComments(devianter.GetComments(id, post.Post.Comments.Cursor, s.Page, 1))
260260
261 s.ExecuteTemplate("deviantion.htm", &s)
261 s.ExecuteTemplate("deviantion.htm", "html", &s)
262262}
263263
264264func (s skunkyart) DD() {
@@ -281,11 +281,16 @@ func (s skunkyart) DD() {
281281 More: dd.HasMore,
282282 })
283283 if !s.Atom {
284 s.ExecuteTemplate("daily.htm", &s)
284 s.ExecuteTemplate("daily.htm", "html", &s)
285285 }
286286}
287287
288288func (s skunkyart) Search() {
289 if s.Query == "" {
290 s.ReturnHTTPError(400)
291 return
292 }
293
289294 var err error
290295 ss := &s.Templates.Search
291296 switch s.Type {
@@ -338,6 +343,7 @@ func (s skunkyart) Search() {
338343 }
339344 default:
340345 s.ReturnHTTPError(400)
346 return
341347 }
342348 try(err)
343349
@@ -348,7 +354,7 @@ func (s skunkyart) Search() {
348354 })
349355 }
350356
351 s.ExecuteTemplate("search.htm", &s)
357 s.ExecuteTemplate("search.htm", "html", &s)
352358}
353359
354360func (s skunkyart) Emojitar(name string) {
@@ -367,6 +373,6 @@ func (s skunkyart) Emojitar(name string) {
367373func (s skunkyart) About() {
368374 s.Templates.About.Nsfw = CFG.Nsfw
369375 s.Templates.About.Proxy = CFG.Proxy
370 try(json.Unmarshal([]byte(Templates["instances.json"]), &s.Templates.About))
371 s.ExecuteTemplate("about.htm", &s)
376 try(json.Unmarshal(instances, &s.Templates.About))
377 s.ExecuteTemplate("about.htm", "html", &s)
372378}
config.example.json +1 −5
@@ -8,11 +8,7 @@
88 "max-size": 1024,
99 "update-interval": 5
1010 },
11 "dirs-to-memory": [
12 "html",
13 "css",
14 "misc"
15 ],
11 "static-path": "static",
1612 "download-proxy": "http://127.0.0.1:8080",
1713 "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",
1814 "proxy": true,
html/daily.htm deleted −28
@@ -1,28 +0,0 @@
1<!DOCTYPE html>
2<html>
3 <head>
4 <title>SkunkyArt | Daily Deviations</title>
5 <link rel="stylesheet" href="{{.BasePath}}stylesheet">
6 <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico">
7 </head>
8 <main>
9 <header>
10 <h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a> | <a href="?atom=true">RSS</a></h1>
11 <form method="get" action="{{.BasePath}}search">
12 <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
13 <select name="type">
14 <option value="all">All</option>
15 <option value="tag">Tag</option>
16 <option value="r">Groups</option>
17 </select>
18 <button type="submit">Search!</button>
19 </form>
20 </header>
21 {{if ne .Templates.DDStrips ""}}
22 <h2 id="strips"><a href="#strips">#</a> Strips</h2>
23 {{.Templates.DDStrips}}
24 {{end}}
25 <h2 id="content"><a href="#content">#</a> Content</h2>
26 {{.Templates.SomeList}}
27 </main>
28</html>
\ No newline at end of file
html/search.htm deleted −30
@@ -1,30 +0,0 @@
1<!DOCTYPE html>
2<html>
3 <head>
4 <title>SkunkyArt | Search "{{.QueryRaw}}"</title>
5 <link rel="stylesheet" href="{{.BasePath}}stylesheet">
6 <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico">
7 </head>
8 <main>
9 <header>
10 <h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a></h1>
11 <form method="get" action="search">
12 <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
13 <select name="type">
14 <option value="all">All</option>
15 <option value="tag">Tag</option>
16 <option value="r">Groups</option>
17 </select>
18 <button type="submit">Search!</button>
19 </form>
20 </header>
21 {{if ne .Templates.Search.List ""}}
22 {{if ne .Templates.Search.Content.Total 0}}
23 <h1>Results by request '{{.QueryRaw}}': {{.Templates.Search.Content.Total}}</h1>
24 {{end}}
25 {{.Templates.Search.List}}
26 {{else}}
27 <p>No results :(</p>
28 {{end}}
29 </main>
30</html>
\ No newline at end of file
main.go +2 −1
@@ -2,6 +2,7 @@ package main
22
33import (
44 "skunkyart/app"
5 "skunkyart/static"
56 "time"
67
78 "git.macaw.me/skunky/devianter"
@@ -12,7 +13,7 @@ func main() {
1213
1314 app.ExecuteCommandLineArguments()
1415 app.ExecuteConfig()
15 app.CopyTemplatesToMemory()
16 static.CopyTemplatesToMemory()
1617
1718 go func() {
1819 for {
css/skunky.css → static/css/skunky.css renamed +11 −2
@@ -22,12 +22,19 @@ header form {
2222header {
2323 display: flex;
2424}
25form {
26 font-size: 0;
27 border: solid #164e3e 1px;
28 max-width: fit-content;
29}
2530form input, button, select {
2631 background-color: #134134;
2732 padding: 5px;
2833 color: whitesmoke;
29 border: 0px;
30 border-radius: 1px;
34 border: 0;
35}
36input:focus {
37 outline: none;
3138}
3239
3340/* BLOCKS */
@@ -146,6 +153,8 @@ form input, button, select {
146153
147154 header form {
148155 font-size: 60%;
156 max-width: unset;
157 border: 0;
149158 }
150159 header, center {
151160 text-align: center;
html/about.htm → static/html/about.htm renamed +2 −17
@@ -1,23 +1,8 @@
11<!DOCTYPE html>
22<html>
3 <head>
4 <title>SkunkyArt</title>
5 <link rel="stylesheet" href="{{.BasePath}}stylesheet">
6 <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico">
7 </head>
3 {{template "head" .}}
84 <main>
9 <header>
10 <h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a></h1>
11 <form method="get" action="{{.BasePath}}search">
12 <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
13 <select name="type">
14 <option value="all">All</option>
15 <option value="tag">Tag</option>
16 <option value="r">Groups</option>
17 </select>
18 <button type="submit">Search!</button>
19 </form>
20 </header>
5 {{template "header" .}}
216 <p>
227 SkunkyArt is an alternative frontend for deviantart.com, written in Go.
238 </p>
static/html/daily.htm added +13
@@ -0,0 +1,13 @@
1<!DOCTYPE html>
2<html>
3 {{template "head" . }}
4 <main>
5 {{template "header" . }}
6 {{if ne .Templates.DDStrips ""}}
7 <h2 id="strips"><a href="#strips">#</a> Strips</h2>
8 {{.Templates.DDStrips}}
9 {{end}}
10 <h2 id="content"><a href="#content">#</a> Content</h2>
11 {{.Templates.SomeList}}
12 </main>
13</html>
\ No newline at end of file
html/deviantion.htm → static/html/deviantion.htm renamed +2 −18
@@ -1,24 +1,8 @@
11<!DOCTYPE html>
22<html>
3 <head>
4 <title>SkunkyArt | {{.Templates.Deviation.Post.Deviation.Author.Username}} - {{.Templates.Deviation.Post.Deviation.Title}}</title>
5 <link rel="stylesheet" href="{{.BasePath}}stylesheet">
6 <meta name="referrer" content="no-referrer" />
7 <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico">
8 </head>
3 {{template "head" . }}
94 <main>
10 <header>
11 <h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a></h1>
12 <form method="get" action="{{.BasePath}}search">
13 <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
14 <select name="type">
15 <option value="all">All</option>
16 <option value="tag">Tag</option>
17 <option value="r">Groups</option>
18 </select>
19 <button type="submit">Search!</button>
20 </form>
21 </header>
5 {{template "header" . }}
226 <figure>
237 <img src="{{.BasePath}}media/emojitar/{{.Templates.Deviation.Post.Deviation.Author.Username}}?type=a" width="30px">
248 <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)}}
html/gruser.htm → static/html/gruser.htm renamed +3 −13
@@ -1,21 +1,11 @@
11<!DOCTYPE html>
22<html>
3 <head>
4 <title>SkunkyArt |
5 {{if eq .Type 'a'}}
6 {{.Templates.GroupUser.GR.Owner.Username}}
7 {{else}}
8 gallery of {{.Templates.GroupUser.GR.Owner.Username}}
9 {{end}}
10 </title>
11 <link rel="stylesheet" href="{{.BasePath}}stylesheet">
12 <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico">
13 </head>
3 {{template "head" . }}
144 <main>
155 <header>
166 <h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a>
17 | <a href="?q={{.Templates.GroupUser.GR.Owner.Username}}&type={{if eq .Type 'a'}}gallery">Gallery{{else}}about">About{{end}}</a>
18 | <a href="?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery&atom=true">RSS</a></h1>
7 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type={{if eq .Type 'a'}}gallery">Gallery{{else}}about">About{{end}}</a>
8 | <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery&atom=true">RSS</a></h1>
199 <form method="get" action="{{.BasePath}}search">
2010 <input type="gallery" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
2111 <input type="hidden" name="usr" value="{{.Templates.GroupUser.GR.Owner.Username}}">
static/html/head.htm added +24
@@ -0,0 +1,24 @@
1{{define "head"}}
2<head>
3 <title>SkunkyArt |
4 {{if eq .Endpoint "search"}}
5 "{{.QueryRaw}}"
6 {{else if eq .Endpoint "post"}}
7 {{.Templates.Deviation.Post.Deviation.Author.Username}} — {{.Templates.Deviation.Post.Deviation.Title}}
8 {{else if eq .Type 'a'}}
9 {{if .Templates.GroupUser.GR.Owner.Username}}
10 {{.Templates.GroupUser.GR.Owner.Username}}
11 {{else}}
12 gallery of {{.Templates.GroupUser.GR.Owner.Username}}
13 {{end}}
14 {{else}}
15 {{.Endpoint}}
16 {{end}}
17 </title>
18
19 <base href="{{.BasePath}}">
20 <meta name="referrer" content="no-referrer" />
21 <link rel="stylesheet" href="{{.BasePath}}stylesheet">
22 <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico">
23</head>
24{{end}}
\ No newline at end of file
static/html/header.htm added +14
@@ -0,0 +1,14 @@
1{{define "header"}}
2<header>
3 <h1><a href="">HOME</a> | <a href="dd">DD</a> {{if eq .Endpoint "dd"}}| <a href="{{.Endpoint}}?atom=true">RSS</a>{{end}}</h1>
4 <form method="get" action="search">
5 <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false" value="{{.QueryRaw}}">
6 <select name="type">
7 <option value="all">All</option>
8 <option value="tag">Tag</option>
9 <option value="r">Groups</option>
10 </select>
11 <button type="submit">Search!</button>
12 </form>
13</header>
14{{end}}
\ No newline at end of file
html/index.htm → static/html/index.htm renamed +4 −4
@@ -2,12 +2,12 @@
22<html>
33 <head>
44 <title>SkunkyArt</title>
5 <link rel="stylesheet" href="{{.}}stylesheet"/>
6 <link rel="icon" type="image/x-icon" href="{{.}}favicon.ico">
5 <link rel="stylesheet" href="stylesheet"/>
6 <link rel="icon" type="image/x-icon" href="favicon.ico">
77 </head>
88 <main>
99 <center>
10 <form method="get" action="{{.}}search">
10 <form method="get" action="search">
1111 <input type="text" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false">
1212 <select name="type">
1313 <option value="all">All</option>
@@ -16,7 +16,7 @@
1616 </select>
1717 <button type="submit">Search!</button>
1818 </form>
19 <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>
19 <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>
2020 </center>
2121 </main>
2222</html>
\ No newline at end of file
static/html/search.htm added +16
@@ -0,0 +1,16 @@
1<!DOCTYPE html>
2<html>
3 {{template "head" . }}
4 <main>
5 {{template "header" . }}
6
7 {{if ne .Templates.Search.List ""}}
8 {{if ne .Templates.Search.Content.Total 0}}
9 <h1>Results by request '{{.QueryRaw}}': {{.Templates.Search.Content.Total}}</h1>
10 {{end}}
11 {{.Templates.Search.List}}
12 {{else}}
13 <p>No results :(</p>
14 {{end}}
15 </main>
16</html>
\ No newline at end of file
misc/logo.png → static/images/logo.png renamed
static/templates-noembed.go added +148
@@ -0,0 +1,148 @@
1//go:build !embed
2// +build !embed
3
4package static
5
6import (
7 "bytes"
8 "io/fs"
9 "os"
10 "strings"
11 "time"
12)
13
14var Templates FS
15
16type file struct {
17 path string
18 name string
19 content []byte
20}
21
22var templateNames = []string{}
23var templates = make(map[string][]file)
24var StaticPath string
25
26func CopyTemplatesToMemory() {
27 baseDir, err := os.ReadDir(StaticPath)
28 try(err)
29
30 for _, c := range baseDir {
31 if c.IsDir() {
32 templateNames = append(templateNames, c.Name())
33
34 var filePath strings.Builder
35 filePath.WriteString(StaticPath)
36 filePath.WriteString("/")
37 filePath.WriteString(c.Name())
38
39 dir, err := os.ReadDir(filePath.String())
40 try(err)
41
42 filePath.WriteString("/")
43 for _, cd := range dir {
44 f, err := os.ReadFile(filePath.String() + cd.Name())
45 try(err)
46 templates[c.Name()] = append(templates[c.Name()], file{
47 content: f,
48 name: cd.Name(),
49 path: c.Name() + "/" + cd.Name(),
50 })
51 }
52 }
53 }
54}
55
56type FS struct{}
57
58func (FS) Open(name string) (fs.File, error) {
59 for i, l := 0, len(templateNames); i < l; i++ {
60 for _, x := range templates[templateNames[i]] {
61 if x.content != nil && name == x.path {
62 return &File{
63 name: x.path,
64 content: bytes.NewBuffer(x.content),
65 }, nil
66 }
67 }
68 }
69 return nil, &fs.PathError{}
70}
71
72func (FS) Glob(pattern string) ([]string, error) {
73 trimmed := strings.Split(pattern, "/")
74 var matches = []string{}
75 for x, s := range templates {
76 for i, l := 0, len(s); i < l && trimmed[0] == x; i++ {
77 s := s[i]
78 matches = append(matches, s.path)
79 }
80 }
81 if len(matches) != 0 {
82 return matches, nil
83 }
84 return nil, &fs.PathError{}
85}
86
87func try(err error) {
88 if err != nil {
89 println(err.Error())
90 os.Exit(1)
91 }
92}
93
94/* сделано на основе https://github.com/psanford/memfs; требуется для корректной работы templates.ParseFS */
95type fileInfo struct {
96 name string
97}
98
99func (fi fileInfo) Name() string {
100 return fi.name
101}
102
103func (fi fileInfo) Size() int64 {
104 return 4096
105}
106
107func (fileInfo) Mode() fs.FileMode {
108 return 0
109}
110
111func (fileInfo) ModTime() time.Time {
112 return time.Time{}
113}
114
115func (fileInfo) IsDir() bool {
116 return false
117}
118
119func (fileInfo) Sys() interface{} {
120 return nil
121}
122
123type File struct {
124 name string
125 content *bytes.Buffer
126 closed bool
127}
128
129func (f *File) Stat() (fs.FileInfo, error) {
130 return fileInfo{
131 name: f.name,
132 }, nil
133}
134
135func (f *File) Read(b []byte) (int, error) {
136 if f.closed {
137 return 0, fs.ErrClosed
138 }
139 return f.content.Read(b)
140}
141
142func (f *File) Close() error {
143 if f.closed {
144 return fs.ErrClosed
145 }
146 f.closed = true
147 return nil
148}
static/templates.go added +16
@@ -0,0 +1,16 @@
1//go:build embed
2// +build embed
3
4package static
5
6import "embed"
7
8//go:embed *
9var Templates embed.FS
10var Enabled bool = true
11
12var StaticPath string
13
14func CopyTemplatesToMemory() {
15 _ = StaticPath
16}