Compare commits

...

13 Commits

37 changed files with 1050 additions and 507 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
**/cache
**/config.json
**/skunkyart
**/skunkyart-*

View File

@ -1,7 +1,9 @@
JSON variant should be used from master — https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/instances.json
|Instance|Yggdrasil|I2P|Tor|NSFW|Proxifying|Modified Sources|Country|
|:------:|:-------:|:-:|:-:|:--:|:--------:|:--------------:|:-----:|
|[skunky.ebloid.ru](https://skunky.ebloid.ru/art)|[Yes](http://[201:eba5:d1fc:bf7b:cfcb:a811:4b8b:7ea3]/art)|No|No| No | No | No | Russia |
|[ls.404.mn](https://ls.404.mn/skunkyart)|[Yes](http://[201:f137:d1ac:920e:cd42:bfd1:1e83:da1d]/skunkyart)|No|No| No | Yes | No | Germany |
|[clovius.club](https://skunky.clovius.club)|No|No|No| Yes | Yes | No | Sweden |
|[bloat.cat](https://skunky.bloat.cat)|No|No|No| Yes | Yes | No | Romania |
|[frontendfriendly.xyz](https://skunkyart.frontendfriendly.xyz)|No|No|No| Yes | Yes | No | Finland |
|[lumaeris.com](https://skunkyart.lumaeris.com)|No|No|No| Yes | Yes | No | US |
|[bloat.cat](https://skunky.bloat.cat)|No|No|No| Yes | Yes | No | Germany |
|[lumaeris.com](https://skunkyart.lumaeris.com)|No|No|No| Yes | Yes | No | Germany |
|[art.bloat.cat](https://art.bloat.cat)|No|No|No| Yes | Yes | No | Germany |

View File

@ -1,15 +1,25 @@
<img src="https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/misc/logo.png" alt="SkunkyArt" title="SkunkyArt Logo" width="20%" loading="lazy"/>
> [!NOTE]
> Currently, due to school, I cannot actively develop this project :(
> However, this does not mean that development has stopped. Just wait for the summer. For questions, write either to the Matrix room or to me in DM.
[![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)
<img src="static/images/logo.png" alt="SkunkyArt" title="SkunkyArt Logo" width="20%" loading="lazy"/>
Instances: [`INSTANCES.md`](https://git.macaw.me/skunky/SkunkyArt/src/branch/master/INSTANCES.md)
[![Matrix room](https://img.shields.io/badge/matrix-000000?style=for-the-badge&logo=Matrix&logoColor=white)](https://go.kde.org/matrix/#/#skunkyart:ls.404.mn)
Instances: [`INSTANCES.md`](/skunky/SkunkyArt/src/branch/master/INSTANCES.md)
# EN 🇺🇸
## Description
SkunkyArt 🦨 — alternative frontend for DevianArt, which works without JS.
## Build (translated via DeepL)
It is recommended to build with the 'embed' tag because it embeds the presets in the binary. If you plan to modify the templates, then do not use this tag. You can also add the `-ldflags "-w -s"` argument (GCCGO has a different name for it — `gccgoflags`) to reduce the size of the output file. Here is an example:
`go build -tags embed -ldflags "-w -s"`
Pre-compiled binaries can be found in the [Releases](https://git.macaw.me/skunky/skunkyart/releases) tab.
## 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.
@ -21,9 +31,15 @@ To do this, you must either make a PR by adding your instance to the `instances.
# RU 🇷🇺
## Описание
SkunkyArt 🦨 — альтернативный фронтенд к DeviantArt, который полностью работает без JS (JavaScript).
## Сборка
Рекомендуется производить сборку с тегом 'embed', поскольку он встраивает заготовки в бинарный файл. Если вы планируете изменять заготовки, то не используйте этот тег. Также вы можете добавить аргумент `-ldflags "-w -s"` (у GCCGO он называется по-другому — `gccgoflags`) для уменьшения размера выходного файла. Вот пример:
`go build -tags embed -ldflags "-w -s"`
Готовые бинари находятся во вкладке [Releases](https://git.macaw.me/skunky/skunkyart/releases).
## Настройка
Пример конфига находится в файле `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 итп.

13
REDIRECTS.md Normal file
View File

@ -0,0 +1,13 @@
# Search
* `deviantart.com/search?q=$QUERY` => `/search?q=$QUERY&type=all`
# Daily Deviations
* `deviantart.com` => `/dd`
# Deviations
* (`$USER_GROUP.deviantart.com/art/$ID`|`deviantart.com/$USER_GROUP/art/$ID`) => `/post/$USER_GROUP/$ID`
# Groups and users
## Main user page
* (`$USER_GROUP.deviantart.com`|`deviantart.com/$USER_GROUP`) => `/group_user?type=about&q=$USER_GROUP`
## Gallery
* (`$USER_GROUP.deviantart.com/gallery`|`deviantart.com/$USER_GROUP/gallery`) => `/group_user?type=gallery&q=$USER_GROUP`
## Favourites
* (`$USER_GROUP.deviantart.com/favourites`|`deviantart.com/$USER_GROUP/favourites`) => `/group_user?type=favourites&q=$USER_GROUP`

View File

@ -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>
@ -12,13 +12,13 @@
# Конфигурация
* `listen` — IP и порт для слушанья; заполняется по такой форме: ip:port
* `uri` — URI инстанса. Пример: `"uri":"/art/"` -> https://skunky.ebloid.ru/art/
* `cache` — Система кеширования; по умолчанию выключена.
* `cache` — Система кеширования; по умолчанию выключена
* `enabled` — Состояние системы кеширования; требуется булёвое значение
* `path` — Полный путь до каталога, куда будет сохраняться кеш
* `lifetime` — Время жизни файла в кеше, требует целочисленное значение, дополненное суффиксом времени (см. 'Единицы времени')
* `max-size` — Максимальный размер файла
* `update-interval` — Интервал для автоматической ротации кеша
* `dirs-to-memory` — Массив, заполнив который скопируются все файлы из указанных каталогов
* `static-path` — Строка, являющаяся путём до статики. SkunkyArt при запуске скопирует содержимое этого каталога в ОЗУ. Однако, если вы собрали фронтенд с тегом 'embed', то этого не произайдёт
* `download-proxy` — Адрес прокси для загрузки файлов
* `user-agent` — Строка, которая используется в качестве User-Agent'а

View File

@ -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>
@ -18,7 +18,7 @@ Time units:
* `lifetime` — Cached file life time, requires numeric value, followed by multiplicative suffix (see Time Units for details)
* `max-size` — Maximum file size in megabytes
* `update-interval` — Automatic rotation interval
* `dirs-to-memory` — This setting determines which directories will be copied to RAM when SkunkyArt is started. Mandatory
* `static-path` — This setting determines path to static, which will be copied to RAM when SkunkyArt is started. Useless if you're use binary compiled with 'embed' tag.
* `download-proxy` — Proxy address for downloading files.
* `user-agent` — String, which SkunkyArt uses as UA

24
TODO.md
View File

@ -1,19 +1,23 @@
# v1.3.x
* Почистить говнокод
* Добавить фильтры поиска
* ~~Сделать порт под FreeBSD~~ ✔️
* **Доделать парсинг описания**
* Избавиться от хардкода под Linux
* ~~Реализовать стрипы в ежедневных артах~~
* Сделать нормальное отображение ошибок
* ~~Исправить баг с навигацией по страницам~~
* ~~Сделать единицы в конфиге более понятными~~
* Добавить возможность включить темплейты в бинарник
* ~~Реализовать миниатюры и оптимизировать CSS под маленькие экраны~~
* ~~Реализовать стрипы в ежедневных артах~~ ✔️
* ~~Исправить баг с навигацией по страницам~~ ✔️
* ~~Сделать нормальное отображение ошибок~~ ✔️
* ~~Сделать единицы в конфиге более понятными~~ ✔️
* Добавить чекер инстанса на работоспособность
* ~~Добавить просмотр понравившихся артов пользователю~~ ✔️
* Добавить возможность включить темплейты в бинарник [P]
* ~~Реализовать миниатюры и оптимизировать CSS под маленькие экраны~~ ✔️
* Написать Makefile и скрипт для автоматического развёртывания инстанса
* **Реализовать отображение контента, отличного от картинок (видео, аудио, etc)**
* Исправить баг с эмоджи, когда некоторые кастомные эмоции могут не отображаться
* Добавить флаг сборки, который позволит собрать бинарник со встроенными темплейтами
* Улучшить систему кеширования: добавить рейтинг для удаления и копирование изображений в ОЗУ
* ~~Добавить аргумент &filename, который будет выдавать файл с нормально выглядещем именем~~ ✔️
* ~~Улучшить систему кеширования: добавить рейтинг для удаления и копирование изображений в ОЗУ~~ ✔️
# v1.4
* Реализовать API
* Реализовать темы
* Перейти на арены в кеше
* Реализовать многоязычный интерфейс

76
app/api.go Normal file
View File

@ -0,0 +1,76 @@
package app
import (
"encoding/json"
"math/rand"
"strings"
"git.macaw.me/skunky/devianter"
)
type API struct {
main *skunkyart
}
type info struct {
Version string `json:"version"`
Settings settingsParams `json:"settings"`
}
func (a API) Info() {
json, err := json.Marshal(info{
Version: a.main.Version,
Settings: settingsParams{
Nsfw: CFG.Nsfw,
Proxy: CFG.Proxy,
},
})
try(err)
a.main.Writer.Write(json)
}
func (a API) Error(description string, status int) {
a.main.Writer.WriteHeader(status)
var response strings.Builder
response.WriteString(`{"error":"`)
response.WriteString(description)
response.WriteString(`"}`)
wr(a.main.Writer, response.String())
}
func (a API) sendMedia(d *devianter.Deviation) {
mediaUrl, name := devianter.UrlFromMedia(d.Media)
a.main.SetFilename(name)
if len(mediaUrl) != 0 {
mediaUrl = mediaUrl[21:]
dot := strings.Index(mediaUrl, ".")
a.main.Writer.Header().Del("Content-Type")
a.main.DownloadAndSendMedia(mediaUrl[:dot], mediaUrl[dot+11:])
}
}
// TODO: сделать фильтры
func (a API) Random() {
for attempt := 1; ; {
if attempt > 3 {
a.Error("Sorry, butt NSFW on this are disabled, and the instance failed to find a random art without NSFW", 500)
}
s, err, daErr := devianter.PerformSearch(string(rand.Intn(999)), rand.Intn(30), 'a')
try(err)
if daErr.RAW != nil {
continue
}
deviation := &s.Results[rand.Intn(len(s.Results))]
if deviation.NSFW && !CFG.Nsfw {
attempt++
continue
}
a.sendMedia(deviation)
return
}
}

138
app/cache.go Normal file
View File

@ -0,0 +1,138 @@
// 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)
if t := s.Args.Get("token"); t != "" {
url.WriteString("?token=")
url.WriteString(t)
}
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{}
}
mx.Unlock()
if tempFS[fileName].Content != nil {
response = tempFS[fileName].Content
tempFS[fileName].Score += 2
break
} else {
file, err := os.Open(filePath)
if err != nil {
if dwnld := Download(url.String()); dwnld.Status == 200 && dwnld.Headers["Content-Type"][0][:5] == "image" {
response = dwnld.Body
try(os.WriteFile(filePath, response, 0700))
} else {
s.ReturnHTTPError(dwnld.Status)
return
}
} else {
file, e := io.ReadAll(file)
try(e)
response = file
}
go func() {
defer restore()
mx.RLock()
tempFS[fileName].Content = response
mx.RUnlock()
for {
time.Sleep(1 * time.Minute)
mx.Lock()
if tempFS[fileName].Score <= 0 {
delete(tempFS, fileName)
mx.Unlock()
return
}
tempFS[fileName].Score--
mx.Unlock()
}
}()
}
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
for {
dir, err := os.ReadDir(c.Path)
if err != nil {
if os.IsNotExist(err) {
os.Mkdir(c.Path, 0700)
continue
}
println(err.Error())
}
for _, file := range dir {
fileName := c.Path + "/" + file.Name()
fileInfo, err := file.Info()
try(err)
if c.Lifetime != "" {
now := time.Now().UnixMilli()
stat := fileInfo.Sys().(*syscall.Stat_t)
time := statTime(stat)
if time+lifetimeParsed <= now {
try(os.RemoveAll(fileName))
}
}
if c.MaxSize != 0 && fileInfo.Size() > c.MaxSize {
try(os.RemoveAll(fileName))
}
}
time.Sleep(time.Second * time.Duration(c.UpdateInterval))
}
}

View File

@ -4,19 +4,20 @@ import (
"bufio"
"bytes"
"encoding/json"
"html/template"
"os"
"time"
)
func ExecuteCommandLineArguments() {
const helpmsg = `SkunkyArt v1.3.1 [CSS improvements for mobile and the strips on Daily Deviations]
var helpmsg = `SkunkyArt v{{.Version}} [{{.Description}}]
Usage:
- [-c|--config] | path to config
- [-a|--add-instance] | generates 'instances.json' and 'INSTANCES.md' files with ur instance
- [-h|--help] | returns this message
Example:
./skunkyart -c config.json
Copyright lost+skunk, X11. https://git.macaw.me/skunky/skunkyart/src/tag/v1.3.1`
Copyright lost+skunk, X11. https://git.macaw.me/skunky/skunkyart/src/tag/v{{.Version}}`
a := os.Args[1:]
for n, x := range a {
@ -28,7 +29,11 @@ Copyright lost+skunk, X11. https://git.macaw.me/skunky/skunkyart/src/tag/v1.3.1`
exit("Not enought arguments", 1)
}
case "-h", "--help":
exit(helpmsg, 0)
var buf bytes.Buffer
t := template.New("help")
t.Parse(helpmsg)
t.Execute(&buf, &Release)
exit(buf.String(), 0)
case "-a", "--add-instance":
addInstance()
}
@ -78,16 +83,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 +118,46 @@ func addInstance() {
settingsVar := &settingsVar.Instances[len(settingsVar.Instances)-1]
var mdstr bytes.Buffer
mdstr.WriteString("\n|")
if settingsVar.Urls.Clearnet != "" {
mdbuilder := func(yes bool, link string, title string) {
switch {
case yes && (title != "" && link != ""):
mdstr.WriteString("[")
mdstr.WriteString(settingsVar.Title)
mdstr.WriteString(title)
mdstr.WriteString("](")
mdstr.WriteString(settingsVar.Urls.Clearnet)
mdstr.WriteString(link)
mdstr.WriteString(")")
} else {
mdstr.WriteString(settingsVar.Title)
case yes && link != "":
mdstr.WriteString("[Yes](")
mdstr.WriteString(link)
mdstr.WriteString(")")
case yes:
mdstr.WriteString("Yes")
default:
mdstr.WriteString("No")
}
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)

View File

@ -4,12 +4,18 @@ import (
"encoding/json"
"os"
"regexp"
"skunkyart/static"
"strconv"
"time"
"git.macaw.me/skunky/devianter"
)
var Release struct {
Version string
Description string
}
type cache_config struct {
Enabled bool
Path string
@ -26,7 +32,7 @@ type config struct {
Proxy, Nsfw bool
UserAgent string `json:"user-agent"`
DownloadProxy string `json:"download-proxy"`
Dirs []string `json:"dirs-to-memory"`
StaticPath string `json:"static-path"`
}
var CFG = config{
@ -38,7 +44,7 @@ var CFG = config{
Path: "cache",
UpdateInterval: 1,
},
Dirs: []string{"html", "css", "misc"},
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,
@ -50,7 +56,6 @@ func ExecuteConfig() {
if CFG.cfg != "" {
f, err := os.ReadFile(CFG.cfg)
tryWithExitStatus(err, 1)
tryWithExitStatus(json.Unmarshal(f, &CFG), 1)
if CFG.Cache.Enabled && !CFG.Proxy {
exit("Incompatible settings detected: cannot use caching media content without proxy", 1)
@ -82,9 +87,16 @@ func ExecuteConfig() {
lifetimeParsed = duration * int64(num)
}
CFG.Cache.MaxSize /= 1024 ^ 2
CFG.Cache.MaxSize *= 1024 ^ 2
go InitCacheSystem()
}
About = instanceAbout{
Proxy: CFG.Proxy,
Nsfw: CFG.Nsfw,
}
static.StaticPath = CFG.StaticPath
devianter.UserAgent = CFG.UserAgent
}
}

View File

@ -9,7 +9,11 @@ import (
"golang.org/x/net/html"
)
func (s skunkyart) ParseComments(c devianter.Comments) string {
func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) string {
if daError.RAW != nil {
return "Failed to fetch comments :("
}
var cmmts strings.Builder
replied := make(map[int]string)
@ -41,7 +45,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(s._pth)
cmmts.WriteString("#")
cmmts.WriteString(strconv.Itoa(x.Parent))
cmmts.WriteString(`">`)
if replied[x.Parent] == "" {
@ -82,6 +88,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
data := &devs[i]
if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !(data.NSFW && !CFG.Nsfw) {
if allowAtom && s.Atom {
s.Writer.Header().Add("Content-type", "application/atom+xml")
id := strconv.Itoa(data.ID)
listContent.WriteString(`<entry><author><name>`)
listContent.WriteString(data.Author.Username)
@ -181,6 +188,7 @@ type text struct {
To int
}
// переписать весь этот пиздец нахуй
func ParseDescription(dscr devianter.Text) string {
var parsedDescription strings.Builder
TagBuilder := func(content string, tags ...string) string {

View File

@ -1,8 +1,10 @@
package app
import (
"io"
"net/http"
u "net/url"
url "net/url"
"skunkyart/static"
"strconv"
"strings"
)
@ -41,41 +43,61 @@ func Router() {
return
}
// функция, что управляет всем
handle := func(w http.ResponseWriter, r *http.Request) {
if h := r.Header["X-Forwarded-Proto"]; len(h) != 0 && h[0] == "https" {
Host = h[0] + "://" + r.Host
} else {
Host = "http://" + r.Host
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) {
path := parsepath(r.URL.Path)
// структура с функциями
var skunky skunkyart
skunky.Writer = w
skunky.Args = r.URL.Query()
skunky.BasePath = CFG.URI
Host = "http://" + r.Host
if h := r.Header["X-Forwarded-Proto"]; len(h) != 0 && h[0] == "https" {
Host = "https://" + r.Host
}
var skunky = skunkyart{Version: Release.Version}
skunky._pth = r.URL.Path
skunky.Args = r.URL.Query()
arg := skunky.Args.Get
p, _ := strconv.Atoi(arg("p"))
skunky.Endpoint = path[1]
skunky.API.main = &skunky
skunky.Writer = w
skunky.BasePath = CFG.URI
skunky.QueryRaw = arg("q")
skunky.Query = u.QueryEscape(skunky.QueryRaw)
skunky.Query = url.QueryEscape(skunky.QueryRaw)
skunky.Page = p
if t := arg("type"); len(t) > 0 {
skunky.Type = rune(t[0])
}
p, _ := strconv.Atoi(arg("p"))
skunky.Page = p
if arg("atom") == "true" {
skunky.Atom = true
}
// пути
switch path[1] {
default:
skunky.ReturnHTTPError(404)
if CFG.Proxy {
w.Header().Add("Content-Security-Policy", "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'")
} else {
w.Header().Add("Content-Security-Policy", "default-src 'self'; img-src 'self' *.wixmp.com; script-src 'none'; style-src 'self' 'unsafe-inline'")
}
w.Header().Add("X-Frame-Options", "DENY")
switch skunky.Endpoint {
// main
case "":
skunky.ExecuteTemplate("index.htm", &CFG.URI)
skunky.ExecuteTemplate("index.htm", "html", &CFG.URI)
case "about":
skunky.Templates.About = About
skunky.ExecuteTemplate("about.htm", "html", &skunky)
case "post":
skunky.Deviation(path[2], path[3])
case "search":
@ -85,20 +107,38 @@ func Router() {
case "group_user":
skunky.GRUser()
// media
case "media":
switch path[2] {
case "file":
if a := arg("filename"); a != "" {
skunky.SetFilename(a)
}
skunky.DownloadAndSendMedia(path[3], next(path, 4))
case "emojitar":
skunky.Emojitar(path[3])
}
case "about":
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"))
// API
case "api":
w.Header().Add("Content-Type", "application/json")
switch path[2] {
case "instance":
skunky.API.Info()
case "random":
skunky.API.Random()
default:
skunky.API.Error("Not Found", 404)
}
// 404
default:
skunky.ReturnHTTPError(404)
}
}

13
app/stat-freebsd.go Normal file
View File

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

13
app/stat.go Normal file
View File

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

View File

@ -1,14 +1,14 @@
package app
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
u "net/url"
"net/url"
"os"
"skunkyart/static"
"strconv"
"strings"
"syscall"
"text/template"
"time"
@ -17,6 +17,8 @@ import (
)
/* INTERNAL */
var wr = io.WriteString
func exit(msg string, code int) {
println(msg)
os.Exit(code)
@ -32,26 +34,99 @@ func tryWithExitStatus(err error, code int) {
}
}
func RefreshInstances() {
for {
func() {
defer func() {
func restore() {
if r := recover(); r != nil {
recover()
}
}()
Templates["instances.json"] = string(Download("https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/instances.json").Body)
}
var instances []byte
var About instanceAbout
func RefreshInstances() {
for {
func() {
defer restore()
instances = Download("https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/instances.json").Body
try(json.Unmarshal(instances, &About))
}()
time.Sleep(1 * time.Hour)
}
}
// some crap for frontend
func (s skunkyart) ExecuteTemplate(file string, data any) {
type instanceAbout struct {
Proxy bool
Nsfw bool
Instances []settings
}
type skunkyart struct {
Writer http.ResponseWriter
_pth string
Args url.Values
Page int
Type rune
Atom bool
BasePath, Endpoint string
Query, QueryRaw string
API API
Version string
Templates struct {
About instanceAbout
SomeList string
DDStrips string
Deviation struct {
Post devianter.Post
Related string
StringTime string
Tags string
Comments string
}
GroupUser struct {
GR devianter.GRuser
Admins string
Group bool
CreationDate string
About struct {
A devianter.About
DescriptionFormatted string
Interests, Social string
Comments string
BG string
BGMeta devianter.Deviation
}
Gallery struct {
Folders string
Pages int
List string
}
}
Search struct {
Content devianter.Search
List string
}
}
}
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())
}
@ -63,13 +138,26 @@ func UrlBuilder(strs ...string) string {
str.WriteString(CFG.URI)
for n, x := range strs {
str.WriteString(x)
if n+1 < l && !(strs[n+1][0] == '?' || strs[n+1][0] == '&') && !(x[0] == '?' || x[0] == '&') {
if n := n + 1; n < l && len(strs[n]) != 0 && !(strs[n][0] == '?' || strs[n][0] == '&') && !(x[0] == '?' || x[0] == '&') {
str.WriteString("/")
}
}
return str.String()
}
func (s skunkyart) Error(dAerr devianter.Error) {
s.Writer.WriteHeader(502)
var msg strings.Builder
msg.WriteString(`<html><link rel="stylesheet" href="`)
msg.WriteString(UrlBuilder("stylesheet"))
msg.WriteString(`" /><h3>DeviantArt error — '`)
msg.WriteString(dAerr.Error)
msg.WriteString("'</h3></html>")
wr(s.Writer, msg.String())
}
func (s skunkyart) ReturnHTTPError(status int) {
s.Writer.WriteHeader(status)
@ -85,21 +173,29 @@ func (s skunkyart) ReturnHTTPError(status int) {
wr(s.Writer, msg.String())
}
func (s skunkyart) SetFilename(name string) {
var filename strings.Builder
filename.WriteString(`filename="`)
filename.WriteString(name)
filename.WriteString(`"`)
s.Writer.Header().Add("Content-Disposition", filename.String())
}
type Downloaded struct {
Headers http.Header
Status int
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 +211,20 @@ 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, ".")
return UrlBuilder("media", "file", url[:dot], url[dot+11:])
mediaUrl, filename := devianter.UrlFromMedia(media, thumb...)
if len(mediaUrl) != 0 && CFG.Proxy {
mediaUrl = mediaUrl[21:]
dot := strings.Index(mediaUrl, ".")
if filename == "" {
filename = "image.gif"
}
return url
return UrlBuilder("media", "file", mediaUrl[:dot], mediaUrl[dot+11:], "&filename=", filename)
} else if !CFG.Proxy {
return mediaUrl
}
return ""
}
func ConvertDeviantArtUrlToSkunkyArt(url string) (output string) {
@ -255,7 +274,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(s._pth)
list.WriteString(`?p=`)
list.WriteString(strconv.Itoa(page))
if s.Type != 0 {
list.WriteString("&type=")

View File

@ -1,10 +1,6 @@
package app
import (
"encoding/json"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
@ -14,65 +10,6 @@ import (
"golang.org/x/net/html"
)
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
Templates struct {
About struct {
Proxy bool
Nsfw bool
Instances []settings
}
SomeList string
DDStrips string
Deviation struct {
Post devianter.Post
Related string
StringTime string
Tags string
Comments string
}
GroupUser struct {
GR devianter.GRuser
Admins string
Group bool
CreationDate string
About struct {
A devianter.About
DescriptionFormatted string
Interests, Social string
Comments string
BG string
BGMeta devianter.Deviation
}
Gallery struct {
Folders string
Pages int
List string
}
}
Search struct {
Content devianter.Search
List string
}
}
}
func (s skunkyart) GRUser() {
if len(s.Query) < 1 {
s.ReturnHTTPError(400)
@ -80,10 +17,15 @@ func (s skunkyart) GRUser() {
}
var g devianter.Group
var daError devianter.Error
g.Name = s.Query
var err error
s.Templates.GroupUser.GR, err = g.GetGroup()
s.Templates.GroupUser.GR, err, daError = g.Get()
try(err)
if daError.RAW != nil {
s.Error(daError)
return
}
group := &s.Templates.GroupUser
@ -104,7 +46,6 @@ func (s skunkyart) GRUser() {
group.About.A = x.ModuleData.About
var about = &group.About.A
group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String()
group.About.DescriptionFormatted = ParseDescription(about.Description)
for _, val := range x.ModuleData.About.SocialLinks {
@ -126,12 +67,7 @@ func (s skunkyart) GRUser() {
group.About.Interests += interest.String()
}
}
group.About.Comments = s.ParseComments(devianter.GetComments(
strconv.Itoa(group.GR.Gruser.ID),
"",
s.Page,
4,
))
group.About.Comments = s.ParseComments(devianter.GetComments(strconv.Itoa(group.GR.Gruser.ID), "", s.Page, 4))
case "cover_deviation":
group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation
@ -146,25 +82,43 @@ func (s skunkyart) GRUser() {
}
}
case 'g':
case 'g', 'f':
var all bool
var content devianter.Group
folderid, _ := strconv.Atoi(s.Args.Get("folder"))
if a := s.Args.Get("all"); a == "true" {
all = true
}
if s.Page == 0 {
s.Page++
}
gallery, err := g.GetGallery(s.Page, folderid)
if s.Type == 'f' {
content, daError = g.Favourites(s.Page, all, folderid)
} else {
content, err, daError = g.Gallery(s.Page, folderid)
try(err)
}
if folderid > 0 {
group.Gallery.List = s.DeviationList(gallery.Content.Results, true, DeviationList{
More: gallery.Content.HasMore,
if daError.RAW != nil {
s.Error(daError)
return
}
if folderid > 0 || (s.Type == 'f' && all) {
group.Gallery.List = s.DeviationList(content.Content.Results, true, DeviationList{
More: content.Content.HasMore,
})
} else {
for _, x := range gallery.Content.Gruser.Page.Modules {
for _, x := range content.Content.Gruser.Page.Modules {
if l := len(x.ModuleData.Folders.Results); l != 0 {
var folders strings.Builder
folders.WriteString(`<h1 id="folders"><a href="#folder">#</a> Folders</h1><div class="folders"><br>`)
for _, x := range x.ModuleData.Folders.Results {
if x.FolderId != -1 && x.Size != 0 {
folders.WriteString(`<div class="block folder-item">`)
if !(x.Thumb.NSFW && !CFG.Nsfw) {
@ -180,7 +134,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)
@ -192,6 +146,7 @@ func (s skunkyart) GRUser() {
folders.WriteString("</div>")
}
}
folders.WriteString(`</div><h1 id="content"><a href="#content">#</a> Content</h1>`)
group.Gallery.Folders = folders.String()
}
@ -209,7 +164,7 @@ func (s skunkyart) GRUser() {
}
if !s.Atom {
s.ExecuteTemplate("gruser.htm", &s)
s.ExecuteTemplate("gruser.htm", "html", &s)
}
}
@ -221,19 +176,26 @@ func (s skunkyart) Deviation(author, postname string) {
return
}
var err devianter.Error
post := &s.Templates.Deviation
id := id_search[len(id_search)-1]
post.Post = devianter.GetDeviation(id, author)
post.Post, err = devianter.GetDeviation(id, author)
if err.RAW != nil {
s.Error(err)
return
}
if post.Post.Comments.Total <= 50 {
post.Post.Comments.Cursor = ""
}
if post.Post.Deviation.TextContent.Excerpt != "" {
post.Post.Description = ParseDescription(post.Post.Deviation.TextContent)
} else {
post.Post.Description = ParseDescription(post.Post.Deviation.Extended.DescriptionText)
}
// время публикации
post.StringTime = post.Post.Deviation.PublishedTime.UTC().String()
post.Post.IMG = ParseMedia(post.Post.Deviation.Media)
for _, x := range post.Post.Deviation.Extended.RelatedContent {
if len(x.Deviations) != 0 {
post.Related += s.DeviationList(x.Deviations, false)
@ -252,17 +214,19 @@ func (s skunkyart) Deviation(author, postname string) {
post.Tags += tag.String()
}
if post.Post.Comments.Total <= 50 {
post.Post.Comments.Cursor = ""
}
post.Comments = s.ParseComments(devianter.GetComments(id, post.Post.Comments.Cursor, s.Page, 1))
post.StringTime = post.Post.Deviation.PublishedTime.UTC().String()
post.Post.IMG = ParseMedia(post.Post.Deviation.Media)
s.ExecuteTemplate("deviantion.htm", &s)
s.ExecuteTemplate("deviantion.htm", "html", &s)
}
func (s skunkyart) DD() {
dd := devianter.GetDailyDeviations(s.Page)
dd, err := devianter.GetDailyDeviations(s.Page)
if err.RAW != nil {
s.Error(err)
return
}
var strips strings.Builder
for _, x := range dd.Strips {
strips.WriteString(`<h3 class="`)
@ -281,18 +245,24 @@ 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
var daError devianter.Error
ss := &s.Templates.Search
switch s.Type {
case 'a', 't':
ss.Content, err = devianter.PerformSearch(s.Query, s.Page, s.Type)
case 'g':
ss.Content, err = devianter.PerformSearch(s.Query, s.Page, s.Type, s.Args.Get("usr"))
ss.Content, err, daError = devianter.PerformSearch(s.Query, s.Page, s.Type)
case 'g', 'f':
ss.Content, err, daError = devianter.PerformSearch(s.Query, s.Page, s.Type, s.Args.Get("usr"))
case 'r': // скраппер, поскольку девиантартовцы зажопили гостевое API для поиска групп
var (
usernames = make(map[int]string)
@ -338,17 +308,23 @@ func (s skunkyart) Search() {
}
default:
s.ReturnHTTPError(400)
return
}
try(err)
if s.Type != 'r' {
if daError.RAW != nil {
s.Error(daError)
return
}
ss.List = s.DeviationList(ss.Content.Results, false, DeviationList{
Pages: ss.Content.Pages,
More: ss.Content.HasMore,
})
}
s.ExecuteTemplate("search.htm", &s)
s.ExecuteTemplate("search.htm", "html", &s)
}
func (s skunkyart) Emojitar(name string) {
@ -363,10 +339,3 @@ func (s skunkyart) Emojitar(name string) {
}
wr(s.Writer, ae)
}
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)
}

View File

@ -1,20 +1,16 @@
{
"listen": "0:3003",
"listen": "0.0.0.0:3003",
"uri": "/",
"cache": {
"enabled": true,
"path": "cache",
"lifetime": null,
"max-size": 1024,
"max-size": 200,
"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,
"nsfw": false
"nsfw": true
}

4
go.mod
View File

@ -1,8 +1,8 @@
module skunkyart
go 1.22.3
go 1.18
require (
git.macaw.me/skunky/devianter v0.2.5
git.macaw.me/skunky/devianter v0.2.6-0.20240904171839-b3c99749f133
golang.org/x/net v0.27.0
)

4
go.sum
View File

@ -1,4 +1,4 @@
git.macaw.me/skunky/devianter v0.2.5 h1:aAc6CG/ghvG130Ob7gGUdK4IV3MSeCD5t3QIJjto1M0=
git.macaw.me/skunky/devianter v0.2.5/go.mod h1:ZLn527xBlnpXrUB1B8z/MhyeiWVK4nPWjyfnhWOE8Is=
git.macaw.me/skunky/devianter v0.2.6-0.20240904171839-b3c99749f133 h1:ziutYUyDmdbsptR8Lj4lNmZUxfgwGsNbHM1mO9ATph8=
git.macaw.me/skunky/devianter v0.2.6-0.20240904171839-b3c99749f133/go.mod h1:ZLn527xBlnpXrUB1B8z/MhyeiWVK4nPWjyfnhWOE8Is=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=

View File

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

View File

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>SkunkyArt</title>
<link rel="stylesheet" href="{{.}}stylesheet"/>
<link rel="icon" type="image/x-icon" href="{{.}}favicon.ico">
</head>
<main>
<center>
<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>
<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>

View File

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

View File

@ -1,15 +1,15 @@
{
"instances": [
{
"title": "skunky.ebloid.ru",
"country": "Russia",
"title": "ls.404.mn",
"country": "Germany",
"urls": {
"ygg": "http://[201:eba5:d1fc:bf7b:cfcb:a811:4b8b:7ea3]/art",
"clearnet": "https://skunky.ebloid.ru/art"
"ygg": "http://[201:f137:d1ac:920e:cd42:bfd1:1e83:da1d]/skunkyart",
"clearnet": "https://ls.404.mn/skunkyart"
},
"settings": {
"nsfw": false,
"proxy": false
"proxy": true,
"nsfw": false
}
},
{
@ -19,41 +19,41 @@
"clearnet": "https://skunky.clovius.club"
},
"settings": {
"nsfw": true,
"proxy": true
"proxy": true,
"nsfw": true
}
},
{
"title": "bloat.cat",
"country": "Romania",
"country": "Germany",
"urls": {
"clearnet": "https://skunky.bloat.cat"
},
"settings": {
"nsfw": true,
"proxy": true
}
},
{
"title": "frontendfriendly.xyz",
"country": "Finland",
"urls": {
"clearnet": "https://skunkyart.frontendfriendly.xyz"
},
"settings": {
"nsfw": true,
"proxy": true
"proxy": true,
"nsfw": true
}
},
{
"title": "lumaeris.com",
"country": "US",
"country": "Germany",
"urls": {
"clearnet": "https://skunkyart.lumaeris.com"
},
"settings": {
"nsfw": true,
"proxy": true
"proxy": true,
"nsfw": true
}
},
{
"title": "art.bloat.cat",
"country": "Germany",
"urls": {
"clearnet": "https://art.bloat.cat"
},
"settings": {
"proxy": true,
"nsfw": true
}
}
]

View File

@ -2,17 +2,20 @@ package main
import (
"skunkyart/app"
"skunkyart/static"
"time"
"git.macaw.me/skunky/devianter"
)
func main() {
app.Release.Version = "1.3.2"
app.Release.Description = "Two API endpoints and template embedding into binary"
go app.RefreshInstances()
app.ExecuteCommandLineArguments()
app.ExecuteConfig()
app.CopyTemplatesToMemory()
static.CopyTemplatesToMemory()
go func() {
for {

View File

@ -19,15 +19,21 @@ header h1 {
header form {
align-self: center;
}
header {
header, form {
display: flex;
}
form {
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 */
@ -140,20 +146,19 @@ form input, button, select {
font-size: 80%
}
center form {
font-size: 60%
}
header form {
font-size: 60%;
}
header, center {
header {
margin-left: 3%;
text-align: center;
display: block;
display: inline-block;
clear: both;
font-size: 200%;
}
form {
font-size: 60%;
border: solid #164e3e 5px;
}
.content {
margin: auto;
display: inherit;

View File

@ -1,27 +1,12 @@
<!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>
<h3><a href="https://go.kde.org/matrix/#/#skunkyart:ebloid.ru" target="_blank">Room in Matrix</a></h3>
<h3><a href="https://go.kde.org/matrix/#/#skunkyart:ls.404.mn" target="_blank">Room in [matrix]</a></h3>
<b>Instance settings:</b>
<ul>
<li><b>NSFW</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li>
@ -59,6 +44,6 @@
{{end}}
</ul>
</details>
<p>Copyright <a href="https://go.kde.org/matrix/#/@softpigeones:ebloid.ru" target="_blank">lost+skunk</a>, X11. <a href="https://git.macaw.me/skunky/skunkyart/src/tag/v1.3.1" target="_blank">SkunkyArt v1.3.1</a></p>
<p>Copyright <a href="https://go.kde.org/matrix/#/@softpigeones:ls.404.mn" target="_blank">lost+skunk</a>, X11. <a href="https://git.macaw.me/skunky/skunkyart/src/tag/v{{.Version}}" target="_blank">SkunkyArt v{{.Version}}</a></p>
</main>
</html>

13
static/html/daily.htm Normal file
View File

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

View File

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

View File

@ -1,26 +1,27 @@
<!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>
<h1>
<a href="{{.BasePath}}">HOME</a>
| <a href="{{.BasePath}}dd">DD</a>
{{if ne .Type 'f'}}
| <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=favourites">Favourites</a>
{{else}}
| <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=about">About</a>
| <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery">Gallery</a>
| <a href="group_user?q={{.Templates.GroupUser.GR.Owner.Username}}&type=favourites">Favourites</a>
{{end}}
| <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}}">
<select name="type">
<option value="gallery">Gallery</option>
<option value="folders">Folders</option>
<option value="all">All</option>
<option value="tag">Tag</option>
<option value="r">Groups</option>

26
static/html/head.htm Normal file
View File

@ -0,0 +1,26 @@
{{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 .Endpoint "group_user"}}
{{if eq .Type 'g'}}
gallery of
{{else if eq .Type 'f'}}
favourites of
{{end}}
{{.Templates.GroupUser.GR.Owner.Username}}
{{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">
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=0.4, user-scalable=no; user-scalable=0"/>
</head>
{{end}}

14
static/html/header.htm Normal file
View File

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

90
static/html/index.htm Normal file
View File

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html>
<head>
<title>SkunkyArt</title>
<link rel="stylesheet" href="{{.}}stylesheet"/>
<link rel="icon" type="image/x-icon" href="{{.}}favicon.ico">
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=0.4, user-scalable=no; user-scalable=0"/>
<style>
main {
display: flex;
max-width: fit-content;
position: absolute;
top: 50%;
right: 50%;
transform: translate(50%, -50%);
}
div {
transform: translate(0, 50%);
margin-left: 4%;
flex-basis: 100%;
height: 30%;
display: block;
max-width: fit-content;
}
div h1, form {
margin: 0;
}
div form {
font-size: 100%;
max-width: 100%
}
div form input {
width: 100%
}
img {
width: 30%;
height: 30%;
}
@media (orientation: portrait) {
main {
display: block;
width: 200%;
}
img {
width: 100%;
height: 100%;
}
form {
width: 200%;
}
div {
margin: -25%;
margin-top: auto;
width: 200%;
}
div h1 {
text-align: center;
}
}
@media (max-width: 1155px) and (orientation: landscape) {
img {
width: 50%;
}
div {
transform: none;
}
}
</style>
</head>
<main>
<img src="{{.}}favicon.ico" title="SkunkyArt logo" draggable="false">
<div>
<h1><a href="{{.}}dd">Daily Deviations</a> | <a href="{{.}}about">About</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>
<h1 style="margin-top: 5%; font-size: 200%; text-align: center;">
<a href="https://git.macaw.me/skunky/SkunkyArt" target="_blank" title="Source Code">SkunkyArt</a>
</h1>
</div>
</main>
</html>

16
static/html/search.htm Normal file
View File

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

View File

Before

Width:  |  Height:  |  Size: 398 KiB

After

Width:  |  Height:  |  Size: 398 KiB

148
static/templates-noembed.go Normal file
View File

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

16
static/templates.go Normal file
View File

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