Compare commits

...

13 Commits
v1.3 ... master

41 changed files with 1659 additions and 785 deletions

1
.gitignore vendored
View File

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

9
INSTANCES.md Normal file
View File

@ -0,0 +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 | Yes | No | Russia |
|[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 | 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 |

101
README.md
View File

@ -1,78 +1,45 @@
<img src="static/images/logo.png" alt="SkunkyArt" title="SkunkyArt Logo" width="20%" loading="lazy"/>
[![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) [![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)
# Instances
|Инстанс|Yggdrasil|I2P|Tor|NSFW|Proxifying|Country| Instances: [`INSTANCES.md`](/skunky/SkunkyArt/src/branch/master/INSTANCES.md)
|:-----:|:-------:|:-:|:-:|:--:|:--------:|:-----:|
|[skunky.ebloid.ru](https://skunky.ebloid.ru/art)|[Yes](http://[201:eba5:d1fc:bf7b:cfcb:a811:4b8b:7ea3]/art)|No|No| No | No | Russia |
|[clovius.club](https://skunky.clovius.club)|No|No|No| Yes | Yes | Sweden |
|[bloat.cat](https://skunky.bloat.cat)|No|No|No| Yes | Yes | Romania |
|[frontendfriendly.xyz](https://skunkyart.frontendfriendly.xyz)|No|No|No| Yes | Yes | Finland |
# EN 🇺🇸 # EN 🇺🇸
## Description ## Description
SkunkyArt 🦨 -- alternative frontend to DeviantArt, which will work without problems even on quite old hardware, due to the lack of JavaScript. SkunkyArt 🦨 — alternative frontend for DevianArt, which works without JS.
## Config ## Build (translated via DeepL)
The sample config is in the `config.example.json` file. To specify your own path to the config, use the CLI argument `-c` or `--config`. 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:
* `listen` -- the address and port on which SkunkyArt will listen
* `base-path` -- the path to the instance. Example: "`base-path`:"/art/" -> https://skunky.ebloid.ru/art/ `go build -tags embed -ldflags "-w -s"`
* `cache` -- caching system; default is off.
* * `path` -- the path to the cache Pre-compiled binaries can be found in the [Releases](https://git.macaw.me/skunky/skunkyart/releases) tab.
* * `lifetime` -- cache file lifetime; measured in Unix milliseconds. ## Setup
* * `max-size` -- maximum file size in bytes. The sample config is in the `config.example.json` file. For custom config, use `--config` option.
* `dirs-to-memory` -- this setting determines which directories will be copied to RAM when SkunkyArt is started. Required See the [`SETUP.md`](/skunky/SkunkyArt/src/branch/master/SETUP.md) file for more info about directives.
* `download-proxy` -- proxy address for downloading files. ## Adding instance to the list
## Examples of reverse proxies 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:
Nginx: 1. the Instance must not use Cloudflare.
```apache
server {
listen 443 ssl;
server_name skunky.example.com;
location ((BASE URL)) { # if you have a separate subdomain for the frontend, insert '/' instead of '((BASE URL))'.
proxy_set_header Scheme $scheme;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_pass http://((IP)):((PORT));
}
}
```
## How do I add my instance to the list?
To do this, you must either make a PR by adding your instance to the `instances.json` file, or report it to the room in Matrix. I don't think it needs any description. However, be aware, this list has a couple rules:
1. the instance must not use Cloudflare.
2. If your instance has modified source code, you need to publish it to any free platform. For example, Github and Gitlab are not. 2. If your instance has modified source code, you need to publish it to any free platform. For example, Github and Gitlab are not.
## Acknowledgements ## Acknowledgements
* [Лис⚛](https://go.kde.org/matrix/#/@fox:matrix.org) -- helped me understand Go and gave me a lot of useful advice on this language. * [Лис⚛](https://go.kde.org/matrix/#/@fox:matrix.org) — helped me understand Go and gave me a lot of useful advice on this language.
* [meoww](https://codeberg.org/meoww) — translated some sentences into English and wrote a service for openrc
# RU 🇷🇺 # RU 🇷🇺
## Описание ## Описание
SkunkyArt 🦨 -- альтернативный фронтенд к DeviantArt, который будет работать без проблем даже на довольно старом оборудовании, за счёт отсутствия JavaScript. SkunkyArt 🦨 — альтернативный фронтенд к DeviantArt, который полностью работает без JS (JavaScript).
## Конфиг ## Сборка
Пример конфига находится в файле `config.example.json`. Чтобы указать свой путь до конфига, используйте CLI-аргумент `-c` или `--config`. Рекомендуется производить сборку с тегом 'embed', поскольку он встраивает заготовки в бинарный файл. Если вы планируете изменять заготовки, то не используйте этот тег. Также вы можете добавить аргумент `-ldflags "-w -s"` (у GCCGO он называется по-другому — `gccgoflags`) для уменьшения размера выходного файла. Вот пример:
* `listen` -- адрес и порт, на котором будет слушать SkunkyArt
* `base-path` -- путь к инстансу. Пример: "base-path": "/art/" -> https://skunky.ebloid.ru/art/ `go build -tags embed -ldflags "-w -s"`
* `cache` -- система кеширования; по умолчанию - выкл.
* * `path` -- путь до кеша Готовые бинари находятся во вкладке [Releases](https://git.macaw.me/skunky/skunkyart/releases).
* * `lifetime` -- время жизни файла в кеше; измеряется в Unix-миллисекундах ## Настройка
* * `max-size` -- максимальный размер файла в байтах Пример конфига находится в файле `config.example.json`. Чтобы указать свой конфиг, используйте cli-аргумент `--config`.
* `dirs-to-memory` -- данная настройка определяет какие каталоги будут скопированы в ОЗУ при запуске SkunkyArt. Обязательна См. [`SETUP-RU.md`](/skunky/SkunkyArt/src/branch/master/SETUP-RU.md) для информации о настройки фронтенда.
* `download-proxy` -- адрес прокси для загрузки файлов ## Добавление инстанса в список
## Примеры reverse-прокси Чтобы это сделать, вы должны либо сделать PR, добавив в файлы `instances.json` и `INSTANCES.md` свой инстанс (можете воспользоваться cli-аргументом `--add-instance`, который автоматически это сделает), либо создать Issue, или сообщить о нём в комнате в Matrix. Учтите, что ваш инстанс должен соблюсти следущие правила:
Nginx: 1. Инстанс не должен использовать Cloudflare итп.
```apache
server {
listen 443 ssl;
server_name skunky.example.com;
location ((BASE URL)) { # если у вас отдельный поддомен для фронтенда, вместо '((BASE URL))' вставляйте '/'
proxy_set_header Scheme $scheme;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_pass http://((IP)):((PORT));
}
}
```
## Как добавить свой инстанс в список?
Чтобы это сделать, вы должны либо сделать PR, добавив в файл `instances.json` свой инстанс, либо сообщить о нём в комнате в Matrix. Думаю, он не нуждается в описании. Однако учтите, у этого списка есть пара правил:
1. Инстанс не должен использовать Cloudflare.
2. Если ваш инстанс имеет модифицированный исходный код, то вам нужно опубликовать его на любую свободную площадку. Например, Github и Gitlab таковыми не являются. 2. Если ваш инстанс имеет модифицированный исходный код, то вам нужно опубликовать его на любую свободную площадку. Например, Github и Gitlab таковыми не являются.
## Благодарности ## Благодарности
* [Лис⚛](https://go.kde.org/matrix/#/@fox:matrix.org) -- помог разобраться в Go и много чего полезного посоветовал по этому языку. * [Лис⚛](https://go.kde.org/matrix/#/@fox:matrix.org) — помог разобраться в Go и много чего полезного посоветовал по этому языку.
* [meoww](https://codeberg.org/meoww) — перевела некоторые предложения на английский язык и написала сервис для openrc

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`

42
SETUP-RU.md Normal file
View File

@ -0,0 +1,42 @@
[English version 🇬🇧](/skunky/SkunkyArt/src/branch/master/SETUP.md)
# Единицы измерения
Размер файла в кеше измеряется в мегабайтах.<br>
Единицы времени:
* `i` — минуты
* `h` — часы
* `w` — недели
* `m` — месяца
* `y` — года
# Конфигурация
* `listen` — IP и порт для слушанья; заполняется по такой форме: ip:port
* `uri` — URI инстанса. Пример: `"uri":"/art/"` -> https://skunky.ebloid.ru/art/
* `cache` — Система кеширования; по умолчанию выключена
* `enabled` — Состояние системы кеширования; требуется булёвое значение
* `path` — Полный путь до каталога, куда будет сохраняться кеш
* `lifetime` — Время жизни файла в кеше, требует целочисленное значение, дополненное суффиксом времени (см. 'Единицы времени')
* `max-size` — Максимальный размер файла
* `update-interval` — Интервал для автоматической ротации кеша
* `static-path` — Строка, являющаяся путём до статики. SkunkyArt при запуске скопирует содержимое этого каталога в ОЗУ. Однако, если вы собрали фронтенд с тегом 'embed', то этого не произайдёт
* `download-proxy` — Адрес прокси для загрузки файлов
* `user-agent` — Строка, которая используется в качестве User-Agent'а
# Настройка обратного прокси
Если вы собираетесь хостить инстанс в Интернете, то вам следует настроить заголовок прокси [`X-Forwarded-Proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto). В противном случае, все ссылки на вашем инстансе будут вида "http". Ниже есть информация о том, как настроить обратное проксирование:
Nginx:
```apache
server {
listen 443 ssl;
server_name skunky.example.com;
# Если используется поддомен, то вместо ((BASE_URL)), укажите '/'.
location ((BASE_URL)) {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_pass http://((IP)):((PORT));
}
}
```

42
SETUP.md Normal file
View File

@ -0,0 +1,42 @@
[Версия на русском языке 🇷🇺](/skunky/SkunkyArt/src/branch/master/SETUP-RU.md)
# Units
Maximum file size in megabytes, requires numeric value.<br>
Time units:
* `i` — minutes
* `h` — hours
* `w` — weeks
* `m` — months
* `y` — years
# Config
* `listen` — IP and port to listen on in the following form: ip:port
* `uri` — Instance URI. Example: `"uri":"/art/"` -> https://skunky.ebloid.ru/art/
* `cache` — Caching system; default is off.
* `enabled` — Caching system state, requires boolean value
* `path` — Path to cache directory, requires absolute filesystem path
* `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
* `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
# Setting up reverse proxy
Pretty much business as usual, except for the [`X-Forwarded-Proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) header setting.
Nginx example configuration:
```apache
server {
listen 443 ssl;
server_name skunky.example.com;
# In case of subdomain, use / instend of ((BASE_URL))
location ((BASE_URL)) {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_pass http://((IP)):((PORT));
}
}
```

22
TODO.md
View File

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

166
app/cli.go Normal file
View File

@ -0,0 +1,166 @@
package app
import (
"bufio"
"bytes"
"encoding/json"
"html/template"
"os"
"time"
)
func ExecuteCommandLineArguments() {
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/v{{.Version}}`
a := os.Args[1:]
for n, x := range a {
switch x {
case "-c", "--config":
if len(a) >= 2 {
CFG.cfg = a[n+1]
} else {
exit("Not enought arguments", 1)
}
case "-h", "--help":
var buf bytes.Buffer
t := template.New("help")
t.Parse(helpmsg)
t.Execute(&buf, &Release)
exit(buf.String(), 0)
case "-a", "--add-instance":
addInstance()
}
}
}
type settingsUrls struct {
I2P string `json:"i2p,omitempty"`
Ygg string `json:"ygg,omitempty"`
Tor string `json:"tor,omitempty"`
Clearnet string `json:"clearnet,omitempty"`
}
type settingsParams struct {
Nsfw bool `json:"nsfw"`
Proxy bool `json:"proxy"`
}
type settings struct {
Title string `json:"title"`
Country string `json:"country"`
ModifiedSrc string `json:"modified-src,omitempty"`
Urls settingsUrls `json:"urls"`
Settings settingsParams `json:"settings"`
}
func addInstance() {
prompt := func(txt string, necessary bool) string {
input := bufio.NewScanner(os.Stdin)
for {
print(txt)
print(": ")
input.Scan()
if i := input.Text(); necessary && i == "" {
println("Please specify the", txt)
} else {
return i
}
}
}
var settingsVar struct {
Instances []settings `json:"instances"`
}
instancesJson, err := os.OpenFile("instances.json", os.O_CREATE|os.O_WRONLY, 0644)
try(err)
defer instancesJson.Close()
instancesFile, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
try(err)
defer instancesFile.Close()
for {
if string(instances) == "" {
print("\rDownloading instance list...")
} else {
println("\r\033[2KDownloaded!")
try(json.Unmarshal(instances, &settingsVar))
settingsVar.Instances = append(settingsVar.Instances, settings{
Title: prompt("Title", true),
Country: prompt("Country", true),
ModifiedSrc: prompt("Link to modified sources", false),
Settings: settingsParams{
Nsfw: CFG.Nsfw,
Proxy: CFG.Proxy,
},
Urls: settingsUrls{
Clearnet: prompt("Clearnet link", false),
Ygg: prompt("Yggdrasil link", false),
Tor: prompt("Onion link", false),
I2P: prompt("I2P link", false),
},
})
j, err := json.MarshalIndent(&settingsVar, "", " ")
try(err)
instancesJson.Write(j)
settingsVar := &settingsVar.Instances[len(settingsVar.Instances)-1]
var mdstr bytes.Buffer
mdbuilder := func(yes bool, link string, title string) {
switch {
case yes && (title != "" && link != ""):
mdstr.WriteString("[")
mdstr.WriteString(title)
mdstr.WriteString("](")
mdstr.WriteString(link)
mdstr.WriteString(")")
case yes && link != "":
mdstr.WriteString("[Yes](")
mdstr.WriteString(link)
mdstr.WriteString(")")
case yes:
mdstr.WriteString("Yes")
default:
mdstr.WriteString("No")
}
mdstr.WriteString("|")
}
mdstr.WriteString("\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]
mdbuilder(url != "", url, "")
}
settings := []bool{settingsVar.Settings.Nsfw, settingsVar.Settings.Proxy}
for i, l := 0, len(settings); i < l; i++ {
mdbuilder(settings[i], "", "")
}
mdbuilder(settingsVar.ModifiedSrc != "", settingsVar.ModifiedSrc, "")
mdstr.WriteString(settingsVar.Country)
mdstr.WriteString("|")
instancesFile.Write(mdstr.Bytes())
break
}
time.Sleep(500 * time.Millisecond)
}
exit("Done! Now add the files 'instances.json' and 'INSTANCES.md' to the 'master' branch in the repository https://git.macaw.me/skunky/SkunkyArt", 0)
}

View File

@ -3,87 +3,100 @@ package app
import ( import (
"encoding/json" "encoding/json"
"os" "os"
"regexp"
"skunkyart/static"
"strconv"
"time" "time"
"git.macaw.me/skunky/devianter"
) )
var Release struct {
Version string
Description string
}
type cache_config struct { type cache_config struct {
Enabled bool Enabled bool
Path string Path string
MaxSize int64 `json:"max-size"` MaxSize int64 `json:"max-size"`
Lifetime int64 Lifetime string
UpdateInterval int64 `json:"update-interval"` UpdateInterval int64 `json:"update-interval"`
} }
type config struct { type config struct {
cfg string cfg string
Listen string Listen string
BasePath string `json:"base-path"` URI string `json:"uri"`
Cache cache_config Cache cache_config
Proxy, Nsfw bool Proxy, Nsfw bool
DownloadProxy string `json:"download-proxy"` UserAgent string `json:"user-agent"`
Dirs []string `json:"dirs-to-memory"` DownloadProxy string `json:"download-proxy"`
StaticPath string `json:"static-path"`
} }
var CFG = config{ var CFG = config{
cfg: "config.json", cfg: "config.json",
Listen: "127.0.0.1:3003", Listen: "127.0.0.1:3003",
BasePath: "/", URI: "/",
Cache: cache_config{ Cache: cache_config{
Enabled: true, Enabled: false,
Path: "cache", Path: "cache",
UpdateInterval: 1, UpdateInterval: 1,
}, },
Dirs: []string{"html", "css"}, StaticPath: "static",
Proxy: true, UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
Nsfw: true, Proxy: true,
Nsfw: true,
} }
var lifetimeParsed int64
func ExecuteConfig() { func ExecuteConfig() {
go func() {
defer func() {
if r := recover(); r != nil {
recover()
}
}()
for {
Templates["instances.json"] = string(Download("https://git.macaw.me/skunky/SkunkyArt/raw/branch/master/instances.json").Body)
time.Sleep(1 * time.Second)
}
}()
const helpmsg = `SkunkyArt v1.3 [refactoring]
Usage:
- [-c|--config] - path to config
- [-h|--help] - returns this message
Example:
./skunkyart -c config.json
Copyright lost+skunk, X11. https://git.macaw.me/skunky/skunkyart/src/tag/v1.3`
a := os.Args
for n, x := range a {
switch x {
case "-c", "--config":
if len(a) >= 3 {
CFG.cfg = a[n+1]
} else {
exit("Not enought arguments", 1)
}
case "-h", "--help":
exit(helpmsg, 0)
}
}
if CFG.cfg != "" { if CFG.cfg != "" {
f, err := os.ReadFile(CFG.cfg) f, err := os.ReadFile(CFG.cfg)
try_with_exitstatus(err, 1) tryWithExitStatus(err, 1)
tryWithExitStatus(json.Unmarshal(f, &CFG), 1)
try_with_exitstatus(json.Unmarshal(f, &CFG), 1)
if CFG.Cache.Enabled && !CFG.Proxy { if CFG.Cache.Enabled && !CFG.Proxy {
exit("Incompatible settings detected: cannot use caching media content without proxy", 1) exit("Incompatible settings detected: cannot use caching media content without proxy", 1)
} }
if CFG.Cache.MaxSize != 0 || CFG.Cache.Lifetime != 0 { if CFG.Cache.Enabled {
if CFG.Cache.Lifetime != "" {
var duration int64
day := 24 * time.Hour.Milliseconds()
numstr := regexp.MustCompile("[0-9]+").FindAllString(CFG.Cache.Lifetime, -1)
num, _ := strconv.Atoi(numstr[len(numstr)-1])
switch unit := CFG.Cache.Lifetime[len(CFG.Cache.Lifetime)-1:]; unit {
case "i":
duration = time.Minute.Milliseconds()
case "h":
duration = time.Hour.Milliseconds()
case "d":
duration = day
case "w":
duration = day * 7
case "m":
duration = day * 30
case "y":
duration = day * 360
default:
exit("Invalid unit specified: "+unit, 1)
}
lifetimeParsed = duration * int64(num)
}
CFG.Cache.MaxSize *= 1024 ^ 2
go InitCacheSystem() 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" "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 var cmmts strings.Builder
replied := make(map[int]string) replied := make(map[int]string)
@ -41,7 +45,9 @@ func (s skunkyart) ParseComments(c devianter.Comments) string {
cmmts.WriteString("</b></a> ") cmmts.WriteString("</b></a> ")
if x.Parent > 0 { if x.Parent > 0 {
cmmts.WriteString(` In reply to <a href="#`) cmmts.WriteString(` In reply to <a href="`)
cmmts.WriteString(Path)
cmmts.WriteString("#")
cmmts.WriteString(strconv.Itoa(x.Parent)) cmmts.WriteString(strconv.Itoa(x.Parent))
cmmts.WriteString(`">`) cmmts.WriteString(`">`)
if replied[x.Parent] == "" { if replied[x.Parent] == "" {
@ -70,17 +76,82 @@ func (s skunkyart) ParseComments(c devianter.Comments) string {
return cmmts.String() return cmmts.String()
} }
func (s skunkyart) DeviationList(devs []devianter.Deviation, content ...DeviationList) string { func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string {
var list strings.Builder
if s.Atom && s.Page > 1 { if s.Atom && s.Page > 1 {
s.ReturnHTTPError(400) s.ReturnHTTPError(400)
return "" return ""
} else if s.Atom { }
var list, listContent strings.Builder
for i, l := 0, len(devs); i < l; i++ {
data := &devs[i]
if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !(data.NSFW && !CFG.Nsfw) {
if 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)
listContent.WriteString(`</name></author><title>`)
listContent.WriteString(data.Title)
listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`)
listContent.WriteString(UrlBuilder("post", data.Author.Username, "atom-"+id))
listContent.WriteString(`"/><id>`)
listContent.WriteString(id)
listContent.WriteString(`</id><published>`)
listContent.WriteString(data.PublishedTime.UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700"))
listContent.WriteString(`</published>`)
listContent.WriteString(`<media:group><media:title>`)
listContent.WriteString(data.Title)
listContent.WriteString(`</media:title><media:thumbinal url="`)
listContent.WriteString(preview)
listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`)
listContent.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url))
listContent.WriteString(`"><img src="`)
listContent.WriteString(fullview)
listContent.WriteString(`"/></a><p>`)
listContent.WriteString(ParseDescription(data.TextContent))
listContent.WriteString(`</p></div></content></entry>`)
} else {
listContent.WriteString(`<div class="block">`)
if fullview != "" && preview != "" {
listContent.WriteString(`<a title="open/download" href="`)
listContent.WriteString(fullview)
listContent.WriteString(`"><img loading="lazy" src="`)
listContent.WriteString(preview)
listContent.WriteString(`" width="15%"></a>`)
} else {
listContent.WriteString(`<h1>[ TEXT ]</h1>`)
}
listContent.WriteString(`<br><a href="`)
listContent.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url))
listContent.WriteString(`">`)
listContent.WriteString(data.Author.Username)
listContent.WriteString(" - ")
listContent.WriteString(data.Title)
if data.NSFW {
listContent.WriteString(` [<span class="nsfw">NSFW</span>]`)
}
if data.AI {
listContent.WriteString(" [🤖]")
}
if data.DD {
listContent.WriteString(` [<span class="dd">DD</span>]`)
}
listContent.WriteString("</a></div>")
}
}
}
if allowAtom && s.Atom {
list.WriteString(`<?xml version="1.0" encoding="UTF-8"?><feed xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">`) list.WriteString(`<?xml version="1.0" encoding="UTF-8"?><feed xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">`)
list.WriteString(`<title>`) list.WriteString(`<title>`)
if s.Type == 0 { if s.Type == 0 {
list.WriteString("Daily Deviations") list.WriteString("Daily Deviations")
} else if len(devs) != 0 { } else if s.Type == 'g' && len(devs) != 0 {
list.WriteString(devs[0].Author.Username) list.WriteString(devs[0].Author.Username)
} else { } else {
list.WriteString("SkunkyArt") list.WriteString("SkunkyArt")
@ -90,75 +161,16 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, content ...Deviatio
list.WriteString(`<link rel="alternate" href="`) list.WriteString(`<link rel="alternate" href="`)
list.WriteString(Host) list.WriteString(Host)
list.WriteString(`"/>`) list.WriteString(`"/>`)
list.WriteString(listContent.String())
list.WriteString("</feed>")
wr(s.Writer, list.String())
} else { } else {
list.WriteString(`<div class="content">`) list.WriteString(`<div class="content">`)
}
for _, data := range devs {
if !(data.NSFW && !CFG.Nsfw) {
url := ParseMedia(data.Media)
if s.Atom {
id := strconv.Itoa(data.ID)
list.WriteString(`<entry><author><name>`)
list.WriteString(data.Author.Username)
list.WriteString(`</name></author><title>`)
list.WriteString(data.Title)
list.WriteString(`</title><link rel="alternate" type="text/html" href="`)
list.WriteString(UrlBuilder("post", data.Author.Username, "atom-"+id))
list.WriteString(`"/><id>`)
list.WriteString(id)
list.WriteString(`</id><published>`)
list.WriteString(data.PublishedTime.UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700"))
list.WriteString(`</published>`)
list.WriteString(`<media:group><media:title>`)
list.WriteString(data.Title)
list.WriteString(`</media:title><media:thumbinal url="`)
list.WriteString(url)
list.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`)
list.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url))
list.WriteString(`"><img src="`)
list.WriteString(url)
list.WriteString(`"/></a><p>`)
list.WriteString(ParseDescription(data.TextContent))
list.WriteString(`</p></div></content></entry>`)
} else {
list.WriteString(`<div class="block">`)
if url != "" {
list.WriteString(`<a title="open/download" href="`)
list.WriteString(url)
list.WriteString(`"><img loading="lazy" src="`)
list.WriteString(url)
list.WriteString(`" width="15%"></a>`)
} else {
list.WriteString(`<h1>[ TEXT ]</h1>`)
}
list.WriteString(`<br><a href="`)
list.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url))
list.WriteString(`">`)
list.WriteString(data.Author.Username)
list.WriteString(" - ")
list.WriteString(data.Title)
// шильдики нсфв, аи и ежедневного поста list.WriteString(listContent.String())
if data.NSFW {
list.WriteString(` [<span class="nsfw">NSFW</span>]`)
}
if data.AI {
list.WriteString(" [🤖]")
}
if data.DD {
list.WriteString(` [<span class="dd">DD</span>]`)
}
list.WriteString("</a></div>")
}
}
}
if s.Atom {
list.WriteString("</feed>")
s.Writer.Write([]byte(list.String()))
return ""
} else {
list.WriteString("</div>") list.WriteString("</div>")
if content != nil { if content != nil {
list.WriteString(s.NavBase(content[0])) list.WriteString(s.NavBase(content[0]))
@ -176,8 +188,9 @@ type text struct {
To int To int
} }
// переписать весь этот пиздец нахуй
func ParseDescription(dscr devianter.Text) string { func ParseDescription(dscr devianter.Text) string {
var parseddescription strings.Builder var parsedDescription strings.Builder
TagBuilder := func(content string, tags ...string) string { TagBuilder := func(content string, tags ...string) string {
l := len(tags) l := len(tags)
for x := 0; x < l; x++ { for x := 0; x < l; x++ {
@ -274,16 +287,18 @@ func ParseDescription(dscr devianter.Text) string {
switch x.Type { switch x.Type {
case "atomic": case "atomic":
d := entities[x.EntityRanges[0].Key] if len(x.EntityRanges) != 0 {
parseddescription.WriteString(`<a href="`) d := entities[x.EntityRanges[0].Key]
parseddescription.WriteString(ConvertDeviantArtUrlToSkunkyArt(d.Url)) parsedDescription.WriteString(`<a href="`)
parseddescription.WriteString(`"><img width="50%" src="`) parsedDescription.WriteString(ConvertDeviantArtUrlToSkunkyArt(d.Url))
parseddescription.WriteString(ParseMedia(d.Media)) parsedDescription.WriteString(`"><img width="50%" src="`)
parseddescription.WriteString(`" title="`) parsedDescription.WriteString(ParseMedia(d.Media))
parseddescription.WriteString(d.Author.Username) parsedDescription.WriteString(`" title="`)
parseddescription.WriteString(" - ") parsedDescription.WriteString(d.Author.Username)
parseddescription.WriteString(d.Title) parsedDescription.WriteString(" - ")
parseddescription.WriteString(`"></a>`) parsedDescription.WriteString(d.Title)
parsedDescription.WriteString(`"></a>`)
}
case "unstyled": case "unstyled":
if l := len(Styles); l != 0 { if l := len(Styles); l != 0 {
for n, r := range Styles { for n, r := range Styles {
@ -292,31 +307,31 @@ func ParseDescription(dscr devianter.Text) string {
tag = "h2" tag = "h2"
} }
parseddescription.WriteString(x.Text[:r.From]) parsedDescription.WriteString(x.Text[:r.From])
if len(urls) != 0 && len(x.EntityRanges) != 0 { if len(urls) != 0 && len(x.EntityRanges) != 0 {
ra := &x.EntityRanges[0] ra := &x.EntityRanges[0]
parseddescription.WriteString(`<a target="_blank" href="`) parsedDescription.WriteString(`<a target="_blank" href="`)
parseddescription.WriteString(urls[ra.Key]) parsedDescription.WriteString(urls[ra.Key])
parseddescription.WriteString(`">`) parsedDescription.WriteString(`">`)
parseddescription.WriteString(r.TXT) parsedDescription.WriteString(r.TXT)
parseddescription.WriteString(`</a>`) parsedDescription.WriteString(`</a>`)
} else if l > n+1 { } else if l > n+1 {
parseddescription.WriteString(r.TXT) parsedDescription.WriteString(r.TXT)
} }
parseddescription.WriteString(TagBuilder(tag, x.Text[r.To:])) parsedDescription.WriteString(TagBuilder(tag, x.Text[r.To:]))
} }
} else { } else {
parseddescription.WriteString(x.Text) parsedDescription.WriteString(x.Text)
} }
} }
parseddescription.WriteString("<br>") parsedDescription.WriteString("<br>")
} }
} else if dl != 0 { } else if dl != 0 {
for tt := html.NewTokenizer(strings.NewReader(dscr.Html.Markup)); ; { for tt := html.NewTokenizer(strings.NewReader(dscr.Html.Markup)); ; {
switch tt.Next() { switch tt.Next() {
case html.ErrorToken: case html.ErrorToken:
return parseddescription.String() return parsedDescription.String()
case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken: case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken:
token := tt.Token() token := tt.Token()
switch token.Data { switch token.Data {
@ -324,11 +339,11 @@ func ParseDescription(dscr devianter.Text) string {
for _, a := range token.Attr { for _, a := range token.Attr {
if a.Key == "href" { if a.Key == "href" {
url := DeleteTrackingFromUrl(a.Val) url := DeleteTrackingFromUrl(a.Val)
parseddescription.WriteString(`<a target="_blank" href="`) parsedDescription.WriteString(`<a target="_blank" href="`)
parseddescription.WriteString(url) parsedDescription.WriteString(url)
parseddescription.WriteString(`">`) parsedDescription.WriteString(`">`)
parseddescription.WriteString(GetValueOfTag(tt)) parsedDescription.WriteString(GetValueOfTag(tt))
parseddescription.WriteString("</a> ") parsedDescription.WriteString("</a> ")
} }
} }
case "img": case "img":
@ -344,24 +359,24 @@ func ParseDescription(dscr devianter.Text) string {
} }
if title != "" { if title != "" {
for x := -1; x < b; x++ { for x := -1; x < b; x++ {
parseddescription.WriteString(`<img src="`) parsedDescription.WriteString(`<img src="`)
parseddescription.WriteString(uri) parsedDescription.WriteString(uri)
parseddescription.WriteString(`" title="`) parsedDescription.WriteString(`" title="`)
parseddescription.WriteString(title) parsedDescription.WriteString(title)
parseddescription.WriteString(`">`) parsedDescription.WriteString(`">`)
} }
} }
} }
case "br", "li", "ul", "p", "b": case "br", "li", "ul", "p", "b":
parseddescription.WriteString(token.String()) parsedDescription.WriteString(token.String())
case "div": case "div":
parseddescription.WriteString("<p> ") parsedDescription.WriteString("<p> ")
} }
case html.TextToken: case html.TextToken:
parseddescription.Write(tt.Text()) parsedDescription.Write(tt.Text())
} }
} }
} }
return parseddescription.String() return parsedDescription.String()
} }

View File

@ -3,16 +3,17 @@ package app
import ( import (
"io" "io"
"net/http" "net/http"
u "net/url" url "net/url"
"skunkyart/static"
"strconv" "strconv"
"strings" "strings"
) )
var Host string var Host, Path string
func Router() { func Router() {
parsepath := func(path string) map[int]string { parsepath := func(path string) map[int]string {
if l := len(CFG.BasePath); len(path) > l { if l := len(CFG.URI); len(path) > l {
path = path[l-1:] path = path[l-1:]
} else { } else {
path = "/" path = "/"
@ -42,41 +43,62 @@ func Router() {
return return
} }
open := func(name string) []byte {
file, err := static.Templates.Open(name)
try(err)
fileReaded, err := io.ReadAll(file)
try(err)
return fileReaded
}
// функция, что управляет всем // функция, что управляет всем
handle := func(w http.ResponseWriter, r *http.Request) { handle := func(w http.ResponseWriter, r *http.Request) {
if h := r.Header["Scheme"]; len(h) != 0 && h[0] == "https" { Path = r.URL.Path
Host = h[0] + "://" + r.Host path := parsepath(Path)
} else { Host = "http://" + r.Host
Host = "http://" + r.Host
if h := r.Header["X-Forwarded-Proto"]; len(h) != 0 && h[0] == "https" {
Host = "https://" + r.Host
} }
path := parsepath(r.URL.Path) var skunky = skunkyart{Version: Release.Version}
// структура с функциями
var skunky skunkyart
skunky.Writer = w
skunky.Args = r.URL.Query()
skunky.BasePath = CFG.BasePath
skunky.Args = r.URL.Query()
arg := skunky.Args.Get 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.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 { if t := arg("type"); len(t) > 0 {
skunky.Type = rune(t[0]) skunky.Type = rune(t[0])
} }
p, _ := strconv.Atoi(arg("p"))
skunky.Page = p
if arg("atom") == "true" { if arg("atom") == "true" {
skunky.Atom = true skunky.Atom = true
} }
// пути if CFG.Proxy {
switch path[1] { w.Header().Add("Content-Security-Policy", "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'")
default: } else {
skunky.ReturnHTTPError(404) 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 "": case "":
skunky.ExecuteTemplate("index.htm", &CFG.BasePath) skunky.ExecuteTemplate("index.htm", "html", &CFG.URI)
case "about":
skunky.Templates.About = About
skunky.ExecuteTemplate("about.htm", "html", &skunky)
case "post": case "post":
skunky.Deviation(path[2], path[3]) skunky.Deviation(path[2], path[3])
case "search": case "search":
@ -86,21 +108,43 @@ func Router() {
case "group_user": case "group_user":
skunky.GRUser() skunky.GRUser()
// media
case "media": case "media":
switch path[2] { switch path[2] {
case "file": case "file":
if a := arg("filename"); a != "" {
skunky.SetFilename(a)
}
skunky.DownloadAndSendMedia(path[3], next(path, 4)) skunky.DownloadAndSendMedia(path[3], next(path, 4))
case "emojitar": case "emojitar":
skunky.Emojitar(path[3]) skunky.Emojitar(path[3])
} }
case "about":
skunky.About()
case "stylesheet": case "stylesheet":
w.Header().Add("content-type", "text/css") w.Header().Add("content-type", "text/css")
io.WriteString(w, Templates["skunky.css"]) w.Write(open("css/skunky.css"))
case "favicon.ico":
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)
} }
} }
http.HandleFunc("/", handle) http.HandleFunc("/", handle)
try_with_exitstatus(http.ListenAndServe(CFG.Listen, nil), 1) println("SkunkyArt is listening on", CFG.Listen)
tryWithExitStatus(http.ListenAndServe(CFG.Listen, nil), 1)
} }

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 package app
import ( import (
"encoding/base64" "encoding/json"
"io" "io"
"net/http" "net/http"
u "net/url" "net/url"
"os" "os"
"skunkyart/static"
"strconv" "strconv"
"strings" "strings"
"syscall"
"text/template" "text/template"
"time" "time"
@ -17,6 +17,8 @@ import (
) )
/* INTERNAL */ /* INTERNAL */
var wr = io.WriteString
func exit(msg string, code int) { func exit(msg string, code int) {
println(msg) println(msg)
os.Exit(code) os.Exit(code)
@ -26,18 +28,104 @@ func try(e error) {
println(e.Error()) println(e.Error())
} }
} }
func try_with_exitstatus(err error, code int) { func tryWithExitStatus(err error, code int) {
if err != nil { if err != nil {
exit(err.Error(), code) exit(err.Error(), code)
} }
} }
func restore() {
if r := recover(); r != nil {
recover()
}
}
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 // 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
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 var buf strings.Builder
tmp := template.New(file) tmp := template.New(file)
tmp, e := tmp.Parse(Templates[file]) tmp, err := tmp.ParseFS(static.Templates, dir+"/*")
try(e) if err != nil {
s.Writer.WriteHeader(500)
wr(s.Writer, err.Error())
return
}
try(tmp.Execute(&buf, &data)) try(tmp.Execute(&buf, &data))
wr(s.Writer, buf.String()) wr(s.Writer, buf.String())
} }
@ -46,16 +134,29 @@ func UrlBuilder(strs ...string) string {
var str strings.Builder var str strings.Builder
l := len(strs) l := len(strs)
str.WriteString(Host) str.WriteString(Host)
str.WriteString(CFG.BasePath) str.WriteString(CFG.URI)
for n, x := range strs { for n, x := range strs {
str.WriteString(x) 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("/") str.WriteString("/")
} }
} }
return str.String() 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) { func (s skunkyart) ReturnHTTPError(status int) {
s.Writer.WriteHeader(status) s.Writer.WriteHeader(status)
@ -71,23 +172,31 @@ func (s skunkyart) ReturnHTTPError(status int) {
wr(s.Writer, msg.String()) 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 { type Downloaded struct {
Headers http.Header Headers http.Header
Status int Status int
Body []byte Body []byte
} }
func Download(url string) (d Downloaded) { func Download(urlString string) (d Downloaded) {
cli := &http.Client{} cli := &http.Client{}
if CFG.DownloadProxy != "" { if CFG.DownloadProxy != "" {
u, e := u.Parse(CFG.DownloadProxy) u, e := url.Parse(CFG.DownloadProxy)
try(e) try(e)
cli.Transport = &http.Transport{Proxy: http.ProxyURL(u)} cli.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
} }
req, e := http.NewRequest("GET", url, nil) req, e := http.NewRequest("GET", urlString, nil)
try(e) try(e)
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0.0") req.Header.Set("User-Agent", CFG.UserAgent)
resp, e := cli.Do(req) resp, e := cli.Do(req)
try(e) try(e)
@ -101,105 +210,29 @@ func Download(url string) (d Downloaded) {
return 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 != 0 {
now := time.Now().UnixMilli()
f, _ := os.Stat(a)
stat := f.Sys().(*syscall.Stat_t)
time := time.Unix(stat.Ctim.Unix()).UnixMilli()
if time+c.Lifetime <= 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)
try_with_exitstatus(e, 1)
for _, x := range dir {
file, e := os.ReadFile(dirname + "/" + x.Name())
try_with_exitstatus(e, 1)
Templates[x.Name()] = string(file)
}
}
}
/* PARSING HELPERS */ /* PARSING HELPERS */
func ParseMedia(media devianter.Media) string { func ParseMedia(media devianter.Media, thumb ...int) string {
url := devianter.UrlFromMedia(media) mediaUrl, filename := devianter.UrlFromMedia(media, thumb...)
if len(url) != 0 && CFG.Proxy { if len(mediaUrl) != 0 && CFG.Proxy {
url = url[21:] mediaUrl = mediaUrl[21:]
dot := strings.Index(url, ".") dot := strings.Index(mediaUrl, ".")
if filename == "" {
return UrlBuilder("media", "file", url[:dot], url[dot+11:]) filename = "image.gif"
}
return UrlBuilder("media", "file", mediaUrl[:dot], mediaUrl[dot+11:], "&filename=", filename)
} else if !CFG.Proxy {
return mediaUrl
} }
return url return ""
} }
func ConvertDeviantArtUrlToSkunkyArt(url string) (output string) { func ConvertDeviantArtUrlToSkunkyArt(url string) (output string) {
if len(url) > 32 && url[27:32] != "stash" { if len(url) > 32 && url[27:32] != "stash" {
url = url[27:] url = url[27:]
toart := strings.Index(url, "/art/") firstshash := strings.Index(url, "/")
if toart != -1 { lastshash := firstshash + strings.Index(url[firstshash+1:], "/")
output = UrlBuilder("post", url[:toart], url[toart+5:]) if lastshash != -1 {
output = UrlBuilder("post", url[:firstshash], url[lastshash+2:])
} }
} }
return return
@ -219,11 +252,10 @@ func BuildUserPlate(name string) string {
func GetValueOfTag(t *html.Tokenizer) string { func GetValueOfTag(t *html.Tokenizer) string {
for tt := t.Next(); ; { for tt := t.Next(); ; {
switch tt { if tt == html.TextToken {
default:
return ""
case html.TextToken:
return string(t.Text()) return string(t.Text())
} else {
return ""
} }
} }
} }
@ -236,16 +268,14 @@ type DeviationList struct {
// FIXME: на некоротрых артах первая страница может вызывать полное отсутствие панели навигации. // FIXME: на некоротрых артах первая страница может вызывать полное отсутствие панели навигации.
func (s skunkyart) NavBase(c DeviationList) string { func (s skunkyart) NavBase(c DeviationList) string {
// TODO: сделать понятнее
// навигация по страницам
var list strings.Builder var list strings.Builder
list.WriteString("<br>")
p := s.Page
// функция для генерации ссылок list.WriteString("<br>")
prevrev := func(msg string, page int, onpage bool) { prevrev := func(msg string, page int, onpage bool) {
if !onpage { if !onpage {
list.WriteString(`<a href="?p=`) list.WriteString(`<a href="`)
list.WriteString(Path)
list.WriteString(`?p=`)
list.WriteString(strconv.Itoa(page)) list.WriteString(strconv.Itoa(page))
if s.Type != 0 { if s.Type != 0 {
list.WriteString("&type=") list.WriteString("&type=")
@ -268,33 +298,26 @@ func (s skunkyart) NavBase(c DeviationList) string {
} }
} }
// вперёд-назад p := s.Page
if p > 1 { if p > 1 {
prevrev("<= Prev |", p-1, false) prevrev("<= Prev |", p-1, false)
} else { } else {
p = 1 p = 1
} }
if c.Pages > 0 { for i, x := p-6, 0; (i <= c.Pages && i <= p+6) && x < 12; i++ {
// назад if i > 0 {
for x := p - 6; x < p && x > 0; x++ { var onPage bool
prevrev(strconv.Itoa(x), x, false) if i == p {
} onPage = true
// вперёд
for x := p; x <= p+6 && c.Pages > p+6; x++ {
if x == p {
prevrev("", x, true)
x++
} }
if x > p { prevrev(strconv.Itoa(i), i, onPage)
prevrev(strconv.Itoa(x), x, false) x++
}
} }
} }
// вперёд-назад
if c.More { if c.More {
prevrev("| Next =>", p+1, false) prevrev("| Next =>", p+1, false)
} }

View File

@ -1,10 +1,6 @@
package app package app
import ( import (
"encoding/json"
"io"
"net/http"
"net/url"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -14,77 +10,6 @@ import (
"golang.org/x/net/html" "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 []struct {
Title string
Country string
Urls []struct {
I2P string `json:"i2p"`
Ygg string
Tor string
Clearnet string
}
Settings struct {
Nsfw bool
Proxy bool
}
}
}
SomeList 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() { func (s skunkyart) GRUser() {
if len(s.Query) < 1 { if len(s.Query) < 1 {
s.ReturnHTTPError(400) s.ReturnHTTPError(400)
@ -92,8 +17,16 @@ func (s skunkyart) GRUser() {
} }
var g devianter.Group var g devianter.Group
var daError devianter.Error
g.Name = s.Query g.Name = s.Query
s.Templates.GroupUser.GR = g.GroupFunc() var err error
s.Templates.GroupUser.GR, err, daError = g.Get()
try(err)
if daError.RAW != nil {
s.Error(daError)
return
}
group := &s.Templates.GroupUser group := &s.Templates.GroupUser
switch s.Type { switch s.Type {
@ -113,7 +46,6 @@ func (s skunkyart) GRUser() {
group.About.A = x.ModuleData.About group.About.A = x.ModuleData.About
var about = &group.About.A var about = &group.About.A
group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String() group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String()
group.About.DescriptionFormatted = ParseDescription(about.Description) group.About.DescriptionFormatted = ParseDescription(about.Description)
for _, val := range x.ModuleData.About.SocialLinks { for _, val := range x.ModuleData.About.SocialLinks {
@ -135,12 +67,7 @@ func (s skunkyart) GRUser() {
group.About.Interests += interest.String() group.About.Interests += interest.String()
} }
} }
group.About.Comments = s.ParseComments(devianter.CommentsFunc( group.About.Comments = s.ParseComments(devianter.GetComments(strconv.Itoa(group.GR.Gruser.ID), "", s.Page, 4))
strconv.Itoa(group.GR.Gruser.ID),
"",
s.Page,
4,
))
case "cover_deviation": case "cover_deviation":
group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation
@ -155,56 +82,77 @@ func (s skunkyart) GRUser() {
} }
} }
case 'g': case 'g', 'f':
var all bool
var content devianter.Group
folderid, _ := strconv.Atoi(s.Args.Get("folder")) folderid, _ := strconv.Atoi(s.Args.Get("folder"))
if a := s.Args.Get("all"); a == "true" {
all = true
}
if s.Page == 0 { if s.Page == 0 {
s.Page++ s.Page++
} }
gallery := g.Gallery(s.Page, folderid) if s.Type == 'f' {
if folderid > 0 { content, daError = g.Favourites(s.Page, all, folderid)
group.Gallery.List = s.DeviationList(gallery.Content.Results, DeviationList{ } else {
More: gallery.Content.HasMore, content, err, daError = g.Gallery(s.Page, folderid)
try(err)
}
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 { } 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 { if l := len(x.ModuleData.Folders.Results); l != 0 {
var folders strings.Builder var folders strings.Builder
folders.WriteString(`<h1 id="folders"><a href="#folder">#</a> Folders</h1><div class="folders"><br>`) folders.WriteString(`<h1 id="folders"><a href="#folder">#</a> Folders</h1><div class="folders"><br>`)
for _, x := range x.ModuleData.Folders.Results { for _, x := range x.ModuleData.Folders.Results {
folders.WriteString(`<div class="block folder-item">`) if x.FolderId != -1 && x.Size != 0 {
folders.WriteString(`<div class="block folder-item">`)
if !(x.Thumb.NSFW && !CFG.Nsfw) { if !(x.Thumb.NSFW && !CFG.Nsfw) {
folders.WriteString(`<a href="`) folders.WriteString(`<a href="`)
folders.WriteString(ConvertDeviantArtUrlToSkunkyArt(x.Thumb.Url)) folders.WriteString(ConvertDeviantArtUrlToSkunkyArt(x.Thumb.Url))
folders.WriteString(`"><img loading="lazy" src="`) folders.WriteString(`"><img loading="lazy" src="`)
folders.WriteString(ParseMedia(x.Thumb.Media)) folders.WriteString(ParseMedia(x.Thumb.Media))
folders.WriteString(`" title="`) folders.WriteString(`" title="`)
folders.WriteString(x.Thumb.Title) folders.WriteString(x.Thumb.Title)
folders.WriteString(`"></a>`) folders.WriteString(`"></a>`)
} else { } else {
folders.WriteString(`<h1>[ <span class="nsfw">NSFW</span> ]</h1>`) folders.WriteString(`<h1>[ <span class="nsfw">NSFW</span> ]</h1>`)
}
folders.WriteString("<br>")
folders.WriteString(`<a href="group_user?folder=`)
folders.WriteString(strconv.Itoa(x.FolderId))
folders.WriteString("&q=")
folders.WriteString(s.Query)
folders.WriteString("&type=")
folders.WriteString(string(s.Type))
folders.WriteString(`">`)
folders.WriteString(x.Name)
folders.WriteString(`</a>`)
folders.WriteString("</div>")
} }
folders.WriteString("<br>")
folders.WriteString(`<a href="?folder=`)
folders.WriteString(strconv.Itoa(x.FolderId))
folders.WriteString("&q=")
folders.WriteString(s.Query)
folders.WriteString("&type=")
folders.WriteString(string(s.Type))
folders.WriteString(`">`)
folders.WriteString(x.Name)
folders.WriteString(`</a>`)
folders.WriteString("</div>")
} }
folders.WriteString(`</div><h1 id="content"><a href="#content">#</a> Content</h1>`) folders.WriteString(`</div><h1 id="content"><a href="#content">#</a> Content</h1>`)
group.Gallery.Folders = folders.String() group.Gallery.Folders = folders.String()
} }
if x.Name == "folder_deviations" { if x.Name == "folder_deviations" {
group.Gallery.List = s.DeviationList(x.ModuleData.Folder.Deviations, DeviationList{ group.Gallery.List = s.DeviationList(x.ModuleData.Folder.Deviations, true, DeviationList{
Pages: x.ModuleData.Folder.Pages, Pages: x.ModuleData.Folder.Pages,
More: x.ModuleData.Folder.HasMore, More: x.ModuleData.Folder.HasMore,
}) })
@ -216,77 +164,105 @@ func (s skunkyart) GRUser() {
} }
if !s.Atom { if !s.Atom {
s.ExecuteTemplate("gruser.htm", &s) s.ExecuteTemplate("gruser.htm", "html", &s)
} }
} }
// посты // посты
func (s skunkyart) Deviation(author, postname string) { func (s skunkyart) Deviation(author, postname string) {
id_search := regexp.MustCompile("[0-9]+").FindAllString(postname, -1) id_search := regexp.MustCompile("[0-9]+").FindAllString(postname, -1)
if len(id_search) >= 1 { if len(id_search) < 1 {
post := &s.Templates.Deviation
id := id_search[len(id_search)-1]
post.Post = devianter.DeviationFunc(id, author)
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)
}
}
// хештэги
for _, x := range post.Post.Deviation.Extended.Tags {
var tag strings.Builder
tag.WriteString(` <a href="`)
tag.WriteString(UrlBuilder("search", "?q=", x.Name, "&type=tag"))
tag.WriteString(`">#`)
tag.WriteString(x.Name)
tag.WriteString("</a>")
post.Tags += tag.String()
}
if post.Post.Comments.Total <= 50 {
post.Post.Comments.Cursor = ""
}
post.Comments = s.ParseComments(devianter.CommentsFunc(id, post.Post.Comments.Cursor, s.Page, 1))
s.ExecuteTemplate("deviantion.htm", &s)
} else {
s.ReturnHTTPError(400) s.ReturnHTTPError(400)
return
} }
var err devianter.Error
post := &s.Templates.Deviation
id := id_search[len(id_search)-1]
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)
}
for _, x := range post.Post.Deviation.Extended.RelatedContent {
if len(x.Deviations) != 0 {
post.Related += s.DeviationList(x.Deviations, false)
}
}
// хештэги
for _, x := range post.Post.Deviation.Extended.Tags {
var tag strings.Builder
tag.WriteString(` <a href="`)
tag.WriteString(UrlBuilder("search", "?q=", x.Name, "&type=tag"))
tag.WriteString(`">#`)
tag.WriteString(x.Name)
tag.WriteString("</a>")
post.Tags += tag.String()
}
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", "html", &s)
} }
func (s skunkyart) DD() { func (s skunkyart) DD() {
dd := devianter.DailyDeviationsFunc(s.Page) dd, err := devianter.GetDailyDeviations(s.Page)
s.Templates.SomeList = s.DeviationList(dd.Deviations, DeviationList{ if err.RAW != nil {
s.Error(err)
return
}
var strips strings.Builder
for _, x := range dd.Strips {
strips.WriteString(`<h3 class="`)
strips.WriteString(x.Codename)
strips.WriteString(`"> <a href="#`)
strips.WriteString(x.Codename)
strips.WriteString(`"># </a>`)
strips.WriteString(x.Title)
strips.WriteString(`</h3>`)
strips.WriteString(s.DeviationList(x.Deviations, false))
}
s.Templates.DDStrips = strips.String()
s.Templates.SomeList = s.DeviationList(dd.Deviations, true, DeviationList{
Pages: 0, Pages: 0,
More: dd.HasMore, More: dd.HasMore,
}) })
if !s.Atom { if !s.Atom {
s.ExecuteTemplate("list.htm", &s) s.ExecuteTemplate("daily.htm", "html", &s)
} }
} }
func (s skunkyart) Search() { func (s skunkyart) Search() {
s.Atom = false if s.Query == "" {
var e error s.ReturnHTTPError(400)
return
}
var err error
var daError devianter.Error
ss := &s.Templates.Search ss := &s.Templates.Search
switch s.Type { switch s.Type {
case 'a', 't': case 'a', 't':
ss.Content, e = devianter.SearchFunc(s.Query, s.Page, s.Type) ss.Content, err, daError = devianter.PerformSearch(s.Query, s.Page, s.Type)
case 'g': case 'g', 'f':
ss.Content, e = devianter.SearchFunc(s.Query, s.Page, s.Type, s.Args.Get("usr")) ss.Content, err, daError = devianter.PerformSearch(s.Query, s.Page, s.Type, s.Args.Get("usr"))
case 'r': // скраппер, поскольку девиантартовцы зажопили гостевое API для поиска групп case 'r': // скраппер, поскольку девиантартовцы зажопили гостевое API для поиска групп
var ( var (
usernames = make(map[int]string) usernames = make(map[int]string)
@ -332,34 +308,34 @@ func (s skunkyart) Search() {
} }
default: default:
s.ReturnHTTPError(400) s.ReturnHTTPError(400)
return
} }
try(e) try(err)
if s.Type != 'r' { if s.Type != 'r' {
ss.List = s.DeviationList(ss.Content.Results, DeviationList{ if daError.RAW != nil {
s.Error(daError)
return
}
ss.List = s.DeviationList(ss.Content.Results, false, DeviationList{
Pages: ss.Content.Pages, Pages: ss.Content.Pages,
More: ss.Content.HasMore, More: ss.Content.HasMore,
}) })
} }
s.ExecuteTemplate("search.htm", &s) s.ExecuteTemplate("search.htm", "html", &s)
} }
func (s skunkyart) Emojitar(name string) { func (s skunkyart) Emojitar(name string) {
if name != "" && (s.Type == 'a' || s.Type == 'e') { if name == "" || !(s.Type == 'a' || s.Type == 'e') {
ae, e := devianter.AEmedia(name, s.Type)
if e != nil {
s.ReturnHTTPError(404)
}
wr(s.Writer, ae)
} else {
s.ReturnHTTPError(400) s.ReturnHTTPError(400)
return
} }
}
func (s skunkyart) About() { ae, e := devianter.AEmedia(name, s.Type)
s.Templates.About.Nsfw = CFG.Nsfw if e != nil {
s.Templates.About.Proxy = CFG.Proxy s.ReturnHTTPError(404)
try(json.Unmarshal([]byte(Templates["instances.json"]), &s.Templates.About)) }
s.ExecuteTemplate("about.htm", &s) wr(s.Writer, ae)
} }

View File

@ -1,18 +1,16 @@
{ {
"listen": "0.0.0.0:3003", "listen": "0.0.0.0:3003",
"base-path": "/", "uri": "/",
"cache": { "cache": {
"enabled": true, "enabled": true,
"path": "/home/skunk/projects/skunkyart/cache", "path": "cache",
"lifetime": null, "lifetime": null,
"max-size": 100000, "max-size": 200,
"update-interval": 5 "update-interval": 5
}, },
"dirs-to-memory": [ "static-path": "static",
"/home/skunk/projects/skunkyart/html", "download-proxy": "http://127.0.0.1:8080",
"/home/skunk/projects/skunkyart/css" "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",
],
"download-proxy": null,
"proxy": true, "proxy": true,
"nsfw": false "nsfw": true
} }

4
go.mod
View File

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

4
go.sum
View File

@ -1,4 +1,4 @@
git.macaw.me/skunky/devianter v0.2.0 h1:2vnMPb1Dax37CbAOfmHcSoK8+1goFkWHbtbh31Ytsww= git.macaw.me/skunky/devianter v0.2.6-0.20240904171839-b3c99749f133 h1:ziutYUyDmdbsptR8Lj4lNmZUxfgwGsNbHM1mO9ATph8=
git.macaw.me/skunky/devianter v0.2.0/go.mod h1:ZLn527xBlnpXrUB1B8z/MhyeiWVK4nPWjyfnhWOE8Is= 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 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=

View File

@ -1,63 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>SkunkyArt</title>
<link rel="stylesheet" href="{{.BasePath}}stylesheet">
</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>
<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>
<h2>Instance settings:</h2>
<ul>
<li><b>NSFW</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li>
<li><b>Proxyfing</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li>
</ul>
<h2>Instances:</h2>
<ul>
{{range .Templates.About.Instances}}
<li><u><b>{{.Title}}</b></u>:
<ul>
<li><b>Country</b>: {{.Country}}</li>
<li><b>URLs</b>: </li>
<ul>
{{range .Urls}}
{{if ne .I2P ""}}
<li><b>I2P</b>: <a href="{{.I2P}}">Yes</a></li>
{{end}}
{{if ne .Ygg ""}}
<li><b>Ygg</b>: <a href="{{.Ygg}}">Yes</a></li>
{{end}}
{{if ne .Tor ""}}
<li><b>Tor</b>: <a href="{{.Tor}}">Yes</a></li>
{{end}}
{{if ne .Clearnet ""}}
<li><b>Clearnet</b>: <a href="{{.Clearnet}}">{{.Clearnet}}</a></li>
{{end}}
{{end}}
</ul>
<li><b>Settings</b>: </li>
<ul>
<li><b>NSFW</b>: <span class="about-{{.Settings.Nsfw}}">{{if .Settings.Nsfw}}YES{{else}}NO{{end}}</span></li>
<li><b>Proxyfing</b>: <span class="about-{{.Settings.Proxy}}">{{if .Settings.Proxy}}YES{{else}}NO{{end}}</span></li>
</ul>
</ul>
</li>
{{end}}
</ul>
<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" target="_blank">SkunkyArt v1.3</a></p>
</main>
</html>

View File

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>SkunkyArt</title>
<link rel="stylesheet" href="{{.}}stylesheet"/>
</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,22 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>SkunkyArt</title>
<link rel="stylesheet" href="{{.BasePath}}stylesheet">
</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>
{{.Templates.SomeList}}
</main>
</html>

View File

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>SkunkyArt | Search "{{.QueryRaw}}"</title>
<link rel="stylesheet" href="{{.BasePath}}stylesheet">
</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

@ -3,46 +3,57 @@
{ {
"title": "skunky.ebloid.ru", "title": "skunky.ebloid.ru",
"country": "Russia", "country": "Russia",
"urls": [{ "urls": {
"ygg": "http://[201:eba5:d1fc:bf7b:cfcb:a811:4b8b:7ea3]/art", "ygg": "http://[201:eba5:d1fc:bf7b:cfcb:a811:4b8b:7ea3]/art",
"clearnet": "https://skunky.ebloid.ru/art" "clearnet": "https://skunky.ebloid.ru/art"
}], },
"settings": { "settings": {
"nsfw": false, "proxy": true,
"proxy": false "nsfw": false
} }
}, },
{ {
"title": "clovius.club", "title": "clovius.club",
"country": "Sweden", "country": "Sweden",
"urls": [{ "urls": {
"clearnet": "https://skunky.clovius.club" "clearnet": "https://skunky.clovius.club"
}], },
"settings": { "settings": {
"nsfw": true, "proxy": true,
"proxy": true "nsfw": true
} }
}, },
{ {
"title": "bloat.cat", "title": "bloat.cat",
"country": "Romania", "country": "Germany",
"urls": [{ "urls": {
"clearnet": "https://skunky.bloat.cat" "clearnet": "https://skunky.bloat.cat"
}], },
"settings": { "settings": {
"nsfw": true, "proxy": true,
"proxy": true "nsfw": true
} }
}, },
{ {
"title": "frontendfriendly.xyz", "title": "lumaeris.com",
"country": "Finland", "country": "Germany",
"urls": [{ "urls": {
"clearnet": "https://skunkyart.frontendfriendly.xyz" "clearnet": "https://skunkyart.lumaeris.com"
}], },
"settings": { "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
} }
} }
] ]

23
main.go
View File

@ -2,17 +2,30 @@ package main
import ( import (
"skunkyart/app" "skunkyart/app"
"skunkyart/static"
"time"
"git.macaw.me/skunky/devianter" "git.macaw.me/skunky/devianter"
) )
func main() { 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.ExecuteConfig()
app.CopyTemplatesToMemory() static.CopyTemplatesToMemory()
err := devianter.UpdateCSRF()
if err != nil { go func() {
println(err.Error()) for {
} err := devianter.UpdateCSRF()
if err != nil {
println(err.Error())
}
time.Sleep(12 * time.Hour)
}
}()
app.Router() app.Router()
} }

View File

@ -0,0 +1,20 @@
#!/sbin/openrc-run
supervisor=supervise-daemon
user=skunkyart:skunkyart
name=SkunkyArt
directory=/opt/skunkyart
command=$directory/bin/skunkyart
description="Privacy-oriented frontend for DeviantArt"
error_logger=logger
output_logger=logger
no_new_privs=true
umask=0077
# if you use old version openrc, uncomment function lower
# start_pre() {
# cd $directory
# }
depend() {
need net localmount bootmisc
}

View File

@ -0,0 +1,11 @@
# Note: i didn't use systemd, so it can be not works :)
[Unit]
Description=Privacy-oriented frontend for DeviantArt
[Service]
Directory=<path-to-dir-with-skunkyart>
ExecStart=<path-to-dir-skunkyart>
[Install]
WantedBy=multi-user.target

View File

@ -1,11 +0,0 @@
#!/sbin/openrc-run
name="SkunkyArt"
description="Privacy frontend for deviantart.com"
supervisor=supervise-daemon
command=<path_to_skunkyart>
command_args="-c <path_to_config>"
directory="<path_to_dir_with_skunkyart>"
depend() {
need net
}

View File

@ -19,15 +19,21 @@ header h1 {
header form { header form {
align-self: center; align-self: center;
} }
header { header, form {
display: flex; display: flex;
} }
form {
border: solid #164e3e 1px;
max-width: fit-content;
}
form input, button, select { form input, button, select {
background-color: #134134; background-color: #134134;
padding: 5px; padding: 5px;
color: whitesmoke; color: whitesmoke;
border: 0px; border: 0;
border-radius: 1px; }
input:focus {
outline: none;
} }
/* BLOCKS */ /* BLOCKS */
@ -131,28 +137,63 @@ form input, button, select {
} }
/* SCREEN OPTIMISATIONS */ /* SCREEN OPTIMISATIONS */
@media screen and (orientation: portrait) { @media (orientation: portrait) {
header { * {
scale: 155%; font-size: 120%
justify-content: center;
} }
ul {
font-size: 80%
}
header {
margin-left: 3%;
text-align: center;
display: inline-block;
clear: both;
font-size: 200%;
}
form {
font-size: 60%;
border: solid #164e3e 5px;
}
.content { .content {
margin: auto; margin: auto;
display: inherit; display: inherit;
scale: 100%; scale: 100%;
} }
.block { .block {
max-width: 60%; margin-top: 10%;
max-width: 200%;
}
.folder-item {
width: 25%
}
.folders {
display: flexbox;
justify-content: center
}
figure img {
width: 10%
}
figure a img {
width: 100%
}
.msg {
font-size: 60%;
max-width: 80%
} }
} }
@media screen and (max-width: 1462px) { @media (max-width: 1462px) and (orientation: landscape) {
.block { .block {
max-width: 30%; max-width: 30%;
} }
} }
@media screen and (min-width: 788px) and (max-width: 884px) { @media (min-width: 788px) and (max-width: 884px) {
.block { .block {
max-width: 35%; max-width: 35%;
} }

49
static/html/about.htm Normal file
View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
{{template "head" .}}
<main>
{{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>
<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>
<li><b>Proxyfing</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li>
</ul>
<details>
<summary><b>Instances:</b></summary>
<ul>
{{range .Templates.About.Instances}}
<li><u><b>{{.Title}}</b></u>:
<ul>
<li><b>Country</b>: {{.Country}}</li>
<li><b>URLs</b>: </li>
<ul>
{{if ne .Urls.I2P ""}}
<li><b>I2P</b>: <a href="{{.Urls.I2P}}">Yes</a></li>
{{end}}
{{if ne .Urls.Ygg ""}}
<li><b>Ygg</b>: <a href="{{.Urls.Ygg}}">Yes</a></li>
{{end}}
{{if ne .Urls.Tor ""}}
<li><b>Tor</b>: <a href="{{.Urls.Tor}}">Yes</a></li>
{{end}}
{{if ne .Urls.Clearnet ""}}
<li><b>Clearnet</b>: <a href="{{.Urls.Clearnet}}">{{.Urls.Clearnet}}</a></li>
{{end}}
</ul>
<li><b>Settings</b>: </li>
<ul>
<li><b>NSFW</b>: <span class="about-{{.Settings.Nsfw}}">{{if .Settings.Nsfw}}YES{{else}}NO{{end}}</span></li>
<li><b>Proxyfing</b>: <span class="about-{{.Settings.Proxy}}">{{if .Settings.Proxy}}YES{{else}}NO{{end}}</span></li>
</ul>
</ul>
</li>
{{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/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,23 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> {{template "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" />
</head>
<main> <main>
<header> {{template "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>
<figure> <figure>
<img src="{{.BasePath}}media/emojitar/{{.Templates.Deviation.Post.Deviation.Author.Username}}?type=a" width="30px"> <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)}} <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,25 +1,27 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> {{template "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">
</head>
<main> <main>
<header> <header>
<h1><a href="{{.BasePath}}">HOME</a> | <a href="{{.BasePath}}dd">DD</a> <h1>
| <a href="?q={{.Templates.GroupUser.GR.Owner.Username}}&type={{if eq .Type 'a'}}gallery">Gallery{{else}}about">About{{end}}</a> <a href="{{.BasePath}}">HOME</a>
| <a href="?q={{.Templates.GroupUser.GR.Owner.Username}}&type=gallery&atom=true">RSS</a></h1> | <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"> <form method="get" action="{{.BasePath}}search">
<input type="gallery" name="q" placeholder="Search for ..." autocomplete="off" autocapitalize="none" spellcheck="false"> <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}}"> <input type="hidden" name="usr" value="{{.Templates.GroupUser.GR.Owner.Username}}">
<select name="type"> <select name="type">
<option value="gallery">Gallery</option> <option value="gallery">Gallery</option>
<option value="folders">Folders</option>
<option value="all">All</option> <option value="all">All</option>
<option value="tag">Tag</option> <option value="tag">Tag</option>
<option value="r">Groups</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>

BIN
static/images/logo.png Normal file

Binary file not shown.

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
}