43 Commits
v1.8 ... v1.18

Author SHA1 Message Date
Bel LaPointe
125455fcb6 trim right 2021-04-08 12:10:45 -05:00
Bel LaPointe
1b8f7f86f4 out of bounds 2021-03-29 07:59:29 -05:00
Bel LaPointe
6ea5f2c675 expandtab 2021-03-19 21:18:05 -05:00
Bel LaPointe
3b91c6782d vim editor 2021-03-16 15:55:46 -05:00
Bel LaPointe
5a16beb676 symlinks ok 2021-02-25 22:27:10 -06:00
Bel LaPointe
817fc57dd1 dont search attachments or git 2021-02-25 22:23:13 -06:00
Bel LaPointe
095ba7820d jsut in case 2021-02-25 22:17:32 -06:00
Bel LaPointe
96cce88aed no more todo 2021-02-25 22:10:18 -06:00
Bel LaPointe
baf739658e create pre-commit file to skip big files 2021-02-25 22:09:50 -06:00
Bel LaPointe
0c6d3a6c6a script 2021-02-25 21:53:48 -06:00
Bel LaPointe
e8ea8d8abf try 2021-02-25 20:56:07 -06:00
Bel LaPointe
79009305a1 mm 2021-02-25 20:46:50 -06:00
Bel LaPointe
03f5742a91 grrr 2021-02-25 20:46:19 -06:00
Bel LaPointe
4ad68109d2 gr 2021-02-25 20:34:53 -06:00
Bel LaPointe
61f97f2f0a big 2021-02-25 20:34:08 -06:00
Bel LaPointe
31ebc8ffbc big 2021-02-25 20:33:45 -06:00
Bel LaPointe
567c74bb57 big file 2021-02-25 20:33:30 -06:00
Bel LaPointe
c4161c9db6 big file 2021-02-25 20:32:54 -06:00
Bel LaPointe
fd67e7033b making a big file 2021-02-25 20:19:44 -06:00
Bel LaPointe
cbd287e20e no log 2021-02-25 19:28:58 -06:00
Bel LaPointe
479caef353 create raw endpoint, link to it for attachments 2021-02-25 19:28:44 -06:00
Bel LaPointe
8fefc60f6b impl attach 2021-02-25 17:00:22 -06:00
Bel LaPointe
014076dd06 todo 2021-02-25 16:43:57 -06:00
Bel LaPointe
d408229ba9 UI to attach 2021-02-25 16:43:32 -06:00
Bel LaPointe
47d6c37819 stupid scroll 2021-02-25 16:22:40 -06:00
Bel LaPointe
1eb8fa7223 rm dead 2021-02-25 15:45:23 -06:00
Bel LaPointe
f9c28b3c45 exporterer 2020-09-28 10:06:17 -06:00
Bel LaPointe
a26d34c2b3 export req less 2020-09-28 10:05:11 -06:00
Bel LaPointe
4d076ede3d export markdown 2020-09-28 09:48:34 -06:00
Bel LaPointe
f60d0618e6 notes on ro dont have comments 2020-09-28 09:39:42 -06:00
Bel LaPointe
b975a7c103 Explore other html renders but find nothing 2020-09-27 11:35:55 -06:00
Bel LaPointe
0e9150b8f6 Explicit extensions 2020-09-27 11:05:07 -06:00
Bel LaPointe
4fdbee6df5 Whoopsies fixed it 2020-08-07 13:46:39 -06:00
Bel LaPointe
e6a126ea0b Remove dbeug logs 2020-08-07 13:08:42 -06:00
Bel LaPointe
43d44a4518 Header floats kthnx css and comments 2020-08-07 12:42:50 -06:00
Bel LaPointe
8bf7503c8e Add comments per heading 2020-08-07 12:30:27 -06:00
Bel LaPointe
785215bd3c name from recursive resolved plaintext 2020-08-07 11:35:52 -06:00
Bel LaPointe
952e04815a read only mode a gogo 2020-08-06 21:20:29 -06:00
bel
d6c95a536c fix selection color 2020-04-05 01:00:26 +00:00
bel
9785ef5e1e Add serach bar to search page 2020-03-09 03:09:45 +00:00
bel
3b7aa70e44 fix table and toc colors 2020-03-09 03:06:54 +00:00
Bel LaPointe
c067b426a9 Fix colors breaking floating css because fuck 2020-03-08 20:37:48 -06:00
bel
da968c2fd4 update color scheme 2020-02-01 23:01:37 +00:00
40 changed files with 878 additions and 367 deletions

File diff suppressed because one or more lines are too long

129
config/rotate.py Executable file
View File

@@ -0,0 +1,129 @@
#! /usr/local/bin/python3
def main(args) :
print("args", args)
for i in args :
rotate(i)
def rotate(x) :
print("input: {}", x)
rgb = hex_to_rgb(x)
print("rgb: {}", rgb)
hsl = rgb_to_hsl(rgb)
print("hsl: {}", hsl)
import os
env = os.environ
if "DEG" in env :
deg = int(env["DEG"])
else :
deg = 110
hsl = rotate_hsl(hsl, deg)
print("hsl': {}", hsl)
rgb = hsl_to_rgb(hsl)
print("rgb': {}", rgb)
print(rgb_to_hex(rgb))
def hex_to_rgb(x) :
if x.startswith("#") :
x = x[1:]
r = x[0:2]
g = x[2:4]
b = x[4:6]
return (
hex_to_dec(r),
hex_to_dec(g),
hex_to_dec(b),
)
def hex_to_dec(x) :
s = 0
mul = 1
for i in range(len(x)):
c = x[len(x)-i-1]
c = c.upper()
digit = ord(c) - ord('0')
if not c.isdigit() :
digit = ord(c) - ord('A') + 10
s += mul * digit
mul *= 16
return s
def rgb_to_hsl(rgb) :
return (
compute_h(rgb),
compute_s(rgb),
compute_l(rgb),
)
def compute_h(rgb) :
r, g, b, cmax, cmin, delta = compute_hsl_const(rgb)
if delta == 0 :
return 0
if r == cmax :
return 60 * ( ( (g - b)/delta ) % 6)
if g == cmax :
return 60 * ( ( (b - r)/delta ) + 2)
if b == cmax :
return 60 * ( ( (r - g)/delta ) + 4)
def compute_s(rgb) :
r, g, b, cmax, cmin, delta = compute_hsl_const(rgb)
if delta == 0 :
return 0
return delta / ( 1 - (abs(2*compute_l(rgb)-1)) )
def compute_l(rgb) :
r, g, b, cmax, cmin, delta = compute_hsl_const(rgb)
return (cmax + cmin) / 2
def compute_hsl_const(rgb) :
rgb = [ i/255.0 for i in rgb ]
return rgb[:] + [max(rgb), min(rgb), max(rgb)-min(rgb)]
def rotate_hsl(hsl, deg) :
return (
(hsl[0] + deg) % 360,
hsl[1],
hsl[2],
)
def hsl_to_rgb(hsl) :
h = hsl[0]
s = hsl[1]
l = hsl[2]
c = s * (1 - abs(2 * l))
x = c * (1 - abs((h / 60) % 2 - 1))
m = l - (c / 2)
rgbp = ()
if h < 60 :
rgbp = (c, x, 0)
if h < 120 :
rgbp = (x, c, 0)
if h < 180 :
rgbp = (0, c, x)
if h < 240 :
rgbp = (0, x, c)
if h < 300 :
rgbp = (x, 0, c)
else:
rgbp = (c, 0, x)
r = rgbp[0]
g = rgbp[1]
b = rgbp[2]
return (
(r+m)*255,
(g+m)*255,
(b+m)*255,
)
def rgb_to_hex(rgb) :
r = min(max(int(rgb[0]), 0), 255)
g = min(max(int(rgb[1]), 0), 255)
b = min(max(int(rgb[2]), 0), 255)
return "#{:02x}{:02x}{:02x}".format(r, g, b)
from sys import argv
main(argv[1:])

1
config/water.css Submodule

Submodule config/water.css added at 576eee5b82

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"local/notes-server/config" "local/notes-server/config"
"local/notes-server/notes/editor"
"local/notes-server/server" "local/notes-server/server"
"local/notes-server/versions" "local/notes-server/versions"
"log" "log"
@@ -12,6 +13,7 @@ import (
) )
func main() { func main() {
log.Println(len(editor.CodeMirrorCSS))
server := server.New() server := server.New()
if err := server.Routes(); err != nil { if err := server.Routes(); err != nil {
panic(err) panic(err)

63
notes/comment.go Executable file
View File

@@ -0,0 +1,63 @@
package notes
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"io/ioutil"
"local/notes-server/filetree"
"os"
"path"
"strings"
)
func (n *Notes) Comment(urlPath string, lineno int, comment string) error {
p := filetree.NewPathFromURL(urlPath)
if stat, err := os.Stat(p.Local); err != nil {
return errors.New("cannot comment as it does not exist")
} else if stat.IsDir() {
return errors.New("cannot comment on a dir")
}
f, err := os.Open(p.Local)
if err != nil {
return err
}
defer f.Close()
f2, err := ioutil.TempFile(os.TempDir(), path.Base(p.Local)+".*")
if err != nil {
return err
}
defer f2.Close()
reader := bufio.NewReader(f)
writer := io.Writer(f2)
for i := 0; i < lineno+1; i++ {
line, _, err := reader.ReadLine()
if err == io.EOF {
break
}
if err != nil {
return err
}
_, err = io.Copy(writer, bytes.NewReader(line))
if err != nil {
return err
}
_, err = writer.Write([]byte("\n"))
if err != nil {
return err
}
}
formatted := fmt.Sprintf("> *%s*\n\n", comment)
_, err = io.Copy(writer, strings.NewReader(formatted))
if err != nil {
return err
}
_, err = io.Copy(writer, reader)
if err != nil && err != io.EOF {
return err
}
f2.Close()
return os.Rename(f2.Name(), p.Local)
}

56
notes/comment_test.go Normal file
View File

@@ -0,0 +1,56 @@
package notes
import (
"bytes"
"io/ioutil"
"local/notes-server/config"
"os"
"path"
"strings"
"testing"
)
func TestComment(t *testing.T) {
d, err := ioutil.TempDir(os.TempDir(), "testComment.*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(d)
f, err := ioutil.TempFile(d, "testFile.*")
if err != nil {
t.Fatal(err)
}
f.Write([]byte(`
hello
world
# i have a heading
## i have a subber heading
### i have a[heading with](http://google.com) a hyperlink
## *I think this heading is in italics*
`))
f.Close()
fpath := path.Join("/comment", strings.TrimPrefix(f.Name(), d))
config.Root = d
n := &Notes{}
t.Logf("d=%s, fpath=%s", d, fpath)
if err := n.Comment("/comment/a", 5, "a"); err == nil {
t.Error(err)
}
if err := n.Comment(fpath, -1, "illegal line no"); err != nil {
t.Error(err)
}
if err := n.Comment(fpath, 10000, "big line no"); err == nil {
t.Error(err)
}
if err := n.Comment(fpath, 0, "first_line_OK"); err != nil {
t.Error(err)
} else if b, err := ioutil.ReadFile(f.Name()); err != nil {
t.Error(err)
} else if !bytes.Contains(b, []byte("> *first_line_OK*\n")) {
t.Errorf("%s", b)
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"local/notes-server/filetree" "local/notes-server/filetree"
"local/notes-server/notes/editor"
"strings" "strings"
) )
@@ -26,11 +27,49 @@ func editFile(p filetree.Path) string {
} }
b, _ := ioutil.ReadFile(p.Local) b, _ := ioutil.ReadFile(p.Local)
return fmt.Sprintf(` return fmt.Sprintf(`
<form action="/submit/%s" method="post" style="width:100%%; height: 90%%"> <div class="form">
<table style="width:100%%; height: 90%%"> <form action="/submit/%s" method="post">
<textarea name="content" style="width:100%%; min-height:90%%; cursor:crosshair;">%s</textarea> <textarea id="mytext" name="content" style="cursor:crosshair;">%s</textarea>
</table> <button type="submit">Submit</button>
<button type="submit">Submit</button> </form>
</form> </div>
`, href, b) <!--
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.59.4/codemirror.min.js
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.59.4/codemirror.min.css
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.59.4/keymap/vim.min.js
https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.59.4/theme/dracula.min.css
-->
<script>%s</script>
<style>%s</style>
<script>%s</script>
<style>%s</style>
<script>
if( ! /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ) {
CodeMirror.fromTextArea(document.getElementById("mytext"), {
lineNumbers: true,
mode: "md",
theme: "dracula",
keyMap: "vim",
smartIndent: true,
indentUnit: 3,
tabSize: 3,
indentWithTabs: false,
lineWrapping: true,
autofocus: true,
dragDrop: false,
spellcheck: true,
autocorrect: false,
autocapitalize: false,
extraKeys: {
Tab: (cm) => {
var spaces = Array(cm.getOption("indentUnit") + 1).join(" ");
cm.replaceSelection(spaces);
},
},
})
} else {
console.log(navigator.userAgent)
}
</script>
`, href, b, editor.CodeMirrorJS, editor.CodeMirrorCSS, editor.CodeMirrorVIM, editor.CodeMirrorTheme)
} }

1
notes/editor/codemirror.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
notes/editor/codemirror.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
notes/editor/dracula.min.css vendored Normal file
View File

@@ -0,0 +1 @@
.cm-s-dracula .CodeMirror-gutters,.cm-s-dracula.CodeMirror{background-color:#282a36!important;color:#f8f8f2!important;border:none}.cm-s-dracula .CodeMirror-gutters{color:#282a36}.cm-s-dracula .CodeMirror-cursor{border-left:solid thin #f8f8f0}.cm-s-dracula .CodeMirror-linenumber{color:#6d8a88}.cm-s-dracula .CodeMirror-selected{background:rgba(255,255,255,.1)}.cm-s-dracula .CodeMirror-line::selection,.cm-s-dracula .CodeMirror-line>span::selection,.cm-s-dracula .CodeMirror-line>span>span::selection{background:rgba(255,255,255,.1)}.cm-s-dracula .CodeMirror-line::-moz-selection,.cm-s-dracula .CodeMirror-line>span::-moz-selection,.cm-s-dracula .CodeMirror-line>span>span::-moz-selection{background:rgba(255,255,255,.1)}.cm-s-dracula span.cm-comment{color:#6272a4}.cm-s-dracula span.cm-string,.cm-s-dracula span.cm-string-2{color:#f1fa8c}.cm-s-dracula span.cm-number{color:#bd93f9}.cm-s-dracula span.cm-variable{color:#50fa7b}.cm-s-dracula span.cm-variable-2{color:#fff}.cm-s-dracula span.cm-def{color:#50fa7b}.cm-s-dracula span.cm-operator{color:#ff79c6}.cm-s-dracula span.cm-keyword{color:#ff79c6}.cm-s-dracula span.cm-atom{color:#bd93f9}.cm-s-dracula span.cm-meta{color:#f8f8f2}.cm-s-dracula span.cm-tag{color:#ff79c6}.cm-s-dracula span.cm-attribute{color:#50fa7b}.cm-s-dracula span.cm-qualifier{color:#50fa7b}.cm-s-dracula span.cm-property{color:#66d9ef}.cm-s-dracula span.cm-builtin{color:#50fa7b}.cm-s-dracula span.cm-type,.cm-s-dracula span.cm-variable-3{color:#ffb86c}.cm-s-dracula .CodeMirror-activeline-background{background:rgba(255,255,255,.1)}.cm-s-dracula .CodeMirror-matchingbracket{text-decoration:underline;color:#fff!important}

17
notes/editor/embed.go Normal file
View File

@@ -0,0 +1,17 @@
package editor
import _ "embed"
var (
//go:embed codemirror.min.js
CodeMirrorJS string
//go:embed codemirror.min.css
CodeMirrorCSS string
//go:embed vim.min.js
CodeMirrorVIM string
//go:embed dracula.min.css
CodeMirrorTheme string
)

1
notes/editor/vim.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,23 @@
package notes package notes
import ( import (
"bytes"
"encoding/base64"
"errors" "errors"
"fmt"
"io"
"io/ioutil" "io/ioutil"
"local/notes-server/filetree" "local/notes-server/filetree"
"local/notes-server/notes/md"
"path"
"regexp"
"strings"
"github.com/gomarkdown/markdown" "github.com/fairlyblank/md2min"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser" "github.com/yuin/goldmark"
blackfriday "gopkg.in/russross/blackfriday.v2"
) )
func (n *Notes) File(urlPath string) (string, error) { func (n *Notes) File(urlPath string) (string, error) {
@@ -16,10 +26,108 @@ func (n *Notes) File(urlPath string) (string, error) {
return "", errors.New("path is dir") return "", errors.New("path is dir")
} }
b, _ := ioutil.ReadFile(p.Local) b, _ := ioutil.ReadFile(p.Local)
renderer := html.NewRenderer(html.RendererOptions{ return n.gomarkdown(urlPath, b)
Flags: html.CommonFlags | html.TOC, return n.blackfriday(urlPath, b)
}) return n.goldmark(urlPath, b)
parser := parser.NewWithExtensions(parser.CommonExtensions | parser.HeadingIDs | parser.AutoHeadingIDs | parser.Titleblock) return n.md2min(urlPath, b)
content := markdown.ToHTML(b, parser, renderer) }
return string(content) + "\n", nil
func (n *Notes) blackfriday(urlPath string, b []byte) (string, error) {
renderer := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{
Flags: blackfriday.TOC | blackfriday.Smartypants | blackfriday.SmartypantsFractions | blackfriday.SmartypantsDashes | blackfriday.SmartypantsQuotesNBSP | blackfriday.CompletePage,
})
return string(blackfriday.Run(
b,
blackfriday.WithRenderer(renderer),
)), nil
}
func (n *Notes) goldmark(urlPath string, b []byte) (string, error) {
buff := bytes.NewBuffer(nil)
err := goldmark.Convert(b, buff)
return string(buff.Bytes()), err
}
func (n *Notes) md2min(urlPath string, b []byte) (string, error) {
mdc := md2min.New("h1")
buff := bytes.NewBuffer(nil)
err := mdc.Parse(b, buff)
return string(buff.Bytes()), err
}
func (n *Notes) gomarkdown(urlPath string, b []byte) (string, error) {
return md.Gomarkdown(b, n.commentFormer(urlPath, b))
}
func (n *Notes) commentFormer(urlPath string, md []byte) html.RenderNodeFunc {
urlPath = strings.TrimPrefix(urlPath, "/")
urlPath = strings.TrimPrefix(urlPath, strings.Split(urlPath, "/")[0])
lines := bytes.Split(md, []byte("\n"))
cur := -1
nextHeader := func() {
cur++
for cur < len(lines) {
for _, opener_closer := range [][]string{{"```", "```"}, {`<summary>`, `</summary>`}} {
if cur < len(lines) && bytes.Contains(lines[cur], []byte(opener_closer[0])) {
cur++
for cur < len(lines) && !bytes.Contains(lines[cur], []byte(opener_closer[1])) {
cur++
}
cur++
}
}
if cur >= len(lines) {
break
}
line := lines[cur]
ok, err := regexp.Match(`^\s*#+\s*.+$`, line)
if err != nil {
panic(err)
} else if ok {
return
}
cur++
}
}
return func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
if heading, ok := node.(*ast.Heading); !n.RO && ok && !entering {
nextHeader()
fmt.Fprintf(w, `
<form method="POST" action=%q class="comment">
<input name="lineno" type="number" style="display:none" value="%d"/>
<input autocomplete="off" name="content" type="text"/>
<input type="submit"/>
</form>
`, path.Join("/comment", urlPath)+"#"+heading.HeadingID, cur)
}
return ast.GoToNext, false
}
}
func (n *Notes) commentFormerOld() html.RenderNodeFunc {
return func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
if heading, ok := node.(*ast.Heading); ok {
if !entering {
literal := ""
ast.WalkFunc(heading, func(n ast.Node, e bool) ast.WalkStatus {
if leaf := n.AsLeaf(); e && leaf != nil {
if literal != "" {
literal += ".*"
}
literal += string(leaf.Literal)
}
return ast.GoToNext
})
level := heading.Level
id := base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf(`^[ \t]*%s\s*%s(].*)?\s*$`, strings.Repeat("#", level), literal)))
fmt.Fprintf(w, `
<form method="POST" target="/comment" name="%s" class="comment">
<input autocomplete="off" name="content" type="text"/>
<input type="submit"/>
</form>
`, id)
}
}
return ast.GoToNext, false
}
} }

35
notes/md/file.go Executable file
View File

@@ -0,0 +1,35 @@
package md
import (
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
func Gomarkdown(b []byte, renderHook html.RenderNodeFunc) (string, error) {
renderer := html.NewRenderer(html.RendererOptions{
Flags: html.CommonFlags | html.TOC,
RenderNodeHook: renderHook,
})
ext := parser.NoExtensions
for _, extension := range []parser.Extensions{
parser.NoIntraEmphasis,
parser.Tables,
parser.FencedCode,
parser.Autolink,
parser.Strikethrough,
parser.SpaceHeadings,
parser.HeadingIDs,
parser.BackslashLineBreak,
parser.DefinitionLists,
parser.MathJax,
parser.Titleblock,
parser.AutoHeadingIDs,
parser.Includes,
} {
ext |= extension
}
parser := parser.NewWithExtensions(ext)
content := markdown.ToHTML(b, parser, renderer)
return string(content) + "\n", nil
}

View File

@@ -4,10 +4,12 @@ import "local/notes-server/config"
type Notes struct { type Notes struct {
root string root string
RO bool
} }
func New() *Notes { func New() *Notes {
return &Notes{ return &Notes{
root: config.Root, root: config.Root,
RO: config.ReadOnly,
} }
} }

View File

@@ -57,7 +57,7 @@ func (n *Notes) Search(phrase string) (string, error) {
if err != nil { if err != nil {
return err return err
} }
if info.IsDir() { if !info.Mode().IsRegular() {
return nil return nil
} }
if size := info.Size(); size < 1 || size > (5*1024*1024) { if size := info.Size(); size < 1 || size > (5*1024*1024) {
@@ -67,12 +67,11 @@ func (n *Notes) Search(phrase string) (string, error) {
if err != nil && err.Error() == "bufio.Scanner: token too long" { if err != nil && err.Error() == "bufio.Scanner: token too long" {
err = nil err = nil
} }
if err == nil && ok {
p := filetree.NewPathFromLocal(path.Dir(walked))
files.Push(p, info)
}
if err != nil { if err != nil {
log.Printf("failed to scan %v: %v", walked, err) log.Printf("failed to scan %v: %v", walked, err)
} else if ok {
p := filetree.NewPathFromLocal(path.Dir(walked))
files.Push(p, info)
} }
return err return err
}, },
@@ -81,6 +80,12 @@ func (n *Notes) Search(phrase string) (string, error) {
} }
func grepFile(file string, searcher *searcher) (bool, error) { func grepFile(file string, searcher *searcher) (bool, error) {
if d := path.Base(path.Dir(file)); strings.HasPrefix(d, ".") && strings.HasSuffix(d, ".attachments") {
return false, nil
}
if strings.Contains(file, "/.git/") {
return false, nil
}
f, err := os.Open(file) f, err := os.Open(file)
if err != nil { if err != nil {
return false, err return false, err

View File

@@ -1,24 +0,0 @@
package notes
import (
"html"
"local/notes-server/filetree"
"net/http"
"path"
"strings"
)
func (n *Notes) Create(w http.ResponseWriter, r *http.Request) {
content := r.FormValue("base")
content = html.UnescapeString(content)
content = strings.ReplaceAll(content, "\r", "")
urlPath := path.Join(r.URL.Path, content)
p := filetree.NewPathFromURL(urlPath)
if p.IsDir() {
w.WriteHeader(http.StatusBadRequest)
return
}
url := *r.URL
url.Path = path.Join("/edit/", p.BaseHREF)
http.Redirect(w, r, url.String(), http.StatusSeeOther)
}

View File

@@ -1 +0,0 @@
package notes

View File

@@ -1,37 +0,0 @@
package notes
import (
"fmt"
"io/ioutil"
"net/http"
"path"
)
func notesDir(p Path, w http.ResponseWriter, r *http.Request) {
dirs, files := lsDir(p)
content := dirs.List()
notesDirHead(p, w)
block(content, w)
fmt.Fprintln(w, files.List())
}
func notesDirHead(p Path, w http.ResponseWriter) {
fmt.Fprintf(w, `
<form action=%q method="get">
<input type="text" name="base"></input>
<button type="submit">Create</button>
</form>
`, path.Join("/create/", p.BaseHREF))
}
func lsDir(path Path) (Paths, Paths) {
dirs := newDirs()
files := newFiles()
found, _ := ioutil.ReadDir(path.Local)
for _, f := range found {
dirs.Push(path, f)
files.Push(path, f)
}
return Paths(*dirs), Paths(*files)
}

View File

@@ -1,26 +0,0 @@
package notes
import (
"net/http/httptest"
"testing"
)
func TestLsDir(t *testing.T) {
p := Path{Local: "/usr/local"}
dirs, files := lsDir(p)
if len(dirs) == 0 {
t.Fatal(len(dirs))
}
if len(files) == 0 {
t.Fatal(len(files))
}
t.Log(dirs)
t.Log(files)
}
func TestNotesDir(t *testing.T) {
path := Path{Local: "/usr/local"}
w := httptest.NewRecorder()
notesDir(path, w, nil)
t.Logf("%s", w.Body.Bytes())
}

View File

@@ -1,43 +0,0 @@
package notes
import (
"fmt"
"io/ioutil"
"net/http"
"strings"
)
func (s *Server) edit(w http.ResponseWriter, r *http.Request) {
p := NewPathFromURL(r.URL.Path)
if p.IsDir() {
http.NotFound(w, r)
return
}
head(w, r)
editHead(w, p)
editFile(w, p)
foot(w, r)
}
func editHead(w http.ResponseWriter, p Path) {
fmt.Fprintln(w, h2(p.MultiLink()))
}
func editFile(w http.ResponseWriter, p Path) {
href := p.HREF
href = strings.TrimPrefix(href, "/")
hrefs := strings.SplitN(href, "/", 2)
href = hrefs[0]
if len(hrefs) > 1 {
href = hrefs[1]
}
b, _ := ioutil.ReadFile(p.Local)
fmt.Fprintf(w, `
<form action="/submit/%s" method="post" style="width:100%%; height: 90%%">
<table style="width:100%%; height: 90%%">
<textarea name="content" style="width:100%%; min-height:90%%">%s</textarea>
</table>
<button type="submit">Submit</button>
</form>
`, href, b)
}

View File

@@ -1 +0,0 @@
package notes

View File

@@ -1,29 +0,0 @@
package notes
import (
"fmt"
"io/ioutil"
"net/http"
"path"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
func notesFile(p Path, w http.ResponseWriter, r *http.Request) {
b, _ := ioutil.ReadFile(p.Local)
notesFileHead(p, w)
renderer := html.NewRenderer(html.RendererOptions{
Flags: html.CommonFlags | html.TOC,
})
parser := parser.NewWithExtensions(parser.CommonExtensions | parser.HeadingIDs | parser.AutoHeadingIDs | parser.Titleblock)
content := markdown.ToHTML(b, parser, renderer)
fmt.Fprintf(w, "%s\n", content)
}
func notesFileHead(p Path, w http.ResponseWriter) {
fmt.Fprintf(w, `
<a href=%q><input type="button" value="Edit"></input></a>
`, path.Join("/edit/", p.BaseHREF))
}

View File

@@ -1,46 +0,0 @@
package notes
import (
"fmt"
"io/ioutil"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestNotesFile(t *testing.T) {
f, err := ioutil.TempFile(os.TempDir(), "until*")
if err != nil {
t.Fatal(err)
}
defer os.Remove(f.Name())
fmt.Fprintln(f, `
# Hello
## World
* This
* is
* bullets
| My | table | goes |
|----|-------|------|
| h | e | n |
`)
f.Close()
w := httptest.NewRecorder()
p := Path{Local: f.Name()}
notesFile(p, w, nil)
s := string(w.Body.Bytes())
shouldContain := []string{
"tbody",
"h1",
"h2",
}
for _, should := range shouldContain {
if !strings.Contains(s, should) {
t.Fatalf("%s: %s", should, s)
}
}
t.Logf("%s", s)
}

View File

@@ -1,13 +0,0 @@
package notes
import "local/notes-server/config"
type Notes struct {
root string
}
func New() *Notes {
return &Notes{
root: config.Root,
}
}

View File

@@ -1,27 +0,0 @@
package notes
import (
"fmt"
"net/http"
)
func (s *Server) notes(w http.ResponseWriter, r *http.Request) {
p := NewPathFromURL(r.URL.Path)
if p.IsDir() {
head(w, r)
notesHead(w, p)
notesDir(p, w, r)
foot(w, r)
} else if p.IsFile() {
head(w, r)
notesHead(w, p)
notesFile(p, w, r)
foot(w, r)
} else {
http.NotFound(w, r)
}
}
func notesHead(w http.ResponseWriter, p Path) {
fmt.Fprintln(w, h2(p.MultiLink()))
}

View File

@@ -1 +0,0 @@
package notes

View File

@@ -1,31 +0,0 @@
package notes
import (
"fmt"
"html"
"io/ioutil"
"net/http"
"os"
"path"
"strings"
)
func (s *Server) submit(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.NotFound(w, r)
return
}
content := r.FormValue("content")
content = html.UnescapeString(content)
content = strings.ReplaceAll(content, "\r", "")
p := NewPathFromURL(r.URL.Path)
os.MkdirAll(path.Dir(p.Local), os.ModePerm)
if err := ioutil.WriteFile(p.Local, []byte(content), os.ModePerm); err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, err)
} else {
url := *r.URL
url.Path = path.Join("/notes/", p.BaseHREF)
http.Redirect(w, r, url.String(), http.StatusSeeOther)
}
}

View File

@@ -1 +0,0 @@
package notes

48
server/attach.go Executable file
View File

@@ -0,0 +1,48 @@
package server
import (
"errors"
"io"
"local/notes-server/filetree"
"net/http"
"os"
"path"
"strings"
)
func (s *Server) attach(w http.ResponseWriter, r *http.Request) {
if err := s._attach(w, r); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) _attach(w http.ResponseWriter, r *http.Request) error {
r.ParseMultipartForm(100 << 20)
file, handler, err := r.FormFile("file")
if err != nil {
return err
}
defer file.Close()
ft := filetree.NewPathFromURL(r.URL.Path)
local := ft.Local
target := path.Join(path.Dir(local), "."+path.Base(local)+".attachments", handler.Filename)
os.MkdirAll(path.Dir(target), os.ModePerm)
if fi, err := os.Stat(target); err != nil && !os.IsNotExist(err) {
return err
} else if err == nil && fi.IsDir() {
return errors.New("invalid path")
}
f, err := os.Create(target)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, file); err != nil {
return err
}
http.Redirect(w, r, "/notes"+strings.TrimPrefix(ft.HREF, "/attach"), http.StatusSeeOther)
return nil
}

35
server/comment.go Executable file
View File

@@ -0,0 +1,35 @@
package server
import (
"html"
"local/notes-server/filetree"
"net/http"
"path"
"strconv"
"strings"
)
func (s *Server) comment(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.NotFound(w, r)
return
}
linenos := r.FormValue("lineno")
lineno, err := strconv.Atoi(linenos)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
comment := r.FormValue("content")
comment = html.UnescapeString(comment)
comment = strings.ReplaceAll(comment, "\r", "")
err = s.Notes.Comment(r.URL.Path, lineno, comment)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
url := *r.URL
url.Path = path.Join("/notes/", filetree.NewPathFromURL(r.URL.Path).BaseHREF)
http.Redirect(w, r, url.String(), http.StatusSeeOther)
}

View File

@@ -2,9 +2,12 @@ package server
import ( import (
"fmt" "fmt"
"local/notes-server/config"
"local/notes-server/filetree" "local/notes-server/filetree"
"net/http" "net/http"
"path" "path"
"regexp"
"strings"
) )
func (s *Server) notes(w http.ResponseWriter, r *http.Request) { func (s *Server) notes(w http.ResponseWriter, r *http.Request) {
@@ -22,7 +25,7 @@ func (s *Server) notes(w http.ResponseWriter, r *http.Request) {
} }
func notesHead(w http.ResponseWriter, p filetree.Path) { func notesHead(w http.ResponseWriter, p filetree.Path) {
fmt.Fprintln(w, h2(p.MultiLink(), "margin: 0; position: fixed; padding: .25em; background-color: #202b38; width: 100%; top: 0;")) fmt.Fprintln(w, h2(p.MultiLink()))
htmlSearch(w) htmlSearch(w)
} }
@@ -48,39 +51,83 @@ func (s *Server) file(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
fileHead(w, filetree.NewPathFromURL(r.URL.Path).BaseHREF) s.fileHead(w, r.URL.Path)
fmt.Fprintln(w, file) fmt.Fprintln(w, file)
} }
func fileHead(w http.ResponseWriter, baseHREF string) { func (s *Server) fileHead(w http.ResponseWriter, path string) {
baseHREF := filetree.NewPathFromURL(path).BaseHREF
htmlEdit(w, baseHREF) htmlEdit(w, baseHREF)
htmlDelete(w, baseHREF) htmlDelete(w, baseHREF)
s.htmlAttachments(w, path)
} }
func htmlEdit(w http.ResponseWriter, baseHREF string) { func htmlEdit(w http.ResponseWriter, baseHREF string) {
if config.ReadOnly {
return
}
fmt.Fprintf(w, `<div style='display:inline-block'> fmt.Fprintf(w, `<div style='display:inline-block'>
<a href=%q><input type="button" value="Edit"></input></a> <a href=%q><input type="button" value="Edit"></input></a>
</div><br>`, path.Join("/edit/", baseHREF)) </div><br>`, path.Join("/edit/", baseHREF))
} }
func htmlDelete(w http.ResponseWriter, baseHREF string) { func htmlDelete(w http.ResponseWriter, baseHREF string) {
if config.ReadOnly {
return
}
fmt.Fprintf(w, `<div style='display:inline-block'> fmt.Fprintf(w, `<div style='display:inline-block'>
<a href=%q><input type="button" value="Delete" onclick="return confirm('Delete?');"></input></a> <a href=%q><input type="button" value="Delete" onclick="return confirm('Delete?');"></input></a>
</div><br>`, path.Join("/delete/", baseHREF)) </div><br>`, path.Join("/delete/", baseHREF))
} }
func (s *Server) htmlAttachments(w http.ResponseWriter, urlPath string) {
dir := path.Dir(urlPath)
f := "." + path.Base(urlPath) + ".attachments"
_, files, _ := s.Notes.Dir(path.Join(dir, f))
form := fmt.Sprintf(`
<form enctype="multipart/form-data" action="/attach/%s" method="post">
<input type="file" name="file" required/>
<input type="submit" value="+"/>
</form>
`, strings.TrimPrefix(urlPath, "/notes/"))
if config.ReadOnly {
form = ""
}
lines := strings.Split(files, "\n")
for i := range lines {
pattern := regexp.MustCompile(`\.(png|jpg|jpeg|gif)">`)
if !pattern.MatchString(lines[i]) {
lines[i] = strings.ReplaceAll(lines[i], "<a", "<a download")
}
lines[i] = strings.ReplaceAll(lines[i], `href="/notes`, `href="/raw`)
}
files = strings.Join(lines, "\n")
fmt.Fprintf(w, `<div style='display:inline-block' class="attachments">
<details>
<summary style="display: flex">
<span style="flex-grow: 2">Attachments</span>
</summary>
%s
%s
</details>
</div><br>`, form, files)
}
func htmlCreate(w http.ResponseWriter, baseHREF string) { func htmlCreate(w http.ResponseWriter, baseHREF string) {
if config.ReadOnly {
return
}
fmt.Fprintf(w, ` fmt.Fprintf(w, `
<form action=%q method="get"> <form action=%q method="get">
<input type="text" name="base"></input> <input type="text" name="base"></input>
<button type="submit">Create</button> <button type="submit">Create</button>
</form> </form>
`, path.Join("/create/", baseHREF)) `, path.Join("/create/", baseHREF))
} }
func htmlSearch(w http.ResponseWriter) { func htmlSearch(w http.ResponseWriter) {
fmt.Fprintf(w, ` fmt.Fprintf(w, `
<form action=%q method="post" style="padding-top: 2.5em"> <form action=%q method="post" >
<input type="text" name="keywords"></input> <input type="text" name="keywords"></input>
<button type="submit">Search</button> <button type="submit">Search</button>
</form> </form>

33
server/raw.go Executable file
View File

@@ -0,0 +1,33 @@
package server
import (
"io"
"local/notes-server/filetree"
"local/simpleserve/simpleserve"
"net/http"
"os"
)
func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
if err := s._raw(w, r); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) _raw(w http.ResponseWriter, r *http.Request) error {
simpleserve.SetContentTypeIfMedia(w, r)
p := filetree.NewPathFromURL(r.URL.Path)
if !p.IsFile() {
http.NotFound(w, r)
return nil
}
f, err := os.Open(p.Local)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(w, f); err != nil {
return err
}
return nil
}

View File

@@ -3,49 +3,65 @@ package server
import ( import (
"fmt" "fmt"
"local/gziphttp" "local/gziphttp"
"local/notes-server/config"
"local/router" "local/router"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings"
) )
func (s *Server) Routes() error { func (s *Server) Routes() error {
wildcard := router.Wildcard wildcard := router.Wildcard
endpoints := []struct { endpoints := map[string]struct {
path string
handler http.HandlerFunc handler http.HandlerFunc
}{ }{
{ "/": {
path: "/",
handler: s.root, handler: s.root,
}, },
{ fmt.Sprintf("raw/%s%s", wildcard, wildcard): {
path: fmt.Sprintf("notes/%s%s", wildcard, wildcard), handler: s.gzip(s.authenticate(s.raw)),
},
fmt.Sprintf("notes/%s%s", wildcard, wildcard): {
handler: s.gzip(s.authenticate(s.notes)), handler: s.gzip(s.authenticate(s.notes)),
}, },
{ fmt.Sprintf("edit/%s%s", wildcard, wildcard): {
path: fmt.Sprintf("edit/%s%s", wildcard, wildcard),
handler: s.gzip(s.authenticate(s.edit)), handler: s.gzip(s.authenticate(s.edit)),
}, },
{ fmt.Sprintf("delete/%s%s", wildcard, wildcard): {
path: fmt.Sprintf("delete/%s%s", wildcard, wildcard),
handler: s.gzip(s.authenticate(s.delete)), handler: s.gzip(s.authenticate(s.delete)),
}, },
{ fmt.Sprintf("submit/%s%s", wildcard, wildcard): {
path: fmt.Sprintf("submit/%s%s", wildcard, wildcard),
handler: s.gzip(s.authenticate(s.submit)), handler: s.gzip(s.authenticate(s.submit)),
}, },
{ fmt.Sprintf("create/%s%s", wildcard, wildcard): {
path: fmt.Sprintf("create/%s%s", wildcard, wildcard),
handler: s.gzip(s.authenticate(s.create)), handler: s.gzip(s.authenticate(s.create)),
}, },
{ fmt.Sprintf("attach/%s%s", wildcard, wildcard): {
path: fmt.Sprintf("search"), handler: s.gzip(s.authenticate(s.attach)),
},
fmt.Sprintf("comment/%s%s", wildcard, wildcard): {
handler: s.gzip(s.authenticate(s.comment)),
},
fmt.Sprintf("search"): {
handler: s.gzip(s.authenticate(s.search)), handler: s.gzip(s.authenticate(s.search)),
}, },
} }
for _, endpoint := range endpoints { for path, endpoint := range endpoints {
if err := s.Add(endpoint.path, endpoint.handler); err != nil { if config.ReadOnly {
for _, prefix := range []string{
"edit/",
"delete/",
"create/",
"submit/",
"attach/",
} {
if strings.HasPrefix(path, prefix) {
endpoint.handler = http.NotFound
}
}
}
if err := s.Add(path, endpoint.handler); err != nil {
return err return err
} }
} }

View File

@@ -21,6 +21,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
} }
head(w, r) head(w, r)
fmt.Fprintln(w, h2(filetree.NewPathFromURL("/notes").MultiLink())) fmt.Fprintln(w, h2(filetree.NewPathFromURL("/notes").MultiLink()))
htmlSearch(w)
fmt.Fprintln(w, h1(keywords), results) fmt.Fprintln(w, h1(keywords), results)
foot(w, r) foot(w, r)
} }

View File

@@ -1,6 +1,8 @@
package server package server
import ( import (
"bytes"
"fmt"
"html" "html"
"local/notes-server/filetree" "local/notes-server/filetree"
"net/http" "net/http"
@@ -16,6 +18,7 @@ func (s *Server) submit(w http.ResponseWriter, r *http.Request) {
content := r.FormValue("content") content := r.FormValue("content")
content = html.UnescapeString(content) content = html.UnescapeString(content)
content = strings.ReplaceAll(content, "\r", "") content = strings.ReplaceAll(content, "\r", "")
content = trimLines(content)
err := s.Notes.Submit(r.URL.Path, content) err := s.Notes.Submit(r.URL.Path, content)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -25,3 +28,12 @@ func (s *Server) submit(w http.ResponseWriter, r *http.Request) {
url.Path = path.Join("/notes/", filetree.NewPathFromURL(r.URL.Path).BaseHREF) url.Path = path.Join("/notes/", filetree.NewPathFromURL(r.URL.Path).BaseHREF)
http.Redirect(w, r, url.String(), http.StatusSeeOther) http.Redirect(w, r, url.String(), http.StatusSeeOther)
} }
func trimLines(s string) string {
buff := bytes.NewBuffer(nil)
for _, line := range strings.Split(s, "\n") {
fmt.Fprintln(buff, strings.TrimRight(line, "\n \r\t"))
}
fixed := string(buff.Bytes())
return fixed[:len(fixed)-1]
}

36
server/submit_test.go Normal file
View File

@@ -0,0 +1,36 @@
package server
import "testing"
func TestTrimLines(t *testing.T) {
t.Run("no newline at end", func(t *testing.T) {
s := `
hello
world`
s += " "
s += " \t"
got := trimLines(s)
want := `
hello
world`
if got != want {
t.Fatalf("want %q, got %q", want, got)
}
})
t.Run("noop", func(t *testing.T) {
s := `hi`
got := trimLines(s)
want := `hi`
if got != want {
t.Fatalf("want %q, got %q", want, got)
}
})
t.Run("newline", func(t *testing.T) {
s := "hi\n"
got := trimLines(s)
want := "hi\n"
if got != want {
t.Fatalf("want %q, got %q", want, got)
}
})
}

35
versions/max_file_size.go Normal file
View File

@@ -0,0 +1,35 @@
package versions
import (
"fmt"
"local/notes-server/config"
"strings"
)
func getScript() string {
return strings.ReplaceAll(script, "{{{MAXSIZE}}}", fmt.Sprint(config.MaxSizeMB<<20))
}
const script = `
#!/bin/bash
function main() {
local maxsize={{{MAXSIZE}}}
if [[ "$maxsize" == 0 ]]; then
return
fi
(
git diff --name-only --cached
git diff --name-only
git ls-files --others --exclude-standard
) 2>&1 \
| sort -u \
| while read -r file; do
local size="$(du -sk "$file" | awk '{print $1}')000"
if [ "$size" -gt "$maxsize" ]; then
echo "file=$file, size=$size, max=$maxsize" >&2
git reset HEAD -- "$file"
fi
done
}
main
`

View File

@@ -2,8 +2,13 @@ package versions
import ( import (
"fmt" "fmt"
"io/ioutil"
"local/notes-server/config" "local/notes-server/config"
"log"
"os"
"os/exec" "os/exec"
"path"
"strings"
"time" "time"
) )
@@ -15,7 +20,9 @@ func New() (*Versions, error) {
v.cmd("git", "init") v.cmd("git", "init")
v.cmd("git", "config", "user.email", "user@user.user") v.cmd("git", "config", "user.email", "user@user.user")
v.cmd("git", "config", "user.name", "user") v.cmd("git", "config", "user.name", "user")
return v, nil s := getScript()
err := ioutil.WriteFile(path.Join(config.Root, "./.git/hooks/pre-commit"), []byte(s), os.ModePerm)
return v, err
} }
func (v *Versions) Gitmmit() error { func (v *Versions) Gitmmit() error {
@@ -39,6 +46,9 @@ func (v *Versions) Commit() error {
func (v *Versions) cmd(cmd string, args ...string) error { func (v *Versions) cmd(cmd string, args ...string) error {
command := exec.Command(cmd, args...) command := exec.Command(cmd, args...)
command.Dir = config.Root command.Dir = config.Root
_, err := command.CombinedOutput() out, err := command.CombinedOutput()
if err != nil {
log.Println(cmd, args, ":", strings.TrimSpace(string(out)))
}
return err return err
} }

View File

@@ -1,31 +0,0 @@
<html>
<header>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!-- https://cdn.jsdelivr.net/gh/kognise/water.css@latest/dist/dark.min.css -->
<style>
@charset "UTF-8";body{font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;line-height:1.4;max-width:800px;margin:20px auto;padding:0 10px;color:#dbdbdb;background:#202b38;text-rendering:optimizeLegibility}button,input,textarea{transition:background-color .1s linear,border-color .1s linear,color .1s linear,box-shadow .1s linear,transform .1s ease}h1{font-size:2.2em;margin-top:0}h1,h2,h3,h4,h5,h6{margin-bottom:12px}h1,h2,h3,h4,h5,h6,strong{color:#fff}b,h1,h2,h3,h4,h5,h6,strong,th{font-weight:600}blockquote{border-left:4px solid rgba(0,150,191,.67);margin:1.5em 0;padding:.5em 1em;font-style:italic}blockquote>footer{margin-top:10px;font-style:normal}address,blockquote cite{font-style:normal}a[href^=mailto]:before{content:"📧 "}a[href^=tel]:before{content:"📞 "}a[href^=sms]:before{content:"💬 "}button,input[type=button],input[type=checkbox],input[type=submit]{cursor:pointer}input:not([type=checkbox]):not([type=radio]),select{display:block}button,input,select,textarea{color:#fff;background-color:#161f27;font-family:inherit;font-size:inherit;margin-right:6px;margin-bottom:6px;padding:10px;border:none;border-radius:6px;outline:none}button,input:not([type=checkbox]):not([type=radio]),select,textarea{-webkit-appearance:none}textarea{margin-right:0;width:100%;box-sizing:border-box;resize:vertical}button,input[type=button],input[type=submit]{padding-right:30px;padding-left:30px}button:hover,input[type=button]:hover,input[type=submit]:hover{background:#324759}button:focus,input:focus,select:focus,textarea:focus{box-shadow:0 0 0 2px rgba(0,150,191,.67)}button:active,input[type=button]:active,input[type=checkbox]:active,input[type=radio]:active,input[type=submit]:active{transform:translateY(2px)}button:disabled,input:disabled,select:disabled,textarea:disabled{cursor:not-allowed;opacity:.5}::-webkit-input-placeholder{color:#a9a9a9}:-ms-input-placeholder{color:#a9a9a9}::-ms-input-placeholder{color:#a9a9a9}::placeholder{color:#a9a9a9}a{text-decoration:none;color:#41adff}a:hover{text-decoration:underline}code,kbd{background:#161f27;color:#ffbe85;padding:5px;border-radius:6px}pre>code{padding:10px;display:block;overflow-x:auto}img{max-width:100%}hr{border:none;border-top:1px solid #dbdbdb}table{border-collapse:collapse;margin-bottom:10px;width:100%}td,th{padding:6px;text-align:left}th{border-bottom:1px solid #dbdbdb}tbody tr:nth-child(2n){background-color:#161f27}::-webkit-scrollbar{height:10px;width:10px}::-webkit-scrollbar-track{background:#161f27;border-radius:6px}::-webkit-scrollbar-thumb{background:#324759;border-radius:6px}::-webkit-scrollbar-thumb:hover{background:#415c73}
</style>
<style>
nav {
display: block;
background: #161f27;
padding: .5pt;
border-radius: 6px;
}
nav li li li li {
display: none;
}
img {
max-height: 400px;
}
body {
font-size: 125%;
}
</style>
</header>
<body height="100%">
{{{}}}
</body>
<footer>
</footer>
</html>