├── .gitignore ├── conf.json ├── go.mod ├── go.sum ├── LICENSE ├── cmd ├── database.go └── pinotes.go └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | deploy.sh 3 | pinotes 4 | notes.db 5 | requests.http -------------------------------------------------------------------------------- /conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "path": "/home/user/", 3 | "defaultTopic": "browser", 4 | "defaultExtension": ".md", 5 | "port": "8008", 6 | "interfaceIP": "", 7 | "canViewTopics": true, 8 | "dataStoreName": "notes.db" 9 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/quaintdev/pinotes 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/gomarkdown/markdown v0.0.0-20210820032736-385812cbea76 7 | github.com/gorilla/mux v1.8.0 8 | github.com/mattn/go-sqlite3 v1.14.8 9 | ) -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gomarkdown/markdown v0.0.0-20210820032736-385812cbea76 h1:nrGXmvqQjFCxD27hosXjoVaDtPL1tVvJ6iGXucNXCVE= 2 | github.com/gomarkdown/markdown v0.0.0-20210820032736-385812cbea76/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU= 3 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 4 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 5 | github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU= 6 | github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 7 | golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 quaintdev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/database.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | ) 8 | 9 | type DataStore struct { 10 | db *sql.DB 11 | } 12 | 13 | func (s *DataStore) Init(path string) error { 14 | var err error 15 | s.db, err = sql.Open("sqlite3", path) 16 | if err!=nil{ 17 | return fmt.Errorf("error opening database %s", err) 18 | } 19 | mainStmt, _ := s.db.Prepare("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, topic TEXT, content TEXT, created_at DATETIME)") 20 | mainStmt.Exec() 21 | return nil 22 | } 23 | 24 | func (s *DataStore) NewTopic(n Topic) { 25 | stmt, _ := s.db.Prepare("INSERT INTO notes (topic, content, created_at) VALUES (?,?,datetime('now'))") 26 | stmt.Exec(n.Topic, n.Content) 27 | } 28 | 29 | func (s *DataStore) UpdateTopic(n Topic) { 30 | _, err:=s.db.Exec("UPDATE notes SET topic = ?, content = ? WHERE id = ?", n.Topic, n.Content, n.Id) 31 | if err!=nil{ 32 | log.Println(err) 33 | } 34 | } 35 | 36 | func (s *DataStore) ViewTopic(n *Topic) *Topic { 37 | rows, err := s.db.Query("SELECT id, content FROM notes WHERE topic = ?", n.Topic) 38 | if err != nil { 39 | panic(err) 40 | } 41 | defer rows.Close() 42 | for rows.Next() { 43 | err := rows.Scan(&n.Id, &n.Content) 44 | if err != nil { 45 | panic(err) 46 | } 47 | break 48 | } 49 | return n 50 | } 51 | 52 | func (s *DataStore) ListTopics() []string { 53 | rows, err := s.db.Query("SELECT topic FROM notes") 54 | if err != nil { 55 | panic(err) 56 | } 57 | defer rows.Close() 58 | 59 | var topicList []string 60 | for rows.Next() { 61 | var topic string 62 | err := rows.Scan(&topic) 63 | if err != nil { 64 | panic(err) 65 | } 66 | topicList = append(topicList, topic) 67 | } 68 | return topicList 69 | } 70 | 71 | func (s *DataStore) DeleteTopic(n Topic) error{ 72 | _, err := s.db.Exec("DELETE FROM notes WHERE topic = ?", n.Topic) 73 | if err!=nil{ 74 | fmt.Errorf("error deleting topic %s, err: %s", n.Topic, err) 75 | } 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pinotes 2 | 3 | Self-hosted notes solution. Primarily targeting Raspberry PI but should work on 4 | any other system. 5 | 6 | **Breaking change** 7 | 5th Sept 21: 8 | The application now uses sqlite instead of plain markdown files. If you had like to migrate 9 | your existing notes to sqlite database you can use the command line `-migrate` option. All the 10 | markdown files in current directory will be migrated to sqlite database. 11 | 12 | ## Setup 13 | 14 | ### Configure Firewall 15 | We want to make sure that the notes are served within LAN and not on 16 | the Internet. 17 | 18 | ``` 19 | # on raspbian/ubuntu 20 | sudo ufw default deny incoming # disables all incoming connections 21 | sudo ufw allow from 192.168.0.0/16 # allows connections within local LAN 22 | ``` 23 | 24 | ### Install 25 | The setup below is for raspbian. You may have to modify some steps as per your 26 | distribution. 27 | 1. Clone the repository to your desktop 28 | `git clone https://github.com/quaintdev/pinotes.git` 29 | 30 | 2. Create a systemd service file as below and move it to 31 | `/etc/systemd/system/pinotes.service` on your Raspberry pi. 32 | 33 | ```shell 34 | [Unit] 35 | Description=A self hosted notes service 36 | After=network.target 37 | 38 | [Service] 39 | User=pi 40 | WorkingDirectory=/home/user/pinotes 41 | LimitNOFILE=4096 42 | ExecStart=/home/user/pinotes/pinotes.bin 43 | Restart=always 44 | RestartSec=10 45 | StartLimitIntervalSec=0 46 | 47 | [Install] 48 | WantedBy=multi-user.target 49 | ``` 50 | 51 | 2. Create a deployment script as below. You will have to modify it 52 | for your env and Pi version. This is for Raspberry Pi 2 B. 53 | 54 | ```shell 55 | cd build 56 | export CGO_ENABLED=1 57 | export CC=arm-linux-gnueabi-gcc 58 | GOOS=linux GOARCH=arm GOARM=7 go build github.com/quaintdev/pinotes/cmd/ 59 | mv cmd pinotes.bin 60 | scp pinotes.bin user@piaddress:/home/user/pinotes/ 61 | ssh user@piaddress <<'ENDSSH' 62 | cd ~/user/pinotes 63 | sudo systemctl stop pinotes 64 | rm pinotes.bin 65 | sudo systemctl start pinotes 66 | ENDSSH 67 | ``` 68 | 4. Create a config file as per your requirement. You can use config.json 69 | in this repository. 70 | 5. Verify your setup by visiting http://piaddress:8008/. You will see an 71 | empty list of topics `[]` if this is your first time. 72 | 73 | ### Browser Config 74 | 1. Create a search engine in your browser using url http://piaddress:8008/add?q=%s 75 | 2. Assign a keyword such as `pi`. You should now be able to add notes like below 76 | ```shell 77 | pi todo - buy groceries 78 | pi readlater - http://wikipedia.com/ 79 | ``` 80 | 3. All your notes will be saved in 'defaultTopic' defined in config.json 81 | 4. You can view any topic using http://piaddress:8008/topic/topicname 82 | -------------------------------------------------------------------------------- /cmd/pinotes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "github.com/gomarkdown/markdown" 7 | "github.com/gorilla/mux" 8 | _ "github.com/mattn/go-sqlite3" 9 | "io/ioutil" 10 | "log" 11 | "net/http" 12 | "os" 13 | "path" 14 | "strings" 15 | ) 16 | 17 | //Conf configuration for the note service 18 | var config Conf 19 | 20 | type Conf struct { 21 | Path string 22 | DefaultTopic string 23 | DefaultExtension string 24 | Port string 25 | InterfaceIP string 26 | CanViewTopics bool 27 | DataStoreName string 28 | } 29 | 30 | //Topic holds name of the topic and its content 31 | type Topic struct { 32 | Id uint 33 | Topic string 34 | Content string 35 | } 36 | 37 | func main() { 38 | boolPtr := flag.Bool("migrate", false, "set migrate=true to do the migration") 39 | flag.Parse() 40 | 41 | configBytes, err := ioutil.ReadFile("conf.json") 42 | if err != nil { 43 | log.Printf("config not present. err: %s exiting.", err) 44 | return 45 | } 46 | err = json.Unmarshal(configBytes, &config) 47 | if err != nil { 48 | log.Println("invalid config present. err", err) 49 | return 50 | } 51 | 52 | var ds DataStore 53 | err = ds.Init(config.DataStoreName) 54 | if err != nil { 55 | log.Println(err) 56 | return 57 | } 58 | 59 | if *boolPtr { 60 | migrate(ds) 61 | return 62 | } 63 | 64 | router := mux.NewRouter().StrictSlash(true) 65 | router.HandleFunc("/add", handleBrowserRequest(ds)).Methods(http.MethodGet) 66 | router.HandleFunc("/topic/{topic}", handleViewTopic(ds)).Methods(http.MethodGet) 67 | router.HandleFunc("/topic/{topic}", handleDeleteTopic(ds)).Methods(http.MethodDelete) 68 | router.HandleFunc("/topic/{topic}", handleUpdateTopic(ds)).Methods(http.MethodPost) 69 | router.HandleFunc("/list", handleListTopics(ds)).Methods(http.MethodGet) 70 | 71 | log.Println("Started server on", config.InterfaceIP+":"+config.Port) 72 | log.Fatal(http.ListenAndServe(config.InterfaceIP+":"+config.Port, router)) 73 | } 74 | 75 | func handleUpdateTopic(ds DataStore) func(w http.ResponseWriter, r *http.Request) { 76 | return func(w http.ResponseWriter, r *http.Request) { 77 | var topic Topic 78 | err := json.NewDecoder(r.Body).Decode(&topic) 79 | if err != nil { 80 | log.Println("error decoding request") 81 | w.WriteHeader(http.StatusInternalServerError) 82 | return 83 | } 84 | existingTopic := ds.ViewTopic(&Topic{Topic: topic.Topic}) 85 | if len(existingTopic.Content) == 0 { 86 | ds.NewTopic(topic) 87 | } else { 88 | topic.Id = existingTopic.Id 89 | ds.UpdateTopic(topic) 90 | } 91 | w.WriteHeader(http.StatusOK) 92 | } 93 | } 94 | 95 | func handleDeleteTopic(ds DataStore) func(w http.ResponseWriter, r *http.Request) { 96 | return func(w http.ResponseWriter, r *http.Request) { 97 | topic := mux.Vars(r)["topic"] 98 | err := ds.DeleteTopic(Topic{Topic: topic}) 99 | if err != nil { 100 | w.WriteHeader(http.StatusInternalServerError) 101 | return 102 | } 103 | w.WriteHeader(http.StatusOK) 104 | } 105 | } 106 | 107 | func handleListTopics(ds DataStore) func(w http.ResponseWriter, r *http.Request) { 108 | return func(w http.ResponseWriter, r *http.Request) { 109 | json.NewEncoder(w).Encode(ds.ListTopics()) 110 | } 111 | } 112 | 113 | func migrate(ds DataStore) { 114 | files, err := os.ReadDir(config.Path) 115 | if err != nil { 116 | log.Println("Unable to read directory contents. Disable migrate option.") 117 | return 118 | } 119 | for _, f := range files { 120 | if !f.IsDir() { 121 | log.Println("Migrating ...", f.Name()) 122 | file, err := os.ReadFile(path.Join(config.Path, f.Name())) 123 | if err != nil { 124 | log.Println("unable to read file", f.Name()) 125 | return 126 | } 127 | var n Topic 128 | n.Topic = strings.Replace(f.Name(), config.DefaultExtension, "", -1) 129 | n.Content = string(file) 130 | if len(ds.ViewTopic(&Topic{Topic: n.Topic}).Content) == 0 { 131 | ds.NewTopic(n) 132 | } else { 133 | ds.UpdateTopic(n) 134 | } 135 | } 136 | } 137 | log.Println("Migration completed successfully.") 138 | } 139 | 140 | func handleViewTopic(ds DataStore) func(w http.ResponseWriter, r *http.Request) { 141 | return func(w http.ResponseWriter, r *http.Request) { 142 | output := r.URL.Query().Get("output") 143 | topic := mux.Vars(r)["topic"] 144 | content := ds.ViewTopic(&Topic{Topic: topic}).Content 145 | if output == "text" { 146 | w.Write([]byte(content)) 147 | } else { 148 | w.Write(markdown.ToHTML([]byte(content), nil, nil)) 149 | } 150 | } 151 | } 152 | 153 | func handleBrowserRequest(ds DataStore) func(w http.ResponseWriter, r *http.Request) { 154 | return func(w http.ResponseWriter, r *http.Request) { 155 | var n Topic 156 | query := r.URL.Query().Get("q") 157 | log.Println("query received", query) 158 | if len(query) < 2 { 159 | log.Println("expecting query parameter") 160 | return 161 | } 162 | n.Topic = config.DefaultTopic 163 | n.Content = query 164 | 165 | existingTopic := ds.ViewTopic(&Topic{Topic: n.Topic}) 166 | if len(existingTopic.Content) == 0 { 167 | ds.NewTopic(n) 168 | } else { 169 | var sb strings.Builder 170 | sb.WriteString(existingTopic.Content) 171 | sb.WriteString(n.Content) 172 | n.Content = sb.String() 173 | n.Id = existingTopic.Id 174 | ds.UpdateTopic(n) 175 | } 176 | if config.CanViewTopics { 177 | w.Write([]byte(n.Content)) 178 | } else { 179 | w.WriteHeader(http.StatusOK) 180 | } 181 | } 182 | } 183 | 184 | // To build for Raspberry PI 2, use: 185 | // GOOS=linux GOARCH=arm GOARM=7 go build github.com/quaintdev/pinotes 186 | // For Raspberry PI Zero, use: 187 | // GOOS=linux GOARCH=arm GOARM=5 go build github.com/quaintdev/pinotes 188 | --------------------------------------------------------------------------------