Compare commits

..

40 commits
0.9.5 ... 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
16 changed files with 1143 additions and 118 deletions

View file

@ -1,5 +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.

View file

@ -10,7 +10,9 @@ import (
"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() {
@ -21,10 +23,25 @@ func main() {
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 {
@ -34,7 +51,20 @@ func main() {
etag := middleware.NewETag("static", cacheExpiration)
router.Handle("/", middleware.LoggingMiddleware(http.RedirectHandler("/notes/", http.StatusFound)))
router.Handle("/notes/", middleware.LoggingMiddleware(http.StripPrefix("/notes", notesRouter)))
router.Handle(
"/notes/",
sessions.AsMiddleware(
middleware.LoggingMiddleware(
middleware.RejectAnonMiddleware(
"/auth/login/",
http.StripPrefix(
"/notes", notesRouter,
),
),
),
),
)
router.Handle("/auth/", sessions.AsMiddleware(middleware.LoggingMiddleware(http.StripPrefix("/auth", sessionRouter))))
router.Handle(
"/static/",
middleware.LoggingMiddleware(

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

@ -54,19 +54,30 @@ func (asset *Asset) fetchIfNotExists(staticPath string) {
}
type Config struct {
Address string
Protocol string
Extension string
NotesDir string
LogAccess bool
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
assets []Asset = []Asset{
{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"},
{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"
@ -88,6 +99,8 @@ func LoadConfig(path string) {
if err != nil {
log.Fatal(err)
}
log.Printf("Config is %+v", Conf)
}
func FetchAssets() {

View file

@ -19,25 +19,61 @@
<title>{{template "title" .}}</title>
<link href="/static/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
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">
<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>
{{template "navLinks" .}}
</ul>
{{template "navExtra" .}}
<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">

View file

@ -17,17 +17,61 @@
<button class="btn btn-primary" type="submit">Save</button>
</form>
<script src="/static/js/tiny-mde.min.js"></script>
<script src="/static/js/easy-mde.min.js"></script>
<link
rel="stylesheet"
type="text/css"
href="/static/css/tiny-mde.min.css"
href="/static/css/easy-mde.min.css"
/>
<script type="text/javascript">
var tinyMDE = new TinyMDE.Editor({ textarea: "noteBodyInput" });
var commandBar = new TinyMDE.CommandBar({
element: "toolbar",
editor: tinyMDE,
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

@ -3,7 +3,7 @@
<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}}/">
href="{{$note.URL}}/">
<span>{{$note.Title}}</span>
<img src="/static/icons/eye.svg" alt="Edit">
</a>

View file

@ -1,11 +1,113 @@
{{define "title"}}{{.note.Title}}{{end}}
{{define "main"}}
<div>
{{.note.BodyRendered}}
<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,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,19 +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"
"encoding/base64"
"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
@ -22,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.EncodedTitle()))
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)
@ -50,31 +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(encodedTitle string) (*Note, error) {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(encodedTitle))
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
}
title := DecodeTitle(encodedTitle)
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)
}
func (n *Note) EncodedTitle() string {
return base64.StdEncoding.EncodeToString([]byte(n.Title))
type toggleCheckboxTransformer struct {
nthBox int
boxToggled bool
boxChecked bool
}
func DecodeTitle(encodedTitle string) string {
title, err := base64.StdEncoding.DecodeString(encodedTitle)
if err != nil {
log.Printf("Couldn't decode base64 string '%s': %s", encodedTitle, err)
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")
}
return string(title)
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,49 +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{
"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},
"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},
"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)
@ -52,16 +74,17 @@ func view(w http.ResponseWriter, r *http.Request) {
}
func edit(w http.ResponseWriter, r *http.Request) {
encodedTitle := r.PathValue("note")
note, err := notes.LoadNote(encodedTitle)
if err != nil {
title := notes.DecodeTitle(encodedTitle)
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": encodedTitle})
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)
@ -70,16 +93,32 @@ func edit(w http.ResponseWriter, r *http.Request) {
}
func new(w http.ResponseWriter, r *http.Request) {
title := r.FormValue("title")
note := &notes.Note{Title: title}
user := r.Context().Value(middleware.ContextKey("user")).(string)
urlEdit := myurls.Reverse("edit", urls.Repl{"note": note.EncodedTitle()})
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) {
encodedTitle := r.PathValue("note")
err := notes.DeleteNote(encodedTitle)
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)
@ -91,56 +130,134 @@ func delete(w http.ResponseWriter, r *http.Request) {
}
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 != note.EncodedTitle() {
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": note.EncodedTitle()}), 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)
}
type titleAndUrl struct {
Title string
Url string
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 list(w http.ResponseWriter, r *http.Request) {
files, err := os.ReadDir(conf.Conf.NotesDir)
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.Print(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
log.Fatal("You fucked up boy")
http.Error(w, "Box not provided as numeric value", 400)
return
}
titlesAndUrls := make([]titleAndUrl, 0)
note, ok := notes.Notes.GetOne(user, uid)
if !ok {
http.Error(w, "Note not found", 404)
return
}
for _, f := range files {
if !f.IsDir() {
encodedTitle := strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))
title := notes.DecodeTitle(encodedTitle)
toggled, checked := note.ToggleBox(nthBox)
log.Printf("Found note %s (title '%s')", encodedTitle, title)
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) {
user := r.Context().Value(middleware.ContextKey("user")).(string)
tag := r.FormValue("tag")
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: title, Url: myurls.Reverse("view", urls.Repl{"note": encodedTitle})},
titleAndURL{Title: note.Title, URL: myurls.Reverse("view", urls.Repl{"note": note.Uid})},
)
log.Print(titlesAndUrls)
}
}
urlNew := myurls.Reverse("new", urls.Repl{})
err = templ.RenderTemplate(w, "list.tmpl.html", templ.Ctx{"notes": titlesAndUrls, "urlNew": urlNew})
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

@ -6,11 +6,13 @@ import (
"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("templates", "base.tmpl.html"),
@ -26,6 +28,14 @@ func RenderTemplate(w http.ResponseWriter, tmpl string, context any) error {
}
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
}