Compare commits

...

7 Commits

Author SHA1 Message Date
Wancat
22229fc5bc 加入來源帳戶選項 2022-11-05 00:21:44 +08:00
Wancat
ae86204c84 Add query function 2022-11-05 00:13:21 +08:00
Wancat
4ae49b5db6 Add download 2022-11-04 23:45:00 +08:00
Wancat
b1d0bc826f Add edit function 2022-11-04 23:36:23 +08:00
Wancat
7c63223aa0 l10n, create dir on creating file 2022-11-04 23:17:17 +08:00
Wancat
9f99aad680 Add user dir & file 2022-11-04 23:06:32 +08:00
Wancat
bca1735393 Add Template layouts, load session hashkey 2022-11-04 22:45:57 +08:00
18 changed files with 341 additions and 160 deletions

View File

@@ -13,6 +13,7 @@ import (
) )
type AuthStore interface { type AuthStore interface {
Get(user string) bool
Register(user, pass string) error Register(user, pass string) error
Login(user, pass string) (token string, err error) Login(user, pass string) (token string, err error)
Verify(token string) (session Session, err error) Verify(token string) (session Session, err error)
@@ -39,6 +40,11 @@ func New(path string, hashKey []byte) (AuthStore, error) {
return s, err return s, err
} }
func (s Htpasswd) Get(user string) bool {
_, ok := s.accounts[user]
return ok
}
func (s Htpasswd) Register(user, pass string) (err error) { func (s Htpasswd) Register(user, pass string) (err error) {
if _, ok := s.accounts[user]; ok { if _, ok := s.accounts[user]; ok {
return errors.New("user already exists") return errors.New("user already exists")

View File

@@ -1,7 +1,7 @@
package main package main
var SCRIPTS = map[string][]string{ var SCRIPTS = map[string]string{
"balance assets": {"b", "assets", "-X", "$"}, "balance assets": "b assets -X $",
"register": {"r", "--tail", "10"}, "register": "r --tail 10",
"balance this month": {"b", "-b", "this month"}, "balance this month": "b -b \"this month\"",
} }

120
main.go
View File

@@ -1,12 +1,11 @@
package main package main
import ( import (
"encoding/base64"
"flag" "flag"
"log" "log"
"net/http"
"text/template" "text/template"
"github.com/gin-gonic/gin"
"github.com/gorilla/securecookie" "github.com/gorilla/securecookie"
"github.com/lancatlin/ledger-quicknote/auth" "github.com/lancatlin/ledger-quicknote/auth"
) )
@@ -14,123 +13,42 @@ import (
var ledgerTpl *template.Template var ledgerTpl *template.Template
var htmlTpl *template.Template var htmlTpl *template.Template
var LEDGER_FILE string var DATA_DIR string
var LEDGER_INIT string
var WORKING_DIR string
var HOST string var HOST string
var store auth.AuthStore var store auth.AuthStore
const HTPASSWD_FILE = ".htpasswd" const HTPASSWD_FILE = ".htpasswd"
const DEFAULT_JOURNAL = "ledger.txt"
type UserLogin struct {
Email string `form:"email" binding:"required"`
Password string `form:"password" binding:"required"`
}
func init() { func init() {
ledgerTpl = template.Must(template.ParseGlob("tx/*")) ledgerTpl = template.Must(template.ParseGlob("tx/*"))
flag.StringVar(&LEDGER_FILE, "f", "example.txt", "ledger journal file to write") flag.StringVar(&DATA_DIR, "d", "data", "data folder")
flag.StringVar(&LEDGER_INIT, "i", "", "ledger initiation file")
flag.StringVar(&WORKING_DIR, "w", "", "ledger working directory")
flag.StringVar(&HOST, "b", "127.0.0.1:8000", "binding address") flag.StringVar(&HOST, "b", "127.0.0.1:8000", "binding address")
var hashKey string var hashKeyString string
flag.StringVar(&hashKey, "s", "", "session secret") flag.StringVar(&hashKeyString, "s", "", "session secret")
flag.Parse() flag.Parse()
if hashKey == "" { var hashKey []byte
hashKey = string(securecookie.GenerateRandomKey(32))
log.Printf("Generate random session key: %s", hashKey)
}
var err error var err error
store, err = auth.New(HTPASSWD_FILE, []byte(hashKey))
if hashKeyString == "" {
hashKey = securecookie.GenerateRandomKey(32)
log.Printf("Generate random session key: %s", base64.StdEncoding.EncodeToString(hashKey))
} else {
hashKey, err = base64.StdEncoding.DecodeString(hashKeyString)
if err != nil {
panic(err)
}
}
store, err = auth.New(HTPASSWD_FILE, hashKey)
if err != nil { if err != nil {
panic(err) panic(err)
} }
} }
func main() { func main() {
r := gin.Default() r := router()
r.HTMLRender = loadTemplates("templates")
r.GET("/signup", func(c *gin.Context) {
c.HTML(200, "signup.html", nil)
})
r.GET("/signin", func(c *gin.Context) {
c.HTML(200, "signin.html", nil)
})
r.POST("/signup", signup)
r.POST("/signin", signin)
authZone := r.Group("", basicAuth)
authZone.GET("/dashboard", func(c *gin.Context) {
c.HTML(200, "index.html", struct {
Templates []*template.Template
Scripts map[string][]string
}{
ledgerTpl.Templates(),
SCRIPTS,
})
})
authZone.POST("/new", func(c *gin.Context) {
var data TxData
if err := c.ShouldBind(&data); err != nil {
c.AbortWithError(400, err)
return
}
tx, err := newTx(data)
if err != nil {
c.AbortWithError(400, err)
log.Println(err, c.Request.Form)
return
}
c.HTML(200, "new.html", struct {
Tx string
}{tx})
})
authZone.POST("/submit", func(c *gin.Context) {
tx := c.PostForm("tx")
if err := appendToFile(tx); err != nil {
c.AbortWithError(500, err)
return
}
c.HTML(200, "success.html", struct {
Tx string
}{tx})
})
authZone.GET("/exec", func(c *gin.Context) {
name, _ := c.GetQuery("name")
if err := executeScript(c.Writer, name); err != nil {
c.AbortWithError(500, err)
log.Println(err)
return
}
})
log.Fatal(r.Run(HOST)) log.Fatal(r.Run(HOST))
} }
func basicAuth(c *gin.Context) {
cookie, err := c.Cookie("session")
if err == http.ErrNoCookie {
c.Redirect(303, "/signin")
return
}
session, err := store.Verify(cookie)
if err != nil {
c.Redirect(303, "/signin")
return
}
c.Set("user", UserLogin{
Email: session.User,
})
c.Next()
}

123
route.go Normal file
View File

@@ -0,0 +1,123 @@
package main
import (
"io/ioutil"
"log"
"text/template"
"github.com/gin-gonic/gin"
)
func router() *gin.Engine {
r := gin.Default()
r.HTMLRender = loadTemplates("templates")
r.GET("/", func(c *gin.Context) {
HTML(c, 200, "index.html", nil)
})
r.GET("/signup", func(c *gin.Context) {
HTML(c, 200, "signup.html", nil)
})
r.GET("/signin", func(c *gin.Context) {
HTML(c, 200, "signin.html", nil)
})
r.POST("/signup", signup)
r.POST("/signin", signin)
authZone := r.Group("", authenticate)
authZone.GET("/dashboard", func(c *gin.Context) {
HTML(c, 200, "dashboard.html", struct {
Templates []*template.Template
Scripts map[string]string
}{
ledgerTpl.Templates(),
SCRIPTS,
})
})
authZone.POST("/new", func(c *gin.Context) {
var data TxData
if err := c.ShouldBind(&data); err != nil {
c.AbortWithError(400, err)
return
}
tx, err := newTx(data)
if err != nil {
c.AbortWithError(400, err)
log.Println(err, c.Request.Form)
return
}
HTML(c, 200, "new.html", struct {
Tx string
}{tx})
})
authZone.POST("/submit", func(c *gin.Context) {
user := getUser(c)
tx := c.PostForm("tx")
if err := user.appendToFile(tx); err != nil {
c.AbortWithError(500, err)
return
}
HTML(c, 200, "success.html", struct {
Tx string
}{tx})
})
authZone.GET("/edit", func(c *gin.Context) {
user := getUser(c)
f, err := user.ReadFile(DEFAULT_JOURNAL)
if err != nil {
panic(err)
}
defer f.Close()
data, err := ioutil.ReadAll(f)
if err != nil {
panic(err)
}
HTML(c, 200, "edit.html", string(data))
})
authZone.POST("/edit", func(c *gin.Context) {
user := getUser(c)
data := c.PostForm("data")
err := user.overwriteFile(data)
if err != nil {
panic(err)
}
HTML(c, 200, "success.html", struct {
Tx string
}{data})
})
authZone.GET("/download", func(c *gin.Context) {
user := getUser(c)
c.FileAttachment(user.FilePath(DEFAULT_JOURNAL), DEFAULT_JOURNAL)
})
authZone.GET("/query", func(c *gin.Context) {
response := struct {
Query string
Result string
Scripts map[string]string
}{Scripts: SCRIPTS}
user := getUser(c)
var ok bool
var err error
response.Query, ok = c.GetQuery("query")
if ok && response.Query != "" {
response.Result, err = user.query(response.Query)
if err != nil {
panic(err)
}
}
HTML(c, 200, "query.html", response)
})
return r
}

View File

@@ -1,29 +1,54 @@
package main package main
import "github.com/gin-gonic/gin" import (
"net/http"
"github.com/gin-gonic/gin"
)
func authenticate(c *gin.Context) {
cookie, err := c.Cookie("session")
if err == http.ErrNoCookie {
c.Redirect(303, "/signin")
return
}
session, err := store.Verify(cookie)
if err != nil {
c.Redirect(303, "/signin")
return
}
c.Set("user", User{
Email: session.User,
})
c.Next()
}
func getUser(c *gin.Context) User {
return c.MustGet("user").(User)
}
func signup(c *gin.Context) { func signup(c *gin.Context) {
var user UserLogin var user User
if err := c.ShouldBind(&user); err != nil { if err := c.ShouldBind(&user); err != nil {
c.HTML(400, "signup.html", err) HTML(c, 400, "signup.html", err)
return return
} }
if err := store.Register(user.Email, user.Password); err != nil { if err := store.Register(user.Email, user.Password); err != nil {
c.HTML(400, "signup.html", err) HTML(c, 400, "signup.html", err)
return return
} }
signin(c) signin(c)
} }
func signin(c *gin.Context) { func signin(c *gin.Context) {
var user UserLogin var user User
if err := c.ShouldBind(&user); err != nil { if err := c.ShouldBind(&user); err != nil {
c.HTML(400, "signin.html", err) HTML(c, 400, "signin.html", err)
return return
} }
token, err := store.Login(user.Email, user.Password) token, err := store.Login(user.Email, user.Password)
if err != nil { if err != nil {
c.HTML(401, "signin.html", err) HTML(c, 401, "signin.html", err)
return return
} }
c.SetCookie("session", token, 60*60*24*7, "", "", false, false) c.SetCookie("session", token, 60*60*24*7, "", "", false, false)

View File

@@ -5,6 +5,7 @@ import (
"path/filepath" "path/filepath"
"github.com/gin-contrib/multitemplate" "github.com/gin-contrib/multitemplate"
"github.com/gin-gonic/gin"
) )
func loadTemplates(templatesDir string) multitemplate.Renderer { func loadTemplates(templatesDir string) multitemplate.Renderer {
@@ -25,3 +26,21 @@ func loadTemplates(templatesDir string) multitemplate.Renderer {
} }
return r return r
} }
type Data interface{}
type Page struct {
Data
User User
}
func HTML(c *gin.Context, status int, name string, data interface{}) {
output := Page{
Data: data,
}
_, ok := c.Get("user")
if ok {
output.User = c.MustGet("user").(User)
}
c.HTML(status, name, output)
}

16
templates/dashboard.html Normal file
View File

@@ -0,0 +1,16 @@
{{ define "main" }}
<h1>Ledger 速記</h1>
<form action="/new" method="POST">
<label>選擇模板:
{{ range .Templates }}
<input type="radio" name="action" value="{{ .Name }}">{{ .Name }}</option>
{{ end }}
</label><br>
<label>金額:<input name="amount" type="number"></label><br>
<label>目的帳戶:<input name="dest" type="text"></label></br>
<label>來源帳戶:<input name="src" type="text"></label></br>
<label>名稱:<input name="name" type="text"></label></br>
<input type="submit" value="新增記錄">
</form>
{{ template "scripts" .Scripts }}
{{ end }}

8
templates/edit.html Normal file
View File

@@ -0,0 +1,8 @@
{{ define "title" }}編輯{{ end }}
{{ define "main" }}
<h1>編輯帳本</h1>
<form action="/edit" method="POST">
<textarea name="data" rows="15" cols="40">{{ . }}</textarea><br>
<input type="submit" value="儲存">
</form>
{{ end }}

View File

@@ -1,20 +1,3 @@
{{ define "main" }} {{ define "main" }}
<h1>Ledger Quick Note</h1> <h1>Ledger 速記</h1>
<form action="/new" method="POST">
<label>Action:
{{ range .Templates }}
<input type="radio" name="action" value="{{ .Name }}">{{ .Name }}</option>
{{ end }}
</label><br>
<label>Amount: <input name="amount" type="number"></label><br>
<label>Account: <input name="account" type="text"></label></br>
<label>Tx Name: <input name="name" type="text"></label></br>
<input type="submit">
</form>
<h2>Scripts</h2>
<ul>
{{ range $k, $v := .Scripts }}
<li><a href="/exec?name={{ $k }}">{{ $k }}</a></li>
{{ end }}
</ul>
{{ end }} {{ end }}

View File

@@ -1,11 +1,21 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>{{ block "title" . }}Ledger Quick Note{{ end }}</title> <title>{{ block "title" . }}Ledger 速記{{ end }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head> </head>
<body> <body>
{{ block "main" . }} <nav>
<ul>
{{ with .User }}
{{ .Email }}
<li><a href="dashboard">儀表板</a></li>
<li><a href="edit">編輯</a></li>
<li><a href="download">下載</a></li>
<li><a href="query">查詢</a></li>
{{ end }}
</nav>
{{ block "main" .Data }}
{{ end }} {{ end }}
</body> </body>

View File

@@ -0,0 +1,8 @@
{{ define "scripts" }}
<h3>快速查詢</h3>
<ul>
{{ range $k, $v := . }}
<li><a href="/query?query={{ urlquery $v }}">{{ $k }}</a></li>
{{ end }}
</ul>
{{ end }}

12
templates/query.html Normal file
View File

@@ -0,0 +1,12 @@
{{ define "title" }}查詢{{ end }}
{{ define "main" }}
<h1>查詢</h1>
{{ with .Result }}
<pre><code>{{ . }}</code></pre>
{{ end }}
<form action="/query" method="GET">
<input type="text" name="query" value="{{ .Query }}" autofocus>
<input type="submit" value="查詢">
</form>
{{ template "scripts" .Scripts }}
{{ end }}

View File

@@ -1,10 +1,11 @@
{{ define "title" }}Sign Up{{ end }} {{ define "title" }}登入{{ end }}
{{ define "main" }} {{ define "main" }}
<h1>Sign In</h1> <h1>登入</h1>
<form action="/signin" method="POST"> <form action="/signin" method="POST">
<label>Email: <input type="text" name="email"></label><br> <label>Email: <input type="text" name="email"></label><br>
<label>Password: <input type="password" name="password"></label><br> <label>Password: <input type="password" name="password"></label><br>
{{ with .Error }}<p class="error">{{ . }}</p>{{ end }} {{ with .Error }}<p class="error">{{ . }}</p>{{ end }}
<input type="submit" value="Sign Up"> <input type="submit" value="登入">
</form> </form>
<a href="signup">沒有帳號嗎?註冊</a>
{{ end }} {{ end }}

View File

@@ -1,10 +1,11 @@
{{ define "title" }}Sign Up{{ end }} {{ define "title" }}註冊{{ end }}
{{ define "main" }} {{ define "main" }}
<h1>Sign Up</h1> <h1>註冊</h1>
<form action="/signup" method="POST"> <form action="/signup" method="POST">
<label>Email: <input type="text" name="email"></label><br> <label>Email: <input type="text" name="email"></label><br>
<label>Password: <input type="password" name="password"></label><br> <label>Password: <input type="password" name="password"></label><br>
{{ with .Error }}<p class="error">{{ . }}</p>{{ end }} {{ with .Error }}<p class="error">{{ . }}</p>{{ end }}
<input type="submit" value="Sign Up"> <input type="submit" value="註冊">
</form> </form>
<a href="signin">已有帳號嗎?登入</a>
{{ end }} {{ end }}

View File

@@ -2,5 +2,5 @@
{{ define "main" }} {{ define "main" }}
<p><strong>Success</strong></p> <p><strong>Success</strong></p>
<pre><code>{{ .Tx }}</code></pre> <pre><code>{{ .Tx }}</code></pre>
<p><a href="/">Back to home</a></p> <p><a href="/dashboard">Back to home</a></p>
{{ end }} {{ end }}

39
tx.go
View File

@@ -2,9 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"fmt"
"io" "io"
"os"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
@@ -15,7 +13,8 @@ type TxData struct {
Name string `form:"name"` Name string `form:"name"`
Date string Date string
Amount string `form:"amount" binding:"required"` Amount string `form:"amount" binding:"required"`
Account string `form:"account"` Destination string `form:"dest"`
Source string `form:"src"`
} }
func newTx(data TxData) (result string, err error) { func newTx(data TxData) (result string, err error) {
@@ -25,8 +24,8 @@ func newTx(data TxData) (result string, err error) {
return buf.String(), nil return buf.String(), nil
} }
func appendToFile(tx string) (err error) { func (u *User) appendToFile(tx string) (err error) {
f, err := os.OpenFile(LEDGER_FILE, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) f, err := u.AppendFile(DEFAULT_JOURNAL)
if err != nil { if err != nil {
return err return err
} }
@@ -37,14 +36,26 @@ func appendToFile(tx string) (err error) {
return err return err
} }
func executeScript(w io.Writer, name string) (err error) { func (u *User) overwriteFile(tx string) (err error) {
script, ok := SCRIPTS[name] f, err := u.WriteFile(DEFAULT_JOURNAL)
if !ok { if err != nil {
return fmt.Errorf("%s script not found", name) return err
} }
cmd := exec.Command("ledger", append([]string{"--init-file", LEDGER_INIT, "--file", LEDGER_FILE}, script...)...) defer f.Close()
cmd.Dir = WORKING_DIR
cmd.Stdout = w buf := strings.NewReader(strings.ReplaceAll(tx, "\r", "")) // Remove CR generated from browser
cmd.Stderr = w _, err = io.Copy(f, buf)
return cmd.Run() return err
}
func (u *User) query(query string) (result string, err error) {
var buf bytes.Buffer
cmd := exec.Command("ledger", "--file", DEFAULT_JOURNAL)
cmd.Dir = u.Dir()
cmd.Stdin = strings.NewReader(query)
cmd.Stdout = &buf
cmd.Stderr = &buf
err = cmd.Run()
return buf.String(), err
} }

View File

@@ -1,4 +1,4 @@
{{ .Date }} {{ with .Name }}{{ . }}{{ else }}{{ .Account }}{{ end }} {{ .Date }} {{ with .Name }}{{ . }}{{ else }}{{ .Destination }}{{ end }}
{{ .Account }} ${{ .Amount }} {{ .Destination }} ${{ .Amount }}
cash {{ with .Source }}{{ . }}{{ else }}cash{{ end }}

40
user.go Normal file
View File

@@ -0,0 +1,40 @@
package main
import (
"os"
"path"
)
type User struct {
IsLogin bool
Email string `form:"email" binding:"required"`
Password string `form:"password" binding:"required"`
}
func (u *User) Dir() string {
dir := path.Join(DATA_DIR, u.Email)
if err := os.MkdirAll(dir, 0755); err != nil {
panic(err)
}
return dir
}
func (u *User) FilePath(name string) string {
return path.Join(u.Dir(), name)
}
func (u *User) File(name string, mode int) (*os.File, error) {
return os.OpenFile(u.FilePath(name), mode, 0644)
}
func (u *User) AppendFile(name string) (*os.File, error) {
return u.File(name, os.O_WRONLY|os.O_CREATE|os.O_APPEND)
}
func (u *User) ReadFile(name string) (*os.File, error) {
return u.File(name, os.O_RDONLY|os.O_CREATE)
}
func (u *User) WriteFile(name string) (*os.File, error) {
return u.File(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
}