Compare commits

..

No commits in common. "design" and "posts" have entirely different histories.

51 changed files with 275 additions and 792 deletions

1
.gitignore vendored
View File

@ -1,3 +1,2 @@
config.json config.json
hikan.ru hikan.ru
*.server*

View File

@ -1,17 +1,13 @@
::selection { /* Design idea from here https://mo.rijndael.cc/ */
background: purple;
color: #fff;
}
body { body {
font-family: -apple-system,system-ui,blinkmacsystemfont,Segoe UI,roboto,oxygen,ubuntu,Helvetica Neue,arial,sans-serif;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
margin: 0; margin: 0;
align-items: flex-start; align-items: flex-start;
padding: 10px; padding: 10px;
background-image: url("/assets/pic/star.gif"); background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc0MCcgaGVpZ2h0PSc0MCc+PHJlY3QgZmlsbD0nIzkxZWFlNCcgeD0nMCcgeT0nMCcgd2lkdGg9JzIwcHgnIGhlaWdodD0nMjBweCcvPjxyZWN0IGZpbGw9JyM4NmE4ZTcnIHg9JzAnIHk9JzIwJyB3aWR0aD0nMjBweCcgaGVpZ2h0PScyMHB4Jy8+PHJlY3QgZmlsbD0nIzkxZWFlNCcgeD0nMjAnIHk9JzIwJyB3aWR0aD0nMjBweCcgaGVpZ2h0PScyMHB4Jy8+PHJlY3QgZmlsbD0nIzg2YThlNycgeD0nMjAnIHk9JzAnIHdpZHRoPScyMHB4JyBoZWlnaHQ9JzIwcHgnLz48L3N2Zz4=");
background-color: #000; background-repeat: repeat;
} }
header, main, footer { header, main, footer {
@ -31,140 +27,32 @@ main {
div { div {
text-align: left; text-align: left;
background-color: #b1cfd8; background-color: white;
box-shadow: 0 0 3px 3px rgba(135, 182, 196, 0.95); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
box-sizing: border-box; box-sizing: border-box;
border: 1px solid; border: 1px solid;
border-radius: 10px;
width: 100%; width: 100%;
padding: 0 20px; padding: 0 20px;
overflow: hidden;
}
.pagi {
color: #b1cfd8;
text-align: center;
background-color: transparent;
box-shadow: none;
border: none;
font-size: 1.5em;
} }
h1 { h1 {
text-align: center; text-align: center;
padding: 30px; box-sizing: border-box;
position: relative; border-top: 1px solid;
border-bottom: 1px solid;
} }
h1::before, h1::after { .count {
content: ""; display: flex;
position: absolute; justify-content: center;
left: 0;
right: 0;
height: 10px;
background: url('data:image/svg+xml;utf8,<svg viewBox="0 0 100 10" xmlns="http://www.w3.org/2000/svg"><path d="M0,5 Q25,10 50,5 T100,5" fill="none" stroke="%236a8498" stroke-width="2"/></svg>');
} }
h1::before { top: 0; } .count img {
h1::after { bottom: 0; } height: 75px;
a {
cursor: pointer;
color: black;
text-decoration: none;
padding: 0 5px;
background-image: linear-gradient(to bottom, transparent 60%, #77abbb 60%);
background-repeat: no-repeat;
transition: background-color 0.1s ease;
border-radius: 2px;
} }
a:hover { @media (max-width: 1200px) {
background-image: linear-gradient(to bottom, transparent 60%, #778bbb 60%);
}
a[hx-get] {
background-image: linear-gradient(to right, #77abbb, #778bbb);
}
a[hx-get]:hover {
background-image: linear-gradient(to right, #778bbb, #778bbb);
}
pre {
padding: 10px;
background: #77abbb;
border: 1px solid #000;
border-radius: 2px;
overflow-x: auto;
white-space: pre;
word-wrap: normal;
width: 0;
min-width: calc(100% - 22px);
}
details > summary {
cursor: pointer;
}
details[open] > summary {
cursor: pointer;
}
.rotating-pic-container {
width: 100%;
height: 100%;
overflow: hidden;
}
.rotating-pic {
width: 100%;
height: 100%;
animation: swing 1.5s ease-in-out infinite alternate;
}
@keyframes swing {
0% { transform: rotate(-10deg); }
100% { transform: rotate(10deg); }
}
.pulse-container {
text-align: center;
}
.pulse-text {
font-weight: bold;
color: purple;
animation: scalePulse 2s infinite ease-in-out;
}
@keyframes scalePulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@media (max-width: 800px) {
a:hover {
background-color: #77abbb;
}
a[hx-get]:hover {
background: linear-gradient(to right, #77abbb, #778bbb);
}
header, footer, main { header, footer, main {
flex: 1 100%; flex: 1 100%;
} }
details > summary {
cursor: default;
}
details[open] > summary {
cursor: default;
}
} }

BIN
assets/pic/0.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
assets/pic/1.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
assets/pic/2.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
assets/pic/3.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/pic/4.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

BIN
assets/pic/5.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
assets/pic/6.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
assets/pic/7.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

BIN
assets/pic/8.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
assets/pic/9.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

BIN
assets/pic/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/pic/favicon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

BIN
assets/pic/footer.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
assets/pic/header.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

4
eye.sh
View File

@ -3,8 +3,8 @@ stty -echoctl # Отключает вывод управляющих симво
# НАСТРОЙКА СКРИПТА ТУТ ########################################################### # НАСТРОЙКА СКРИПТА ТУТ ###########################################################
DURATION=1 # Задержка между проверками в секундах DURATION=1 # Задержка между проверками в секундах
WATCH_TARGETS=("assets" "mvc" "posts" "tools" "main.go" "config.json") # Массив целей для наблюдения (директории и файлы) WATCH_TARGETS=("assets" "mvc" "posts" "tools" "main.go") # Массив целей для наблюдения (директории и файлы)
BINARY_PATH="./main.server" # Путь до бинарного файла BINARY_PATH="./main" # Путь до бинарного файла
BUILD_CMD="go build -o $BINARY_PATH main.go" # Команда для сборки BUILD_CMD="go build -o $BINARY_PATH main.go" # Команда для сборки
################################################################################### ###################################################################################

52
main.go
View File

@ -8,7 +8,6 @@ import (
"main/mvc/models" "main/mvc/models"
"main/tools" "main/tools"
"net/http" "net/http"
"time"
) )
func main() { func main() {
@ -26,31 +25,6 @@ func main() {
// Настройка маршрутов // Настройка маршрутов
router := setupRoutes(app) router := setupRoutes(app)
// Запуск тикеров
{
// Обновление последнего прослушанного трека ластфм
tools.Ticker(func() {
var lastTrack string
if lastTrack, err = models.LastFMGetLastTrack(app.Cfg.LastFMUsername, app.Cfg.LastFMToken); err != nil {
log.Println(err)
return
}
app.LastfmLastTrack = lastTrack
}, time.Second*app.Cfg.LastFMUpdateInterval)
// Сообщение в лог о количестве элементов в кэше
tools.Ticker(func() {
log.Println("Pages in cache:")
if len(app.PagesCache.Data) != 0 {
for key := range app.PagesCache.Data {
log.Println(key)
}
} else {
log.Println("No pages in the cache")
}
}, time.Second*app.Cfg.CacheLogInterval)
}
// Запуск сервера // Запуск сервера
if ok, err := tools.IsIPInUse(app.Cfg.ServerIP); err != nil { if ok, err := tools.IsIPInUse(app.Cfg.ServerIP); err != nil {
log.Fatal(err) log.Fatal(err)
@ -75,25 +49,13 @@ func setupRoutes(app *models.App) *http.ServeMux {
{ {
// Обработка главной страницы // Обработка главной страницы
router.Handle("/", m(controllers_pages.MainPageHandler(app))) router.Handle("/", m(controllers_pages.MainPageHandler(app)))
} // Обработка страницы со списком постов
router.Handle("/posts/", m(controllers_pages.PostsPageHandler(app)))
// Странички со списками постов // Обработка страничек постов
postsPagesCount := (len(app.PostsMap) + app.Cfg.PostsMaxCountOnPage - 1) / app.Cfg.PostsMaxCountOnPage for key := range app.Posts {
for i := range postsPagesCount { postLink := string(key)
router.Handle( router.Handle(postLink, m(controllers_pages.PostPageHandler(app)))
fmt.Sprintf("/posts/%d", i), }
m(controllers_pages.PostsPageHandler(app)))
}
// Обработка страничек постов
for key := range app.PostsMap {
postLink := string(key)
router.Handle(postLink, m(controllers_pages.PostPageHandler(app)))
}
// Api
{
router.Handle("/api/lastfm", m(controllers.LastFMHandler(app)))
} }
return router return router

View File

@ -1,7 +1,6 @@
package controllers_pages package controllers_pages
import ( import (
"fmt"
"log" "log"
"main/mvc/models" "main/mvc/models"
@ -14,15 +13,12 @@ import (
func MainPageHandler(app *models.App) http.HandlerFunc { func MainPageHandler(app *models.App) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ( var err error
pageData []byte
err error
)
// Количество запросов, обработанных сервером за 24ч // Количество запросов, обработанных сервером за 24ч
if r.Method == "COUNT" { if r.Method == "COUNT" {
var count []byte var count []byte
if count, err = tools.GetJournalctlLogsCount("server", "duration: ", 24); err != nil { if count, err = tools.GetJournalctlLogsCount("server", app.Cfg.ServerDomain, 24); err != nil {
log.Printf("%s", err.Error()) log.Printf("%s", err.Error())
} }
sendCount(w, count) sendCount(w, count)
@ -41,24 +37,17 @@ func MainPageHandler(app *models.App) http.HandlerFunc {
return return
} }
cacheKey := models_pages.MainPageTmplName // Страничка рендерится только если ее нет в кэше
ajax := r.URL.Query().Get("ajax") == "true" pageData, ok := app.PagesCache.Get(models_pages.MainPageTmplName)
if ajax { if !ok {
cacheKey += "?ajax=true" pageData, err = models_pages.RenderMainPage(app.Templates, app.Version)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
app.PagesCache.Set(models_pages.MainPageTmplName, pageData)
} }
if pageData, ok := app.PagesCache.Get(cacheKey); ok {
sendMainPage(w, pageData)
return
}
pageData, err = models_pages.RenderMainPage(app.Templates, app.Version, ajax)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
app.PagesCache.Set(cacheKey, pageData)
sendMainPage(w, pageData) sendMainPage(w, pageData)
}) })
} }
@ -81,7 +70,7 @@ func sendCount(w http.ResponseWriter, data []byte) {
func sendLove(w http.ResponseWriter) { func sendLove(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "13.01.2005\n") w.Write([]byte("13.01.2005\n"))
} }
// Ответ на метод LIMINAL // Ответ на метод LIMINAL
@ -89,5 +78,6 @@ func sendLiminal(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
text := "If you're not careful and you slip out of reality in the wrong place, you'll end up in the Backstage, where there's nothing but the stench of old damp carpet, yellow-colored madness, the endless unbearable hum of fluorescent lights, and roughly six hundred million square miles of randomly arranged empty rooms.\n" text := "If you're not careful and you slip out of reality in the wrong place, you'll end up in the Backstage, where there's nothing but the stench of old damp carpet, yellow-colored madness, the endless unbearable hum of fluorescent lights, and roughly six hundred million square miles of randomly arranged empty rooms.\n"
fmt.Fprint(w, text) w.Write([]byte(text))
} }

View File

@ -4,37 +4,37 @@ import (
"main/mvc/models" "main/mvc/models"
"main/mvc/models/models_pages" "main/mvc/models/models_pages"
"net/http" "net/http"
"strings"
) )
// Обработчик страницы поста // Обработчик главной страницы
func PostPageHandler(app *models.App) http.HandlerFunc { func PostPageHandler(app *models.App) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
link := r.URL.Path var err error
cacheKey := link
ajax := r.URL.Query().Get("ajax") == "true" postLink := r.URL.Path
if ajax {
cacheKey += "?ajax=true" // Ссылки на посты имеют вид postLink = /link/, а если прилетело что то типо /link/123123,
// то надо оставить только часть /link/
secondSlash := strings.IndexByte(postLink[1:], '/')
if secondSlash != -1 {
postLink = postLink[:secondSlash+2]
} }
if pageData, ok := app.PagesCache.Get(cacheKey); ok { // Страничка рендерится только если ее нет в кэше
sendPostPage(w, pageData) pageData, ok := app.PagesCache.Get(postLink)
return if !ok {
post := app.Posts[models_pages.PostLink(postLink)]
pageData, err = models_pages.RenderPostPage(app.Templates, app.Version, post.Data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
app.PagesCache.Set(postLink, pageData)
} }
post := app.PostsMap[models_pages.PostLink(link)]
var (
pageData []byte
err error
)
pageData, err = post.RenderPostPage(app.Templates, app.Version, ajax)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
app.PagesCache.Set(cacheKey, pageData)
sendPostPage(w, pageData) sendPostPage(w, pageData)
}) })
} }

View File

@ -4,45 +4,26 @@ import (
"main/mvc/models" "main/mvc/models"
"main/mvc/models/models_pages" "main/mvc/models/models_pages"
"net/http" "net/http"
"path"
"strconv"
) )
// Обработчик странички со списком постов // Обработчик главной страницы
func PostsPageHandler(app *models.App) http.HandlerFunc { func PostsPageHandler(app *models.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
link := r.URL.Path var err error
cacheKey := link
ajax := r.URL.Query().Get("ajax") == "true" // Страничка рендерится только если ее нет в кэше
if ajax { pageData, ok := app.PagesCache.Get(models_pages.PostsPageTmplName)
cacheKey += "?ajax=true" if !ok {
pageData, err = app.Posts.RenderPostsPage(app.Templates, app.Version)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
app.PagesCache.Set(models_pages.PostsPageTmplName, pageData)
} }
if pageData, ok := app.PagesCache.Get(cacheKey); ok {
sendPostsPage(w, pageData)
return
}
postsList := app.PostsMap.PostsList()
pageNumber, _ := strconv.Atoi(path.Base(link))
page := models_pages.CreatePostsPage(postsList, pageNumber, app.Cfg.PostsMaxCountOnPage)
var (
pageData []byte
err error
)
pageData, err = page.RenderPostsPage(app.Templates, app.Version, ajax)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
app.PagesCache.Set(cacheKey, pageData)
sendPostsPage(w, pageData) sendPostsPage(w, pageData)
} })
} }
// Отправляет страницу // Отправляет страницу

View File

@ -1,21 +0,0 @@
package controllers
import (
"fmt"
"main/mvc/models"
"net/http"
)
// Обработчик главной страницы
func LastFMHandler(app *models.App) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sendLastFM(w, app.LastfmLastTrack)
})
}
// Отправляет страницу
func sendLastFM(w http.ResponseWriter, data string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, data)
}

View File

@ -35,12 +35,6 @@ func loggingMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
fullURL := r.URL.String() log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
log.Printf("%s %s | client: %s | duration: %v",
r.Method,
fullURL,
r.RemoteAddr,
time.Since(start))
}) })
} }

View File

@ -7,12 +7,11 @@ import (
) )
type App struct { type App struct {
Cfg *Config // Сонфиг Cfg *Config // Сонфиг
PostsMap models_pages.PostsMap // Посты Posts models_pages.Posts // Посты
Templates *template.Template // Шаблоны страниц Templates *template.Template // Шаблоны страниц
PagesCache *Cache // Кэш (отрендеренные странички) PagesCache *Cache // Кэш (отрендеренные странички)
LastfmLastTrack string // Последний трек, полученный с ластфм Version int64 // Время запуска
Version int64 // Время запуска
} }
// Инициализирует приложение // Инициализирует приложение
@ -28,7 +27,7 @@ func InitApp() (*App, error) {
return nil, err return nil, err
} }
// Загрузка постов // Загрузка постов
if app.PostsMap, err = models_pages.LoadPosts(app.Cfg.PostsDir); err != nil { if app.Posts, err = models_pages.LoadPosts(app.Cfg.PostsDir); err != nil {
return nil, err return nil, err
} }
// Загрузка шаблонов // Загрузка шаблонов
@ -37,7 +36,5 @@ func InitApp() (*App, error) {
} }
// Инициализация кэша // Инициализация кэша
app.PagesCache = initCache() app.PagesCache = initCache()
// Строка по умолчанию для последнего прослушанного трека
app.LastfmLastTrack = "None"
return app, nil return app, nil
} }

View File

@ -3,7 +3,6 @@ package models
import ( import (
"encoding/json" "encoding/json"
"os" "os"
"time"
) )
const ( const (
@ -11,48 +10,27 @@ const (
) )
type Config struct { type Config struct {
pathsConfig PostsDir string
serverConfig AssetsDir string
lastFMConfig TemplatesDir string
cacheConfig TemplatesExt string
LocalIP string
LocalPort string
ServerIP string
ServerPort string
ServerDomain string
Port string
} }
type pathsConfig struct { func loadConfig(configPath string) (*Config, error) {
PostsDir string `json:"posts_dir"` cfg := &Config{}
AssetsDir string `json:"assets_dir"` configFile, err := os.ReadFile(configPath)
TemplatesDir string `json:"templates_dir"`
TemplatesExt string `json:"templates_ext"`
PostsMaxCountOnPage int `json:"max_posts_per_page"`
}
type serverConfig struct {
LocalIP string `json:"local_ip"`
LocalPort string `json:"local_port"`
ServerIP string `json:"server_ip"`
ServerPort string `json:"server_port"`
ServerDomain string `json:"server_domain"`
}
type lastFMConfig struct {
LastFMUsername string `json:"lastfm_username"`
LastFMToken string `json:"lastfm_token"`
LastFMUpdateInterval time.Duration `json:"lastfm_update_interval"`
}
type cacheConfig struct {
CacheLogInterval time.Duration `json:"cache_log_interval"`
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = json.Unmarshal(configFile, cfg)
var cfg Config if err != nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err return nil, err
} }
return cfg, nil
return &cfg, nil
} }

View File

@ -1,63 +0,0 @@
package models
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type lastFMTrack struct {
Name string `json:"name"`
Artist struct {
Name string `json:"#text"`
} `json:"artist"`
Album struct {
Name string `json:"#text"`
} `json:"album"`
Date struct {
Unix string `json:"#text"`
} `json:"date"`
}
type lastFMResponse struct {
RecentTracks struct {
Tracks []lastFMTrack `json:"track"`
} `json:"recenttracks"`
}
func lastFMGetRecentTracks(username, apiKey string) (*lastFMResponse, error) {
url := fmt.Sprintf("https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=%s&api_key=%s&format=json", username, apiKey)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("lastfm get resp err: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("lastfm read resp body err: %v", err)
}
var lastFMResponse lastFMResponse
if err := json.Unmarshal(body, &lastFMResponse); err != nil {
return nil, fmt.Errorf("lastfm unmarshal err: %v", err)
}
return &lastFMResponse, nil
}
func LastFMGetLastTrack(username, apiKey string) (string, error) {
resp, err := lastFMGetRecentTracks(username, apiKey)
if err != nil {
return "", err
}
var data string
if len(resp.RecentTracks.Tracks) > 0 {
track := resp.RecentTracks.Tracks[0]
data = fmt.Sprintf("%s - %s", track.Name, track.Artist.Name)
data = strings.ReplaceAll(data, "🅴", "[E]")
} else {
return "", fmt.Errorf("len(resp.RecentTracks.Tracks) <= 0")
}
return data, nil
}

View File

@ -8,11 +8,10 @@ import (
const ( const (
// Имя соответствующего шаблона // Имя соответствующего шаблона
MainPageTmplName = "main.gohtml" MainPageTmplName = "main.gohtml"
MainPageTmplNameAjax = "main_ajax.gohtml"
) )
func RenderMainPage(templates *template.Template, version int64, ajax bool) ([]byte, error) { func RenderMainPage(templates *template.Template, version int64) ([]byte, error) {
var pageData bytes.Buffer var pageData bytes.Buffer
context := map[string]any{ context := map[string]any{
@ -20,12 +19,7 @@ func RenderMainPage(templates *template.Template, version int64, ajax bool) ([]b
"renderingTimestamp": time.Now().Unix(), "renderingTimestamp": time.Now().Unix(),
} }
templateName := MainPageTmplName if err := templates.ExecuteTemplate(&pageData, MainPageTmplName, context); err != nil {
if ajax {
templateName = MainPageTmplNameAjax
}
if err := templates.ExecuteTemplate(&pageData, templateName, context); err != nil {
return nil, err return nil, err
} }

View File

@ -6,13 +6,13 @@ import (
"time" "time"
) )
type PostLink string
const ( const (
// Имя соответствующего шаблона // Имя соответствующего шаблона
PostPageTmplName = "post.gohtml" PostPageTmplName = "post.gohtml"
PostPageTmplNameAjax = "post_ajax.gohtml"
) )
type PostLink string
type Post struct { type Post struct {
Link PostLink Link PostLink
Preview template.HTML Preview template.HTML
@ -20,29 +20,23 @@ type Post struct {
Timestamp int64 Timestamp int64
} }
func (p *Post) RenderPostPage(templates *template.Template, version int64, ajax bool) ([]byte, error) { func RenderPostPage(templates *template.Template, version int64, data template.HTML) ([]byte, error) {
var pageData bytes.Buffer var pageData bytes.Buffer
context := map[string]any{ context := map[string]any{
"version": version, "version": version,
"renderingTimestamp": time.Now().Unix(), "renderingTimestamp": time.Now().Unix(),
"data": p.Data, "data": data,
"modTimestamp": p.Timestamp,
} }
templateName := PostPageTmplName if err := templates.ExecuteTemplate(&pageData, PostPageTmplName, context); err != nil {
if ajax {
templateName = PostPageTmplNameAjax
}
if err := templates.ExecuteTemplate(&pageData, templateName, context); err != nil {
return nil, err return nil, err
} }
return pageData.Bytes(), nil return pageData.Bytes(), nil
} }
func createPost(link string, data []byte, timestamp int64) *Post { func newPost(link string, data []byte, timestamp int64) *Post {
previewBuf := make([]byte, 0, 503) previewBuf := make([]byte, 0, 503)
if len(data) > 500 { if len(data) > 500 {

View File

@ -4,33 +4,24 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"html/template" "html/template"
"log"
"main/tools" "main/tools"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
) )
const ( const (
// Имя соответствующего шаблона // Имя соответствующего шаблона
PostsPageTmplName = "posts.gohtml" PostsPageTmplName = "posts.gohtml"
PostsPageTmplNameAjax = "posts_ajax.gohtml"
) )
type PostsMap map[PostLink]*Post type Posts map[PostLink]*Post
type PostsList []*Post
type PostsPage struct {
PostsListOnPage PostsList // список постов на странице
PageNumber int // номер страницы
PagesCount int // общее количество страниц
}
func LoadPosts(dir string) (PostsMap, error) { func LoadPosts(dir string) (Posts, error) {
posts := PostsMap{} posts := Posts{}
err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error { err := filepath.Walk(dir, func(path string, f os.FileInfo, err error) error {
if err != nil { if err != nil {
@ -44,23 +35,9 @@ func LoadPosts(dir string) (PostsMap, error) {
} }
html := tools.MdToHTML(md) html := tools.MdToHTML(md)
link := fmt.Sprintf("/%s/", strings.TrimSuffix(filepath.Base(path), ".md"))
name := filepath.Base(path) timestamp := f.ModTime().Unix()
lowLineIndex := strings.Index(name, "_") posts[PostLink(link)] = newPost(link, html, timestamp)
if lowLineIndex == -1 {
// Обработка случая, если "_" нет в имени
log.Fatal(`post name parse error`)
}
timestampStr := name[:lowLineIndex]
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
if err != nil {
// Ошибка парсинга timestamp
log.Fatal(`post name parse error`)
}
link := fmt.Sprintf("/%s", strings.TrimSuffix(name[lowLineIndex+1:], ".md"))
posts[PostLink(link)] = createPost(link, html, timestamp)
} }
return nil return nil
}) })
@ -72,65 +49,26 @@ func LoadPosts(dir string) (PostsMap, error) {
return posts, nil return posts, nil
} }
// Получение из мапы постов списка постов, отсортированного по ModTimestamp (новые сначала) func (p *Posts) RenderPostsPage(templates *template.Template, version int64) ([]byte, error) {
func (p *PostsMap) PostsList() PostsList { var pageData bytes.Buffer
postsSlice := make(PostsList, 0, len(*p))
postsSlice := make([]*Post, 0, len(*p))
for _, post := range *p { for _, post := range *p {
postsSlice = append(postsSlice, post) postsSlice = append(postsSlice, post)
} }
// Сортирую по ModTimestamp (новые сначала)
sort.Slice(postsSlice, func(i, j int) bool { sort.Slice(postsSlice, func(i, j int) bool {
return postsSlice[i].Timestamp > postsSlice[j].Timestamp return postsSlice[i].Timestamp > postsSlice[j].Timestamp
}) })
return postsSlice
}
func CreatePostsPage(postsList PostsList, pageNumber, postsMaxCountOnPage int) *PostsPage {
// Общее количество страниц
pagesCount := (len(postsList) + postsMaxCountOnPage - 1) / postsMaxCountOnPage
startIndex := pageNumber * postsMaxCountOnPage
endIndex := startIndex + postsMaxCountOnPage
endIndex = min(endIndex, len(postsList))
postsSublistForPageNumber := postsList[startIndex:endIndex]
return &PostsPage{
PostsListOnPage: postsSublistForPageNumber,
PageNumber: pageNumber,
PagesCount: pagesCount,
}
}
func (p *PostsPage) RenderPostsPage(templates *template.Template, version int64, ajax bool) ([]byte, error) {
var pageData bytes.Buffer
var prevPageNumber, nextPageNumber int
if p.PageNumber == 0 {
prevPageNumber = p.PagesCount - 1
} else {
prevPageNumber = p.PageNumber - 1
}
nextPageNumber = (p.PageNumber + 1) % p.PagesCount
context := map[string]any{ context := map[string]any{
"prevPageNumber": prevPageNumber,
"pageNumberInc": p.PageNumber + 1,
"pagesCount": p.PagesCount,
"nextPageNumber": nextPageNumber,
"version": version, "version": version,
"renderingTimestamp": time.Now().Unix(), "renderingTimestamp": time.Now().Unix(),
"posts": p.PostsListOnPage, "posts": postsSlice,
} }
templateName := PostsPageTmplName if err := templates.ExecuteTemplate(&pageData, PostsPageTmplName, context); err != nil {
if ajax {
templateName = PostsPageTmplNameAjax
}
if err := templates.ExecuteTemplate(&pageData, templateName, context); err != nil {
return nil, err return nil, err
} }

View File

@ -1,28 +1,29 @@
{{ define "footer" }} {{ define "footer" }}
<footer> <footer>
<div class="rotating-pic-container"> <div>
<img src="/assets/pic/heh.png?v={{ .version }}" class="rotating-pic"> <img src="/assets/pic/footer.webp?v={{ .version }}" width="100%" height="100%">
</div>
<div>
<p>
and also you can subscribe to my <a href="https://t.me/lolistack" target="_blank">telegram channel </a> with pictures!
</p>
</div> </div>
<div> <div>
<p> <p>
<strong>some system information</strong>: <strong>some system information</strong>:
</p> </p>
<p> <ul>
> unix timestamp of page rendering: <code>{{ .renderingTimestamp }}</code><br/> <li>unix timestamp of page rendering: <code>{{ .renderingTimestamp }}</code></li>
> <code>curl -X LIMINAL https://hikan.ru</code> - what do you know about liminal spaces<br/> <li><code>curl -X LIMINAL https://hikan.ru</code> - what do you know about liminal spaces?</li>
> <code>curl -X COUNT https://hikan.ru</code> - 24-hour server request count<br/> <li>this site code repository - <a href="https://git.hikan.ru/serr/hikan.ru" target="_blank">git.hikan.ru/serr/hikan.ru</a></li>
<li>24-hour server request count (<code>curl -X COUNT https://hikan.ru</code>):</li>
</ul>
<p class="count">
<img src="/assets/pic/0.gif?v={{ .version }}">
<img src="/assets/pic/0.gif?v={{ .version }}">
<img src="/assets/pic/0.gif?v={{ .version }}">
<img src="/assets/pic/0.gif?v={{ .version }}">
</p> </p>
<p>
<details>
<summary class="pulse-text">click here if you want more system details</summary>
<p>
> to check the latest changes to the site you can use it (someday i hope to add a cool log page):
<pre>curl -s "https://git.hikan.ru/api/v1/repos/serr/hikan.ru/commits?limit=10" | jq -r '.[] | .commit.author.date + " | " + (.commit.message | rtrimstr("\n"))'</pre>
or look in the repository <a href="https://git.hikan.ru/serr/hikan.ru" target="_blank">git.hikan.ru/serr/hikan.ru</a>
</p>
</details>
</p>
</div> </div>
<div> <div>
<p> <p>
@ -33,4 +34,16 @@
</p> </p>
</div> </div>
</footer> </footer>
<script>
fetch('/', {method: 'COUNT'})
.then(r => r.text())
.then(num => num.trim())
.then(number => {
if (number.length > 0) {
document.querySelector('p.count').innerHTML =
[...number].map(d => `<img src="/assets/pic/${d}.gif?v={{ .version }}">`).join('');
}
})
.catch(console.error);
</script>
{{ end }} {{ end }}

View File

@ -3,16 +3,7 @@
<title>hikan.ru</title> <title>hikan.ru</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="/assets/pic/favicon.ico?v={{ .version }}" type="image/x-icon"> <link rel="shortcut icon" href="/assets/pic/favicon.png?v={{ .version }}" type="image/x-icon">
<link rel="stylesheet" href="/assets/css/styles.css?v={{ .version }}" type="text/css"> <link rel="stylesheet" href="/assets/css/styles.css?v={{ .version }}" type="text/css">
<script src="/assets/scripts/htmx.js?v={{ .version }}"></script>
<script>
document.addEventListener('htmx:afterSwap', function(evt) {
const triggeringElement = evt.detail.requestConfig.elt;
if (triggeringElement.hasAttribute('data-scroll-top')) {
window.scrollTo(0,0);
}
});
</script>
</head> </head>
{{ end }} {{ end }}

View File

@ -1,17 +1,7 @@
{{ define "header" }} {{ define "header" }}
<header> <header>
<div class="rotating-pic-container"> <div>
<img class="rotating-pic" src="/assets/pic/miffy.png?v={{ .version }}" <img src="/assets/pic/header.webp?v={{ .version }}" width="100%" height="100%">
style="width: 100%; height: 100%;">
</div>
<div class="pulse-container">
<p class="pulse-text">
<code>🎵&nbsp;last played track: </code><code id="lastfm"
hx-get="/api/lastfm"
hx-trigger="load, every 30s"
hx-target="#lastfm"
hx-swap="innerHTML">waiting for a response from server... (JavaScript is required)</code><code>&nbsp;🎵</code>
</p>
</div> </div>
<div> <div>
<h1> <h1>
@ -22,22 +12,10 @@
</p> </p>
</div> </div>
<div> <div>
<p> <ul>
see <a href="/" <li><a href="/">main page</a></li>
hx-get="/?ajax=true" <li><a href="/posts/">posts section</a></li>
hx-target="main" </ul>
hx-swap="outerHTML"
hx-push-url="/"
data-scroll-top="true">
main page</a> or go to
<a href="/posts/0"
hx-get="/posts/0?ajax=true"
hx-target="main"
hx-swap="outerHTML"
hx-push-url="/posts/0"
data-scroll-top="true">
posts section</a>, and also you can subscribe to my <a href="https://t.me/lolistack" target="_blank">telegram channel</a> with pictures!
</p>
</div> </div>
</header> </header>
{{ end }} {{ end }}

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
{{ template "head" . }}
<body>
{{ template "header" . }}
<main>
<div>
<h1>
$whoami
</h1>
<p>
my name is serr (you can easily guess my real name if you speak Russian :d), and i didn't come up with that nickname, i just started being called it
</p>
<p>
i was born in 2003, i'm currently a cybersecurity major at university
</p>
<p>
<code>pronouns: he/him</code>
</p>
</div>
<div>
<h1>
what do i do?
</h1>
<p>
programming is my everything - my job, my hobby, my lifestyle
</p>
<p>
i love growing in all areas of programming - i am literally interested in everything: cybersecurity (chaotically breaking things, analyzing code, writing automated analyzers, and moving bytes back and forth), concurrency/multithreading, web development, low-level programming, cryptography and a lot more!
</p>
<p>
i like the idea of <a href="https://en.wikipedia.org/wiki/Symbolic_execution" target="_blank">symbolic</a>/concolic execution and virtual code execution in general
</p>
</div>
<div>
<h1>
things i love
</h1>
<ul>
<li><strong>coffee</strong>. i REALLY love coffee. almost any. and a lot of</li>
<li><strong>movies and TV series</strong> (especially TV series). i watch something almost every day</li>
<li><strong>true crime</strong>. i'm obsessed with serial killer cases, mysterious disappearances, unsolved murders - all that dark stuff</li>
<li><strong>russian underground rap</strong> like Slava KPSS, Zamay, MB Packet, Ovsyankin etc.</li>
<li><strong>simple and extensible code</strong>. i think if your code is overly complex, it means you are doing something wrong. most things are simpler than they seem</li>
</ul>
</div>
<div>
<h1>
projects
</h1>
<ul>
<li><a href="https://git.hikan.ru/serr" target="_blank">git.hikan.ru/serr</a> - check my repos</li>
<li><del>telegram bot with schedule for SPBPU - <a href="https://t.me/polysched_bot" target="_blank">polysched_bot</a></del> (transferred to a more proactive owner)</li>
<li><del>telegram bot with schedule for SPMI - <a href="https://t.me/gornischedule_bot" target="_blank">gornischedule_bot</a></del> (closed)</li>
</ul>
</div>
<div>
<h1>
nice links
</h1>
<ul>
<li><a href="https://mo.rijndael.cc/" target="_blank">Mo</a>, thx for design idea!</li>
<li>huge collection of Xakep issues - <a href="https://図書館.きく.コム/" target="_blank">図書館.きく.コム</a></li>
<li>i love this website highlighting the Small Web - <a href="https://smallweb.cc/" target="_blank">smallweb</a></li>
<li>very atmospheric forum about black metal - <a href="https://www.lycanthropia.net/" target="_blank">lycanthropia</a></li>
</ul>
</div>
</main>
{{ template "footer" . }}
</body>
</html>

View File

@ -1,9 +0,0 @@
<!DOCTYPE html>
<html lang="en">
{{ template "head" . }}
<body>
{{ template "header" . }}
{{ template "main_ajax" . }}
{{ template "footer" . }}
</body>
</html>

View File

@ -1,72 +0,0 @@
{{ template "main_ajax" . }}
{{ define "main_ajax" }}
<main>
<div>
<p>
<code>welcome to my little site</code>
</p>
</div>
<div>
<h1>
$whoami
</h1>
<p>
my name is serr (you can easily guess my real name if you speak Russian :d), and i didn't come up with that nickname, i just started being called it
</p>
<p>
i was born in 2003, i'm currently a cybersecurity major at university
</p>
<p>
<code>pronouns: he/him</code>
</p>
</div>
<div>
<h1>
what do i do?
</h1>
<p>
programming is my lifestyle
</p>
<p>
i love growing in all areas of programming - i am literally interested in everything: web development, low-level programming, formal grammars and a lot more!
</p>
<p>
i like the idea of <a href="https://en.wikipedia.org/wiki/Symbolic_execution" target="_blank">symbolic</a>/concolic execution and virtual code execution in general
</p>
</div>
<div>
<h1>
things i love
</h1>
<p>
<strong>> coffee</strong>. i REALLY love coffee. almost any. and a lot of<br/>
<strong>> movies and TV series</strong> (especially TV series). i watch something almost every day<br/>
<strong>> true crime</strong>. i'm obsessed with serial killer cases, mysterious disappearances, unsolved murders - all that dark stuff<br/>
<strong>> russian underground rap</strong> like Slava KPSS, Zamay, MB Packet, Ovsyankin etc.<br/>
<strong>> simple and extensible code</strong>. i think if your code is overly complex, it means you are doing something wrong. most things are simpler than they seem
</p>
</div>
<div>
<h1>
projects
</h1>
<p>
> <a href="https://git.hikan.ru/serr/candycache" target="_blank">git.hikan.ru/serr/candycache</a> - simple and efficient Go cache<br/>
> <a href="https://git.hikan.ru/serr/eye-hot-reloader" target="_blank">git.hikan.ru/serr/eye-hot-reloader</a> - lightweight directories monitor with automatic rebuild and restart functionality for any project type<br/>
> <del>telegram bot with schedule for SPBPU - <a href="https://t.me/polysched_bot" target="_blank">polysched_bot</a></del> (transferred to a more proactive owner)<br/>
> <del>telegram bot with schedule for SPMI - <a href="https://t.me/gornischedule_bot" target="_blank">gornischedule_bot</a></del> (closed)<br/>
</p>
</div>
<div>
<h1>
nice links
</h1>
<p>
> huge collection of Xakep issues - <a href="https://図書館.きく.コム/" target="_blank">図書館.きく.コム</a><br/>
> i like to surf here - <a href="https://neocities.org/browse" target="_blank">neocities.org/browse</a><br/>
> very atmospheric forum about black metal - <a href="https://www.lycanthropia.net/" target="_blank">lycanthropia</a><br/>
</p>
</div>
</main>
{{ end }}

View File

@ -3,7 +3,11 @@
{{ template "head" . }} {{ template "head" . }}
<body> <body>
{{ template "header" . }} {{ template "header" . }}
{{ template "post_ajax" . }} <main>
<div>
{{ .data }}
</div>
</main>
{{ template "footer" . }} {{ template "footer" . }}
</body> </body>
</html> </html>

View File

@ -1,12 +0,0 @@
{{ template "post_ajax" . }}
{{ define "post_ajax" }}
<main>
<div>
{{ .data }}
<p>
<code>create time: {{ .modTimestamp }}</code>
</p>
</div>
</main>
{{ end }}

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
{{ template "head" . }}
<body>
{{ template "header" . }}
<main>
<div>
<p>
<code>posts sorted by last mod time</code>
</p>
</div>
{{ range $ind, $post := .posts }}
<div>
<p>
{{ $post.Preview }}
</p>
<p>
<a href="{{ $post.Link }}">read more</a>
</p>
<p>
<code>mod time: {{ $post.Timestamp }}</code>
</p>
</div>
{{ end }}
</main>
{{ template "footer" . }}
</body>
</html>

View File

@ -1,9 +0,0 @@
<!DOCTYPE html>
<html lang="en">
{{ template "head" . }}
<body>
{{ template "header" . }}
{{ template "posts_ajax" . }}
{{ template "footer" . }}
</body>
</html>

View File

@ -1,44 +0,0 @@
{{ template "posts_ajax" . }}
{{ define "posts_ajax" }}
<main>
<div>
<p>
<code>posts sorted by create time</code>
</p>
</div>
{{ range $ind, $post := .posts }}
<div>
<p>
{{ $post.Preview }}
</p>
<p>
<a href="{{ $post.Link }}"
hx-get="{{ $post.Link }}?ajax=true"
hx-target="main"
hx-swap="outerHTML"
hx-push-url="{{ $post.Link }}"
data-scroll-top="true">read more</a>
</p>
<p>
<code>create time: {{ $post.Timestamp }}</code>
</p>
</div>
{{ end }}
<div class="pagi">
<a href="/posts/{{ .prevPageNumber }}"
hx-get="/posts/{{ .prevPageNumber }}?ajax=true"
hx-target="main"
hx-swap="outerHTML"
hx-push-url="/posts/{{ .prevPageNumber }}"
data-scroll-top="true"><-</a>
{{ .pageNumberInc }}/{{ .pagesCount }}
<a href="/posts/{{ .nextPageNumber }}"
hx-get="/posts/{{ .nextPageNumber }}?ajax=true"
hx-target="main"
hx-swap="outerHTML"
hx-push-url="/posts/{{ .nextPageNumber }}"
data-scroll-top="true">-></a>
</div>
</main>
{{ end }}

View File

@ -1,72 +0,0 @@
# Как же все таки изменить байты строки в Go?
Просто захотелось чуть чуть поиграться с пакетом unsafe в Go.
Строки (тип string) в Go являются *immutable*, то есть изменять их нельзя. Ну вообще конечно можно, но не напрямую.
Строка в Go под капотом является структурой вида: **указатель на данные, длина данных.** И первое, что приходит в голову чтобы изменить строку - добраться до поля с указателем, прибавить к нему индекс байта который надо поменять, разыменовать полученный адрес и что то ему присвоить.
Но в реальности все не так просто и при попытке что то положить по вычисленному адресу программа упадет с *segmentation fault (SIGSEGV)*. Чтобы этого избежать, предварительно надо выдать права на запись в страничку памяти где находится целевая строка. Сделать это можно через системные вызовы.
```
package main
import (
"fmt"
"syscall"
"unsafe"
)
func main() {
s := "hello"
fmt.Println("Original:", s)
// Строки в Go - структуры с двумя полями: указатель на данные и длина
// тут строка интерпретируется как структура
strHeader := (*struct {
data unsafe.Pointer
len int
})(unsafe.Pointer(&s))
// Вычисляем начало страницы памяти
pageSize := syscall.Getpagesize()
pageStart := uintptr(strHeader.data) - (uintptr(strHeader.data) % uintptr(pageSize))
fmt.Printf(
"\nАдрес строки = %d\nразмер страницы памяти = %d\nадрес строки относительно начала страницы = %d\nадрес страницы = %d\n\n",
uintptr(strHeader.data),
uintptr(pageSize),
(uintptr(strHeader.data) % uintptr(pageSize)),
pageStart)
// Имея адрес страницы и ее размер даю права на запись на данной странице памяти
ret, _, err := syscall.Syscall(
syscall.SYS_MPROTECT,
pageStart,
uintptr(pageSize),
uintptr(syscall.PROT_READ|syscall.PROT_WRITE),
)
if ret != 0 {
panic("mprotect failed: " + err.Error())
}
// Теперь можно менять строку
// поменяю, например, пятый байт
fifthBytePtr := (*byte)(unsafe.Pointer(uintptr(strHeader.data) + 4))
*fifthBytePtr = 0
// Восстанавливаю дефолтные права
ret, _, err = syscall.Syscall(
syscall.SYS_MPROTECT,
pageStart,
uintptr(pageSize),
uintptr(syscall.PROT_READ),
)
if ret != 0 {
panic("mprotect restore failed: " + err.Error())
}
fmt.Println("Modified:", s) // Должно быть "hell"
}
```
Тестил на **go version go1.22.2 linux/amd64**

View File

@ -0,0 +1,12 @@
# Как же все таки изменить байты строки в Go?
Просто захотелось чуть чуть поиграться с пакетом unsafe в Go.
Строки (тип string) в Go являются *immutable*, то есть изменять их нельзя. Ну вообще конечно можно, но не напрямую.
Строка в Go под капотом является структурой вида: **указатель на данные, длина данных.** И первое, что приходит в голову чтобы изменить строку - добраться до поля с указателем, прибавить к нему индекс байта который надо поменять, разыменовать полученный адрес и что то ему присвоить.
Но в реальности все не так просто и при попытке что то положить по вычисленному адресу программа упадет с *segmentation fault (SIGSEGV)*. Чтобы этого избежать, предварительно надо выдать права на запись в страничку памяти где находится целевая строка. Сделать это можно через системные вызовы.
Код с пояснениями можно скачать [тут](https://git.hikan.ru/serr/unsafe-change-string-go)
Тестил на **go version go1.22.2 linux/amd64**

View File

@ -1,27 +0,0 @@
package tools
import "time"
// Ticker запускает функцию fn с указанным интервалом в отдельной горутине
// Возвращает канал для остановки тикера
func Ticker(fn func(), interval time.Duration) chan struct{} {
if interval <= 0 {
interval = time.Second
}
ticker := time.NewTicker(interval)
stopChan := make(chan struct{})
fn()
go func() {
for {
select {
case <-ticker.C:
fn()
case <-stopChan:
ticker.Stop()
return
}
}
}()
return stopChan
}