├── .gitignore ├── App.svelte ├── DelUserForm.svelte ├── EditUserForm.svelte ├── Grid.svelte ├── LICENSE ├── LoginForm.svelte ├── Makefile ├── README.md ├── RSSView.svelte ├── SignupForm.svelte ├── about.html ├── archive ├── Drag.svelte ├── drag.html ├── drag_svelte.html ├── drag_svelte.js ├── drag_vanilla.js └── rssparts.html ├── cheveron-down.svg ├── freerss.go ├── index.html ├── index.js ├── menu.svg ├── postcss.config.js ├── refresh.svg ├── rollup.config.js ├── screenshots ├── freerss_portal.png └── freerss_withpreview.png ├── spec.txt ├── static ├── coffee.ico └── radio.ico ├── tailwind.config.js └── twsrc.css /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.o 3 | *.db 4 | *.map 5 | node_modules 6 | package.json 7 | package-lock.json 8 | static/style.css 9 | static/bundle.css 10 | static/bundle.js 11 | freerss 12 | 13 | -------------------------------------------------------------------------------- /App.svelte: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

FreeRSS

5 | About 6 | Add Widget 7 |
8 |
9 | {#if ui.username != ""} 10 |
11 | 12 | {ui.username} 13 | 14 | {#if ui.showmenu} 15 |
16 | Change Password 17 | {#if ui.username != "admin"} 18 | Delete Account 19 | {/if} 20 | {#if ui.username == "admin"} 21 | Reset LocalStorage 22 | {/if} 23 |
24 | {/if} 25 |
26 | Logout 27 | {:else} 28 | Login 29 | {/if} 30 |
31 |
32 | 33 | {#if ui.mode == ""} 34 | 35 | {:else if ui.mode == "login"} 36 |
37 |
38 | 39 |
40 |
41 | {:else if ui.mode == "signup"} 42 |
43 |
44 | 45 |
46 |
47 | {:else if ui.mode == "edituser"} 48 |
49 |
50 | 51 |
52 |
53 | {:else if ui.mode == "deluser"} 54 |
55 |
56 | 57 |
58 |
59 | {/if} 60 |
61 | 62 | 162 | 163 | -------------------------------------------------------------------------------- /DelUserForm.svelte: -------------------------------------------------------------------------------- 1 |
2 |

Delete User

3 |
4 | 5 | 6 |
7 |
8 | 9 | 10 |
11 | {#if frm.status != ""} 12 |
13 |

{frm.status}

14 |
15 | {/if} 16 |
17 |
18 | {#if frm.mode == "loading"} 19 | 20 | {:else} 21 | 22 | {/if} 23 | 24 |
25 |
26 |
27 | 28 | 89 | 90 | -------------------------------------------------------------------------------- /EditUserForm.svelte: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
7 | 8 | 9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 | {#if frm.status != ""} 19 |
20 |

{frm.status}

21 |
22 | {/if} 23 |
24 |
25 | {#if frm.mode == "loading"} 26 | 27 | {:else} 28 | 29 | {/if} 30 | 31 |
32 |
33 |
34 | 35 | 104 | 105 | -------------------------------------------------------------------------------- /Grid.svelte: -------------------------------------------------------------------------------- 1 | 292 | 293 |
294 | {#if ui.mode == ""} 295 | {#each ui.cols as col, icol} 296 |
297 | {#each ui.cols[icol] as w, irow (w.wid)} 298 | 299 | {/each} 300 |
301 | {:else} 302 |

You don't have any widgets yet.
303 | Add a new widget or 304 | Add sample widgets 305 |

306 | {/each} 307 | {:else if ui.mode == "loading"} 308 |

Loading...

309 | {/if} 310 |
311 | 312 | 319 | 320 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rob de la Cruz 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 | -------------------------------------------------------------------------------- /LoginForm.svelte: -------------------------------------------------------------------------------- 1 | 63 | 64 |
65 |
66 | 67 | 68 |
69 |
70 | 71 | 72 |
73 | {#if frm.status != ""} 74 |
75 |

{frm.status}

76 |
77 | {/if} 78 |
79 |
80 | {#if frm.mode == "loading"} 81 | 82 | {:else} 83 | 84 | {/if} 85 | 86 |
87 |
88 |
89 | Create New Account 90 |
91 |
92 | 93 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Usage: 2 | # 'make dep' and 'make webtools' to install dependencies. 3 | # 'make clean' to clear all work files 4 | # 'make' to build css and js into static/ 5 | # 'make serve' to start dev webserver 6 | 7 | NODE_VER = 17 8 | 9 | JSFILES = index.js App.svelte Grid.svelte RSSView.svelte LoginForm.svelte SignupForm.svelte EditUserForm.svelte DelUserForm.svelte 10 | 11 | all: freerss static/style.css static/bundle.js 12 | 13 | nodejs: 14 | curl -fsSL https://deb.nodesource.com/setup_$(NODE_VER).x | sudo bash - 15 | sudo apt install nodejs 16 | sudo npm install -g npx 17 | 18 | dep: 19 | go env -w GO111MODULE=auto 20 | go get github.com/mmcdole/gofeed 21 | go get github.com/gorilla/feeds 22 | 23 | webtools: 24 | npm install --save-dev tailwindcss 25 | npm install --save-dev postcss 26 | npm install --save-dev postcss-cli 27 | npm install --save-dev cssnano 28 | npm install --save-dev svelte 29 | npm install --save-dev rollup 30 | npm install --save-dev rollup-plugin-svelte 31 | npm install --save-dev @rollup/plugin-node-resolve 32 | 33 | static/style.css: twsrc.css tailwind.config.js 34 | npx tailwind -i twsrc.css -o twsrc.o 1>/dev/null 35 | npx postcss twsrc.o > static/style.css 36 | #npx tailwind -i twsrc.css -o static/style.css 1>/dev/null 37 | 38 | freerss: freerss.go 39 | go build -o freerss freerss.go 40 | 41 | static/bundle.js: $(JSFILES) 42 | npx rollup -c 43 | 44 | clean: 45 | rm -rf freerss static/*.js static/*.css static/*.map 46 | 47 | serve: 48 | python -m SimpleHTTPServer 49 | 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## FreeRSS 2 | 3 | Web-based RSS Viewer and Portal. 4 | 5 | Dependencies and Tools used: 6 | - [gofeed library](https://github.com/mmcdole/gofeed) 7 | - [Svelte](https://svelte.dev) 8 | - [Tailwind CSS](https://tailwindcss.com) 9 | - [Zondicons](http://www.zondicons.com/) 10 | 11 | ## Usage 12 | 13 | Run once: 14 | 15 | $ make dep 16 | $ make webtools 17 | 18 | Build and test: 19 | 20 | $ make clean 21 | $ make 22 | $ freerss -i portal.db 23 | 24 | Run 'freerss portal.db' to start the web service. 25 | 26 | ## Screenshots 27 | 28 | ![freerss portal](screenshots/freerss_portal.png) 29 | ![widgets with preview](screenshots/freerss_withpreview.png) 30 | 31 | ## Contact 32 | Twitter: @robcomputing 33 | Source: http://github.com/robdelacruz/freerss 34 | 35 | -------------------------------------------------------------------------------- /RSSView.svelte: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | {#if ui.feed} 5 | {#if ui.feed.url != ""} 6 | {ui.feed.title} 7 | {:else} 8 | {ui.feed.title} 9 | {/if} 10 | {:else} 11 | Select Feed 12 | {/if} 13 |

14 |
15 | 18 | 21 | {#if ui.showmenu} 22 |
23 | Settings 24 | Delete 25 |
26 | {/if} 27 |
28 |
29 | 30 | {#if ui.mode == "loading"} 31 |

Loading...

32 | {:else if ui.mode == "display"} 33 | {#if ui.err} 34 |

Error ({ui.err})

35 | {:else if ui.feed && ui.feed.entries} 36 | 52 | {:else} 53 |

No entries

54 | {/if} 55 | {:else if ui.mode == "settings"} 56 |
57 |
58 | 59 | 60 |
61 |
62 | 63 | 64 |
65 |
66 | 70 |
71 | {#if settingsform.status != ""} 72 |
73 |

{settingsform.status}

74 |
75 | {/if} 76 |
77 |
78 | {#if settingsform.mode == "loading"} 79 | 80 | {:else} 81 | 82 | {/if} 83 | 84 |
85 |
86 |
87 | {:else if ui.mode == "delete"} 88 |

delete

89 | {/if} 90 |
91 | 92 | 293 | 294 | -------------------------------------------------------------------------------- /SignupForm.svelte: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
7 | 8 | 9 |
10 |
11 | 12 | 13 |
14 | {#if frm.status != ""} 15 |
16 |

{frm.status}

17 |
18 | {/if} 19 |
20 |
21 | {#if frm.mode == "loading"} 22 | 23 | {:else} 24 | 25 | {/if} 26 | 27 |
28 |
29 |
30 | 31 | 96 | 97 | -------------------------------------------------------------------------------- /about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | About FreeRSS 7 | 8 | 9 | 10 |
11 |
12 |

FreeRSS

13 | About 14 |
15 |
16 |
17 |
18 |
19 |
20 |

FreeRSS v1

21 |

RSS Viewer and Web Portal

22 |

Copyright (c) 2020 by Rob de la Cruz

23 |

Project Page

24 |

Support FreeRSS Development and Hosting

25 |
26 |
27 |
28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /archive/Drag.svelte: -------------------------------------------------------------------------------- 1 | 77 | 78 | 80 | 81 |
82 | {#each cols as col, icol} 83 |
84 | {#each col as w, iwidget} 85 |
{w}
86 | {/each} 87 |
88 | {/each} 89 |
90 | 91 | 92 | 93 | 99 | 100 | -------------------------------------------------------------------------------- /archive/drag.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | jstest 7 | 8 | 9 | 10 | 11 |
12 |
13 |
abc
14 |
def
15 |
ghi
16 |
17 |
18 |
abc
19 |
def
20 |
21 |
22 |
abc
23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /archive/drag_svelte.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | jstest 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /archive/drag_svelte.js: -------------------------------------------------------------------------------- 1 | import Drag from "./Drag.svelte"; 2 | const drag = new Drag({ 3 | target: document.querySelector("#a"), 4 | props: {}, 5 | }); 6 | 7 | -------------------------------------------------------------------------------- /archive/drag_vanilla.js: -------------------------------------------------------------------------------- 1 | function targetHasClass(e, ...cc) { 2 | for (let i=0; i < cc.length; i++) { 3 | let c = cc[i]; 4 | if (e.target.classList.contains(c)) { 5 | return true; 6 | } 7 | } 8 | return false; 9 | } 10 | 11 | function ondragstart(e) { 12 | if (!targetHasClass(e, "widget")) { 13 | return; 14 | } 15 | 16 | e.dataTransfer.setData("text/html", e.target.outerHTML); 17 | } 18 | 19 | function ondragover(e) { 20 | if (!targetHasClass(e, "widget", "dropzone")) { 21 | return; 22 | } 23 | 24 | e.preventDefault(); 25 | e.dataTransfer.dropEffect = "move"; 26 | } 27 | 28 | function ondrop(e) { 29 | if (!targetHasClass(e, "widget", "dropzone")) { 30 | return; 31 | } 32 | 33 | e.preventDefault(); 34 | let outerHTML = e.dataTransfer.getData("text/html"); 35 | 36 | if (targetHasClass(e, "widget")) { 37 | e.target.insertAdjacentHTML("beforebegin", outerHTML); 38 | return; 39 | } 40 | 41 | // "dropzone" column 42 | let childWidgets = e.target.querySelectorAll(".widget"); 43 | if (childWidgets.length == 0) { 44 | // empty column 45 | e.target.insertAdjacentHTML("afterbegin", outerHTML); 46 | return; 47 | } 48 | let bottomWidget = childWidgets[childWidgets.length-1]; 49 | bottomWidget.insertAdjacentHTML("afterend", outerHTML); 50 | } 51 | 52 | function ondragend(e) { 53 | if (!targetHasClass(e, "widget", "dropzone")) { 54 | return; 55 | } 56 | 57 | // if drop was completed 58 | if (e.dataTransfer.dropEffect != "none") { 59 | e.target.remove(); 60 | } 61 | } 62 | 63 | document.addEventListener("dragstart", ondragstart); 64 | document.addEventListener("dragover", ondragover); 65 | document.addEventListener("drop", ondrop); 66 | document.addEventListener("dragend", ondragend); 67 | 68 | -------------------------------------------------------------------------------- /archive/rssparts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | jstest 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 45 | 71 | 91 |
92 | 93 | 141 | 142 |
143 |
144 |

Lew Rockwell

145 | 162 |
163 |
164 |
165 | 166 | 167 | -------------------------------------------------------------------------------- /cheveron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /freerss.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "html" 10 | "io" 11 | "io/ioutil" 12 | "log" 13 | "net/http" 14 | "net/url" 15 | "os" 16 | "path" 17 | "strconv" 18 | "strings" 19 | "time" 20 | 21 | xhtml "golang.org/x/net/html" 22 | "golang.org/x/net/html/atom" 23 | 24 | "github.com/gorilla/feeds" 25 | _ "github.com/mattn/go-sqlite3" 26 | "github.com/mmcdole/gofeed" 27 | "golang.org/x/crypto/bcrypt" 28 | ) 29 | 30 | type PrintFunc func(format string, a ...interface{}) (n int, err error) 31 | 32 | type Feed struct { 33 | Title string `json:"title"` 34 | Url string `json:"url"` 35 | Desc string `json:"desc"` 36 | Pubdate string `json:"pubdate"` 37 | Pubtime time.Time `json:"-"` 38 | Entries []*Entry `json:"entries"` 39 | } 40 | type Entry struct { 41 | Title string `json:"title"` 42 | Url string `json:"url"` 43 | Desc string `json:"desc"` 44 | Body string `json:"body"` 45 | Author string `json:"author"` 46 | Pubdate string `json:"pubdate"` 47 | Pubtime time.Time `json:"-"` 48 | } 49 | type User struct { 50 | Userid int64 51 | Username string 52 | HashedPwd string 53 | } 54 | 55 | func (f *Feed) String() string { 56 | bs, err := json.MarshalIndent(f, "", "\t") 57 | if err != nil { 58 | return "" 59 | } 60 | return string(bs) 61 | } 62 | func (e *Entry) String() string { 63 | bs, err := json.MarshalIndent(e, "", "\t") 64 | if err != nil { 65 | return "" 66 | } 67 | return string(bs) 68 | } 69 | func parseFeed(gfparser *gofeed.Parser, body string, maxitems int) (*Feed, error) { 70 | gf, err := gfparser.ParseString(body) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | f := Feed{} 76 | f.Title = gf.Title 77 | f.Url = gf.Link 78 | f.Desc = gf.Description 79 | convdate(gf.PublishedParsed, &f.Pubtime, &f.Pubdate) 80 | 81 | if maxitems == 0 { 82 | maxitems = len(gf.Items) 83 | } 84 | 85 | for i, it := range gf.Items { 86 | e := Entry{} 87 | e.Title = it.Title 88 | e.Url = it.Link 89 | e.Desc = it.Description 90 | e.Body = it.Content 91 | convdate(it.PublishedParsed, &e.Pubtime, &e.Pubdate) 92 | 93 | f.Entries = append(f.Entries, &e) 94 | 95 | if i >= maxitems-1 { 96 | break 97 | } 98 | } 99 | return &f, nil 100 | } 101 | func convdate(t *time.Time, dt *time.Time, sdt *string) { 102 | if t != nil { 103 | *dt = *t 104 | *sdt = dt.Format(time.RFC3339) 105 | } 106 | } 107 | 108 | func main() { 109 | err := run(os.Args[1:]) 110 | if err != nil { 111 | fmt.Fprintf(os.Stderr, "%s\n", err) 112 | os.Exit(1) 113 | } 114 | } 115 | 116 | func runtesthash(args []string) error { 117 | if len(args) == 0 { 118 | return errors.New("Please specify a username") 119 | } 120 | if len(args) == 1 { 121 | username := args[0] 122 | shash := genHash(username) 123 | fmt.Printf("%s\n", shash) 124 | return nil 125 | } 126 | 127 | username := args[0] 128 | shash := args[1] 129 | if validateHash(shash, username) { 130 | fmt.Printf("validate ok\n") 131 | } else { 132 | fmt.Printf("not validated\n") 133 | } 134 | return nil 135 | } 136 | func runtestsignup(args []string) error { 137 | sw, parms := parseArgs(args) 138 | // [-i new_file] Create and initialize db file 139 | if sw["i"] != "" { 140 | dbfile := sw["i"] 141 | if fileExists(dbfile) { 142 | return fmt.Errorf("File '%s' already exists. Can't initialize it.\n", dbfile) 143 | } 144 | createTables(dbfile) 145 | return nil 146 | } 147 | 148 | // Need to specify a db file as first parameter. 149 | if len(parms) == 0 { 150 | return errors.New("Specify a db file") 151 | } 152 | 153 | // Exit if db file doesn't exist. 154 | dbfile := parms[0] 155 | if !fileExists(dbfile) { 156 | return fmt.Errorf(`Database file '%s' doesn't exist. Create one using: 157 | freerss -i 158 | `, dbfile) 159 | } 160 | 161 | db, err := sql.Open("sqlite3", dbfile) 162 | if err != nil { 163 | return fmt.Errorf("Error opening '%s' (%s)\n", dbfile, err) 164 | } 165 | 166 | if len(parms) < 3 { 167 | return fmt.Errorf("Specify a username and password") 168 | } 169 | username := parms[1] 170 | pwd := parms[2] 171 | err = signup(db, username, pwd) 172 | if err != nil { 173 | return err 174 | } 175 | return nil 176 | } 177 | 178 | func rundiscoverrss(args []string) error { 179 | if len(args) == 0 { 180 | return errors.New("Please specify a feed url") 181 | } 182 | qurl := args[0] 183 | feeds, err := discoverfeeds(qurl) 184 | if err != nil { 185 | return err 186 | } 187 | 188 | if len(feeds) == 0 { 189 | fmt.Println("No feeds found.") 190 | return nil 191 | } 192 | 193 | fmt.Println("Found feeds:") 194 | for _, feed := range feeds { 195 | fmt.Println(feed) 196 | } 197 | return nil 198 | } 199 | 200 | func run(args []string) error { 201 | sw, parms := parseArgs(args) 202 | 203 | // [-i new_file] Create and initialize db file 204 | if sw["i"] != "" { 205 | dbfile := sw["i"] 206 | if fileExists(dbfile) { 207 | return fmt.Errorf("File '%s' already exists. Can't initialize it.\n", dbfile) 208 | } 209 | createTables(dbfile) 210 | return nil 211 | } 212 | 213 | // Need to specify a db file as first parameter. 214 | if len(parms) == 0 { 215 | s := `Usage: 216 | 217 | Start webservice using database file: 218 | freerss [port] 219 | 220 | Initialize new database file: 221 | freerss -i 222 | 223 | ` 224 | fmt.Printf(s) 225 | return nil 226 | } 227 | 228 | // Exit if db file doesn't exist. 229 | dbfile := parms[0] 230 | if !fileExists(dbfile) { 231 | return fmt.Errorf(`Database file '%s' doesn't exist. Create one using: 232 | freerss -i 233 | `, dbfile) 234 | } 235 | 236 | db, err := sql.Open("sqlite3", dbfile) 237 | if err != nil { 238 | return fmt.Errorf("Error opening '%s' (%s)\n", dbfile, err) 239 | } 240 | 241 | gfparser := gofeed.NewParser() 242 | 243 | http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/radio.ico") }) 244 | http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) 245 | http.Handle("/", http.StripPrefix("/", http.FileServer(http.Dir("./")))) 246 | http.HandleFunc("/api/feed/", feedHandler(nil, gfparser)) 247 | http.HandleFunc("/api/discoverfeed/", discoverfeedHandler(nil, gfparser)) 248 | http.HandleFunc("/api/login/", loginHandler(db)) 249 | http.HandleFunc("/api/signup/", signupHandler(db)) 250 | http.HandleFunc("/api/edituser/", edituserHandler(db)) 251 | http.HandleFunc("/api/deluser/", deluserHandler(db)) 252 | http.HandleFunc("/api/savegrid/", savegridHandler(db)) 253 | http.HandleFunc("/api/loadgrid/", loadgridHandler(db)) 254 | http.HandleFunc("/api/testfeed/", testfeedHandler(db)) 255 | 256 | port := "8000" 257 | if len(parms) > 1 { 258 | port = parms[1] 259 | } 260 | fmt.Printf("Listening on %s...\n", port) 261 | err = http.ListenAndServe(fmt.Sprintf(":%s", port), nil) 262 | return err 263 | } 264 | 265 | func createTables(newfile string) { 266 | if fileExists(newfile) { 267 | s := fmt.Sprintf("File '%s' already exists. Can't initialize it.\n", newfile) 268 | fmt.Printf(s) 269 | os.Exit(1) 270 | } 271 | 272 | db, err := sql.Open("sqlite3", newfile) 273 | if err != nil { 274 | fmt.Printf("Error opening '%s' (%s)\n", newfile, err) 275 | os.Exit(1) 276 | } 277 | 278 | ss := []string{ 279 | "CREATE TABLE user (user_id INTEGER PRIMARY KEY NOT NULL, username TEXT UNIQUE, password TEXT);", 280 | "INSERT INTO user (user_id, username, password) VALUES (1, 'admin', '');", 281 | "CREATE TABLE savedgrid (user_id INTEGER PRIMARY KEY NOT NULL, gridjson TEXT);", 282 | } 283 | 284 | tx, err := db.Begin() 285 | if err != nil { 286 | log.Printf("DB error (%s)\n", err) 287 | os.Exit(1) 288 | } 289 | for _, s := range ss { 290 | _, err := txexec(tx, s) 291 | if err != nil { 292 | tx.Rollback() 293 | log.Printf("DB error (%s)\n", err) 294 | os.Exit(1) 295 | } 296 | } 297 | err = tx.Commit() 298 | if err != nil { 299 | log.Printf("DB error (%s)\n", err) 300 | os.Exit(1) 301 | } 302 | } 303 | 304 | //*** DB functions *** 305 | func sqlstmt(db *sql.DB, s string) *sql.Stmt { 306 | stmt, err := db.Prepare(s) 307 | if err != nil { 308 | log.Fatalf("db.Prepare() sql: '%s'\nerror: '%s'", s, err) 309 | } 310 | return stmt 311 | } 312 | func sqlexec(db *sql.DB, s string, pp ...interface{}) (sql.Result, error) { 313 | stmt := sqlstmt(db, s) 314 | defer stmt.Close() 315 | return stmt.Exec(pp...) 316 | } 317 | func txstmt(tx *sql.Tx, s string) *sql.Stmt { 318 | stmt, err := tx.Prepare(s) 319 | if err != nil { 320 | log.Fatalf("tx.Prepare() sql: '%s'\nerror: '%s'", s, err) 321 | } 322 | return stmt 323 | } 324 | func txexec(tx *sql.Tx, s string, pp ...interface{}) (sql.Result, error) { 325 | stmt := txstmt(tx, s) 326 | defer stmt.Close() 327 | return stmt.Exec(pp...) 328 | } 329 | 330 | //*** Helper functions *** 331 | 332 | // Helper function to make fmt.Fprintf(w, ...) calls shorter. 333 | // Ex. 334 | // Replace: 335 | // fmt.Fprintf(w, "

Some text %s.

", str) 336 | // fmt.Fprintf(w, "

Some other text %s.

", str) 337 | // with the shorter version: 338 | // P := makeFprintf(w) 339 | // P("

Some text %s.

", str) 340 | // P("

Some other text %s.

", str) 341 | func makeFprintf(w io.Writer) func(format string, a ...interface{}) (n int, err error) { 342 | return func(format string, a ...interface{}) (n int, err error) { 343 | return fmt.Fprintf(w, format, a...) 344 | } 345 | } 346 | func listContains(ss []string, v string) bool { 347 | for _, s := range ss { 348 | if v == s { 349 | return true 350 | } 351 | } 352 | return false 353 | } 354 | func fileExists(file string) bool { 355 | _, err := os.Stat(file) 356 | if err != nil && os.IsNotExist(err) { 357 | return false 358 | } 359 | return true 360 | } 361 | func makePrintFunc(w io.Writer) func(format string, a ...interface{}) (n int, err error) { 362 | // Return closure enclosing io.Writer. 363 | return func(format string, a ...interface{}) (n int, err error) { 364 | return fmt.Fprintf(w, format, a...) 365 | } 366 | } 367 | func atoi(s string) int { 368 | if s == "" { 369 | return 0 370 | } 371 | n, err := strconv.Atoi(s) 372 | if err != nil { 373 | return 0 374 | } 375 | return n 376 | } 377 | func idtoi(sid string) int64 { 378 | return int64(atoi(sid)) 379 | } 380 | func itoa(n int64) string { 381 | return strconv.FormatInt(n, 10) 382 | } 383 | func atof(s string) float64 { 384 | if s == "" { 385 | return 0.0 386 | } 387 | f, err := strconv.ParseFloat(s, 64) 388 | if err != nil { 389 | return 0.0 390 | } 391 | return f 392 | } 393 | 394 | func unescapeUrl(qurl string) string { 395 | returl := "/" 396 | if qurl != "" { 397 | returl, _ = url.QueryUnescape(qurl) 398 | } 399 | return returl 400 | } 401 | func escape(s string) string { 402 | return html.EscapeString(s) 403 | } 404 | 405 | func parseArgs(args []string) (map[string]string, []string) { 406 | switches := map[string]string{} 407 | parms := []string{} 408 | 409 | standaloneSwitches := []string{} 410 | definitionSwitches := []string{"i"} 411 | fNoMoreSwitches := false 412 | curKey := "" 413 | 414 | for _, arg := range args { 415 | if fNoMoreSwitches { 416 | // any arg after "--" is a standalone parameter 417 | parms = append(parms, arg) 418 | } else if arg == "--" { 419 | // "--" means no more switches to come 420 | fNoMoreSwitches = true 421 | } else if strings.HasPrefix(arg, "--") { 422 | switches[arg[2:]] = "y" 423 | curKey = "" 424 | } else if strings.HasPrefix(arg, "-") { 425 | if listContains(definitionSwitches, arg[1:]) { 426 | // -a "val" 427 | curKey = arg[1:] 428 | continue 429 | } 430 | for _, ch := range arg[1:] { 431 | // -a, -b, -ab 432 | sch := string(ch) 433 | if listContains(standaloneSwitches, sch) { 434 | switches[sch] = "y" 435 | } 436 | } 437 | } else if curKey != "" { 438 | switches[curKey] = arg 439 | curKey = "" 440 | } else { 441 | // standalone parameter 442 | parms = append(parms, arg) 443 | } 444 | } 445 | 446 | return switches, parms 447 | } 448 | 449 | func handleErr(w http.ResponseWriter, err error, sfunc string) { 450 | log.Printf("%s: server error (%s)\n", sfunc, err) 451 | http.Error(w, fmt.Sprintf("%s", err), 500) 452 | } 453 | func handleDbErr(w http.ResponseWriter, err error, sfunc string) bool { 454 | if err == sql.ErrNoRows { 455 | http.Error(w, "Not found.", 404) 456 | return true 457 | } 458 | if err != nil { 459 | log.Printf("%s: database error (%s)\n", sfunc, err) 460 | http.Error(w, "Server database error.", 500) 461 | return true 462 | } 463 | return false 464 | } 465 | func handleTxErr(tx *sql.Tx, err error) bool { 466 | if err != nil { 467 | tx.Rollback() 468 | return true 469 | } 470 | return false 471 | } 472 | 473 | func genHash(sinput string) string { 474 | bsHash, err := bcrypt.GenerateFromPassword([]byte(sinput), bcrypt.DefaultCost) 475 | if err != nil { 476 | return "" 477 | } 478 | return string(bsHash) 479 | } 480 | func validateHash(shash, sinput string) bool { 481 | if shash == "" && sinput == "" { 482 | return true 483 | } 484 | err := bcrypt.CompareHashAndPassword([]byte(shash), []byte(sinput)) 485 | if err != nil { 486 | return false 487 | } 488 | return true 489 | } 490 | 491 | func findUser(db *sql.DB, username string) *User { 492 | s := "SELECT user_id, username, password FROM user WHERE username = ?" 493 | row := db.QueryRow(s, username) 494 | var u User 495 | err := row.Scan(&u.Userid, &u.Username, &u.HashedPwd) 496 | if err == sql.ErrNoRows { 497 | return nil 498 | } 499 | if err != nil { 500 | return nil 501 | } 502 | return &u 503 | } 504 | func isUsernameExists(db *sql.DB, username string) bool { 505 | if findUser(db, username) == nil { 506 | return false 507 | } 508 | return true 509 | } 510 | 511 | func feedHandler(db *sql.DB, gfparser *gofeed.Parser) http.HandlerFunc { 512 | return func(w http.ResponseWriter, r *http.Request) { 513 | qurl := unescapeUrl(r.FormValue("url")) 514 | if qurl == "" { 515 | http.Error(w, "?url= required", 401) 516 | return 517 | } 518 | qmaxitems := atoi(r.FormValue("maxitems")) 519 | 520 | res, err := http.Get(qurl) 521 | if err != nil { 522 | http.Error(w, fmt.Sprintf("Not found: %s", qurl), 404) 523 | return 524 | } 525 | defer res.Body.Close() 526 | bs, err := ioutil.ReadAll(res.Body) 527 | if err != nil { 528 | http.Error(w, fmt.Sprintf("error reading feed (%s)", err), 404) 529 | return 530 | } 531 | 532 | w.Header().Set("Content-Type", "application/json") 533 | P := makeFprintf(w) 534 | f, err := parseFeed(gfparser, string(bs), qmaxitems) 535 | if err != nil { 536 | handleErr(w, err, "feedHandler") 537 | return 538 | } 539 | P("%s\n", f) 540 | } 541 | } 542 | 543 | func discoverfeedHandler(db *sql.DB, gfparser *gofeed.Parser) http.HandlerFunc { 544 | return func(w http.ResponseWriter, r *http.Request) { 545 | qurl := unescapeUrl(r.FormValue("url")) 546 | if qurl == "" { 547 | http.Error(w, "?url= required", 401) 548 | return 549 | } 550 | 551 | feeds, err := discoverfeeds(qurl) 552 | if err != nil { 553 | handleErr(w, err, "discoverfeedHandler") 554 | return 555 | } 556 | bs, err := json.MarshalIndent(feeds, "", "\t") 557 | if err != nil { 558 | handleErr(w, err, "discoverfeedHandler") 559 | return 560 | } 561 | 562 | w.Header().Set("Content-Type", "application/json") 563 | P := makeFprintf(w) 564 | P("%s\n", string(bs)) 565 | } 566 | } 567 | func discoverfeeds(qurl string) ([]string, error) { 568 | res, err := http.Get(qurl) 569 | if err != nil { 570 | return nil, err 571 | } 572 | defer res.Body.Close() 573 | 574 | bs, err := ioutil.ReadAll(res.Body) 575 | if err != nil { 576 | return nil, err 577 | } 578 | 579 | feeds := []string{} 580 | 581 | // Check if url is already an rss feed. 582 | gfparser := gofeed.NewParser() 583 | if isValidFeed(gfparser, bs) { 584 | feeds = append(feeds, qurl) 585 | } 586 | ubase, _ := url.Parse(qurl) 587 | 588 | surls := getFeedLinks(bs) 589 | for _, surl := range surls { 590 | surl = completeFeedUrl(ubase, surl) 591 | feeds = append(feeds, surl) 592 | } 593 | 594 | return feeds, nil 595 | } 596 | func getAttr(tok xhtml.Token, k string) string { 597 | for _, attr := range tok.Attr { 598 | if attr.Key == k { 599 | return attr.Val 600 | } 601 | } 602 | return "" 603 | } 604 | func isValidFeed(gfparser *gofeed.Parser, bs []byte) bool { 605 | _, err := parseFeed(gfparser, string(bs), 0) 606 | if err != nil { 607 | return false 608 | } 609 | return true 610 | } 611 | func getFeedLinks(bs []byte) []string { 612 | var feeds []string 613 | 614 | z := xhtml.NewTokenizer(bytes.NewReader(bs)) 615 | for { 616 | tt := z.Next() 617 | if tt == xhtml.ErrorToken { 618 | break 619 | } 620 | 621 | tok := z.Token() 622 | if tok.DataAtom != atom.Link { 623 | continue 624 | } 625 | stype := getAttr(tok, "type") 626 | if stype != "application/rss+xml" && stype != "application/atom+xml" { 627 | continue 628 | } 629 | href := getAttr(tok, "href") 630 | if href == "" { 631 | continue 632 | } 633 | feeds = append(feeds, href) 634 | } 635 | 636 | return feeds 637 | } 638 | func completeFeedUrl(ubase *url.URL, sfeedurl string) string { 639 | ufeed, _ := url.Parse(sfeedurl) 640 | if ufeed.Scheme == "" { 641 | ufeed.Scheme = ubase.Scheme 642 | } 643 | if ufeed.Host == "" { 644 | ufeed.Host = ubase.Host 645 | } 646 | // if feed is relative to baseurl 647 | if !strings.HasPrefix(ufeed.Path, "/") { 648 | ufeed.Path = path.Join(ubase.Path, ufeed.Path) 649 | } 650 | return ufeed.String() 651 | } 652 | 653 | func genTok(u *User) string { 654 | tok := genHash(fmt.Sprintf("%s_%s", u.Username, u.HashedPwd)) 655 | return tok 656 | } 657 | func validateTok(tok string, u *User) bool { 658 | return validateHash(tok, fmt.Sprintf("%s_%s", u.Username, u.HashedPwd)) 659 | } 660 | 661 | var ErrLoginIncorrect = errors.New("Incorrect username or password") 662 | 663 | func login(db *sql.DB, username, pwd string) (string, error) { 664 | var u User 665 | s := "SELECT user_id, username, password FROM user WHERE username = ?" 666 | row := db.QueryRow(s, username) 667 | err := row.Scan(&u.Userid, &u.Username, &u.HashedPwd) 668 | if err == sql.ErrNoRows { 669 | return "", ErrLoginIncorrect 670 | } 671 | if err != nil { 672 | return "", err 673 | } 674 | if !validateHash(u.HashedPwd, pwd) { 675 | return "", ErrLoginIncorrect 676 | } 677 | 678 | // Return session token, this will be used to authenticate username 679 | // on every request by calling validateTok() 680 | tok := genTok(&u) 681 | return tok, nil 682 | } 683 | 684 | type LoginResult struct { 685 | Tok string `json:"tok"` 686 | Error string `json:"error"` 687 | } 688 | 689 | func loginHandler(db *sql.DB) http.HandlerFunc { 690 | type LoginReq struct { 691 | Username string `json:"username"` 692 | Pwd string `json:"pwd"` 693 | } 694 | return func(w http.ResponseWriter, r *http.Request) { 695 | if r.Method != "POST" { 696 | http.Error(w, "Use POST method", 401) 697 | return 698 | } 699 | bs, err := ioutil.ReadAll(r.Body) 700 | if err != nil { 701 | handleErr(w, err, "loginHandler") 702 | return 703 | } 704 | var loginreq LoginReq 705 | err = json.Unmarshal(bs, &loginreq) 706 | if err != nil { 707 | handleErr(w, err, "loginHandler") 708 | return 709 | } 710 | 711 | var result LoginResult 712 | tok, err := login(db, loginreq.Username, loginreq.Pwd) 713 | if err != nil { 714 | result.Error = fmt.Sprintf("%s", err) 715 | } 716 | result.Tok = tok 717 | 718 | w.Header().Set("Content-Type", "application/json") 719 | P := makeFprintf(w) 720 | bs, _ = json.MarshalIndent(result, "", "\t") 721 | P("%s\n", string(bs)) 722 | } 723 | } 724 | 725 | func signup(db *sql.DB, username, pwd string) error { 726 | if isUsernameExists(db, username) { 727 | return fmt.Errorf("username '%s' already exists", username) 728 | } 729 | 730 | hashedPwd := genHash(pwd) 731 | s := "INSERT INTO user (username, password) VALUES (?, ?);" 732 | _, err := sqlexec(db, s, username, hashedPwd) 733 | if err != nil { 734 | return fmt.Errorf("DB error creating user: %s", err) 735 | } 736 | return nil 737 | } 738 | func signupHandler(db *sql.DB) http.HandlerFunc { 739 | type SignupReq struct { 740 | Username string `json:"username"` 741 | Pwd string `json:"pwd"` 742 | } 743 | return func(w http.ResponseWriter, r *http.Request) { 744 | if r.Method != "POST" { 745 | http.Error(w, "Use POST method", 401) 746 | return 747 | } 748 | 749 | bs, err := ioutil.ReadAll(r.Body) 750 | if err != nil { 751 | handleErr(w, err, "signupHandler") 752 | return 753 | } 754 | var signupreq SignupReq 755 | err = json.Unmarshal(bs, &signupreq) 756 | if err != nil { 757 | handleErr(w, err, "signupHandler") 758 | return 759 | } 760 | if signupreq.Username == "" { 761 | http.Error(w, "username required", 401) 762 | return 763 | } 764 | 765 | w.Header().Set("Content-Type", "application/json") 766 | P := makeFprintf(w) 767 | 768 | // Attempt to sign up new user. 769 | var result LoginResult 770 | err = signup(db, signupreq.Username, signupreq.Pwd) 771 | if err != nil { 772 | result.Error = fmt.Sprintf("%s", err) 773 | bs, _ := json.MarshalIndent(result, "", "\t") 774 | P("%s\n", string(bs)) 775 | return 776 | } 777 | 778 | // Log in the newly signed up user. 779 | tok, err := login(db, signupreq.Username, signupreq.Pwd) 780 | result.Tok = tok 781 | if err != nil { 782 | result.Error = fmt.Sprintf("%s", err) 783 | } 784 | bs, _ = json.MarshalIndent(result, "", "\t") 785 | P("%s\n", string(bs)) 786 | } 787 | } 788 | 789 | func edituser(db *sql.DB, username, pwd string, newpwd string) error { 790 | // Validate existing password 791 | _, err := login(db, username, pwd) 792 | if err != nil { 793 | return err 794 | } 795 | 796 | // Set new password 797 | hashedPwd := genHash(newpwd) 798 | s := "UPDATE user SET password = ? WHERE username = ?" 799 | _, err = sqlexec(db, s, hashedPwd, username) 800 | if err != nil { 801 | return fmt.Errorf("DB error updating user password: %s", err) 802 | } 803 | return nil 804 | } 805 | func edituserHandler(db *sql.DB) http.HandlerFunc { 806 | type EditUserReq struct { 807 | Username string `json:"username"` 808 | Pwd string `json:"pwd"` 809 | NewPwd string `json:"newpwd"` 810 | } 811 | return func(w http.ResponseWriter, r *http.Request) { 812 | if r.Method != "POST" { 813 | http.Error(w, "Use POST method", 401) 814 | return 815 | } 816 | 817 | bs, err := ioutil.ReadAll(r.Body) 818 | if err != nil { 819 | handleErr(w, err, "edituserHandler") 820 | return 821 | } 822 | var req EditUserReq 823 | err = json.Unmarshal(bs, &req) 824 | if err != nil { 825 | handleErr(w, err, "edituserHandler") 826 | return 827 | } 828 | if req.Username == "" { 829 | http.Error(w, "username required", 401) 830 | return 831 | } 832 | 833 | w.Header().Set("Content-Type", "application/json") 834 | P := makeFprintf(w) 835 | 836 | // Attempt to edit user. 837 | var result LoginResult 838 | err = edituser(db, req.Username, req.Pwd, req.NewPwd) 839 | if err != nil { 840 | result.Error = fmt.Sprintf("%s", err) 841 | bs, _ := json.MarshalIndent(result, "", "\t") 842 | P("%s\n", string(bs)) 843 | return 844 | } 845 | 846 | // Log in the newly edited user. 847 | tok, err := login(db, req.Username, req.NewPwd) 848 | result.Tok = tok 849 | if err != nil { 850 | result.Error = fmt.Sprintf("%s", err) 851 | } 852 | bs, _ = json.MarshalIndent(result, "", "\t") 853 | P("%s\n", string(bs)) 854 | } 855 | } 856 | 857 | func deluser(db *sql.DB, username, pwd string) error { 858 | // Validate existing password 859 | _, err := login(db, username, pwd) 860 | if err != nil { 861 | return err 862 | } 863 | 864 | // Delete user 865 | s := "DELETE FROM user WHERE username = ?" 866 | _, err = sqlexec(db, s, username) 867 | if err != nil { 868 | return fmt.Errorf("DB error deleting user: %s", err) 869 | } 870 | return nil 871 | } 872 | func deluserHandler(db *sql.DB) http.HandlerFunc { 873 | type DelUserReq struct { 874 | Username string `json:"username"` 875 | Pwd string `json:"pwd"` 876 | } 877 | return func(w http.ResponseWriter, r *http.Request) { 878 | if r.Method != "POST" { 879 | http.Error(w, "Use POST method", 401) 880 | return 881 | } 882 | 883 | bs, err := ioutil.ReadAll(r.Body) 884 | if err != nil { 885 | handleErr(w, err, "deluserHandler") 886 | return 887 | } 888 | var req DelUserReq 889 | err = json.Unmarshal(bs, &req) 890 | if err != nil { 891 | handleErr(w, err, "deluserHandler") 892 | return 893 | } 894 | if req.Username == "" { 895 | http.Error(w, "username required", 401) 896 | return 897 | } 898 | 899 | // Attempt to delete user. 900 | var result LoginResult 901 | err = deluser(db, req.Username, req.Pwd) 902 | if err != nil { 903 | result.Error = fmt.Sprintf("%s", err) 904 | } 905 | 906 | w.Header().Set("Content-Type", "application/json") 907 | P := makeFprintf(w) 908 | bs, _ = json.MarshalIndent(result, "", "\t") 909 | P("%s\n", string(bs)) 910 | } 911 | } 912 | 913 | func saveGrid(db *sql.DB, userid int64, gridjson string) error { 914 | s := "INSERT OR REPLACE INTO savedgrid (user_id, gridjson) VALUES (?, ?);" 915 | _, err := sqlexec(db, s, userid, gridjson) 916 | if err != nil { 917 | return fmt.Errorf("DB error saving grid: %s", err) 918 | } 919 | return nil 920 | } 921 | func savegridHandler(db *sql.DB) http.HandlerFunc { 922 | return func(w http.ResponseWriter, r *http.Request) { 923 | if r.Method != "POST" { 924 | http.Error(w, "Use POST method", 401) 925 | return 926 | } 927 | 928 | q := r.URL.Query() 929 | username := q.Get("username") 930 | tok := q.Get("tok") 931 | if username == "" { 932 | http.Error(w, "username required", 401) 933 | return 934 | } 935 | if tok == "" { 936 | http.Error(w, "tok required", 401) 937 | return 938 | } 939 | bs, err := ioutil.ReadAll(r.Body) 940 | if err != nil { 941 | handleErr(w, err, "saveGrid") 942 | return 943 | } 944 | gridjson := string(bs) 945 | 946 | u := findUser(db, username) 947 | if u == nil { 948 | http.Error(w, fmt.Sprintf("No user '%s'", username), 401) 949 | return 950 | } 951 | if !validateTok(tok, u) { 952 | http.Error(w, fmt.Sprintf("Token not validated for '%s' ", u.Username), 401) 953 | return 954 | } 955 | err = saveGrid(db, u.Userid, gridjson) 956 | if err != nil { 957 | handleErr(w, err, "saveGrid") 958 | return 959 | } 960 | } 961 | } 962 | 963 | func loadGrid(db *sql.DB, userid int64) string { 964 | s := "SELECT gridjson FROM savedgrid WHERE user_id = ?" 965 | row := db.QueryRow(s, userid) 966 | var gridjson string 967 | err := row.Scan(&gridjson) 968 | if err == sql.ErrNoRows { 969 | return "" 970 | } 971 | if err != nil { 972 | fmt.Printf("loadGrid() err: %s\n", err) 973 | return "" 974 | } 975 | return gridjson 976 | } 977 | func loadgridHandler(db *sql.DB) http.HandlerFunc { 978 | return func(w http.ResponseWriter, r *http.Request) { 979 | username := r.FormValue("username") 980 | tok := r.FormValue("tok") 981 | if username == "" { 982 | http.Error(w, "username required", 401) 983 | return 984 | } 985 | if tok == "" { 986 | http.Error(w, "tok required", 401) 987 | return 988 | } 989 | 990 | u := findUser(db, username) 991 | if u == nil { 992 | http.Error(w, fmt.Sprintf("No user '%s'", username), 401) 993 | return 994 | } 995 | if !validateTok(tok, u) { 996 | http.Error(w, fmt.Sprintf("Token not validated for '%s' ", u.Username), 401) 997 | return 998 | } 999 | gridjson := loadGrid(db, u.Userid) 1000 | if gridjson == "" { 1001 | gridjson = "[]" 1002 | } 1003 | 1004 | w.Header().Set("Content-Type", "application/json") 1005 | P := makeFprintf(w) 1006 | P("%s\n", gridjson) 1007 | } 1008 | } 1009 | 1010 | func testfeedHandler(db *sql.DB) http.HandlerFunc { 1011 | return func(w http.ResponseWriter, r *http.Request) { 1012 | // Allow requests from all sites. 1013 | w.Header().Set("Access-Control-Allow-Origin", "*") 1014 | w.Header().Set("Content-Type", "application/rss+xml") 1015 | P := makeFprintf(w) 1016 | 1017 | now := time.Now() 1018 | f := feeds.Feed{ 1019 | Title: "FreeRSS test rss feed", 1020 | Link: &feeds.Link{Href: "http://freerss.robdelacruz.com/api/testfeed"}, 1021 | Description: "Test RSS feed with inline script", 1022 | Author: &feeds.Author{}, 1023 | Created: now, 1024 | } 1025 | item := feeds.Item{ 1026 | Title: "test item with inline script", 1027 | Link: &feeds.Link{Href: "http://freerss.robdelacruz.com/api/testfeed"}, 1028 | Description: `

This is markup containing an inline script. 1029 | click me 1030 |

1031 | `, 1032 | Author: &feeds.Author{}, 1033 | Created: now, 1034 | } 1035 | f.Items = []*feeds.Item{ 1036 | &item, 1037 | } 1038 | rss, err := f.ToRss() 1039 | if err != nil { 1040 | handleErr(w, err, "testfeedHandler") 1041 | return 1042 | } 1043 | P("%s\n", rss) 1044 | } 1045 | } 1046 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | FreeRSS 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import App from "./App.svelte"; 2 | let app = new App({ 3 | target: document.querySelector("#container"), 4 | props: { 5 | }, 6 | }); 7 | 8 | -------------------------------------------------------------------------------- /menu.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('cssnano')({ 4 | preset: 'default', 5 | }), 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from "rollup-plugin-svelte"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | 4 | export default { 5 | input: "index.js", 6 | output: { 7 | file: "static/bundle.js", 8 | format: "iife", 9 | name: "app", 10 | sourcemap: true 11 | }, 12 | plugins: [ 13 | svelte({ 14 | compilerOptions: { 15 | dev: true, 16 | css: false, 17 | } 18 | }), 19 | resolve({ 20 | browser: true, 21 | dedupe: ["svelte"] 22 | }) 23 | ] 24 | }; 25 | 26 | -------------------------------------------------------------------------------- /screenshots/freerss_portal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robdelacruz/freerss/dad387db57b5e9ec5b4862404ea343a2a1af5f6d/screenshots/freerss_portal.png -------------------------------------------------------------------------------- /screenshots/freerss_withpreview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robdelacruz/freerss/dad387db57b5e9ec5b4862404ea343a2a1af5f6d/screenshots/freerss_withpreview.png -------------------------------------------------------------------------------- /spec.txt: -------------------------------------------------------------------------------- 1 | https://github.com/mmcdole/gofeed 2 | https://github.com/gorilla/feeds 3 | 4 | feedsearch: 5 | https://feedsearch.dev/api/v1/search?result=true&url=twit.tv 6 | https://developer.feedly.com/v3/search/ 7 | 8 | https://stackoverflow.com/questions/45267125/how-to-generate-unique-random-alphanumeric-tokens-in-golang 9 | 10 | https://stackoverflow.com/questions/8054429/how-do-i-handle-a-click-anywhere-in-the-page-even-when-a-certain-element-stops 11 | 12 | -------------------------------------------------------------------------------- /static/coffee.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robdelacruz/freerss/dad387db57b5e9ec5b4862404ea343a2a1af5f6d/static/coffee.ico -------------------------------------------------------------------------------- /static/radio.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robdelacruz/freerss/dad387db57b5e9ec5b4862404ea343a2a1af5f6d/static/radio.ico -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./*.{html,js,svelte,css}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | } 8 | 9 | -------------------------------------------------------------------------------- /twsrc.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | @tailwind components; 4 | 5 | @tailwind utilities; 6 | 7 | .w-widget {width: 20rem;} 8 | 9 | .widget {@apply bg-gray-800 text-gray-200 mb-2 py-2 px-4;} 10 | 11 | .linklist li {@apply mb-2 leading-tight;} 12 | 13 | .content h1, .content h2, .content h3, .content h4 {@apply text-xs;} 14 | .content p, .content ul, .content ol, .content blockquote, .content pre {@apply mb-1;} 15 | .content a {@apply underline;} 16 | .content ul {@apply list-disc list-inside;} 17 | .content ol {@apply list-decimal list-inside;} 18 | .content blockquote {@apply bg-gray-700 px-8 py-4;} 19 | .content pre {@apply bg-gray-700 p-2;} 20 | .content code {@apply font-mono;} 21 | .content img[src*="#thumb"] {width: 100px;} 22 | .content img[src*="#sm"] {width: 150px;} 23 | .content img[src*="#med"] {width: 180px;} 24 | .content img[src*="#lg"] {width: 200px;} 25 | .content img[src*="#xl"] {width: 300px;} 26 | .content img[src*="#stretch"] {width: 100%;} 27 | .content img[src*="#left"] {@apply float-left mr-2;} 28 | .content img[src*="#right"] {@apply float-right ml-2;} 29 | .content iframe {@apply w-full;} 30 | 31 | --------------------------------------------------------------------------------