Compare commits

...

13 commits
0.9.16 ... main

13 changed files with 404 additions and 125 deletions

View file

@ -1,12 +1,8 @@
# gonotes
A toyish project for a shared notebook of sorts implemented in go. It's mostly
a learning opportunity for using go, and partially an intent to end up with a
shared notebook!
It's not shared in the sense of simultaneous editing, don't do that!
## TODO
* handle static urls in the django url mapping style
* Style up the templates better
A shared notebook of sorts implemented in go. Shared here means that the
content is visible to more than one user. It does not currently support
simultaneous editing and there are no plans to do that. Furthe, it
*currently* does not support the concept of a lock or checkout when a note
is edited. It's the responsibility of the editing parties to prevent races
and conflicts.

View file

@ -12,18 +12,26 @@ import (
"forgejo.gwairfelin.com/max/gonotes/internal/middleware"
"forgejo.gwairfelin.com/max/gonotes/internal/notes"
"forgejo.gwairfelin.com/max/gonotes/internal/notes/views"
"golang.org/x/oauth2"
)
func main() {
var confFile string
sessions := middleware.NewSessionStore()
flag.StringVar(&confFile, "c", "/etc/gonotes/conf.toml", "Specify path to config file.")
flag.Parse()
conf.LoadConfig(confFile)
oauth := &oauth2.Config{
ClientID: conf.Conf.OIDC.ClientID,
ClientSecret: conf.Conf.OIDC.ClientSecret,
Endpoint: oauth2.Endpoint{AuthURL: conf.Conf.OIDC.AuthURL, TokenURL: conf.Conf.OIDC.TokenURL},
RedirectURL: conf.Conf.OIDC.RedirectURL,
}
sessions := middleware.NewSessionStore(oauth, "/auth")
err := notes.Init()
if err != nil {
log.Fatal(err)
@ -33,6 +41,7 @@ func main() {
router := http.NewServeMux()
notesRouter := views.GetRoutes("/notes")
sessionRouter := sessions.Routes.GetRouter()
cacheExpiration, err := time.ParseDuration("24h")
if err != nil {
@ -41,31 +50,21 @@ func main() {
etag := middleware.NewETag("static", cacheExpiration)
if !conf.Conf.Production {
router.HandleFunc("/login/", func(w http.ResponseWriter, r *http.Request) {
user := r.FormValue("user")
if len(user) == 0 {
user = "anon"
}
sessions.Login(user, w)
http.Redirect(w, r, "/notes/", http.StatusFound)
})
}
router.Handle("/logout/", sessions.AsMiddleware(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := r.FormValue("user")
if len(user) == 0 {
user = "anon"
}
sessions.Logout(w, r)
http.Redirect(w, r, "/notes/", http.StatusFound)
})))
router.Handle("/", middleware.LoggingMiddleware(http.RedirectHandler("/notes/", http.StatusFound)))
router.Handle("/notes/", sessions.AsMiddleware(middleware.LoggingMiddleware(http.StripPrefix("/notes", notesRouter))))
router.Handle(
"/notes/",
sessions.AsMiddleware(
middleware.LoggingMiddleware(
middleware.RejectAnonMiddleware(
"/auth/login/",
http.StripPrefix(
"/notes", notesRouter,
),
),
),
),
)
router.Handle("/auth/", sessions.AsMiddleware(middleware.LoggingMiddleware(http.StripPrefix("/auth", sessionRouter))))
router.Handle(
"/static/",
middleware.LoggingMiddleware(

8
go.mod
View file

@ -8,9 +8,7 @@ require (
forgejo.gwairfelin.com/max/gispatcho v0.1.2
github.com/pelletier/go-toml/v2 v2.2.3
github.com/teekennedy/goldmark-markdown v0.5.1
)
require (
github.com/yuin/goldmark-meta v1.1.0 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
github.com/yuin/goldmark-meta v1.1.0
golang.org/x/oauth2 v0.34.0
gopkg.in/yaml.v2 v2.3.0
)

3
go.sum
View file

@ -18,6 +18,9 @@ github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUei
github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
go.abhg.dev/goldmark/toc v0.11.0 h1:IRixVy3/yVPKvFBc37EeBPi8XLTXrtH6BYaonSjkF8o=
go.abhg.dev/goldmark/toc v0.11.0/go.mod h1:XMFIoI1Sm6dwF9vKzVDOYE/g1o5BmKXghLG8q/wJNww=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

66
internal/auth/oauth.go Normal file
View file

@ -0,0 +1,66 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
"golang.org/x/oauth2"
)
func GenerateStateOAUTHCookie(w http.ResponseWriter, prefix string) string {
b := make([]byte, 16)
rand.Read(b)
state := base64.URLEncoding.EncodeToString(b)
cookie := http.Cookie{
Name: "oauthstate", Value: state,
MaxAge: 30, Secure: true, HttpOnly: true, Path: prefix,
}
http.SetCookie(w, &cookie)
return state
}
func GetUserFromForgejo(code string, oauth *oauth2.Config) (string, error) {
// Use code to get token and get user info from Google.
token, err := oauth.Exchange(context.Background(), code)
if err != nil {
return "", fmt.Errorf("code exchange wrong: %s", err.Error())
}
request, err := http.NewRequest("GET", conf.Conf.OIDC.UserinfoURL, nil)
if err != nil {
return "", fmt.Errorf("failed to init http client for userinfo: %s", err.Error())
}
request.Header.Set("Authorization", fmt.Sprintf("token %s", token.AccessToken))
response, err := http.DefaultClient.Do(request)
if err != nil {
return "", fmt.Errorf("failed getting user info: %s", err.Error())
}
defer response.Body.Close()
uInf := make(map[string]any)
err = json.NewDecoder(response.Body).Decode(&uInf)
if err != nil {
return "", fmt.Errorf("failed to parse response as json: %s", err.Error())
}
username, ok := uInf["preferred_username"]
if !ok {
return "", fmt.Errorf("no username in response: %s", err.Error())
}
userStr, ok := username.(string)
if !ok {
return "", fmt.Errorf("username not a string: %s", err.Error())
}
return userStr, nil
}

View file

@ -60,6 +60,15 @@ type Config struct {
NotesDir string
LogAccess bool
Production bool
OIDC struct {
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
AuthURL string `toml:"auth_url"`
TokenURL string `toml:"token_url"`
RedirectURL string `toml:"redirect_url"`
UserinfoURL string `toml:"userinfo_url"`
}
AnonCIDRs []string `toml:"anon_networks"`
}
var (
@ -67,8 +76,8 @@ var (
assets []Asset = []Asset{
{Path: "css/bootstrap.min.css", Url: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"},
{Path: "js/bootstrap.bundle.min.js", Url: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"},
{Path: "css/tiny-mde.min.css", Url: "https://unpkg.com/tiny-markdown-editor/dist/tiny-mde.min.css"},
{Path: "js/tiny-mde.min.js", Url: "https://unpkg.com/tiny-markdown-editor/dist/tiny-mde.min.js"},
{Path: "css/easy-mde.min.css", Url: "https://unpkg.com/easymde/dist/easymde.min.css"},
{Path: "js/easy-mde.min.js", Url: "https://unpkg.com/easymde/dist/easymde.min.js"},
{Path: "icons/eye.svg", Url: "https://raw.githubusercontent.com/twbs/icons/refs/heads/main/icons/eye.svg"},
}
BaseTemplate string = "base.tmpl.html"

View file

@ -62,9 +62,15 @@
</ul>
</li>
{{template "navLinks" .}}
{{if eq .user "anon"}}
<li>
<a class="nav-link" href="/logout/">Logout {{.user}}</a>
<a class="nav-link" href="/auth/login/">Login</a>
</li>
{{else}}
<li>
<a class="nav-link" href="/auth/logout/">Logout {{.user}}</a>
</li>
{{end}}
</ul>
{{template "navExtra" .}}
</div>

View file

@ -17,38 +17,48 @@
<button class="btn btn-primary" type="submit">Save</button>
</form>
<script src="/static/js/tiny-mde.min.js"></script>
<script src="/static/js/easy-mde.min.js"></script>
<link
rel="stylesheet"
type="text/css"
href="/static/css/tiny-mde.min.css"
href="/static/css/easy-mde.min.css"
/>
<script type="text/javascript">
var tinyMDE = new TinyMDE.Editor({ textarea: "noteBodyInput" });
var commandBar = new TinyMDE.CommandBar({
element: "toolbar",
editor: tinyMDE,
commands: [
'bold', 'italic', 'strikethrough',
var easyMDE = new EasyMDE({
textarea: "noteBodyInput",
toolbar: [
'bold',
'italic',
'strikethrough',
'heading',
'|',
'code',
'unordered-list',
'ordered-list',
{
name: 'checklist',
action: (e) => {
e.codemirror.replaceSelection('* [ ] ');
e.codemirror.focus();
},
className: 'fa fa-check-square-o',
title: 'Add task list',
},
'|',
'h1', 'h2',
'link',
'|',
'ul', 'ol',
'|',
'blockquote', 'hr',
'|',
'insertLink', 'insertImage'
'preview',
'side-by-side'
],
autosave: {enabled: true, uniqueId: "{{.note.Uid}}"},
forceSync: true
});
let editTimeout;
let autoSaveDelay = 2000;
tinyMDE.addEventListener("change", function() {
easyMDE.codemirror.on("change" , function() {
clearTimeout(editTimeout);
editTimeout = setTimeout(() => {
let form = document.querySelector("form");

View file

@ -1,6 +1,13 @@
{{define "title"}}{{.note.Title}}{{end}}
{{define "main"}}
<div>
<style>
li:has(> input[type="checkbox"]) {
padding-top: 0.1em;
padding-bottom: 0.1em;
cursor: default;
}
</style>
<div id="noteContent">
{{.note.BodyRendered}}
</div>
<div class="d-flex justify-content-between">
@ -77,17 +84,27 @@
{{end}}
<script>
let checkBoxes = document.querySelectorAll('input[type=checkbox]')
for (const i in checkBoxes) {
let checkBoxes = document.querySelectorAll('input[type=checkbox]');
let noteContentWrapper = document.querySelector('#noteContent');
console.log(checkBoxes.keys())
for (const i of checkBoxes.keys()) {
let box = checkBoxes[i]
let parent = box.parentNode
box.disabled = false
box.onchange = function(event) {
let form = new FormData()
form.append("box", i)
box.onclick = function(event) {
return false;
}
parent.onclick = function(event) {
let form = new FormData();
form.append("box", i);
fetch("togglebox/", {method: "POST", body: form}).then((response) => {
location.reload();
return response.json();
}).then((json) => {
box.checked = json.checked;
})
}
}

View file

@ -0,0 +1,99 @@
// Middleware designed to reject requests from anon users unless from 'safe'
// IP addresses
package middleware
import (
"errors"
"fmt"
"log"
"net"
"net/http"
"strings"
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
)
type netList []net.IPNet
const ipHeader = "x-forwarded-for"
// Check if any IPNet in the netList contains the given IP
func (n *netList) Contains(ip net.IP) bool {
for _, net := range *n {
if contains := net.Contains(ip); contains {
return true
}
}
return false
}
// Redirect to redirect url any request where the user is anon and the request
// does not appear to come from a safe origin
func RejectAnonMiddleware(redirect string, next http.Handler) http.Handler {
safeOriginNets := make(netList, 0, len(conf.Conf.AnonCIDRs))
for _, cidr := range conf.Conf.AnonCIDRs {
_, net, err := net.ParseCIDR(cidr)
if err != nil {
log.Printf("ignoring invalid cidr: %s", err)
continue
}
safeOriginNets = append(safeOriginNets, *net)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(ContextKey("user")).(string)
originIP, err := getOriginIP(r)
if err != nil {
log.Printf("unable to check origin ip: %s", err)
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
return
}
log.Printf("origin ip: %s", originIP)
safeOrigin := safeOriginNets.Contains(originIP)
if user == "anon" && !safeOrigin {
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
return
}
next.ServeHTTP(w, r)
})
}
// Get the origin ip from the x-forwarded-for header, or the source of
// the request if not available
func getOriginIP(r *http.Request) (net.IP, error) {
sourceIpHeader, ok := r.Header[http.CanonicalHeaderKey(ipHeader)]
if !ok {
addrParts := strings.Split(r.RemoteAddr, ":")
if len(addrParts) == 0 {
return nil, errors.New("no source ip available")
}
ip := net.ParseIP(addrParts[0])
if ip == nil {
return nil, fmt.Errorf("ip could not be parsed: %s", addrParts[0])
}
return ip, nil
}
if len(sourceIpHeader) != 1 {
return nil, fmt.Errorf("header has more than 1 value: %s=%v", ipHeader, sourceIpHeader)
}
ips := strings.Split(sourceIpHeader[0], ",")
ip := net.ParseIP(ips[0])
if ip == nil {
return nil, fmt.Errorf("not parseable as ip: %s", ips[0])
}
return ip, nil
}

View file

@ -4,7 +4,12 @@ package middleware
import (
"context"
"crypto/rand"
"log"
"net/http"
urls "forgejo.gwairfelin.com/max/gispatcho"
"forgejo.gwairfelin.com/max/gonotes/internal/auth"
"golang.org/x/oauth2"
)
type Session struct {
@ -13,14 +18,29 @@ type Session struct {
type SessionStore struct {
sessions map[string]Session
oauth *oauth2.Config
Routes urls.URLs
}
type ContextKey string
func NewSessionStore() SessionStore {
return SessionStore{sessions: make(map[string]Session, 10)}
func NewSessionStore(oauth *oauth2.Config, prefix string) SessionStore {
store := SessionStore{
sessions: make(map[string]Session, 10),
oauth: oauth,
}
store.Routes = urls.URLs{
Prefix: prefix,
URLs: map[string]urls.URL{
"login": {Path: "/login/", Protocol: "GET", Handler: store.LoginViewOAUTH},
"callback": {Path: "/callback/", Protocol: "GET", Handler: store.CallbackViewOAUTH},
"logout": {Path: "/logout/", Protocol: "GET", Handler: store.LogoutView},
},
}
return store
}
// Log a user in
func (s *SessionStore) Login(user string, w http.ResponseWriter) string {
sessionID := rand.Text()
s.sessions[sessionID] = Session{User: user}
@ -34,7 +54,8 @@ func (s *SessionStore) Login(user string, w http.ResponseWriter) string {
return sessionID
}
func (s *SessionStore) Logout(w http.ResponseWriter, r *http.Request) {
// View to logout a user
func (s *SessionStore) LogoutView(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(ContextKey("session")).(string)
delete(s.sessions, session)
@ -44,42 +65,68 @@ func (s *SessionStore) Logout(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &cookie)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
// View to log in a user via oauth
func (s *SessionStore) LoginViewOAUTH(w http.ResponseWriter, r *http.Request) {
log.Printf("%+v", *s.oauth)
oauthState := auth.GenerateStateOAUTHCookie(w, s.Routes.Prefix)
url := s.oauth.AuthCodeURL(oauthState)
log.Printf("Redirecting to %s", url)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
// Oauth callback view
func (s *SessionStore) CallbackViewOAUTH(w http.ResponseWriter, r *http.Request) {
// Read oauthState from Cookie
oauthState, err := r.Cookie("oauthstate")
if err != nil {
log.Printf("An error occured during login: %s", err)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
log.Printf("%v", oauthState)
if r.FormValue("state") != oauthState.Value {
log.Println("invalid oauth state")
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
username, err := auth.GetUserFromForgejo(r.FormValue("code"), s.oauth)
if err != nil {
log.Println(err.Error())
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
s.Login(username, w)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
// Turn the session store into a middleware.
// Sets the user on the context based on the available session cookie
func (s *SessionStore) AsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionCookie, err := r.Cookie("id")
// No session yet
if err != nil {
user := r.Header.Get("X-Auth-Request-User")
user := "anon"
var cookieVal string
// Session exists
if err == nil {
session, ok := s.sessions[sessionCookie.Value]
if user != "" {
sessionID := s.Login(user, w)
nextWithSessionContext(w, r, next, user, sessionID)
} else {
http.Redirect(w, r, "/login/", http.StatusFound)
// Session not expired
if ok {
user = session.User
cookieVal = sessionCookie.Value
}
return
}
session, ok := s.sessions[sessionCookie.Value]
// Session expired
if !ok {
user := r.Header.Get("X-Auth-Request-User")
if user != "" {
sessionID := s.Login(user, w)
nextWithSessionContext(w, r, next, user, sessionID)
} else {
http.Redirect(w, r, "/login/", http.StatusFound)
}
return
}
nextWithSessionContext(w, r, next, session.User, sessionCookie.Value)
nextWithSessionContext(w, r, next, user, cookieVal)
})
}

View file

@ -5,13 +5,16 @@ package notes
import (
"bufio"
"bytes"
"cmp"
"crypto/rand"
"errors"
"fmt"
"html/template"
"log"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"time"
@ -98,14 +101,26 @@ func Init() error {
return nil
}
func (ns *noteSet) Sort() []*Note {
orderByTitle := func(a, b *Note) int {
return cmp.Compare(a.Title, b.Title)
}
return slices.SortedFunc(maps.Keys(*ns), orderByTitle)
}
func (ns *NoteStore) Get(owner string) noteSet {
return ns.notes[owner]
}
func (ns *NoteStore) GetOrdered(owner string) []*Note {
notes := ns.Get(owner)
return notes.Sort()
}
func (ns *NoteStore) GetOne(owner string, uid string) (*Note, bool) {
notes := ns.Get(owner)
for note, _ := range notes {
for note := range notes {
if note.Uid == uid {
log.Printf("Found single note during GetOne %s", note.Title)
return note, true
@ -121,8 +136,9 @@ func (ns *NoteStore) Add(note *Note, user string) {
ns.notes[user][note] = true
}
func (ns *NoteStore) Del(note *Note, user string) {
func (ns *NoteStore) Del(note *Note, user string) error {
delete(ns.notes[user], note)
return note.Delete()
}
func (ns *NoteStore) UserTags(user string) []string {
@ -145,13 +161,7 @@ func (ns *NoteStore) UserTags(user string) []string {
log.Printf("Tagset is %v", tagSet)
tags := make([]string, len(tagSet))
i := 0
for tag := range tagSet {
tags[i] = tag
i++
}
return tags
return slices.Sorted(maps.Keys(tagSet))
}
func fmtPath(path string) string {
@ -331,12 +341,16 @@ func (n *Note) DelViewer(viewer string) {
delete(n.Viewers, viewer)
}
func DeleteNote(uid string) error {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(uid))
func (n *Note) Delete() error {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(n.Uid))
return os.Remove(filename)
}
type toggleCheckboxTransformer struct{ nthBox int }
type toggleCheckboxTransformer struct {
nthBox int
boxToggled bool
boxChecked bool
}
func (t *toggleCheckboxTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
boxesFound := 0
@ -358,6 +372,8 @@ func (t *toggleCheckboxTransformer) Transform(node *ast.Document, reader text.Re
}
box.IsChecked = !box.IsChecked
t.boxToggled = true
t.boxChecked = box.IsChecked
return ast.WalkStop, nil
})
}
@ -380,8 +396,8 @@ func renderTaskCheckBox(writer util.BufWriter, source []byte, node ast.Node, ent
return ast.WalkContinue, err
}
func (n *Note) ToggleBox(nthBox int) {
checkboxTransformer := toggleCheckboxTransformer{nthBox: nthBox}
func (n *Note) ToggleBox(nthBox int) (bool, bool) {
checkboxTransformer := toggleCheckboxTransformer{nthBox: nthBox, boxToggled: false}
transformer := util.Prioritized(&checkboxTransformer, 0)
renderer := markdown.NewRenderer()
@ -397,13 +413,10 @@ func (n *Note) ToggleBox(nthBox int) {
md.Convert(n.Body, &buf)
n.Body = buf.Bytes()
n.Save()
return checkboxTransformer.boxToggled, checkboxTransformer.boxChecked
}
func (n *Note) HasTag(tag string) bool {
for _, tag_ := range n.Tags {
if tag_ == tag {
return true
}
}
return false
return slices.Contains(n.Tags, tag)
}

View file

@ -1,6 +1,7 @@
package views
import (
"encoding/json"
"log"
"net/http"
"strconv"
@ -106,10 +107,18 @@ func new(w http.ResponseWriter, r *http.Request) {
}
func delete(w http.ResponseWriter, r *http.Request) {
// user := r.Context().Value(middleware.ContextKey("user")).(string)
user := r.Context().Value(middleware.ContextKey("user")).(string)
uid := r.PathValue("note")
note, ok := notes.Notes.GetOne(user, uid)
if !ok {
http.NotFound(w, r)
}
err := notes.Notes.Del(note, user)
encodedTitle := r.PathValue("note")
err := notes.DeleteNote(encodedTitle)
if err != nil {
log.Print(err.Error())
http.Error(w, "Couldn't delete note", http.StatusInternalServerError)
@ -181,18 +190,25 @@ func togglebox(w http.ResponseWriter, r *http.Request) {
nthBox, err := strconv.Atoi(r.FormValue("box"))
if err != nil {
log.Fatal("You fucked up boy")
http.Error(w, "Box not provided as numeric value", 400)
return
}
note, ok := notes.Notes.GetOne(user, uid)
if !ok {
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": uid}), http.StatusFound)
http.Error(w, "Note not found", 404)
return
}
note.ToggleBox(nthBox)
toggled, checked := note.ToggleBox(nthBox)
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": note.Uid}), http.StatusFound)
if !toggled {
http.Error(w, "Failed to toggle box", 500)
} else {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{"checked": checked})
}
}
type titleAndURL struct {
@ -207,11 +223,11 @@ func list(w http.ResponseWriter, r *http.Request) {
titlesAndUrls := make([]titleAndURL, 0)
ns := notes.Notes.Get(user)
ns := notes.Notes.GetOrdered(user)
log.Printf("Notes: %+v", notes.Notes)
log.Printf("Notes for %s: %+v", user, ns)
for note := range ns {
for _, note := range ns {
if tag == "" || note.HasTag(tag) {
titlesAndUrls = append(
titlesAndUrls,