Compare commits
62 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a955c49373 | |||
| 0498aadcf2 | |||
| 38506f7e8b | |||
| 8eaa0afda1 | |||
| 35a5dfc17a | |||
| 55c7e00ad6 | |||
| 63405b6dc2 | |||
| a1c5827641 | |||
| a01f6dec23 | |||
| d30327817e | |||
| a750f646a9 | |||
| 1772e57ee8 | |||
| cb24ab18c5 | |||
| d7b56c3b86 | |||
| 38bbc35922 | |||
| 1df4f3f807 | |||
| 7a44876e77 | |||
| e52f0cbe98 | |||
| 95865257a3 | |||
| 105275f3e0 | |||
| 4fb4bec5a8 | |||
| a54abeaea2 | |||
| 8340647f08 | |||
| 94735318b6 | |||
| 3de1b8b714 | |||
| 33cc62b3fc | |||
| a90859e151 | |||
| c2e6612647 | |||
| c098abf14f | |||
| 3c60b6265e | |||
| 4fda818e6e | |||
| bb775ec55f | |||
| de66fb0b77 | |||
| 03b6bb12ca | |||
| 25bcf4d706 | |||
| 3c792decd6 | |||
| 17dd20478d | |||
| 352d9555ba | |||
| 4aa09ce502 | |||
| 0f2400435f | |||
| 6f8796c83f | |||
| eb0c264ad7 | |||
| 6e56cefe6f | |||
| 06495fa358 | |||
| f2e52a18e5 | |||
| 91cc68efe7 | |||
| 3623fb8e8e | |||
| c16aa1603d | |||
| 107f11a23a | |||
| e3fc5f5f10 | |||
| 28438725c7 | |||
| a9e304ee08 | |||
| 8edd857d22 | |||
| 868d2a7bbd | |||
| da11dbd1c8 | |||
| 68a349f072 | |||
| e4f7ff6218 | |||
| 551dcf5905 | |||
| c82db192e1 | |||
| 8f60040f86 | |||
| 90280ce7b8 | |||
| c384d86e59 |
28 changed files with 1461 additions and 212 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,3 +1,5 @@
|
|||
main
|
||||
saved_notes
|
||||
static
|
||||
conf.toml
|
||||
gonotes
|
||||
|
|
|
|||
11
Makefile
Normal file
11
Makefile
Normal 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
|
||||
16
README.md
16
README.md
|
|
@ -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
7
cmd/fetch-static/main.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
import "forgejo.gwairfelin.com/max/gonotes/internal/conf"
|
||||
|
||||
func main() {
|
||||
conf.FetchAssets()
|
||||
}
|
||||
|
|
@ -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
5
conf.example.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
extension = "md"
|
||||
notesdir = "/var/lib/gonotes/saved_notes"
|
||||
address = ":8080"
|
||||
protocol = "tcp"
|
||||
logAccess = false
|
||||
17
conf.toml
17
conf.toml
|
|
@ -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
6
go.mod
|
|
@ -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
14
go.sum
|
|
@ -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
66
internal/auth/oauth.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
0
internal/conf/static/.gitignore
vendored
Normal file
55
internal/conf/static/icons/favicon.svg
Normal file
55
internal/conf/static/icons/favicon.svg
Normal 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 |
91
internal/conf/templates/base.tmpl.html
Normal file
91
internal/conf/templates/base.tmpl.html
Normal 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}}
|
||||
77
internal/conf/templates/edit.tmpl.html
Normal file
77
internal/conf/templates/edit.tmpl.html
Normal 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}}
|
||||
12
internal/conf/templates/list.tmpl.html
Normal file
12
internal/conf/templates/list.tmpl.html
Normal 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}}
|
||||
113
internal/conf/templates/view.tmpl.html
Normal file
113
internal/conf/templates/view.tmpl.html
Normal 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}}
|
||||
52
internal/middleware/etag_cache.go
Normal file
52
internal/middleware/etag_cache.go
Normal 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()),
|
||||
}
|
||||
}
|
||||
36
internal/middleware/logger.go
Normal file
36
internal/middleware/logger.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
99
internal/middleware/reject_anon.go
Normal file
99
internal/middleware/reject_anon.go
Normal 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
|
||||
}
|
||||
146
internal/middleware/session.go
Normal file
146
internal/middleware/session.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ¬es.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 := ¬es.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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
||||
Loading…
Add table
Reference in a new issue