diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | README.md | 26 | ||||
-rw-r--r-- | config.go | 220 | ||||
-rw-r--r-- | config/instances | 4 | ||||
-rw-r--r-- | config/langs | 3 | ||||
-rw-r--r-- | go.mod | 5 | ||||
-rw-r--r-- | go.sum | 4 | ||||
-rw-r--r-- | static/main.css | 73 | ||||
-rw-r--r-- | takeoff.go | 62 | ||||
-rw-r--r-- | templates/index.html | 56 |
10 files changed, 454 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2221094 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +takeoff diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd32316 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Takeoff! + +A very crude start page. + +## Raison d'être + +I just wanted a quick way of searching through different searXNG instances. It's +a very crude and overkill implementation (it could have easily been made without +a backend, i.e. html/css-only), but I might add some other bells and whistles +here and there for which the backend might be of help. + +It was made just to serve my needs as a start page and nothing more. + +## Configuration + +The searXNG instances should be listed in `config/instances`, one per each line +without `http(s)://`. + +Languages to be made available as an option should be listed in `config/langs` +where each line is a separate language with the first column being the language +code and the second column (separated by a single tab character) the +human-friendly name of the language. + +## TODO + +* Weather at server location diff --git a/config.go b/config.go new file mode 100644 index 0000000..7164e93 --- /dev/null +++ b/config.go @@ -0,0 +1,220 @@ +package main + +import ( + "log" + "os" + "path" + "strings" + "sync/atomic" + + "github.com/fsnotify/fsnotify" +) + +type Lang struct { + Code string + Name string +} + +// Wrapper over atomic.Value; contains []string +type atomicStringSlice struct { + v atomic.Value +} +// Wrapper over atomic.Value; contains []Lang +type atomicLangSlice struct { + v atomic.Value +} + +// A config that is just a list of lines of text +type lineListConfig struct { + path string + items atomicStringSlice +} + +// A config that is list of two-column lines sepparated by a tab character +type langListConfig struct { + path string + langs atomicLangSlice +} + +// Interface for all types of configs that can be updated/reloaded +type updatableConfig interface { + update() error +} + +// The data type that will watch for changes in some updatableConfig types and +// call their update() methods on changes +type configWatcher struct { + lists map[string]updatableConfig + watcher *fsnotify.Watcher + dirpath string +} + +var watcher configWatcher + +func (v *atomicStringSlice) Load() []string { + return v.v.Load().([]string) +} + +func (v *atomicStringSlice) Store(val []string) { + v.v.Store(val) +} + +func (v *atomicLangSlice) Load() []Lang { + return v.v.Load().([]Lang) +} + +func (v *atomicLangSlice) Store(val []Lang) { + v.v.Store(val) +} + +// Re-read the file of this and update the items list. +func (c *lineListConfig) update() error { + data, err := os.ReadFile(c.path) + if err != nil { + return err + } + + // Cut the string into substrings line by line omitting empty lines. + s := string(data) + sep := "\n" + items := make([]string, 0, 8) + for { + if i := strings.Index(s, sep); i >= 0 { + sub := s[:i] + if len(sub) > 0 { + items = append(items, sub) + } + s = s[i+len(sep):] + continue + } + break + } + c.items.Store(items) + + return nil +} + +// Return the slice of strings atomically +func (c *lineListConfig) getList() []string { + return c.items.Load() +} + +// Re-read the file of this and update the items list. +func (c *langListConfig) update() error { + data, err := os.ReadFile(c.path) + if err != nil { + return err + } + + // Cut the string into substring line by line omitting empty lines, and + // sepparating the code and the name of the language. + s := string(data) + sep := "\n" + langs := make([]Lang, 0, 8) + for { + if i := strings.Index(s, sep); i >= 0 { + sub := s[:i] + if len(sub) > 0 { + cols := strings.SplitN(sub, "\t", 2) + if len(cols) < 2 { + log.Println("WARNING: bad language format:", sub) + } else { + langs = append(langs, Lang{ cols[0], cols[1] }) + } + } + s = s[i+len(sep):] + continue + } + break + } + c.langs.Store(langs) + + return nil +} + +// Return the slice of strings atomically +func (c *langListConfig) getList() []Lang { + return c.langs.Load() +} + +// Make a new lineListConfig from the file that is inside the configWatcher dir +// with the name give in this method. The name of the file will be automatically +// watched by the configWatcher. Returns a new instance of a lineListConfig if +// there were no errors. +func newLineListConfig(name string) (*lineListConfig, error) { + var err error + path := path.Join(watcher.dirpath, name) + + c := &lineListConfig{path: path} + err = c.update() + watcher.lists[path] = c + + return c, err +} + +// Make a new langListConfig; the same story as with lineListConfig +func newLangListConfig(name string) (*langListConfig, error) { + var err error + path := path.Join(watcher.dirpath, name) + + c := &langListConfig{path: path} + err = c.update() + watcher.lists[path] = c + + return c, err +} + +// Initialize and start the config watcher. +func startConfigWatcher(path string) error { + var err error + + watcher.lists = make(map[string]updatableConfig) + watcher.dirpath = path + + watcher.watcher, err = fsnotify.NewWatcher() + if err != nil { + return err + } + err = watcher.watcher.Add(path) + if err != nil { + return err + } + + go func() { + for { + select { + case e, ok := <-watcher.watcher.Events: + if !ok { + return + } + if e.Has(fsnotify.Write) { + cl, ok := watcher.lists[e.Name] + if ok { + if err := cl.update(); err != nil { + log.Println( + "read error: couldn't update", e.Name, ":", err, + ) + break + } + log.Println("reloaded config list from file:", e.Name) + } + } + case err, ok := <-watcher.watcher.Errors: + if !ok { + return + } + log.Println("watcher error:", err) + } + } + }() + + return err +} + +// Stop the configList watcher. +func stopConfigWatcher() { + err := watcher.watcher.Close() + if err != nil { + log.Println("watcher error: on close:", err) + } +} diff --git a/config/instances b/config/instances new file mode 100644 index 0000000..6d840c1 --- /dev/null +++ b/config/instances @@ -0,0 +1,4 @@ +search.rhscz.eu +paulgo.io +searx.fmac.xyz +searx.be diff --git a/config/langs b/config/langs new file mode 100644 index 0000000..c0412b6 --- /dev/null +++ b/config/langs @@ -0,0 +1,3 @@ +en-US 🇺🇸 English +es-MX 🇲🇽 Español +ru-RU 🇷🇺 Русский @@ -0,0 +1,5 @@ +module git.yaroslavps.com/takeoff + +go 1.16 + +require github.com/fsnotify/fsnotify v1.6.0 @@ -0,0 +1,4 @@ +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +golang.org/x/sys v0.0.0-20220908164124-27713097b956 h1:XeJjHH1KiLpKGb6lvMiksZ9l0fVUh+AmGcm0nOMEBOY= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/static/main.css b/static/main.css new file mode 100644 index 0000000..568171e --- /dev/null +++ b/static/main.css @@ -0,0 +1,73 @@ +* { + box-sizing: border-box; + color: #fcf8e2; + font-family: monospace; +} + +body { + background-color: #000; + /* background: #000 repeat center fixed url("/static/grid.png"); */ + margin: 0; +} + +a { + color: #81acc1; +} + +.header-container, +.main-container, +select { + background-color: #000; +} + +.header-container, +.main-container { + margin: 0 auto; + padding: 1em; +} + +.header-container > pre { + font-size: 2em; + text-align: center; + color: #5b8277; +} + +.header-container { + max-width: 800px; + border-top: 1px solid #fcf8e2; +} + +.main-container { + max-width: 800px; + border-bottom: 1px solid #fcf8e2; +} + +input[type="text"] { + padding: 0.2em 0.5em; + background-color: #242424; +} + +#search-form input[type="text"] { + width: 100%; +} + +.btn { + padding: 0.2em 0.5em; + background-color: #5b8277; +} + +.btn:hover { + background-color: #7fac96; +} + +@media (max-width: 1100px){ + .header-container > pre { + font-size: 1em; + } +} + +@media (max-width: 720px){ + .header-container > pre { + font-size: 0.7em; + } +} diff --git a/takeoff.go b/takeoff.go new file mode 100644 index 0000000..7f7dd09 --- /dev/null +++ b/takeoff.go @@ -0,0 +1,62 @@ +package main + +import ( + "flag" + "html/template" + "log" + "net/http" + "path" +) + +var ( + addr = flag.String("a", "localhost:8888", "listen address") + cdir = flag.String("c", "config", "path to configuration directory") + sdir = flag.String("s", "static", "path to static files directory") + tdir = flag.String("t", "templates", "path to templates directory") +) + +var ( + instances *lineListConfig + langs *langListConfig +) + +var homeTemplate string + +var tData struct { + Instances []string + Langs []Lang +} + +func home(w http.ResponseWriter, r *http.Request) { + tData.Instances = instances.getList() + tData.Langs = langs.getList() + t := template.Must(template.ParseFiles(homeTemplate)) + t.Execute(w, &tData) +} + +func serve() { + files := http.FileServer(http.Dir(*sdir)) + http.HandleFunc("/", home) + http.Handle("/static/", http.StripPrefix("/static/", files)) + log.Fatal(http.ListenAndServe(*addr, nil)) +} + +func main() { + var err error + + flag.Parse() + + startConfigWatcher(*cdir) + defer stopConfigWatcher() + + instances, err = newLineListConfig("instances") + langs, err = newLangListConfig("langs") + if err != nil { + log.Fatal("FATAL: config list error: ", err) + } + + homeTemplate = path.Join(*tdir, "index.html") + + log.Printf("takeoff v0 listening at http://%s\n", *addr) + serve() +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..66b552f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,56 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta content="width=device-width, initial-scale=1" name="viewport"> + <title>Takeoff!</title> + <link rel="stylesheet" href="/static/main.css"> + </head> + <body> + <br> + <div class="header-container"> + <pre> + _________ __ __ __________ ______________ + /_ __/ | / //_// ____/ __ \/ ____/ ____/ / + / / / /| | / ,\ / __/ / / / / /_ / /_ / / + / / / ___ |/ /| |/ /___/ /_/ / __/ / __/ /_/ +/_/ /_/ |_/_/ |_/_____/\____/_/ /_/ (_) + </pre> + </div> + <div class="main-container"> + <form id="search-form" method="post" action="https://{{ index .Instances 0 }}/search"> + <input type="text" name="q" id="q"> + <p>Search with:</p> + <div class="subform"> + {{ range .Instances }} + <button class="instance btn" formaction="https://{{ . }}/search"> + {{ . }} + </button> + {{ end }} + </div> + <p>Options:</p> + <div class="subform"> + <select id="language" name="language"> + {{ range .Langs }} + <option value="{{ .Code }}">{{ .Name }}</option> + {{ end }} + <option value="all">Default</option> + <option value="auto">Auto</option> + </select> + <select name="time_range" id="time_range"> + <option value="" selected>Anytime</option> + <option value="day">Last day</option> + <option value="week">Last week</option> + <option value="month">Last month</option> + <option value="year">Last year</option> + </select> + <select name="safesearch" id="safesearch"> + <option value="2">SafeSearch: Strict</option> + <option value="1">SafeSearch: Moderate</option> + <option value="0" selected>SafeSearch: None</option> + </select> + </div> + </form> + </div> + </body> +</html> |