Compare commits

..

No commits in common. "main" and "v0.1.2" have entirely different histories.
main ... v0.1.2

28 changed files with 212 additions and 1461 deletions

2
.gitignore vendored
View file

@ -1,5 +1,3 @@
main
saved_notes
static
conf.toml
gonotes

View file

@ -1,11 +0,0 @@
# vim: noexpandtab ai sw=4 ts=4
all: build
build:
go run cmd/fetch-static/main.go
go build -o gonotes cmd/server/main.go
run:
./gonotes -c ./conf.toml
dev: build run

View file

@ -1,8 +1,12 @@
# gonotes
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.
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

View file

@ -1,7 +0,0 @@
package main
import "forgejo.gwairfelin.com/max/gonotes/internal/conf"
func main() {
conf.FetchAssets()
}

View file

@ -5,72 +5,29 @@ import (
"log"
"net"
"net/http"
"os"
"time"
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
"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
flag.StringVar(&confFile, "c", "/etc/gonotes/conf.toml", "Specify path to config file.")
flag.StringVar(&confFile, "c", "/etc/gonotes/conf.toml", "Specify path to config file. Default is /etc/gonotes/conf.toml")
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)
}
log.SetOutput(os.Stdout)
router := http.NewServeMux()
notesRouter := views.GetRoutes("/notes")
sessionRouter := sessions.Routes.GetRouter()
cacheExpiration, err := time.ParseDuration("24h")
if err != nil {
log.Fatal(err)
}
etag := middleware.NewETag("static", cacheExpiration)
router.Handle("/", middleware.LoggingMiddleware(http.RedirectHandler("/notes/", http.StatusFound)))
router.Handle("/notes/", 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(
middleware.StaticEtagMiddleware(
*etag,
http.FileServer(http.FS(conf.Static)),
conf.Conf.Static.Root,
logger(
http.StripPrefix(
"/static",
http.FileServer(http.Dir(conf.Conf.Static.Dir)),
),
),
)
@ -81,3 +38,10 @@ func main() {
}
log.Fatal(http.Serve(listener, router))
}
func logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Print(r.URL.Path)
next.ServeHTTP(w, r)
})
}

View file

@ -1,5 +0,0 @@
extension = "md"
notesdir = "/var/lib/gonotes/saved_notes"
address = ":8080"
protocol = "tcp"
logAccess = false

17
conf.toml Normal file
View file

@ -0,0 +1,17 @@
extension = "md"
notesdir = "saved_notes"
address = ":8080"
protocol = "tcp"
[templates]
dir = "templates"
base = "base.tmpl.html"
[static]
dir = "/home/max/src/gonotes/static"
root = "/static/"
assets = [
{ path = "css/bootstrap.min.css", url = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" },
{ 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" },
]

6
go.mod
View file

@ -1,14 +1,10 @@
module forgejo.gwairfelin.com/max/gonotes
go 1.24.5
go 1.23.5
require github.com/yuin/goldmark v1.7.8
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
github.com/yuin/goldmark-meta v1.1.0
golang.org/x/oauth2 v0.34.0
gopkg.in/yaml.v2 v2.3.0
)

14
go.sum
View file

@ -6,23 +6,9 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rhysd/go-fakeio v1.0.0 h1:+TjiKCOs32dONY7DaoVz/VPOdvRkPfBkEyUDIpM8FQY=
github.com/rhysd/go-fakeio v1.0.0/go.mod h1:joYxF906trVwp2JLrE4jlN7A0z6wrz8O6o1UjarbFzE=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/teekennedy/goldmark-markdown v0.5.1 h1:2lIlJ3AcIwaD1wFl4dflJSJFMhRTKEsEj+asVsu6M/0=
github.com/teekennedy/goldmark-markdown v0.5.1/go.mod h1:so260mNSPELuRyynZY18719dRYlD+OSnAovqsyrOMOM=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,66 +0,0 @@
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

@ -1,7 +1,6 @@
package conf
import (
"embed"
"errors"
"io"
"log"
@ -18,7 +17,7 @@ type Asset struct {
Url string
}
func (asset *Asset) fetchIfNotExists(staticPath string) {
func (asset *Asset) FetchIfNotExists(staticPath string) {
destPath := filepath.Join(staticPath, asset.Path)
err := os.MkdirAll(path.Dir(destPath), os.FileMode(0750))
@ -54,38 +53,22 @@ func (asset *Asset) fetchIfNotExists(staticPath string) {
}
type Config struct {
Address string
Protocol string
Extension string
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"`
Address string
Protocol string
Extension string
NotesDir string
Templates struct {
Dir string
Base string
}
Static struct {
Dir string
Root string
Assets []Asset
}
AnonCIDRs []string `toml:"anon_networks"`
}
var (
Conf Config
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/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"
//go:embed static/*
Static embed.FS
//go:embed templates/*
Templates embed.FS
)
var Conf Config
func LoadConfig(path string) {
var err error
@ -100,11 +83,7 @@ func LoadConfig(path string) {
log.Fatal(err)
}
log.Printf("Config is %+v", Conf)
}
func FetchAssets() {
for _, asset := range assets {
asset.fetchIfNotExists("./internal/conf/static")
for _, asset := range Conf.Static.Assets {
asset.FetchIfNotExists(Conf.Static.Dir)
}
}

View file

View file

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="16"
height="16"
fill="currentColor"
class="bi bi-journal-text"
viewBox="0 0 16 16"
version="1.1"
id="svg3"
sodipodi:docname="journal-text.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="28.90625"
inkscape:cx="6.4691892"
inkscape:cy="7.4205405"
inkscape:window-width="1262"
inkscape:window-height="1368"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg3" />
<ellipse
style="fill:#3d7b92;stroke:none;stroke-width:0.580872;stroke-dasharray:0.580872, 3.48524;fill-opacity:1"
id="path4"
cy="7.9568334"
cx="8.0086689"
rx="7.7174926"
ry="7.7520442" />
<path
d="M 5.8994595,9.9378378 A 0.3547027,0.38756756 0 0 1 6.2541622,9.5502707 H 7.672973 a 0.3547027,0.38756756 0 0 1 0,0.7751343 H 6.2541622 A 0.3547027,0.38756756 0 0 1 5.8994595,9.9378378 m 0,-1.5502703 A 0.3547027,0.38756756 0 0 1 6.2541622,8 h 3.5470271 a 0.3547027,0.38756756 0 0 1 0,0.7751349 H 6.2541622 A 0.3547027,0.38756756 0 0 1 5.8994595,8.3875675 m 0,-1.5502704 A 0.3547027,0.38756756 0 0 1 6.2541622,6.4497296 h 3.5470271 a 0.35470279,0.38756765 0 0 1 0,0.7751353 H 6.2541622 A 0.3547027,0.38756756 0 0 1 5.8994595,6.8372971 m 0,-1.5502701 A 0.3547027,0.38756756 0 0 1 6.2541622,4.8994595 h 3.5470271 a 0.3547027,0.38756756 0 0 1 0,0.775135 H 6.2541622 A 0.3547027,0.38756756 0 0 1 5.8994595,5.287027"
id="path1"
style="fill:#ffffff;fill-opacity:1;stroke-width:0.741542" />
<path
d="m 4.4806487,1.798919 h 7.0940543 a 1.4188108,1.5502703 0 0 1 1.41881,1.5502703 v 9.3016217 a 1.4188108,1.5502703 0 0 1 -1.41881,1.550269 H 4.4806487 A 1.4188108,1.5502703 0 0 1 3.0618379,12.650811 v -0.775135 h 0.7094053 v 0.775135 a 0.7094054,0.77513514 0 0 0 0.7094055,0.775135 h 7.0940543 a 0.7094054,0.77513514 0 0 0 0.709405,-0.775135 V 3.3491893 A 0.7094054,0.77513514 0 0 0 11.574703,2.5740541 H 4.4806487 A 0.7094054,0.77513514 0 0 0 3.7712432,3.3491893 v 0.775135 H 3.0618379 V 3.3491893 A 1.4188108,1.5502703 0 0 1 4.4806487,1.798919"
id="path2"
style="fill:#ffffff;fill-opacity:1;stroke-width:0.741542" />
<path
d="M 3.0618379,5.6745945 V 5.287027 a 0.3547027,0.38756756 0 0 1 0.7094053,0 V 5.6745945 H 4.125946 a 0.35470271,0.38756757 0 0 1 0,0.7751351 H 2.7071351 a 0.35470271,0.38756757 0 0 1 0,-0.7751351 z M 3.0618379,8 V 7.6124324 a 0.3547027,0.38756756 0 0 1 0.7094053,0 V 8 H 4.125946 a 0.3547027,0.38756756 0 0 1 0,0.7751349 H 2.7071351 A 0.3547027,0.38756756 0 0 1 2.7071351,8 Z m 0,2.325405 V 9.9378378 a 0.3547027,0.38756756 0 0 1 0.7094053,0 V 10.325405 H 4.125946 a 0.3547027,0.38756756 0 0 1 0,0.775135 H 2.7071351 a 0.3547027,0.38756756 0 0 1 0,-0.775135 z"
id="path3"
style="fill:#ffffff;fill-opacity:1;stroke-width:0.741542" />
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,91 +0,0 @@
{{define "navLinks"}}
{{end}}
{{define "navExtra"}}
<form class="row row-cols-lg-auto align-items-center" method="GET" action="{{ .urlNew }}">
<div class="col-12">
<input class="form-control mr-sm-2" type="text" placeholder="Title" aria-label="Title" name="title">
</div>
<div class="col-12">
<button class="btn btn-success my-2 my-sm-0" type="submit">New Note</button>
</div>
</form>
{{end}}
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{template "title" .}}</title>
<link href="/static/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"
crossorigin="anonymous">
<link rel="icon" type="image/svg+xml"
href="/static/icons/favicon.svg">
<script src="/static/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"></script>
</head>
<body>
<nav class="navbar navbar-expand-sm bg-body-tertiary mb-3">
<div class="container-fluid">
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse" data-bs-target="#navCollapse"
aria-controls="navCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse mt-2" id="navCollapse">
<a class="navbar-brand" href="/">
<img src="/static/icons/favicon.svg" width="30" height="30" class="d-inline-block align-top" alt="">
GoNotes
</a>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="/notes/">All Notes</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Tags
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item nav-link" href="/notes/">All</a>
</li>
{{range .userTags}}
<li>
<a class="dropdown-item nav-link" href="/notes/?tag={{.}}">{{.}}</a>
</li>
{{end}}
</ul>
</li>
{{template "navLinks" .}}
{{if eq .user "anon"}}
<li>
<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>
</div>
</nav>
<div class="container">
<div class="row justify-content-md-center">
<div class="col-md-6">
<h1>{{template "title" .}}</h1>
<main>
{{template "main" .}}
</main>
</div>
</div>
</div>
</body>
</html>
{{end}}

View file

@ -1,77 +0,0 @@
{{define "title"}}Edit: {{.note.Title}}{{end}}
{{define "navExtra"}}<!-- -->{{end}}
{{define "main"}}
<form action="{{.urlSave}}" method="POST">
<div class="mb-3">
<input type="text" class="form-control" id="noteTitleInput" name="title" aria-described-by="titleHelp" value="{{.note.Title}}"/>
<div id="titleHelp" class="form-text">Enter your note title</div>
</div>
<div class="mb-3">
<div class="border rounded rounded-1">
<div id="toolbar"></div>
<textarea class="form-control" id="noteBodyInput" name="body" aria-described-by="bodyHelp">{{.text}}</textarea>
</div>
<div id="bodyHelp" class="form-text">Enter your note content in markdown</div>
</div>
<button class="btn btn-primary" type="submit">Save</button>
</form>
<script src="/static/js/easy-mde.min.js"></script>
<link
rel="stylesheet"
type="text/css"
href="/static/css/easy-mde.min.css"
/>
<script type="text/javascript">
var easyMDE = new EasyMDE({
textarea: "noteBodyInput",
toolbar: [
'bold',
'italic',
'strikethrough',
'heading',
'|',
'unordered-list',
'ordered-list',
{
name: 'checklist',
action: (e) => {
e.codemirror.replaceSelection('* [ ] ');
e.codemirror.focus();
},
className: 'fa fa-check-square-o',
title: 'Add task list',
},
'|',
'link',
'|',
'preview',
'side-by-side'
],
autosave: {enabled: true, uniqueId: "{{.note.Uid}}"},
forceSync: true
});
let editTimeout;
let autoSaveDelay = 2000;
easyMDE.codemirror.on("change" , function() {
clearTimeout(editTimeout);
editTimeout = setTimeout(() => {
let form = document.querySelector("form");
let formData = new FormData(form);
fetch("{{.urlSave}}", {
method: "POST",
body: formData,
}).then((data) => {
console.log("Saved Note")
});
}, autoSaveDelay);
});
</script>
{{end}}

View file

@ -1,12 +0,0 @@
{{define "title"}}All Notes{{end}}
{{define "main"}}
<div class="list-group list-group-flush">
{{range $note := .notes}}
<a class="list-group-item list-group-item-action d-flex justify-content-between"
href="{{$note.URL}}/">
<span>{{$note.Title}}</span>
<img src="/static/icons/eye.svg" alt="Edit">
</a>
{{end}}
</div>
{{end}}

View file

@ -1,113 +0,0 @@
{{define "title"}}{{.note.Title}}{{end}}
{{define "main"}}
<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">
<a class="btn btn-primary" href="{{.urlEdit}}">Edit</a>
<a class="btn btn-danger" href="{{.urlDelete}}">Delete</a>
</div>
{{ if .isOwner }}
<div class="accordion mt-3" id="supplementaryAccordion">
<div class="accordion-item">
<h3 class="accordion-header">
<button class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapseOwnership"
aria-expanded="false"
aria-controls="collapseOwnership">
Ownership
</button>
</h3>
<div id="collapseOwnership" class="accordion-collapse collapse" data-bs-parent="#supplementaryAccordion">
<div class="accordion-body">
{{if .note.Viewers}}
<p>This note is owned by <em>{{.note.Owner}}</em> and is further visible to</p>
<form action="{{.urlUnshare}}" method="POST">
<table class="table vertical-align-middle">
{{range .viewers}}
<tr>
<td>{{.}}</td>
<td class="text-end">
<button class="btn btn-outline-warning btn-sm" type="submit" name="viewer" value="{{.}}">Un-Share</button>
</td>
</tr>
{{end}}
</table>
</form>
{{else}}
<p>This note is owned by <em>{{.note.Owner}}</em>.</p>
{{end}}
<form action="{{.urlShare}}" method="POST">
<div class="mb-3">
<input type="text" class="form-control" id="viewerInput" name="viewer" aria-described-by="viewerHelp" />
<div id="viewerHelp" class="form-text">Share with other user</div>
</div>
<button class="btn btn-primary" type="submit">Share</button>
</form>
</div>
</div>
</div>
<div class="accordion-item">
<h3 class="accordion-header">
<button class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapseTags"
aria-expanded="false"
aria-controls="collapseTags">
Tags
</button>
</h3>
<div id="collapseTags" class="accordion-collapse collapse" data-bs-parent="#supplementaryAccordion">
<div class="accordion-body">
<form action="{{.urlSetTags}}" method="POST">
<div class="mb-3">
<input type="text" class="form-control" id="tagInput" name="tags" aria-described-by="tagHelp" value="{{.tags}}"/>
<div id="tagHelp" class="form-text">Tags</div>
</div>
<button class="btn btn-primary" type="submit">Set Tags</button>
</form>
</div>
</div>
</div>
</div>
{{end}}
<script>
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.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) => {
return response.json();
}).then((json) => {
box.checked = json.checked;
})
}
}
</script>
{{end}}

View file

@ -1,52 +0,0 @@
// Middleware to add naive caching headers
package middleware
import (
"fmt"
"log"
"net/http"
"strings"
"time"
)
type ETag struct {
Name string
Value string
Expiration time.Duration
}
func (etag *ETag) Header() string {
return fmt.Sprintf("\"gn-%s-%s\"", etag.Name, etag.Value)
}
func (etag *ETag) CacheControlHeader() string {
return fmt.Sprintf("max-age=%d", int(etag.Expiration.Seconds()))
}
func StaticEtagMiddleware(etag ETag, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cc := etag.CacheControlHeader()
w.Header().Set("ETag", etag.Header())
w.Header().Set("Cache-Control", cc)
log.Printf("Returned etag with Cache-Control '%s'", cc)
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, etag.Value) {
log.Print("ETag cache hit")
w.WriteHeader(http.StatusNotModified)
return
}
}
next.ServeHTTP(w, r)
})
}
// GenerateETagFromBuffer calculates etag value from one or more buffers (e.g. embedded files)
func NewETag(name string, expiration time.Duration) *ETag {
return &ETag{
Name: name,
Expiration: expiration,
Value: fmt.Sprintf("%d", time.Now().Unix()),
}
}

View file

@ -1,36 +0,0 @@
// Middleware to Log out requests and response status code
package middleware
import (
"log"
"net/http"
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
)
// Response writer that store the status code for logging
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter {
return &loggingResponseWriter{w, http.StatusOK}
}
func (w *loggingResponseWriter) WriteHeader(code int) {
w.statusCode = code
w.ResponseWriter.WriteHeader(code)
}
// Middleware to log out requests and response status code
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lwr := NewLoggingResponseWriter(w)
next.ServeHTTP(lwr, r)
if conf.Conf.LogAccess {
log.Printf("%s %s %s%s %d", r.RemoteAddr, r.Method, r.Host, r.URL.Path, lwr.statusCode)
}
})
}

View file

@ -1,99 +0,0 @@
// 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

@ -1,146 +0,0 @@
// Package middleware to deal with sessions
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 {
User string
}
type SessionStore struct {
sessions map[string]Session
oauth *oauth2.Config
Routes urls.URLs
}
type ContextKey string
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}
cookie := http.Cookie{
Name: "id", Value: sessionID, MaxAge: 3600,
Secure: true, HttpOnly: true, Path: "/",
}
http.SetCookie(w, &cookie)
return sessionID
}
// 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)
cookie := http.Cookie{
Name: "id", Value: "", MaxAge: -1,
Secure: true, HttpOnly: true, Path: "/",
}
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")
user := "anon"
var cookieVal string
// Session exists
if err == nil {
session, ok := s.sessions[sessionCookie.Value]
// Session not expired
if ok {
user = session.User
cookieVal = sessionCookie.Value
}
}
nextWithSessionContext(w, r, next, user, cookieVal)
})
}
func nextWithSessionContext(w http.ResponseWriter, r *http.Request, next http.Handler, user string, sessionID string) {
ctx := r.Context()
ctx = context.WithValue(
context.WithValue(
ctx,
ContextKey("user"),
user,
),
ContextKey("session"),
sessionID,
)
next.ServeHTTP(w, r.WithContext(ctx))
}

View file

@ -1,34 +1,18 @@
// Package notes implements a data structure for reasoning about and rendering
// Notes implements a data structure for reasoning about and rendering
// markdown notes
package notes
import (
"bufio"
"bytes"
"cmp"
"crypto/rand"
"errors"
"fmt"
"html/template"
"log"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"time"
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
markdown "github.com/teekennedy/goldmark-markdown"
"github.com/yuin/goldmark"
meta "github.com/yuin/goldmark-meta"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
astext "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"gopkg.in/yaml.v2"
)
// Note is the central data structure. It can be Saved, Rendered and Loaded
@ -37,195 +21,26 @@ type Note struct {
Title string
BodyRendered template.HTML
Body []byte
Owner string
Viewers map[string]struct{}
Uid string
LastModified time.Time
Tags []string
}
type noteSet map[*Note]bool
type NoteStore struct {
notes map[string]noteSet
}
var (
Notes NoteStore
md goldmark.Markdown
)
func Init() error {
Notes = NoteStore{
notes: make(map[string]noteSet),
}
md = goldmark.New(
goldmark.WithExtensions(
extension.TaskList, meta.Meta,
),
)
notesDir := conf.Conf.NotesDir
files, err := os.ReadDir(notesDir)
if err != nil {
if os.IsNotExist(err) {
os.MkdirAll(notesDir, os.FileMode(0o750))
} else {
log.Print(err.Error())
return err
}
}
log.Printf("Looking in %s", notesDir)
for _, f := range files {
if !f.IsDir() {
uid := strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))
note, err := loadNote(uid)
if err != nil {
return err
}
title := note.Title
log.Printf("Found note %s (title '%s', owner %s)", uid, title, note.Owner)
Notes.Add(note, note.Owner)
for viewer := range note.Viewers {
Notes.Add(note, viewer)
}
}
}
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 {
if note.Uid == uid {
log.Printf("Found single note during GetOne %s", note.Title)
return note, true
}
}
return nil, false
}
func (ns *NoteStore) Add(note *Note, user string) {
if ns.notes[user] == nil {
ns.notes[user] = make(noteSet)
}
ns.notes[user][note] = true
}
func (ns *NoteStore) Del(note *Note, user string) error {
delete(ns.notes[user], note)
return note.Delete()
}
func (ns *NoteStore) UserTags(user string) []string {
tagSet := make(map[string]bool)
notes, ok := ns.notes[user]
if !ok {
return make([]string, 0)
}
log.Printf("Got notes for user %v", notes)
for note := range notes {
log.Printf("considering note %s (%s)", note.Title, note.Tags)
for _, tag := range note.Tags {
tagSet[tag] = true
}
}
log.Printf("Tagset is %v", tagSet)
return slices.Sorted(maps.Keys(tagSet))
}
func fmtPath(path string) string {
return fmt.Sprintf("%s.%s", path, conf.Conf.Extension)
}
func (n *Note) marshalFrontmatter() ([]byte, error) {
viewers := make([]string, 0, len(n.Viewers))
for viewer := range n.Viewers {
viewers = append(viewers, viewer)
}
frontmatter := make(map[string]any)
frontmatter["owner"] = n.Owner
frontmatter["viewers"] = viewers
frontmatter["title"] = n.Title
frontmatter["tags"] = n.Tags
marshaled, err := yaml.Marshal(&frontmatter)
return marshaled, err
}
func (n *Note) ViewersAsList() []string {
keys := make([]string, 0, len(n.Viewers))
for key := range n.Viewers {
keys = append(keys, key)
}
return keys
}
func NewNoteNoSave(title string, owner string) *Note {
note := &Note{Title: title, Owner: owner}
note.Viewers = make(map[string]struct{})
note.Tags = make([]string, 0, 5)
return note
}
func NewNote(title string, owner string) *Note {
note := NewNoteNoSave(title, owner)
note.Uid = rand.Text()
note.Save()
Notes.Add(note, note.Owner)
return note
}
// Save a note to a path derived from the title
func (n *Note) Save() error {
frontmatter, err := n.marshalFrontmatter()
if err != nil {
return err
}
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(n.Uid))
return os.WriteFile(
filename,
[]byte(fmt.Sprintf("---\n%s---\n%s", frontmatter, n.Body)),
0o600,
)
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(n.Title))
return os.WriteFile(filename, n.Body, 0600)
}
// Render the markdown content of the note to HTML
func (n *Note) Render() {
var buf bytes.Buffer
md := goldmark.New(
goldmark.WithExtensions(
extension.TaskList,
),
)
err := md.Convert(n.Body, &buf)
if err != nil {
log.Fatal(err)
@ -234,189 +49,17 @@ func (n *Note) Render() {
n.BodyRendered = template.HTML(buf.String())
}
// loadNote from the disk. The path is derived from the uid
func loadNote(uid string) (*Note, error) {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(uid))
f, err := os.Open(filename)
// Load a note from the disk. The path is derived from the title
func LoadNote(title string) (*Note, error) {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(title))
body, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
defer f.Close()
stat, err := os.Stat(filename)
if err != nil {
return nil, err
}
bodyScanner := bufio.NewScanner(f)
body := make([]byte, 0, 10)
fullBody := make([]byte, 0, 10)
inFrontmatter := false
for bodyScanner.Scan() {
fullBody = append(fullBody, bodyScanner.Bytes()...)
fullBody = append(fullBody, '\n')
text := bodyScanner.Text()
if text == "---" {
if !inFrontmatter {
inFrontmatter = true
} else {
inFrontmatter = false
break
}
}
}
for bodyScanner.Scan() {
body = append(body, bodyScanner.Bytes()...)
body = append(body, '\n')
}
var buf bytes.Buffer
context := parser.NewContext()
if err := md.Convert([]byte(fullBody), &buf, parser.WithContext(context)); err != nil {
return nil, err
}
metaData := meta.Get(context)
log.Printf("note has frontmatter %+v", metaData)
owner, ok := metaData["owner"].(string)
if !ok {
return nil, errors.New("invalid note, missing 'owner' in frontmatter")
}
title, ok := metaData["title"].(string)
if !ok {
return nil, errors.New("invalid note, missing 'title' in frontmatter")
}
note := NewNoteNoSave(title, owner)
note.Uid = uid
note.Body = body
note.LastModified = stat.ModTime()
viewers := metaData["viewers"].([]any)
for _, viewer := range viewers {
v, ok := viewer.(string)
if !ok {
return nil, errors.New("invalid note, non string type in 'viewers' in frontmatter")
}
if v == "" {
continue
}
note.AddViewer(v)
}
log.Printf("Note %s shared with %v", note.Title, note.Viewers)
tags, ok := metaData["tags"]
if ok {
tags := tags.([]any)
for _, tag := range tags {
t, ok := tag.(string)
if !ok {
return nil, errors.New("invalid note, non string type in 'tags' in frontmatter")
}
if t == "" {
continue
}
note.Tags = append(note.Tags, t)
}
}
return note, nil
return &Note{Title: title, Body: body}, nil
}
func (n *Note) AddViewer(viewer string) {
n.Viewers[viewer] = struct{}{}
}
func (n *Note) DelViewer(viewer string) {
delete(n.Viewers, viewer)
}
func (n *Note) Delete() error {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(n.Uid))
func DeleteNote(title string) error {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(title))
return os.Remove(filename)
}
type toggleCheckboxTransformer struct {
nthBox int
boxToggled bool
boxChecked bool
}
func (t *toggleCheckboxTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
boxesFound := 0
// Walk to find checkbox, toggle checked status
ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
box, ok := n.(*astext.TaskCheckBox)
if !ok {
return ast.WalkContinue, nil
}
if boxesFound < t.nthBox {
boxesFound += 1
return ast.WalkContinue, nil
}
box.IsChecked = !box.IsChecked
t.boxToggled = true
t.boxChecked = box.IsChecked
return ast.WalkStop, nil
})
}
func renderTaskCheckBox(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
var err error
var _ int
n, ok := node.(*astext.TaskCheckBox)
if !ok {
return ast.WalkContinue, errors.New("not a TaskCheckBox")
}
if entering {
// Render a box if necessary
if n.IsChecked {
_, err = writer.Write([]byte("[x] "))
} else {
_, err = writer.Write([]byte("[ ] "))
}
}
return ast.WalkContinue, err
}
func (n *Note) ToggleBox(nthBox int) (bool, bool) {
checkboxTransformer := toggleCheckboxTransformer{nthBox: nthBox, boxToggled: false}
transformer := util.Prioritized(&checkboxTransformer, 0)
renderer := markdown.NewRenderer()
renderer.Register(astext.KindTaskCheckBox, renderTaskCheckBox)
var buf bytes.Buffer
md := goldmark.New(goldmark.WithRenderer(renderer))
md.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(extension.NewTaskCheckBoxParser(), 0),
), parser.WithASTTransformers(transformer))
md.Convert(n.Body, &buf)
n.Body = buf.Bytes()
n.Save()
return checkboxTransformer.boxToggled, checkboxTransformer.boxChecked
}
func (n *Note) HasTag(tag string) bool {
return slices.Contains(n.Tags, tag)
}

View file

@ -1,71 +1,47 @@
package views
import (
"encoding/json"
"log"
"net/http"
"strconv"
"os"
"path/filepath"
"strings"
urls "forgejo.gwairfelin.com/max/gispatcho"
"forgejo.gwairfelin.com/max/gonotes/internal/middleware"
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
"forgejo.gwairfelin.com/max/gonotes/internal/notes"
"forgejo.gwairfelin.com/max/gonotes/internal/templ"
)
var myurls urls.URLs
func addRequestContext(r *http.Request, ctx templ.Ctx) templ.Ctx {
return ctx
}
func GetRoutes(prefix string) *http.ServeMux {
myurls = urls.URLs{
Prefix: prefix,
URLs: map[string]urls.URL{
"list": {Path: "/", Protocol: "GET", Handler: list},
"new": {Path: "/new", Protocol: "GET", Handler: new},
"view_": {Path: "/{note}", Protocol: "GET", Handler: view},
"view": {Path: "/{note}/", Protocol: "GET", Handler: view},
"setTags": {Path: "/{note}/tags/", Protocol: "POST", Handler: setTags},
"delete": {Path: "/{note}/delete/", Protocol: "GET", Handler: delete},
"edit": {Path: "/{note}/edit/", Protocol: "GET", Handler: edit},
"share": {Path: "/{note}/share/", Protocol: "POST", Handler: share},
"unshare": {Path: "/{note}/unshare/", Protocol: "POST", Handler: unshare},
"save": {Path: "/{note}/edit/save/", Protocol: "POST", Handler: save},
"togglebox": {Path: "/{note}/togglebox/", Protocol: "POST", Handler: togglebox},
"view": {Path: "/{note}/", Protocol: "GET", Handler: view},
"delete": {Path: "/{note}/delete/", Protocol: "GET", Handler: delete},
"edit": {Path: "/{note}/edit/", Protocol: "GET", Handler: edit},
"save": {Path: "/{note}/edit/save/", Protocol: "POST", Handler: save},
"list": {Path: "/", Protocol: "GET", Handler: list},
},
}
return myurls.GetRouter()
}
func view(w http.ResponseWriter, r *http.Request) {
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)
title := r.PathValue("note")
note, err := notes.LoadNote(title)
urlEdit := myurls.Reverse("edit", urls.Repl{"note": title})
urlDelete := myurls.Reverse("delete", urls.Repl{"note": title})
if err != nil {
http.Redirect(w, r, urlEdit, http.StatusFound)
return
}
viewers := note.ViewersAsList()
context := templ.Ctx{
"note": note,
"urlEdit": myurls.Reverse("edit", urls.Repl{"note": uid}),
"urlDelete": myurls.Reverse("delete", urls.Repl{"note": uid}),
"urlNew": myurls.Reverse("new", urls.Repl{}),
"urlShare": myurls.Reverse("share", urls.Repl{"note": uid}),
"urlUnshare": myurls.Reverse("unshare", urls.Repl{"note": uid}),
"urlSetTags": myurls.Reverse("setTags", urls.Repl{"note": uid}),
"viewers": viewers,
"tags": strings.Join(note.Tags, " "),
"isOwner": user == note.Owner,
}
context := templ.Ctx{"note": note, "urlEdit": urlEdit, "urlDelete": urlDelete}
note.Render()
err := templ.RenderTemplate(w, r, "view.tmpl.html", context)
err = templ.RenderTemplate(w, "view.tmpl.html", context)
if err != nil {
log.Print(err.Error())
http.Error(w, "Couldn't load template", http.StatusInternalServerError)
@ -74,17 +50,15 @@ func view(w http.ResponseWriter, r *http.Request) {
}
func edit(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(middleware.ContextKey("user")).(string)
uid := r.PathValue("note")
note, ok := notes.Notes.GetOne(user, uid)
if !ok {
note = notes.NewNote("", user)
title := r.PathValue("note")
note, err := notes.LoadNote(title)
if err != nil {
note = &notes.Note{Title: title}
}
urlSave := myurls.Reverse("save", urls.Repl{"note": uid})
urlSave := myurls.Reverse("save", urls.Repl{"note": title})
context := templ.Ctx{"note": note, "urlSave": urlSave, "text": string(note.Body)}
err := templ.RenderTemplate(w, r, "edit.tmpl.html", context)
err = templ.RenderTemplate(w, "edit.tmpl.html", context)
if err != nil {
log.Print(err.Error())
http.Error(w, "Couldn't load template", http.StatusInternalServerError)
@ -92,172 +66,55 @@ func edit(w http.ResponseWriter, r *http.Request) {
}
}
func new(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(middleware.ContextKey("user")).(string)
title := r.FormValue("title")
if len(title) == 0 {
title = "<New Note>"
}
note := notes.NewNote(title, user)
urlEdit := myurls.Reverse("edit", urls.Repl{"note": note.Uid})
http.Redirect(w, r, urlEdit, http.StatusFound)
}
func delete(w http.ResponseWriter, r *http.Request) {
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)
title := r.PathValue("note")
err := notes.DeleteNote(title)
if err != nil {
log.Print(err.Error())
http.Error(w, "Couldn't delete note", http.StatusInternalServerError)
return
}
urlList := myurls.Reverse("list", urls.Repl{})
http.Redirect(w, r, urlList, http.StatusFound)
urlDelete := myurls.Reverse("list", urls.Repl{})
http.Redirect(w, r, urlDelete, http.StatusFound)
}
func save(w http.ResponseWriter, r *http.Request) {
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)
}
oldTitle := r.PathValue("note")
title := r.FormValue("title")
body := r.FormValue("body")
log.Printf("About to save to note %+v", note)
note.Title = title
note.Body = []byte(body)
note := &notes.Note{Title: title, Body: []byte(body)}
note.Save()
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": note.Uid}), http.StatusFound)
}
func share(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(middleware.ContextKey("user")).(string)
uid := r.PathValue("note")
note, ok := notes.Notes.GetOne(user, uid)
if !ok || note.Owner != user {
http.NotFound(w, r)
if oldTitle != title {
notes.DeleteNote(oldTitle)
}
viewer := r.FormValue("viewer")
note.AddViewer(viewer)
note.Save()
notes.Notes.Add(note, viewer)
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": note.Uid}), http.StatusFound)
}
func unshare(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(middleware.ContextKey("user")).(string)
uid := r.PathValue("note")
note, ok := notes.Notes.GetOne(user, uid)
if !ok || note.Owner != user {
http.NotFound(w, r)
}
viewer := r.FormValue("viewer")
note.DelViewer(viewer)
note.Save()
notes.Notes.Del(note, viewer)
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": note.Uid}), http.StatusFound)
}
func togglebox(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(middleware.ContextKey("user")).(string)
uid := r.PathValue("note")
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.Error(w, "Note not found", 404)
return
}
toggled, checked := note.ToggleBox(nthBox)
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 {
Title string
URL string
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": title}), http.StatusFound)
}
func list(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(middleware.ContextKey("user")).(string)
files, err := os.ReadDir(conf.Conf.NotesDir)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
tag := r.FormValue("tag")
titles := make([]string, 0)
titlesAndUrls := make([]titleAndURL, 0)
ns := notes.Notes.GetOrdered(user)
log.Printf("Notes: %+v", notes.Notes)
log.Printf("Notes for %s: %+v", user, ns)
for _, note := range ns {
if tag == "" || note.HasTag(tag) {
titlesAndUrls = append(
titlesAndUrls,
titleAndURL{Title: note.Title, URL: myurls.Reverse("view", urls.Repl{"note": note.Uid})},
)
for _, f := range files {
if !f.IsDir() {
title := strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))
titles = append(titles, title)
}
}
urlNew := myurls.Reverse("new", urls.Repl{})
err := templ.RenderTemplate(w, r, "list.tmpl.html", templ.Ctx{"notes": titlesAndUrls, "urlNew": urlNew})
err = templ.RenderTemplate(w, "list.tmpl.html", templ.Ctx{"titles": titles})
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
func setTags(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(middleware.ContextKey("user")).(string)
uid := r.PathValue("note")
tags := r.FormValue("tags")
note, ok := notes.Notes.GetOne(user, uid)
if !ok || note.Owner != user {
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": uid}), http.StatusFound)
return
}
note.Tags = strings.Split(tags, " ")
note.Save()
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": note.Uid}), http.StatusFound)
}

View file

@ -3,39 +3,27 @@ package templ
import (
"html/template"
"net/http"
"os"
"path/filepath"
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
"forgejo.gwairfelin.com/max/gonotes/internal/middleware"
"forgejo.gwairfelin.com/max/gonotes/internal/notes"
)
type Ctx map[string]any
func RenderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, context Ctx) error {
var err error
func RenderTemplate(w http.ResponseWriter, tmpl string, context any) error {
files := []string{
filepath.Join("templates", "base.tmpl.html"),
filepath.Join("templates", tmpl),
filepath.Join(conf.Conf.Templates.Dir, conf.Conf.Templates.Base),
filepath.Join(conf.Conf.Templates.Dir, tmpl),
}
for _, f := range files {
file, err := conf.Templates.Open(f)
_, err := os.Stat(f)
if err != nil {
return err
}
file.Close()
}
t, err := template.ParseFS(conf.Templates, files...)
if err != nil {
return err
}
user := r.Context().Value(middleware.ContextKey("user")).(string)
context["user"] = user
context["userTags"] = notes.Notes.UserTags(user)
err = t.ExecuteTemplate(w, "base", context)
t, err := template.ParseFiles(files...)
t.ExecuteTemplate(w, "base", context)
return err
}

36
templates/base.tmpl.html Normal file
View file

@ -0,0 +1,36 @@
{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{template "title" .}}</title>
<link href="/static/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous">
</head>
<body>
<nav class="navbar navbar-expand-sm bg-body-tertiary mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">GoNotes</a>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="/notes/">All Notes</a>
</li>
</ul>
</div>
</nav>
<div class="container">
<div class="row justify-content-md-center">
<div class="col-md-6">
<h1>{{template "title" .}}</h1>
<main>
{{template "main" .}}
</main>
</div>
</div>
</div>
</body>
</html>
{{end}}

32
templates/edit.tmpl.html Normal file
View file

@ -0,0 +1,32 @@
{{define "title"}}Edit {{.note.Title}}{{end}}
{{define "main"}}
<form action="{{.urlSave}}" method="POST">
<div class="mb-3">
<input type="text" class="form-control" id="noteTitleInput" name="title" aria-described-by="titleHelp" value="{{.note.Title}}"/>
<div id="titleHelp" class="form-text">Enter your note title</div>
</div>
<div class="mb-3">
<div class="border rounded rounded-1">
<div id="toolbar"></div>
<textarea class="form-control" id="noteBodyInput" name="body" aria-described-by="bodyHelp">{{.text}}</textarea>
</div>
<div id="bodyHelp" class="form-text">Enter your note content in markdown</div>
</div>
<button class="btn btn-primary" type="submit">Save</button>
</form>
<script src="/static/js/tiny-mde.min.js"></script>
<link
rel="stylesheet"
type="text/css"
href="/static/css/tiny-mde.min.css"
/>
<script type="text/javascript">
var tinyMDE = new TinyMDE.Editor({ textarea: "noteBodyInput" });
var commandBar = new TinyMDE.CommandBar({
element: "toolbar",
editor: tinyMDE,
});
</script>
{{end}}

10
templates/list.tmpl.html Normal file
View file

@ -0,0 +1,10 @@
{{define "title"}}All Notes{{end}}
{{define "main"}}
<ul>
{{range $title := .titles}}
<li>
<a href="{{$title}}/">{{$title}}</a>
</li>
{{end}}
</ul>
{{end}}

11
templates/view.tmpl.html Normal file
View file

@ -0,0 +1,11 @@
{{define "title"}}{{.note.Title}}{{end}}
{{define "main"}}
<div>
{{.note.BodyRendered}}
</div>
<div>
<a href="{{.urlEdit}}">Edit</a>
<a href="{{.urlDelete}}">Delete</a>
</div>
{{end}}