diff --git a/database.go b/database.go index ff77a31..3b347b5 100644 --- a/database.go +++ b/database.go @@ -4,12 +4,39 @@ import ( "io/fs" "log" "os" + "slices" "strings" + + "golang.org/x/text/search" ) type Database struct { Keys []string Entries map[string]string + matcher *search.Matcher +} + +func (db *Database) search(query string) []string { // returns keys (entry names) + results := []string{} + // compile patterns + queryPatterns := []*search.Pattern{} + for _, q := range strings.Split(query, " ") { // per word + queryPatterns = append(queryPatterns, db.matcher.CompileString(q)) + } + // search + for _, k := range db.Keys { + patternsFound := 0 + for _, p := range queryPatterns { + if s, _ := p.IndexString(db.Entries[k]); s != -1 { + patternsFound++ // this pattern was found + } + } + if patternsFound == len(queryPatterns) && !slices.Contains(results, k) { + // if all patterns were found, add the key (entry name) to the list + results = append(results, k) + } + } + return results } func BuildDB(directory string) Database { @@ -19,14 +46,16 @@ func BuildDB(directory string) Database { var keys []string entries := map[string]string{} // get files in directory and read them - directory = strings.TrimRight(directory, "/") // we don't need that last / -> if '/' is used as directory, you are dumb. + directory = strings.TrimRight(directory, "/") // we don't need that last /, don't use the root directory / entriesDirFs := os.DirFS(directory) keys, err := fs.Glob(entriesDirFs, "*") if err != nil { logger.Panicln(err) } for _, k := range keys { contentB, err := os.ReadFile(directory + "/" + k) if err != nil { logger.Panicln(err) } - entries[k] = string(contentB) + content := string(contentB) + entries[k] = content } - return Database{Keys: keys, Entries: entries} + matcher := search.New(ContentLanguage, search.IgnoreCase, search.IgnoreDiacritics) + return Database{Keys: keys, Entries: entries, matcher: matcher} } diff --git a/go.mod b/go.mod index 510bb9a..baa456d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/ChaoticByte/plaintext-encyclopedia go 1.22.4 + +require golang.org/x/text v0.17.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8d2179e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= diff --git a/main.go b/main.go index b3abde5..368deb4 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "log" "net/http" "os" + "path" "strings" ) @@ -28,16 +29,13 @@ func loadTemplate() { func handleApplication(w http.ResponseWriter, req *http.Request) { var entry string var err error - entryName := strings.Trim(req.URL.Path, "/") - if entryName != "" { - if strings.Contains(entryName, "/") || strings.Contains(entryName, "..") { - // path traversal - logger.Println("Possible path traversal attempt from", req.RemoteAddr, "to", entryName) - w.WriteHeader(http.StatusForbidden) - return - } + entryName := path.Base(req.URL.Path) + if entryName != "/" { // load entry entry = db.Entries[entryName] + if entry == "" { // redirect if entry doesn't exist (or is empty) + http.Redirect(w, req, "/", http.StatusTemporaryRedirect) + } } err = appTemplate.ExecuteTemplate( w, "app", @@ -45,6 +43,16 @@ func handleApplication(w http.ResponseWriter, req *http.Request) { if err != nil { logger.Println(err) } } +func handleSearchAPI(w http.ResponseWriter, req *http.Request) { + searchQuery := path.Base(req.URL.Path) + if searchQuery == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + results := db.search(searchQuery) + w.Write([]byte(strings.Join(results, "\n"))) +} + func main() { // get logger logger = log.Default() @@ -57,6 +65,7 @@ func main() { http.Handle("/static/", staticHandler) // handle application http.HandleFunc("/", handleApplication) + http.HandleFunc("/search/", handleSearchAPI) // start server logger.Println("Starting server on", ServerListen) err := http.ListenAndServe(ServerListen, nil) diff --git a/public/index.html b/public/index.html index ea79cbf..9ae6588 100644 --- a/public/index.html +++ b/public/index.html @@ -17,11 +17,16 @@ {{- else -}} -
+
+ + +
{{- range .TOC -}}
{{ . }}
{{- end -}}
+
+ {{- end}} diff --git a/public/static/search.js b/public/static/search.js new file mode 100644 index 0000000..0775aca --- /dev/null +++ b/public/static/search.js @@ -0,0 +1,45 @@ +(() => { + let searchBox = document.getElementById("search-box"); + let searchResults = document.getElementById("search-results"); + let toc = document.getElementById("toc"); + + /** + * @param {string} results + */ + function updateSearchResults(results) { + if (results.length > 0) { + searchResults.innerHTML = ""; + for (let i = 0; i < results.length; i++) { + let resultElem = document.createElement("div"); + let resultAnchor = document.createElement("a"); + resultAnchor.href = results[i]; // we should be at /, so this is right + resultAnchor.innerText = results[i]; + resultElem.appendChild(resultAnchor); + searchResults.appendChild(resultElem); + } + toc.classList.add("hidden"); + searchResults.classList.remove("hidden"); + } else { + toc.classList.remove("hidden"); + searchResults.classList.add("hidden"); + } + } + + async function handleSearchInput() { + // get search query + const query = searchBox.value; + if (query == "") { + updateSearchResults([]); + return + } + // make request + const response = await fetch("/search/" + query); + if (!response.ok) { + throw new Error("Couldn't search, status code ", response.status); + } + let result = await response.text(); + updateSearchResults(result.split('\n')); + } + + searchBox.addEventListener("input", handleSearchInput); +})(); diff --git a/public/static/style.css b/public/static/style.css index 184bf65..a57e3f5 100644 --- a/public/static/style.css +++ b/public/static/style.css @@ -7,10 +7,11 @@ body { width: 100vw; font-family: sans-serif; } -a:hover { text-decoration: underline; } +a:hover, a:focus { text-decoration: underline; } a { color: black; text-decoration: none; + outline: none; } .homebtn { @@ -36,16 +37,34 @@ a { } .content { white-space: pre-line; } -.toc { - box-sizing: border-box; - padding: 2rem; +.home-main { width: 100%; + padding: 2rem; + box-sizing: border-box; + gap: 1rem; +} +.home-main, .toc, #search-results { + display: flex; + flex-direction: column; +} +.toc, #search-results, #search-box { padding: 0 .2rem; } +.toc, #search-results { gap: .2rem; } +#search-box { + width: 100%; + border: none; + outline: none; + border-bottom: 1px solid #00000040; + height: 2rem; + font-size: 1rem; +} +#search-box:active, #search-box:focus, #search-box:hover { + border-bottom: 1px solid black; } +.hidden { display: none !important; } + @media only screen and (max-width: 750px) { - .main { - margin-top: 4rem; - } + .main { margin-top: 4rem; } .main > h1, .content { width: 90vw; max-width: unset; diff --git a/settings.go b/settings.go index 85979cd..3e0b3d2 100644 --- a/settings.go +++ b/settings.go @@ -1,7 +1,10 @@ package main -var ServerListen = ":7000" -var EntriesDirectory = "./entries" -var TemplateFile = "./public/index.html" -var StaticDirectory = "./public/static" -var MainTitle = "Encyclopedia" +import "golang.org/x/text/language" + +const ServerListen = ":7000" +const EntriesDirectory = "./entries" +const TemplateFile = "./public/index.html" +const StaticDirectory = "./public/static" +const MainTitle = "Encyclopedia" +var ContentLanguage = language.English