Compare commits

..

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

14 changed files with 167 additions and 671 deletions

View file

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

View file

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

8
go.mod
View file

@ -8,7 +8,9 @@ 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
)
require (
github.com/yuin/goldmark-meta v1.1.0 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
)

3
go.sum
View file

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

View file

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

View file

@ -60,24 +60,14 @@ type Config struct {
NotesDir string
LogAccess bool
Production bool
OIDC struct {
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
AuthURL string `toml:"auth_url"`
TokenURL string `toml:"token_url"`
RedirectURL string `toml:"redirect_url"`
UserinfoURL string `toml:"userinfo_url"`
}
AnonCIDRs []string `toml:"anon_networks"`
}
var (
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: "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: "icons/eye.svg", Url: "https://raw.githubusercontent.com/twbs/icons/refs/heads/main/icons/eye.svg"},
}
BaseTemplate string = "base.tmpl.html"

View file

@ -19,61 +19,28 @@
<title>{{template "title" .}}</title>
<link href="/static/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
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>
<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" .}}
<li>
<a class="nav-link" href="/logout/">Logout {{.user}}</a>
</li>
</ul>
{{template "navExtra" .}}
</div>
</nav>
<div class="container">

View file

@ -17,61 +17,37 @@
<button class="btn btn-primary" type="submit">Save</button>
</form>
<script src="/static/js/easy-mde.min.js"></script>
<script src="/static/js/tiny-mde.min.js"></script>
<link
rel="stylesheet"
type="text/css"
href="/static/css/easy-mde.min.css"
href="/static/css/tiny-mde.min.css"
/>
<script type="text/javascript">
var easyMDE = new EasyMDE({
textarea: "noteBodyInput",
toolbar: [
'bold',
'italic',
'strikethrough',
'heading',
var tinyMDE = new TinyMDE.Editor({ textarea: "noteBodyInput" });
var commandBar = new TinyMDE.CommandBar({
element: "toolbar",
editor: tinyMDE,
commands: [
'bold', 'italic', 'strikethrough',
'|',
'unordered-list',
'ordered-list',
{
name: 'checklist',
action: (e) => {
e.codemirror.replaceSelection('* [ ] ');
e.codemirror.focus();
},
className: 'fa fa-check-square-o',
title: 'Add task list',
},
'code',
'|',
'link',
'h1', 'h2',
'|',
'preview',
'side-by-side'
'ul', 'ol', {name: "checklist", title: "Check List", action: makeChecklist},
'|',
'blockquote', 'hr',
'|',
'insertLink', 'insertImage'
],
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);
});
function makeChecklist(editor) {
editor.wrapSelection("* [ ] ", "")
};
</script>
{{end}}

View file

@ -1,113 +1,48 @@
{{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>
{{.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}}
<div class="mt-2">
<h3>Ownership</h3>
{{if .note.Viewers}}
<p>This note is owned by <em>{{.note.Owner}}</em> and is further visible to</p>
<ul>
{{range .viewers}}
<li>{{.}}</li>
{{end}}
</ul>
{{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>
<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>
</div>
{{end}}
<script>
let checkBoxes = document.querySelectorAll('input[type=checkbox]');
let noteContentWrapper = document.querySelector('#noteContent');
console.log(checkBoxes.keys())
let checkBoxes = document.querySelectorAll('input[type=checkbox]')
for (const i in checkBoxes) {
let box = checkBoxes[i]
box.disabled = false
for (const i of checkBoxes.keys()) {
let box = checkBoxes[i]
let parent = box.parentNode
box.disabled = false
box.onchange = function(event) {
let form = new FormData()
form.append("box", i)
box.onclick = function(event) {
return false;
fetch("togglebox/", {method: "POST", body: form}).then((response) => {
location.reload();
})
}
}
parent.onclick = function(event) {
let form = new FormData();
form.append("box", i);
fetch("togglebox/", {method: "POST", body: form}).then((response) => {
return response.json();
}).then((json) => {
box.checked = json.checked;
})
}
}
</script>
{{end}}

View file

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

View file

@ -4,12 +4,7 @@ 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 {
@ -18,29 +13,14 @@ type Session struct {
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
func NewSessionStore() SessionStore {
return SessionStore{sessions: make(map[string]Session, 10)}
}
// Log a user in
func (s *SessionStore) Login(user string, w http.ResponseWriter) string {
sessionID := rand.Text()
s.sessions[sessionID] = Session{User: user}
@ -54,8 +34,7 @@ func (s *SessionStore) Login(user string, w http.ResponseWriter) string {
return sessionID
}
// View to logout a user
func (s *SessionStore) LogoutView(w http.ResponseWriter, r *http.Request) {
func (s *SessionStore) Logout(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(ContextKey("session")).(string)
delete(s.sessions, session)
@ -65,68 +44,38 @@ func (s *SessionStore) LogoutView(w http.ResponseWriter, r *http.Request) {
}
http.SetCookie(w, &cookie)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
// View to log in a user via oauth
func (s *SessionStore) LoginViewOAUTH(w http.ResponseWriter, r *http.Request) {
log.Printf("%+v", *s.oauth)
oauthState := auth.GenerateStateOAUTHCookie(w, s.Routes.Prefix)
url := s.oauth.AuthCodeURL(oauthState)
log.Printf("Redirecting to %s", url)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
// Oauth callback view
func (s *SessionStore) CallbackViewOAUTH(w http.ResponseWriter, r *http.Request) {
// Read oauthState from Cookie
oauthState, err := r.Cookie("oauthstate")
if err != nil {
log.Printf("An error occured during login: %s", err)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
log.Printf("%v", oauthState)
if r.FormValue("state") != oauthState.Value {
log.Println("invalid oauth state")
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
username, err := auth.GetUserFromForgejo(r.FormValue("code"), s.oauth)
if err != nil {
log.Println(err.Error())
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
s.Login(username, w)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
// Turn the session store into a middleware.
// Sets the user on the context based on the available session cookie
func (s *SessionStore) AsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionCookie, err := r.Cookie("id")
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
// No session yet
if err != nil {
user := r.Header.Get("X-Auth-Request-User")
if user != "" {
sessionID := s.Login(user, w)
nextWithSessionContext(w, r, next, user, sessionID)
} else {
http.Redirect(w, r, "/login/", http.StatusFound)
return
}
}
nextWithSessionContext(w, r, next, user, cookieVal)
session, ok := s.sessions[sessionCookie.Value]
// Session expired
if !ok {
user := r.Header.Get("X-Auth-Request-User")
if user != "" {
sessionID := s.Login(user, w)
nextWithSessionContext(w, r, next, user, sessionID)
} else {
http.Redirect(w, r, "/login/", http.StatusFound)
return
}
}
nextWithSessionContext(w, r, next, session.User, sessionCookie.Value)
})
}

View file

@ -5,18 +5,14 @@ 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"
@ -40,14 +36,10 @@ type Note struct {
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
notes map[string][]*Note
}
var (
@ -57,7 +49,7 @@ var (
func Init() error {
Notes = NoteStore{
notes: make(map[string]noteSet),
notes: make(map[string][]*Note),
}
md = goldmark.New(
@ -71,7 +63,7 @@ func Init() error {
files, err := os.ReadDir(notesDir)
if err != nil {
if os.IsNotExist(err) {
os.MkdirAll(notesDir, os.FileMode(0o750))
os.MkdirAll(notesDir, os.FileMode(0750))
} else {
log.Print(err.Error())
return err
@ -101,28 +93,16 @@ func Init() error {
return nil
}
func (ns *noteSet) Sort() []*Note {
orderByTitle := func(a, b *Note) int {
return cmp.Compare(a.Title, b.Title)
}
return slices.SortedFunc(maps.Keys(*ns), orderByTitle)
}
func (ns *NoteStore) Get(owner string) noteSet {
func (ns *NoteStore) Get(owner string) []*Note {
return ns.notes[owner]
}
func (ns *NoteStore) GetOrdered(owner string) []*Note {
notes := ns.Get(owner)
return notes.Sort()
}
func (ns *NoteStore) GetOne(owner string, uid string) (*Note, bool) {
notes := ns.Get(owner)
for note := range notes {
for _, note := range notes {
if note.Uid == uid {
log.Printf("Found single note during GetOne %s", note.Title)
log.Printf("Found single note during GetOne %+v", note)
return note, true
}
}
@ -130,38 +110,7 @@ func (ns *NoteStore) GetOne(owner string, uid string) (*Note, bool) {
}
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))
ns.notes[user] = append(ns.notes[user], note)
}
func fmtPath(path string) string {
@ -174,11 +123,10 @@ func (n *Note) marshalFrontmatter() ([]byte, error) {
for viewer := range n.Viewers {
viewers = append(viewers, viewer)
}
frontmatter := make(map[string]any)
frontmatter := make(map[string]interface{})
frontmatter["owner"] = n.Owner
frontmatter["viewers"] = viewers
frontmatter["title"] = n.Title
frontmatter["tags"] = n.Tags
marshaled, err := yaml.Marshal(&frontmatter)
return marshaled, err
@ -195,7 +143,6 @@ func (n *Note) ViewersAsList() []string {
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
}
@ -218,7 +165,7 @@ func (n *Note) Save() error {
return os.WriteFile(
filename,
[]byte(fmt.Sprintf("---\n%s---\n%s", frontmatter, n.Body)),
0o600,
0600,
)
}
@ -243,11 +190,6 @@ func loadNote(uid string) (*Note, error) {
}
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)
@ -294,9 +236,8 @@ func loadNote(uid string) (*Note, error) {
note := NewNoteNoSave(title, owner)
note.Uid = uid
note.Body = body
note.LastModified = stat.ModTime()
viewers := metaData["viewers"].([]any)
viewers := metaData["viewers"].([]interface{})
for _, viewer := range viewers {
v, ok := viewer.(string)
@ -312,24 +253,6 @@ func loadNote(uid string) (*Note, error) {
}
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
}
@ -337,20 +260,12 @@ func (n *Note) AddViewer(viewer string) {
n.Viewers[viewer] = struct{}{}
}
func (n *Note) DelViewer(viewer string) {
delete(n.Viewers, viewer)
}
func (n *Note) Delete() error {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(n.Uid))
func DeleteNote(uid string) error {
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(uid))
return os.Remove(filename)
}
type toggleCheckboxTransformer struct {
nthBox int
boxToggled bool
boxChecked bool
}
type toggleCheckboxTransformer struct{ nthBox int }
func (t *toggleCheckboxTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
boxesFound := 0
@ -372,8 +287,6 @@ func (t *toggleCheckboxTransformer) Transform(node *ast.Document, reader text.Re
}
box.IsChecked = !box.IsChecked
t.boxToggled = true
t.boxChecked = box.IsChecked
return ast.WalkStop, nil
})
}
@ -396,8 +309,8 @@ func renderTaskCheckBox(writer util.BufWriter, source []byte, node ast.Node, ent
return ast.WalkContinue, err
}
func (n *Note) ToggleBox(nthBox int) (bool, bool) {
checkboxTransformer := toggleCheckboxTransformer{nthBox: nthBox, boxToggled: false}
func (n *Note) ToggleBox(nthBox int) {
checkboxTransformer := toggleCheckboxTransformer{nthBox: nthBox}
transformer := util.Prioritized(&checkboxTransformer, 0)
renderer := markdown.NewRenderer()
@ -413,10 +326,4 @@ func (n *Note) ToggleBox(nthBox int) (bool, bool) {
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,11 +1,9 @@
package views
import (
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
urls "forgejo.gwairfelin.com/max/gispatcho"
"forgejo.gwairfelin.com/max/gonotes/internal/middleware"
@ -27,11 +25,9 @@ func GetRoutes(prefix string) *http.ServeMux {
"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},
},
@ -51,19 +47,12 @@ func view(w http.ResponseWriter, r *http.Request) {
}
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,
}
urlEdit := myurls.Reverse("edit", urls.Repl{"note": uid})
urlNew := myurls.Reverse("new", urls.Repl{})
urlDelete := myurls.Reverse("delete", urls.Repl{"note": uid})
urlShare := myurls.Reverse("share", urls.Repl{"note": uid})
context := templ.Ctx{"note": note, "urlEdit": urlEdit, "urlDelete": urlDelete, "urlNew": urlNew, "urlShare": urlShare, "viewers": viewers}
note.Render()
err := templ.RenderTemplate(w, r, "view.tmpl.html", context)
if err != nil {
@ -107,18 +96,10 @@ func new(w http.ResponseWriter, r *http.Request) {
}
func delete(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(middleware.ContextKey("user")).(string)
uid := r.PathValue("note")
note, ok := notes.Notes.GetOne(user, uid)
if !ok {
http.NotFound(w, r)
}
err := notes.Notes.Del(note, user)
// user := r.Context().Value(middleware.ContextKey("user")).(string)
encodedTitle := r.PathValue("note")
err := notes.DeleteNote(encodedTitle)
if err != nil {
log.Print(err.Error())
http.Error(w, "Couldn't delete note", http.StatusInternalServerError)
@ -154,31 +135,13 @@ func share(w http.ResponseWriter, r *http.Request) {
uid := r.PathValue("note")
note, ok := notes.Notes.GetOne(user, uid)
if !ok || note.Owner != user {
if !ok {
http.NotFound(w, r)
}
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)
}
@ -190,25 +153,18 @@ func togglebox(w http.ResponseWriter, r *http.Request) {
nthBox, err := strconv.Atoi(r.FormValue("box"))
if err != nil {
log.Fatal("You fucked up boy")
http.Error(w, "Box not provided as numeric value", 400)
return
}
note, ok := notes.Notes.GetOne(user, uid)
if !ok {
http.Error(w, "Note not found", 404)
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": uid}), http.StatusFound)
return
}
toggled, checked := note.ToggleBox(nthBox)
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})
}
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": note.Uid}), http.StatusFound)
}
type titleAndURL struct {
@ -219,21 +175,17 @@ type titleAndURL struct {
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)
ns := notes.Notes.Get(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})},
)
}
titlesAndUrls = append(
titlesAndUrls,
titleAndURL{Title: note.Title, URL: myurls.Reverse("view", urls.Repl{"note": note.Uid})},
)
}
urlNew := myurls.Reverse("new", urls.Repl{})
@ -245,19 +197,3 @@ func list(w http.ResponseWriter, r *http.Request) {
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

@ -7,7 +7,6 @@ import (
"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
@ -32,9 +31,7 @@ func RenderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, context
return err
}
user := r.Context().Value(middleware.ContextKey("user")).(string)
context["user"] = user
context["userTags"] = notes.Notes.UserTags(user)
context["user"] = r.Context().Value(middleware.ContextKey("user")).(string)
err = t.ExecuteTemplate(w, "base", context)
return err