Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e79c0571 | ||
|
|
f931053650 | ||
|
|
f2175e41a9 | ||
|
|
2d16bf0ad5 | ||
|
|
69ac5f1cbf | ||
|
|
125455fcb6 | ||
|
|
1b8f7f86f4 | ||
|
|
6ea5f2c675 | ||
|
|
3b91c6782d | ||
|
|
5a16beb676 | ||
|
|
817fc57dd1 | ||
|
|
095ba7820d | ||
|
|
96cce88aed | ||
|
|
baf739658e | ||
|
|
0c6d3a6c6a | ||
|
|
e8ea8d8abf | ||
|
|
79009305a1 | ||
|
|
03f5742a91 | ||
|
|
4ad68109d2 | ||
|
|
61f97f2f0a | ||
|
|
31ebc8ffbc | ||
|
|
567c74bb57 | ||
|
|
c4161c9db6 | ||
|
|
fd67e7033b | ||
|
|
cbd287e20e | ||
|
|
479caef353 | ||
|
|
8fefc60f6b | ||
|
|
014076dd06 | ||
|
|
d408229ba9 | ||
|
|
47d6c37819 | ||
|
|
1eb8fa7223 | ||
|
|
f9c28b3c45 | ||
|
|
a26d34c2b3 | ||
|
|
4d076ede3d | ||
|
|
f60d0618e6 | ||
|
|
b975a7c103 | ||
|
|
0e9150b8f6 | ||
|
|
4fdbee6df5 | ||
|
|
e6a126ea0b | ||
|
|
43d44a4518 | ||
|
|
8bf7503c8e | ||
|
|
785215bd3c | ||
|
|
952e04815a | ||
|
|
d6c95a536c | ||
|
|
9785ef5e1e | ||
|
|
3b7aa70e44 | ||
|
|
c067b426a9 | ||
|
|
da968c2fd4 | ||
|
|
563eb7bb61 | ||
|
|
b7f13bf33d | ||
|
|
d73cbe9e0c | ||
|
|
f582410c40 | ||
|
|
f53fc80f68 | ||
|
|
16335d796b | ||
|
|
a7df97aae5 | ||
|
|
4a7efd4016 | ||
|
|
5be556a4cf | ||
|
|
72894cd5cc | ||
|
|
f0a1c21678 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
|
||||
vendor
|
||||
gollum
|
||||
public
|
||||
**.sw*
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
FROM frolvlad/alpine-glibc:alpine-3.9_glibc-2.29
|
||||
RUN apk update && apk add --no-cache ca-certificates git
|
||||
|
||||
FROM golang:1.13-alpine as certs
|
||||
RUN apk update && apk add --no-cache ca-certificates
|
||||
|
||||
FROM busybox:glibc
|
||||
RUN mkdir -p /var/log
|
||||
WORKDIR /main
|
||||
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -10,3 +13,4 @@ ENV GOPATH=""
|
||||
ENV MNT="/mnt/"
|
||||
ENTRYPOINT ["/main/exec-notes-server"]
|
||||
CMD []
|
||||
|
||||
|
||||
4
TODO
4
TODO
@@ -30,10 +30,10 @@ x main test -
|
||||
x TOC levels
|
||||
x delete pages
|
||||
x search
|
||||
FTS
|
||||
x FTS
|
||||
https://stackoverflow.com/questions/26709971/could-this-be-more-efficient-in-go
|
||||
x move auth as flag in router
|
||||
x . and ../** as roots cause bugs in listing and loading and linking
|
||||
x `create` at root is a 400, base= in URL (when `create` input is empty)
|
||||
x versioning
|
||||
delete top-level pages
|
||||
versioning
|
||||
|
||||
146
config/config.go
146
config/config.go
File diff suppressed because one or more lines are too long
129
config/rotate.py
Executable file
129
config/rotate.py
Executable 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
1
config/water.css
Submodule
Submodule config/water.css added at 576eee5b82
@@ -20,9 +20,20 @@ func NewPathFromLocal(p string) Path {
|
||||
if strings.HasPrefix(root, "./") {
|
||||
root = root[2:]
|
||||
}
|
||||
if strings.HasSuffix(root, "/") {
|
||||
root = root[:len(root)-1]
|
||||
}
|
||||
splits := strings.SplitN(p, root, 2)
|
||||
for len(splits) > 0 && splits[0] == "" {
|
||||
splits = splits[1:]
|
||||
}
|
||||
if len(splits) == 0 {
|
||||
splits = []string{"", ""}
|
||||
}
|
||||
href := splits[0]
|
||||
if len(splits) > 1 && (splits[0] == "" || splits[0] == "/") {
|
||||
if len(splits) == 1 && (splits[0] == root || splits[0] == config.Root) {
|
||||
href = ""
|
||||
} else if splits[0] == "" || splits[0] == "/" {
|
||||
href = splits[1]
|
||||
}
|
||||
href = path.Join("/notes", href)
|
||||
|
||||
2
main.go
2
main.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"local/notes-server/config"
|
||||
"local/notes-server/notes/editor"
|
||||
"local/notes-server/server"
|
||||
"local/notes-server/versions"
|
||||
"log"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Println(len(editor.CodeMirrorCSS))
|
||||
server := server.New()
|
||||
if err := server.Routes(); err != nil {
|
||||
panic(err)
|
||||
|
||||
63
notes/comment.go
Executable file
63
notes/comment.go
Executable 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
56
notes/comment_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"local/notes-server/filetree"
|
||||
"os"
|
||||
)
|
||||
|
||||
func (n *Notes) Delete(urlPath string) error {
|
||||
p := filetree.NewPathFromURL(urlPath)
|
||||
if p.IsDir() {
|
||||
return errors.New("path is dir")
|
||||
err := os.Remove(p.Local)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to delete %q => %q: %v", urlPath, p.Local, err)
|
||||
}
|
||||
return os.Remove(p.Local)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io/ioutil"
|
||||
"local/notes-server/config"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -11,10 +12,43 @@ func TestDelete(t *testing.T) {
|
||||
config.Root = "/tmp"
|
||||
ioutil.WriteFile("/tmp/a", []byte("hi"), os.ModePerm)
|
||||
n := &Notes{}
|
||||
|
||||
t.Run("delete 404", func(t *testing.T) {
|
||||
if err := n.Delete("/notes/a"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if _, err := os.Stat("/tmp/a"); err == nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete w. content", func(t *testing.T) {
|
||||
d, err := ioutil.TempDir(os.TempDir(), "trydel*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.Root = path.Dir(d)
|
||||
for i := 0; i < 3; i++ {
|
||||
f, err := ioutil.TempFile(d, "file*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
if err := n.Delete("/abc/" + path.Base(d)); err == nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete empty dir", func(t *testing.T) {
|
||||
d2p := os.TempDir()
|
||||
d2, err := ioutil.TempDir(d2p, "trydel*")
|
||||
config.Root = path.Dir(d2p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := n.Delete("/abc/" + path.Base(d2)); err != nil {
|
||||
t.Errorf("failed to del empty dir %s in %s: %v", d2, d2p, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"local/notes-server/filetree"
|
||||
"local/notes-server/notes/editor"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -26,11 +27,49 @@ func editFile(p filetree.Path) string {
|
||||
}
|
||||
b, _ := ioutil.ReadFile(p.Local)
|
||||
return fmt.Sprintf(`
|
||||
<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>
|
||||
<div class="form">
|
||||
<form action="/submit/%s" method="post">
|
||||
<textarea id="mytext" name="content" style="cursor:crosshair;">%s</textarea>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
`, href, b)
|
||||
</div>
|
||||
<!--
|
||||
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
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
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
1
notes/editor/dracula.min.css
vendored
Normal 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
17
notes/editor/embed.go
Normal 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
1
notes/editor/vim.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
124
notes/file.go
124
notes/file.go
@@ -1,13 +1,23 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"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/parser"
|
||||
"github.com/yuin/goldmark"
|
||||
blackfriday "gopkg.in/russross/blackfriday.v2"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
b, _ := ioutil.ReadFile(p.Local)
|
||||
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)
|
||||
return string(content) + "\n", nil
|
||||
return n.gomarkdown(urlPath, b)
|
||||
return n.blackfriday(urlPath, b)
|
||||
return n.goldmark(urlPath, b)
|
||||
return n.md2min(urlPath, b)
|
||||
}
|
||||
|
||||
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
35
notes/md/file.go
Executable 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
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import "local/notes-server/config"
|
||||
|
||||
type Notes struct {
|
||||
root string
|
||||
RO bool
|
||||
}
|
||||
|
||||
func New() *Notes {
|
||||
return &Notes{
|
||||
root: config.Root,
|
||||
RO: config.ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,53 +2,111 @@ package notes
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"local/notes-server/filetree"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type searcher struct {
|
||||
patterns []*regexp.Regexp
|
||||
}
|
||||
|
||||
func newSearcher(phrase string) (*searcher, error) {
|
||||
phrases := strings.Split(phrase, " ")
|
||||
patterns := make([]*regexp.Regexp, 0)
|
||||
for _, phrase := range phrases {
|
||||
if len(phrase) == 0 {
|
||||
continue
|
||||
}
|
||||
pattern, err := regexp.Compile("(?i)" + phrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patterns = append(patterns, pattern)
|
||||
}
|
||||
if len(patterns) < 1 {
|
||||
return nil, errors.New("no search specified")
|
||||
}
|
||||
return &searcher{
|
||||
patterns: patterns,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *searcher) matches(input []byte) bool {
|
||||
for _, pattern := range s.patterns {
|
||||
if !pattern.Match(input) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (n *Notes) Search(phrase string) (string, error) {
|
||||
searcher, err := newSearcher(phrase)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
perffiles := filetree.NewFiles()
|
||||
files := filetree.NewFiles()
|
||||
err := filepath.Walk(n.root,
|
||||
err = filepath.Walk(n.root,
|
||||
func(walked string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
p := filetree.NewPathFromLocal(path.Dir(walked))
|
||||
if ok, _ := searcher.stream(strings.NewReader(info.Name())); ok {
|
||||
perffiles.Push(p, info)
|
||||
return nil
|
||||
}
|
||||
if size := info.Size(); size < 1 || size > (5*1024*1024) {
|
||||
return nil
|
||||
}
|
||||
ok, err := grepFile(walked, []byte(phrase))
|
||||
ok, err := searcher.file(walked)
|
||||
if err != nil && err.Error() == "bufio.Scanner: token too long" {
|
||||
err = nil
|
||||
}
|
||||
if err == nil && ok {
|
||||
p := filetree.NewPathFromLocal(path.Dir(walked))
|
||||
files.Push(p, info)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("failed to scan %v: %v", walked, err)
|
||||
} else if ok {
|
||||
files.Push(p, info)
|
||||
}
|
||||
return err
|
||||
},
|
||||
)
|
||||
return filetree.Paths(*files).List(true), err
|
||||
for _, file := range *files {
|
||||
*perffiles = append(*perffiles, file)
|
||||
}
|
||||
return filetree.Paths(*perffiles).List(true), err
|
||||
}
|
||||
|
||||
func grepFile(file string, phrase []byte) (bool, error) {
|
||||
func (searcher *searcher) file(file string) (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)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
return searcher.stream(f)
|
||||
}
|
||||
|
||||
func (searcher *searcher) stream(r io.Reader) (bool, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
if bytes.Contains(scanner.Bytes(), phrase) {
|
||||
if searcher.matches(scanner.Bytes()) {
|
||||
return true, scanner.Err()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestSearch(t *testing.T) {
|
||||
t.Fatal(v, result)
|
||||
}
|
||||
|
||||
result, err = n.Search("4")
|
||||
result, err = n.Search("number.4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
asdf
|
||||
|
||||
this contains my search string
|
||||
@@ -1,5 +1,18 @@
|
||||
asdf
|
||||
searchString
|
||||
# h1
|
||||
|
||||
hi
|
||||
|
||||
here is my new line
|
||||
## h2
|
||||
|
||||
hi
|
||||
|
||||
### h3
|
||||
|
||||
hi
|
||||
|
||||
#### h4
|
||||
|
||||
hi
|
||||
|
||||
* bullet
|
||||
* 1
|
||||
@@ -1 +1,3 @@
|
||||
hi
|
||||
# Hello
|
||||
|
||||
## World
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package notes
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package notes
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package notes
|
||||
|
||||
import "local/notes-server/config"
|
||||
|
||||
type Notes struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func New() *Notes {
|
||||
return &Notes{
|
||||
root: config.Root,
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package notes
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package notes
|
||||
48
server/attach.go
Executable file
48
server/attach.go
Executable 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
35
server/comment.go
Executable 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)
|
||||
}
|
||||
@@ -22,8 +22,8 @@ func h1(content string) string {
|
||||
return h("1", content)
|
||||
}
|
||||
|
||||
func h2(content string) string {
|
||||
return h("2", content)
|
||||
func h2(content string, style ...string) string {
|
||||
return h("2", content, style...)
|
||||
}
|
||||
|
||||
func h3(content string) string {
|
||||
@@ -38,6 +38,10 @@ func h5(content string) string {
|
||||
return h("5", content)
|
||||
}
|
||||
|
||||
func h(level, content string) string {
|
||||
return fmt.Sprintf("\n<h%s>\n%s\n</h%s>\n", level, content, level)
|
||||
func h(level, content string, style ...string) string {
|
||||
s := ""
|
||||
if len(style) > 0 {
|
||||
s = fmt.Sprintf("style=%q", style[0])
|
||||
}
|
||||
return fmt.Sprintf("\n<h%s %s>\n%s\n</h%s>\n", level, s, content, level)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestBlock(t *testing.T) {
|
||||
|
||||
func TestH(t *testing.T) {
|
||||
s := strings.ReplaceAll(strings.TrimSpace(h2("hi")), "\n", ".")
|
||||
if ok, err := regexp.MatchString("<h2>.*hi.*<.h2>", s); err != nil {
|
||||
if ok, err := regexp.MatchString("<h2[ ]*>.*hi.*<.h2>", s); err != nil {
|
||||
t.Fatal(err, s)
|
||||
} else if !ok {
|
||||
t.Fatal(ok, s)
|
||||
|
||||
104
server/notes.go
104
server/notes.go
@@ -2,9 +2,12 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"local/notes-server/config"
|
||||
"local/notes-server/filetree"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) notes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -23,12 +26,7 @@ func (s *Server) notes(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func notesHead(w http.ResponseWriter, p filetree.Path) {
|
||||
fmt.Fprintln(w, h2(p.MultiLink()))
|
||||
fmt.Fprintf(w, `
|
||||
<form action=%q method="post">
|
||||
<input type="text" name="keywords"></input>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
`, "/search")
|
||||
htmlSearch(w)
|
||||
}
|
||||
|
||||
func (s *Server) dir(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -43,12 +41,8 @@ func (s *Server) dir(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func dirHead(w http.ResponseWriter, baseHREF string) {
|
||||
fmt.Fprintf(w, `
|
||||
<form action=%q method="get">
|
||||
<input type="text" name="base"></input>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
`, path.Join("/create/", baseHREF))
|
||||
htmlCreate(w, baseHREF)
|
||||
htmlDelete(w, baseHREF)
|
||||
}
|
||||
|
||||
func (s *Server) file(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -57,15 +51,85 @@ func (s *Server) file(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fileHead(w, filetree.NewPathFromURL(r.URL.Path).BaseHREF)
|
||||
s.fileHead(w, r.URL.Path)
|
||||
fmt.Fprintln(w, file)
|
||||
}
|
||||
|
||||
func fileHead(w http.ResponseWriter, baseHREF string) {
|
||||
fmt.Fprintf(w, `
|
||||
<a href=%q><input type="button" value="Edit"></input></a>
|
||||
`, path.Join("/edit/", baseHREF))
|
||||
fmt.Fprintf(w, `
|
||||
<a href=%q><input type="button" value="Delete"></input></a>
|
||||
`, path.Join("/delete/", baseHREF))
|
||||
func (s *Server) fileHead(w http.ResponseWriter, path string) {
|
||||
baseHREF := filetree.NewPathFromURL(path).BaseHREF
|
||||
htmlEdit(w, baseHREF)
|
||||
htmlDelete(w, baseHREF)
|
||||
s.htmlAttachments(w, path)
|
||||
}
|
||||
|
||||
func htmlEdit(w http.ResponseWriter, baseHREF string) {
|
||||
if config.ReadOnly {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `<div style='display:inline-block; margin-top: .75em; margin-bottom: .75em;'>
|
||||
<a href=%q class="button">Edit</a>
|
||||
</div><br>`, path.Join("/edit/", baseHREF))
|
||||
}
|
||||
|
||||
func htmlDelete(w http.ResponseWriter, baseHREF string) {
|
||||
if config.ReadOnly {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `<div style='display:inline-block; margin-top: .75em; margin-bottom: .75em;'>
|
||||
<a href=%q class="button" onclick="return confirm('Delete?');">Delete</a>
|
||||
</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) {
|
||||
if config.ReadOnly {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `
|
||||
<form action=%q method="get">
|
||||
<input type="text" name="base"></input>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
`, path.Join("/create/", baseHREF))
|
||||
}
|
||||
|
||||
func htmlSearch(w http.ResponseWriter) {
|
||||
fmt.Fprintf(w, `
|
||||
<form action=%q method="post" >
|
||||
<input type="text" name="keywords"></input>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
`, "/search")
|
||||
}
|
||||
|
||||
33
server/raw.go
Executable file
33
server/raw.go
Executable 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
|
||||
}
|
||||
@@ -2,48 +2,66 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"local/gziphttp"
|
||||
"local/notes-server/config"
|
||||
"local/router"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) Routes() error {
|
||||
wildcard := router.Wildcard
|
||||
endpoints := []struct {
|
||||
path string
|
||||
endpoints := map[string]struct {
|
||||
handler http.HandlerFunc
|
||||
}{
|
||||
{
|
||||
path: "/",
|
||||
"/": {
|
||||
handler: s.root,
|
||||
},
|
||||
{
|
||||
path: fmt.Sprintf("notes/%s%s", wildcard, wildcard),
|
||||
handler: s.authenticate(s.notes),
|
||||
fmt.Sprintf("raw/%s%s", wildcard, wildcard): {
|
||||
handler: s.gzip(s.authenticate(s.raw)),
|
||||
},
|
||||
{
|
||||
path: fmt.Sprintf("edit/%s%s", wildcard, wildcard),
|
||||
handler: s.authenticate(s.edit),
|
||||
fmt.Sprintf("notes/%s%s", wildcard, wildcard): {
|
||||
handler: s.gzip(s.authenticate(s.notes)),
|
||||
},
|
||||
{
|
||||
path: fmt.Sprintf("delete/%s%s", wildcard, wildcard),
|
||||
handler: s.authenticate(s.delete),
|
||||
fmt.Sprintf("edit/%s%s", wildcard, wildcard): {
|
||||
handler: s.gzip(s.authenticate(s.edit)),
|
||||
},
|
||||
{
|
||||
path: fmt.Sprintf("submit/%s%s", wildcard, wildcard),
|
||||
handler: s.authenticate(s.submit),
|
||||
fmt.Sprintf("delete/%s%s", wildcard, wildcard): {
|
||||
handler: s.gzip(s.authenticate(s.delete)),
|
||||
},
|
||||
{
|
||||
path: fmt.Sprintf("create/%s%s", wildcard, wildcard),
|
||||
handler: s.authenticate(s.create),
|
||||
fmt.Sprintf("submit/%s%s", wildcard, wildcard): {
|
||||
handler: s.gzip(s.authenticate(s.submit)),
|
||||
},
|
||||
{
|
||||
path: fmt.Sprintf("search"),
|
||||
handler: s.authenticate(s.search),
|
||||
fmt.Sprintf("create/%s%s", wildcard, wildcard): {
|
||||
handler: s.gzip(s.authenticate(s.create)),
|
||||
},
|
||||
fmt.Sprintf("attach/%s%s", wildcard, wildcard): {
|
||||
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)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if err := s.Add(endpoint.path, endpoint.handler); err != nil {
|
||||
for path, endpoint := range endpoints {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -54,3 +72,17 @@ func (s *Server) root(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = "/notes"
|
||||
http.Redirect(w, r, r.URL.String(), http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func (s *Server) gzip(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if gziphttp.Can(r) {
|
||||
gz := gziphttp.New(w)
|
||||
defer gz.Close()
|
||||
w = gz
|
||||
}
|
||||
if filepath.Ext(r.URL.Path) == ".css" {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
head(w, r)
|
||||
fmt.Fprintln(w, h2(filetree.NewPathFromURL("/notes").MultiLink()))
|
||||
htmlSearch(w)
|
||||
fmt.Fprintln(w, h1(keywords), results)
|
||||
foot(w, r)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html"
|
||||
"local/notes-server/filetree"
|
||||
"net/http"
|
||||
@@ -16,6 +18,7 @@ func (s *Server) submit(w http.ResponseWriter, r *http.Request) {
|
||||
content := r.FormValue("content")
|
||||
content = html.UnescapeString(content)
|
||||
content = strings.ReplaceAll(content, "\r", "")
|
||||
content = trimLines(content)
|
||||
err := s.Notes.Submit(r.URL.Path, content)
|
||||
if err != nil {
|
||||
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)
|
||||
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
36
server/submit_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
38
versions/max_file_size.go
Normal file
38
versions/max_file_size.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"local/notes-server/config"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getScript() string {
|
||||
maxSizeMB := config.MaxSizeMB
|
||||
if maxSizeMB == 0 {
|
||||
maxSizeMB = 1
|
||||
}
|
||||
return strings.ReplaceAll(script, "{{{MAXSIZE}}}", fmt.Sprint(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
|
||||
`
|
||||
@@ -2,8 +2,13 @@ package versions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"local/notes-server/config"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,7 +20,9 @@ func New() (*Versions, error) {
|
||||
v.cmd("git", "init")
|
||||
v.cmd("git", "config", "user.email", "user@user.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 {
|
||||
@@ -39,6 +46,9 @@ func (v *Versions) Commit() error {
|
||||
func (v *Versions) cmd(cmd string, args ...string) error {
|
||||
command := exec.Command(cmd, args...)
|
||||
command.Dir = config.Root
|
||||
_, err := command.CombinedOutput()
|
||||
out, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
log.Println(cmd, args, ":", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
28
wrapper.html
28
wrapper.html
@@ -1,28 +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;
|
||||
}
|
||||
</style>
|
||||
</header>
|
||||
<body height="100%">
|
||||
{{{}}}
|
||||
</body>
|
||||
<footer>
|
||||
</footer>
|
||||
</html>
|
||||
Reference in New Issue
Block a user