Compare commits

...

62 commits
v0.1.2 ... main

Author SHA1 Message Date
a955c49373 Fix delete behaviour 2026-01-05 21:13:00 +00:00
0498aadcf2 Ensure tags are sorted 2026-01-05 20:53:41 +00:00
38506f7e8b Sort notes by title for list 2026-01-05 20:49:27 +00:00
8eaa0afda1 Tweak readme 2025-12-11 21:39:56 +00:00
35a5dfc17a Add comments 2025-12-11 21:36:13 +00:00
55c7e00ad6 Make netlist configurable 2025-12-11 21:34:33 +00:00
63405b6dc2 Add reject anon middleware 2025-12-11 21:18:23 +00:00
a1c5827641 Refactor forgejo user interaction 2025-12-10 20:56:17 +00:00
a01f6dec23 Comment some session functions 2025-12-10 20:45:33 +00:00
d30327817e Refactor oauth login 2025-12-10 20:40:41 +00:00
a750f646a9 Implement oidc client of sorts 2025-12-10 16:34:40 +00:00
1772e57ee8 Make checking boxes not require full page reload 2025-11-13 14:44:24 +00:00
cb24ab18c5 Use tinyMDE which has better checklist support 2025-11-13 13:45:09 +00:00
d7b56c3b86 Fix integrity sri 2025-11-05 13:22:35 +00:00
38bbc35922 Remove noisy log statements 2025-11-05 09:27:35 +00:00
1df4f3f807 Add mechanism to set tags on note 2025-11-05 09:27:08 +00:00
7a44876e77 Allow navbar to collapse on small screens 2025-11-05 08:55:10 +00:00
e52f0cbe98 Pretty up filtering by tag 2025-11-05 08:49:01 +00:00
95865257a3 Support listing notes by tag 2025-11-05 08:46:22 +00:00
105275f3e0 Implement beginnings of tagging 2025-11-04 17:05:49 +00:00
4fb4bec5a8 Allow unsharing, only show share controls for owner 2025-10-09 21:04:45 +01:00
a54abeaea2 Add notes to note store when shared 2025-10-09 20:27:03 +01:00
8340647f08 Chill out logging of note content a little 2025-09-30 16:33:29 +01:00
94735318b6 Try to fix panic 2025-09-30 16:27:00 +01:00
3de1b8b714 Load LastModified time on note 2025-09-30 11:14:29 +01:00
33cc62b3fc Auto save note 2025-09-30 11:07:54 +01:00
a90859e151 Avoid referencing bad variable 2025-09-30 10:28:58 +01:00
c2e6612647 Change logging 2025-09-30 10:17:05 +01:00
c098abf14f Auto login using X-Auth-Request-User header 2025-09-30 09:57:10 +01:00
3c60b6265e Log out all headers in session 2025-09-30 09:29:03 +01:00
4fda818e6e Further work on note ownership and sharing 2025-08-23 22:18:36 +01:00
bb775ec55f Store title in frontmatter, make fname uid only 2025-08-23 21:09:39 +01:00
de66fb0b77 Store and save metadata in frontmatter 2025-08-08 21:10:10 +01:00
03b6bb12ca Keep note ownership in frontmatter and shiz 2025-07-31 17:02:08 +01:00
25bcf4d706 Add half arsed user separation 2025-07-30 13:41:03 +01:00
3c792decd6 Remove logging of session token 2025-07-30 09:35:31 +01:00
17dd20478d Make start on session management 2025-07-30 09:35:01 +01:00
352d9555ba Allow ticking and unticking checkboxes 2025-07-29 12:44:01 +01:00
4aa09ce502 Attempt to implement checkbox shitter 2025-07-25 13:44:21 +01:00
0f2400435f Don't fall over on empty note title 2025-07-25 10:48:41 +01:00
6f8796c83f Base64 encode note titles 2025-06-27 22:56:50 +01:00
eb0c264ad7 Add doc comments 2025-06-27 20:46:52 +01:00
6e56cefe6f Move logging middleware into internal middleware package 2025-06-27 20:42:07 +01:00
06495fa358 Add caching to static files based on etag 2025-06-27 20:39:17 +01:00
f2e52a18e5 Something something etag middleware 2025-06-27 19:58:15 +01:00
91cc68efe7 Log to stdout 2025-06-26 22:42:03 +01:00
3623fb8e8e Add full and configurable access logging 2025-06-26 22:00:28 +01:00
c16aa1603d Add favicon 2025-06-26 22:00:14 +01:00
107f11a23a Embed all of static 2025-06-26 21:22:30 +01:00
e3fc5f5f10 Remove stale static config 2025-06-25 22:53:22 +01:00
28438725c7 Compile to gonotes 2025-06-25 22:52:33 +01:00
a9e304ee08 Fix embedding of static files 2025-06-25 22:49:57 +01:00
8edd857d22 Embed templates and static files 2025-06-25 22:43:34 +01:00
868d2a7bbd Don't make assets configurable 2025-06-25 21:30:57 +01:00
da11dbd1c8 Use eye icon for viewing 2025-06-25 21:30:36 +01:00
68a349f072 Markup links that should be buttons as buttons 2025-06-24 22:38:10 +01:00
e4f7ff6218 Polish the list template 2025-06-24 22:37:47 +01:00
551dcf5905 Allow creating new note without trailing slash 2025-06-24 22:14:32 +01:00
c82db192e1 Add redirect from root to /notes/ 2025-06-24 22:13:05 +01:00
8f60040f86 Specify example path of templates directory 2025-06-24 19:55:31 +01:00
90280ce7b8 Don't specify default in helptext 2025-06-24 19:48:55 +01:00
c384d86e59 Add example config 2025-06-18 22:28:43 +01:00
28 changed files with 1461 additions and 212 deletions

2
.gitignore vendored
View file

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

11
Makefile Normal file
View file

@ -0,0 +1,11 @@
# 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,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.

7
cmd/fetch-static/main.go Normal file
View file

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

View file

@ -5,29 +5,72 @@ 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. Default is /etc/gonotes/conf.toml")
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)
}
log.SetOutput(os.Stdout)
router := http.NewServeMux()
notesRouter := views.GetRoutes("/notes")
sessionRouter := sessions.Routes.GetRouter()
router.Handle("/notes/", http.StripPrefix("/notes", notesRouter))
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(
conf.Conf.Static.Root,
logger(
http.StripPrefix(
"/static",
http.FileServer(http.Dir(conf.Conf.Static.Dir)),
"/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)),
),
),
)
@ -38,10 +81,3 @@ 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)
})
}

5
conf.example.toml Normal file
View file

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

View file

@ -1,17 +0,0 @@
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,10 +1,14 @@
module forgejo.gwairfelin.com/max/gonotes
go 1.23.5
go 1.24.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,9 +6,23 @@ 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=

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

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

0
internal/conf/static/.gitignore vendored Normal file
View file

View file

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

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

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

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

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

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

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

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

@ -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

@ -0,0 +1,146 @@
// 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,18 +1,34 @@
// Notes implements a data structure for reasoning about and rendering
// Package 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
@ -21,26 +37,195 @@ 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 {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(n.Title))
return os.WriteFile(filename, n.Body, 0600)
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,
)
}
// 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)
@ -49,17 +234,189 @@ func (n *Note) Render() {
n.BodyRendered = template.HTML(buf.String())
}
// 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)
// 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)
if err != nil {
return nil, err
}
return &Note{Title: title, Body: body}, nil
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
}
func DeleteNote(title string) error {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(title))
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))
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,47 +1,71 @@
package views
import (
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
urls "forgejo.gwairfelin.com/max/gispatcho"
"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/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{
"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},
"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},
},
}
return myurls.GetRouter()
}
func view(w http.ResponseWriter, r *http.Request) {
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)
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)
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, "view.tmpl.html", context)
err := templ.RenderTemplate(w, r, "view.tmpl.html", context)
if err != nil {
log.Print(err.Error())
http.Error(w, "Couldn't load template", http.StatusInternalServerError)
@ -50,15 +74,17 @@ func view(w http.ResponseWriter, r *http.Request) {
}
func edit(w http.ResponseWriter, r *http.Request) {
title := r.PathValue("note")
note, err := notes.LoadNote(title)
if err != nil {
note = &notes.Note{Title: title}
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)
}
urlSave := myurls.Reverse("save", urls.Repl{"note": title})
urlSave := myurls.Reverse("save", urls.Repl{"note": uid})
context := templ.Ctx{"note": note, "urlSave": urlSave, "text": string(note.Body)}
err = templ.RenderTemplate(w, "edit.tmpl.html", context)
err := templ.RenderTemplate(w, r, "edit.tmpl.html", context)
if err != nil {
log.Print(err.Error())
http.Error(w, "Couldn't load template", http.StatusInternalServerError)
@ -66,55 +92,172 @@ 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) {
title := r.PathValue("note")
err := notes.DeleteNote(title)
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)
if err != nil {
log.Print(err.Error())
http.Error(w, "Couldn't delete note", http.StatusInternalServerError)
return
}
urlDelete := myurls.Reverse("list", urls.Repl{})
http.Redirect(w, r, urlDelete, http.StatusFound)
urlList := myurls.Reverse("list", urls.Repl{})
http.Redirect(w, r, urlList, http.StatusFound)
}
func save(w http.ResponseWriter, r *http.Request) {
oldTitle := r.PathValue("note")
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.FormValue("title")
body := r.FormValue("body")
note := &notes.Note{Title: title, Body: []byte(body)}
log.Printf("About to save to note %+v", note)
note.Title = title
note.Body = []byte(body)
note.Save()
if oldTitle != title {
notes.DeleteNote(oldTitle)
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)
}
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": title}), http.StatusFound)
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
}
func list(w http.ResponseWriter, r *http.Request) {
files, err := os.ReadDir(conf.Conf.NotesDir)
if err != nil {
log.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
user := r.Context().Value(middleware.ContextKey("user")).(string)
titles := make([]string, 0)
tag := r.FormValue("tag")
for _, f := range files {
if !f.IsDir() {
title := strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))
titles = append(titles, title)
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})},
)
}
}
err = templ.RenderTemplate(w, "list.tmpl.html", templ.Ctx{"titles": titles})
urlNew := myurls.Reverse("new", urls.Repl{})
err := templ.RenderTemplate(w, r, "list.tmpl.html", templ.Ctx{"notes": titlesAndUrls, "urlNew": urlNew})
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,27 +3,39 @@ 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, tmpl string, context any) error {
func RenderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, context Ctx) error {
var err error
files := []string{
filepath.Join(conf.Conf.Templates.Dir, conf.Conf.Templates.Base),
filepath.Join(conf.Conf.Templates.Dir, tmpl),
filepath.Join("templates", "base.tmpl.html"),
filepath.Join("templates", tmpl),
}
for _, f := range files {
_, err := os.Stat(f)
file, err := conf.Templates.Open(f)
if err != nil {
return err
}
file.Close()
}
t, err := template.ParseFiles(files...)
t.ExecuteTemplate(w, "base", context)
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)
return err
}

View file

@ -1,36 +0,0 @@
{{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}}

View file

@ -1,32 +0,0 @@
{{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}}

View file

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

View file

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