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
|
main
|
||||||
saved_notes
|
saved_notes
|
||||||
static
|
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
|
# gonotes
|
||||||
|
|
||||||
A toyish project for a shared notebook of sorts implemented in go. It's mostly
|
A shared notebook of sorts implemented in go. Shared here means that the
|
||||||
a learning opportunity for using go, and partially an intent to end up with a
|
content is visible to more than one user. It does not currently support
|
||||||
shared notebook!
|
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
|
||||||
It's not shared in the sense of simultaneous editing, don't do that!
|
is edited. It's the responsibility of the editing parties to prevent races
|
||||||
|
and conflicts.
|
||||||
## TODO
|
|
||||||
|
|
||||||
* handle static urls in the django url mapping style
|
|
||||||
* Style up the templates better
|
|
||||||
|
|
|
||||||
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"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
|
"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"
|
"forgejo.gwairfelin.com/max/gonotes/internal/notes/views"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var confFile string
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
conf.LoadConfig(confFile)
|
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()
|
router := http.NewServeMux()
|
||||||
notesRouter := views.GetRoutes("/notes")
|
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(
|
router.Handle(
|
||||||
conf.Conf.Static.Root,
|
"/notes/",
|
||||||
logger(
|
sessions.AsMiddleware(
|
||||||
http.StripPrefix(
|
middleware.LoggingMiddleware(
|
||||||
"/static",
|
middleware.RejectAnonMiddleware(
|
||||||
http.FileServer(http.Dir(conf.Conf.Static.Dir)),
|
"/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))
|
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
|
module forgejo.gwairfelin.com/max/gonotes
|
||||||
|
|
||||||
go 1.23.5
|
go 1.24.5
|
||||||
|
|
||||||
require github.com/yuin/goldmark v1.7.8
|
require github.com/yuin/goldmark v1.7.8
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forgejo.gwairfelin.com/max/gispatcho v0.1.2
|
forgejo.gwairfelin.com/max/gispatcho v0.1.2
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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
|
package conf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -17,7 +18,7 @@ type Asset struct {
|
||||||
Url string
|
Url string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (asset *Asset) FetchIfNotExists(staticPath string) {
|
func (asset *Asset) fetchIfNotExists(staticPath string) {
|
||||||
destPath := filepath.Join(staticPath, asset.Path)
|
destPath := filepath.Join(staticPath, asset.Path)
|
||||||
|
|
||||||
err := os.MkdirAll(path.Dir(destPath), os.FileMode(0750))
|
err := os.MkdirAll(path.Dir(destPath), os.FileMode(0750))
|
||||||
|
|
@ -53,22 +54,38 @@ func (asset *Asset) FetchIfNotExists(staticPath string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Address string
|
Address string
|
||||||
Protocol string
|
Protocol string
|
||||||
Extension string
|
Extension string
|
||||||
NotesDir string
|
NotesDir string
|
||||||
Templates struct {
|
LogAccess bool
|
||||||
Dir string
|
Production bool
|
||||||
Base string
|
OIDC struct {
|
||||||
}
|
ClientID string `toml:"client_id"`
|
||||||
Static struct {
|
ClientSecret string `toml:"client_secret"`
|
||||||
Dir string
|
AuthURL string `toml:"auth_url"`
|
||||||
Root string
|
TokenURL string `toml:"token_url"`
|
||||||
Assets []Asset
|
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) {
|
func LoadConfig(path string) {
|
||||||
var err error
|
var err error
|
||||||
|
|
@ -83,7 +100,11 @@ func LoadConfig(path string) {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, asset := range Conf.Static.Assets {
|
log.Printf("Config is %+v", Conf)
|
||||||
asset.FetchIfNotExists(Conf.Static.Dir)
|
}
|
||||||
|
|
||||||
|
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
|
// markdown notes
|
||||||
package notes
|
package notes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
|
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
|
||||||
|
markdown "github.com/teekennedy/goldmark-markdown"
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
|
meta "github.com/yuin/goldmark-meta"
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
"github.com/yuin/goldmark/extension"
|
"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
|
// Note is the central data structure. It can be Saved, Rendered and Loaded
|
||||||
|
|
@ -21,26 +37,195 @@ type Note struct {
|
||||||
Title string
|
Title string
|
||||||
BodyRendered template.HTML
|
BodyRendered template.HTML
|
||||||
Body []byte
|
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 {
|
func fmtPath(path string) string {
|
||||||
return fmt.Sprintf("%s.%s", path, conf.Conf.Extension)
|
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
|
// Save a note to a path derived from the title
|
||||||
func (n *Note) Save() error {
|
func (n *Note) Save() error {
|
||||||
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(n.Title))
|
frontmatter, err := n.marshalFrontmatter()
|
||||||
return os.WriteFile(filename, n.Body, 0600)
|
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
|
// Render the markdown content of the note to HTML
|
||||||
func (n *Note) Render() {
|
func (n *Note) Render() {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
md := goldmark.New(
|
|
||||||
goldmark.WithExtensions(
|
|
||||||
extension.TaskList,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
err := md.Convert(n.Body, &buf)
|
err := md.Convert(n.Body, &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
@ -49,17 +234,189 @@ func (n *Note) Render() {
|
||||||
n.BodyRendered = template.HTML(buf.String())
|
n.BodyRendered = template.HTML(buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load a note from the disk. The path is derived from the title
|
// loadNote from the disk. The path is derived from the uid
|
||||||
func LoadNote(title string) (*Note, error) {
|
func loadNote(uid string) (*Note, error) {
|
||||||
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(title))
|
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(uid))
|
||||||
body, err := os.ReadFile(filename)
|
f, err := os.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
func (n *Note) AddViewer(viewer string) {
|
||||||
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(title))
|
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)
|
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
|
package views
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"strconv"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
urls "forgejo.gwairfelin.com/max/gispatcho"
|
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/notes"
|
||||||
"forgejo.gwairfelin.com/max/gonotes/internal/templ"
|
"forgejo.gwairfelin.com/max/gonotes/internal/templ"
|
||||||
)
|
)
|
||||||
|
|
||||||
var myurls urls.URLs
|
var myurls urls.URLs
|
||||||
|
|
||||||
|
func addRequestContext(r *http.Request, ctx templ.Ctx) templ.Ctx {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
func GetRoutes(prefix string) *http.ServeMux {
|
func GetRoutes(prefix string) *http.ServeMux {
|
||||||
myurls = urls.URLs{
|
myurls = urls.URLs{
|
||||||
Prefix: prefix,
|
Prefix: prefix,
|
||||||
URLs: map[string]urls.URL{
|
URLs: map[string]urls.URL{
|
||||||
"view": {Path: "/{note}/", Protocol: "GET", Handler: view},
|
"list": {Path: "/", Protocol: "GET", Handler: list},
|
||||||
"delete": {Path: "/{note}/delete/", Protocol: "GET", Handler: delete},
|
"new": {Path: "/new", Protocol: "GET", Handler: new},
|
||||||
"edit": {Path: "/{note}/edit/", Protocol: "GET", Handler: edit},
|
"view_": {Path: "/{note}", Protocol: "GET", Handler: view},
|
||||||
"save": {Path: "/{note}/edit/save/", Protocol: "POST", Handler: save},
|
"view": {Path: "/{note}/", Protocol: "GET", Handler: view},
|
||||||
"list": {Path: "/", Protocol: "GET", Handler: list},
|
"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()
|
return myurls.GetRouter()
|
||||||
}
|
}
|
||||||
|
|
||||||
func view(w http.ResponseWriter, r *http.Request) {
|
func view(w http.ResponseWriter, r *http.Request) {
|
||||||
title := r.PathValue("note")
|
user := r.Context().Value(middleware.ContextKey("user")).(string)
|
||||||
note, err := notes.LoadNote(title)
|
|
||||||
urlEdit := myurls.Reverse("edit", urls.Repl{"note": title})
|
uid := r.PathValue("note")
|
||||||
urlDelete := myurls.Reverse("delete", urls.Repl{"note": title})
|
note, ok := notes.Notes.GetOne(user, uid)
|
||||||
if err != nil {
|
|
||||||
http.Redirect(w, r, urlEdit, http.StatusFound)
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
return
|
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()
|
note.Render()
|
||||||
err = templ.RenderTemplate(w, "view.tmpl.html", context)
|
err := templ.RenderTemplate(w, r, "view.tmpl.html", context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err.Error())
|
log.Print(err.Error())
|
||||||
http.Error(w, "Couldn't load template", http.StatusInternalServerError)
|
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) {
|
func edit(w http.ResponseWriter, r *http.Request) {
|
||||||
title := r.PathValue("note")
|
user := r.Context().Value(middleware.ContextKey("user")).(string)
|
||||||
note, err := notes.LoadNote(title)
|
|
||||||
if err != nil {
|
uid := r.PathValue("note")
|
||||||
note = ¬es.Note{Title: title}
|
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)}
|
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 {
|
if err != nil {
|
||||||
log.Print(err.Error())
|
log.Print(err.Error())
|
||||||
http.Error(w, "Couldn't load template", http.StatusInternalServerError)
|
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) {
|
func delete(w http.ResponseWriter, r *http.Request) {
|
||||||
title := r.PathValue("note")
|
user := r.Context().Value(middleware.ContextKey("user")).(string)
|
||||||
err := notes.DeleteNote(title)
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
log.Print(err.Error())
|
log.Print(err.Error())
|
||||||
http.Error(w, "Couldn't delete note", http.StatusInternalServerError)
|
http.Error(w, "Couldn't delete note", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
urlDelete := myurls.Reverse("list", urls.Repl{})
|
urlList := myurls.Reverse("list", urls.Repl{})
|
||||||
http.Redirect(w, r, urlDelete, http.StatusFound)
|
http.Redirect(w, r, urlList, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(w http.ResponseWriter, r *http.Request) {
|
func save(w http.ResponseWriter, r *http.Request) {
|
||||||
oldTitle := r.PathValue("note")
|
user := r.Context().Value(middleware.ContextKey("user")).(string)
|
||||||
|
uid := r.PathValue("note")
|
||||||
|
note, ok := notes.Notes.GetOne(user, uid)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
title := r.FormValue("title")
|
title := r.FormValue("title")
|
||||||
body := r.FormValue("body")
|
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()
|
note.Save()
|
||||||
|
|
||||||
if oldTitle != title {
|
http.Redirect(w, r, myurls.Reverse("view", urls.Repl{"note": note.Uid}), http.StatusFound)
|
||||||
notes.DeleteNote(oldTitle)
|
}
|
||||||
|
|
||||||
|
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) {
|
func list(w http.ResponseWriter, r *http.Request) {
|
||||||
files, err := os.ReadDir(conf.Conf.NotesDir)
|
user := r.Context().Value(middleware.ContextKey("user")).(string)
|
||||||
if err != nil {
|
|
||||||
log.Print(err.Error())
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
titles := make([]string, 0)
|
tag := r.FormValue("tag")
|
||||||
|
|
||||||
for _, f := range files {
|
titlesAndUrls := make([]titleAndURL, 0)
|
||||||
if !f.IsDir() {
|
|
||||||
title := strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))
|
ns := notes.Notes.GetOrdered(user)
|
||||||
titles = append(titles, title)
|
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 {
|
if err != nil {
|
||||||
log.Print(err.Error())
|
log.Print(err.Error())
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
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 (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
|
"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
|
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{
|
files := []string{
|
||||||
filepath.Join(conf.Conf.Templates.Dir, conf.Conf.Templates.Base),
|
filepath.Join("templates", "base.tmpl.html"),
|
||||||
filepath.Join(conf.Conf.Templates.Dir, tmpl),
|
filepath.Join("templates", tmpl),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
_, err := os.Stat(f)
|
file, err := conf.Templates.Open(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
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