aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md26
-rw-r--r--config.go220
-rw-r--r--config/instances4
-rw-r--r--config/langs3
-rw-r--r--go.mod5
-rw-r--r--go.sum4
-rw-r--r--static/main.css73
-rw-r--r--takeoff.go62
-rw-r--r--templates/index.html56
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 🇷🇺 Русский
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..5c1795f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module git.yaroslavps.com/takeoff
+
+go 1.16
+
+require github.com/fsnotify/fsnotify v1.6.0
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..60d7d6e
--- /dev/null
+++ b/go.sum
@@ -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>