70 Commits
v1.1 ... v1.2

Author SHA1 Message Date
Nazar Kanaev
4f1e029d0b v1.2 2021-02-11 20:57:20 +00:00
Nazar Kanaev
9b93a959e5 fix: deleting item when all feeds is selected causes ui crash 2021-02-11 20:56:27 +00:00
Nazar Kanaev
68d269658f mobile/tablet layout tweaks 2021-02-11 20:37:30 +00:00
Nazar Kanaev
875b87b0d6 non-draggable in mobile/tablet resolution 2021-02-11 20:37:30 +00:00
Nazar Kanaev
3e79cd7944 todo entry 2021-02-05 11:15:07 +00:00
Nazar Kanaev
0ea8972ede update todo 2021-02-03 21:30:25 +00:00
Nazar Kanaev
a7113addd0 public todo 2021-02-03 21:24:06 +00:00
Nazar Kanaev
84df847898 platform notes 2021-02-03 21:18:09 +00:00
Nazar Kanaev
333d9373dd break long words 2021-02-01 21:19:17 +00:00
Nazar Kanaev
eb90c5b9aa attribution 2021-01-29 13:33:31 +00:00
Nazar Kanaev
3371e1afff add changelog 2021-01-27 15:46:57 +00:00
Nazar Kanaev
8aafb1b729 open urls with basepath 2021-01-27 15:46:38 +00:00
hcl
1a0db29aa6 prevent route leak 2021-01-25 21:02:29 +00:00
hcl
52073e7e81 make route handling better 2021-01-25 21:02:29 +00:00
hcl
4f79c919f0 Add non-root url path support 2021-01-25 21:02:29 +00:00
Nazar Kanaev
63b265fa04 move hacking.md 2021-01-19 23:35:39 +00:00
Nazar Kanaev
6b01d9d7b9 linux installation guide 2021-01-19 23:35:29 +00:00
Nazar Kanaev
20a0a6724a open flag 2021-01-18 23:16:13 +00:00
Nazar Kanaev
e79cb9e6e0 done 2021-01-07 12:06:40 +00:00
Nazar Kanaev
d7ddcc04b5 show feed errors 2021-01-07 12:06:14 +00:00
Nazar Kanaev
99684a4b2f store feed errors 2021-01-07 11:32:42 +00:00
Nazar Kanaev
6a6153ca48 fix unsafe methods 2021-01-04 14:46:36 +00:00
Nazar Kanaev
23a4ff3af6 https 2021-01-04 14:40:25 +00:00
Nazar Kanaev
d6c2ba5812 simple csrf protection 2021-01-04 14:15:28 +00:00
Nazar Kanaev
fa0237b546 remove todo 2021-01-04 14:04:48 +00:00
Nazar Kanaev
9fcaad6b2f hmac-based auth 2021-01-04 14:03:51 +00:00
Nazar Kanaev
edc7d56219 log db file 2021-01-04 11:51:31 +00:00
Nazar Kanaev
d0a2b80ecc logout ui 2020-12-16 16:49:35 +00:00
Nazar Kanaev
e2d80af81d login 2020-12-16 16:24:50 +00:00
Nazar Kanaev
eccd383c1c rename param auth -> auth-file 2020-12-16 15:58:32 +00:00
Nazar Kanaev
db7a178a8d remove fever code 2020-12-16 00:02:45 +00:00
Nazar Kanaev
62e0caa950 drop fever support 2020-12-15 23:49:26 +00:00
Nazar Kanaev
46d8c98aff remove slash 2020-11-10 23:42:17 +00:00
Nazar Kanaev
05634ebdb7 rename skipauth -> manualauth 2020-11-10 23:34:38 +00:00
Nazar Kanaev
0e2da62081 login page 2020-11-03 21:54:55 +00:00
Nazar Kanaev
94d1659ad5 remove prints 2020-11-03 21:51:16 +00:00
Nazar Kanaev
0745c92e9a auth parameter 2020-11-03 20:58:26 +00:00
Nazar Kanaev
7c06952a7d rename vars 2020-11-03 20:42:54 +00:00
Nazar Kanaev
e2d8ca3506 fever api fixes 2020-11-01 15:57:52 +00:00
Nazar Kanaev
4f20f537c0 remove todo 2020-11-01 15:51:15 +00:00
Nazar Kanaev
a0b42b27b3 fever: handle invalid requests 2020-11-01 15:50:52 +00:00
Nazar Kanaev
288fa3979a refactoring 2020-11-01 15:49:04 +00:00
Nazar Kanaev
e4cc96ef09 fever api feed last_updated_on_time 2020-11-01 15:45:58 +00:00
Nazar Kanaev
60a947f131 fever api last_refreshed_on_time 2020-11-01 15:33:24 +00:00
Nazar Kanaev
0226c8da23 fever item endpoint max_id parameter 2020-11-01 15:27:25 +00:00
Nazar Kanaev
b0364087ad fever write api [wip] 2020-10-25 16:29:26 +00:00
Nazar Kanaev
40a9773beb fever hot links stub 2020-10-22 22:30:44 +01:00
Nazar Kanaev
790a275443 fever with_ids fix 2020-10-22 22:24:50 +01:00
Nazar Kanaev
9b9addf3e6 fever items (with_ids, since_id) 2020-10-20 22:52:53 +01:00
Nazar Kanaev
57d2437e9c fever favicons endpoint 2020-10-20 21:57:02 +01:00
Nazar Kanaev
a13aea478e attack of the monster feeds from the future 2020-10-20 21:29:36 +01:00
Nazar Kanaev
6def522f38 http states field for future health check 2020-10-20 20:59:35 +01:00
Nazar Kanaev
6a63d49823 go fmt 2020-10-20 20:54:05 +01:00
Nazar Kanaev
b766cb4ac5 minor settings fix 2020-10-20 20:52:09 +01:00
Nazar Kanaev
32ab1fefa9 change http state storage 2020-10-20 20:48:01 +01:00
Nazar Kanaev
70761c47eb minor ui fix 2020-10-20 19:10:25 +01:00
Nazar Kanaev
6a09d52b85 add missing fever endpoints 2020-10-19 22:05:38 +01:00
Nazar Kanaev
fcaf23d6bc initial fever api 2020-10-19 21:59:14 +01:00
Nazar Kanaev
54c2a6458d fix deleting feeds 2020-10-18 15:40:06 +01:00
Nazar Kanaev
05032ec428 search for favicon right after adding new feed 2020-10-17 22:29:20 +01:00
Nazar Kanaev
e24b905adc ignore all feeds with 4xx and 5xx errors 2020-10-17 13:40:00 +01:00
Nazar Kanaev
f27d0c4cd7 conditional http get 2020-10-17 13:27:12 +01:00
Nazar Kanaev
11a2aa2b4a always run sql init script on start 2020-10-17 12:50:46 +01:00
Nazar Kanaev
2eee8baa26 store http state 2020-10-17 12:47:45 +01:00
Nazar Kanaev
0949ffc027 use minute interval 2020-10-17 12:41:40 +01:00
Nazar Kanaev
286538c5d0 break long words 2020-10-16 14:58:22 +01:00
Nazar Kanaev
78844def40 refresh rate 2020-10-13 22:16:24 +01:00
Nazar Kanaev
e17ce0fb31 refresh rate ui 2020-10-12 20:42:30 +01:00
Yang Bin
55a1c297be fix docker image issues
- use alpine as image base
- add ca-certificates to fix https failed
- define default db file
- expose http port
2020-10-09 22:32:57 +01:00
nkanaev
9bb7ae7902 Update hacking.md 2020-10-06 12:22:01 +01:00
28 changed files with 765 additions and 127 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-out"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -56,7 +56,18 @@
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span> <span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
Refresh Feeds Refresh Feeds
</b-dropdown-item-button> </b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider> <b-dropdown-divider></b-dropdown-divider>
<b-dropdown-header>Refresh</b-dropdown-header>
<b-dropdown-item-button @click.stop="refreshRate = min" v-for="min in [0, 60]">
<span class="icon mr-1" :class="{invisible: refreshRate != min}">{% inline "check.svg" %}</span>
<span v-if="min == 0">Manually</span>
<span v-if="min == 60">Every hour</span>
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-header>Sort by</b-dropdown-header> <b-dropdown-header>Sort by</b-dropdown-header>
<b-dropdown-item-button @click.stop="itemSortNewestFirst=true"> <b-dropdown-item-button @click.stop="itemSortNewestFirst=true">
<span class="icon mr-1" :class="{invisible: !itemSortNewestFirst}">{% inline "check.svg" %}</span> <span class="icon mr-1" :class="{invisible: !itemSortNewestFirst}">{% inline "check.svg" %}</span>
@@ -73,16 +84,21 @@
id="opml-import" id="opml-import"
@change="importOPML" @change="importOPML"
name="opml" name="opml"
style="opacity: 0; width: 1px; height: 0; position: absolute;"> style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import"> <label class="dropdown-item mb-0 cursor-pointer" for="opml-import">
<span class="icon mr-1">{% inline "download.svg" %}</span> <span class="icon mr-1">{% inline "download.svg" %}</span>
Import Import
</label> </label>
</b-dropdown-form> </b-dropdown-form>
<b-dropdown-item href="/opml/export"> <b-dropdown-item href="./opml/export">
<span class="icon mr-1">{% inline "upload.svg" %}</span> <span class="icon mr-1">{% inline "upload.svg" %}</span>
Export Export
</b-dropdown-item> </b-dropdown-item>
<b-dropdown-divider v-if="authenticated"></b-dropdown-divider>
<b-dropdown-item-button v-if="authenticated" @click="logout()">
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
Log out
</b-dropdown-item-button>
</b-dropdown> </b-dropdown>
</div> </div>
<div class="p-2 overflow-auto border-top flex-grow-1"> <div class="p-2 overflow-auto border-top flex-grow-1">
@@ -121,7 +137,7 @@
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected"> <input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100"> <div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span> <span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
<span class="icon mr-2" v-else><img v-lazy="'/api/feeds/'+feed.id+'/icon'" alt=""></span> <span class="icon mr-2" v-else><img v-lazy="'./api/feeds/'+feed.id+'/icon'" alt=""></span>
<span class="flex-fill text-left text-truncate">{{ feed.title }}</span> <span class="flex-fill text-left text-truncate">{{ feed.title }}</span>
<span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</span> <span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</span>
</div> </div>
@@ -318,9 +334,14 @@
<div v-for="feed in folder.feeds" class="list-row d-flex align-items-center" :key="feed.id"> <div v-for="feed in folder.feeds" class="list-row d-flex align-items-center" :key="feed.id">
<div class="w-100 text-truncate"> <div class="w-100 text-truncate">
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span> <span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
<span class="icon mr-2" v-else><img v-lazy="'/api/feeds/'+feed.id+'/icon'" alt=""></span> <span class="icon mr-2" v-else><img v-lazy="'./api/feeds/'+feed.id+'/icon'" alt=""></span>
{{ feed.title }} {{ feed.title }}
</div> </div>
<span class="icon flex-shrink-0 mx-2"
v-b-tooltip.hover.top="feed_errors[feed.id]"
v-if="feed_errors[feed.id]">
{% inline "alert-circle.svg" %}
</span>
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<b-dropdown right no-caret lazy variant="link" class="settings-dropdown" toggle-class="text-decoration-none"> <b-dropdown right no-caret lazy variant="link" class="settings-dropdown" toggle-class="text-decoration-none">
<template v-slot:button-content> <template v-slot:button-content>

View File

@@ -2,9 +2,12 @@
(function() { (function() {
var api = function(method, endpoint, data) { var api = function(method, endpoint, data) {
var headers = {'Content-Type': 'application/json'}
if (['post', 'put', 'delete'].indexOf(method) !== -1)
headers['x-requested-by'] = 'yarr'
return fetch(endpoint, { return fetch(endpoint, {
method: method, method: method,
headers: {'Content-Type': 'application/json'}, headers: headers,
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
} }
@@ -12,7 +15,7 @@
var json = function(res) { var json = function(res) {
return res.json() return res.json()
} }
var param = function(query) { var param = function(query) {
if (!query) return '' if (!query) return ''
return '?' + Object.keys(query).map(function(key) { return '?' + Object.keys(query).map(function(key) {
@@ -23,71 +26,74 @@
window.api = { window.api = {
feeds: { feeds: {
list: function() { list: function() {
return api('get', '/api/feeds').then(json) return api('get', './api/feeds').then(json)
}, },
create: function(data) { create: function(data) {
return api('post', '/api/feeds', data).then(json) return api('post', './api/feeds', data).then(json)
}, },
update: function(id, data) { update: function(id, data) {
return api('put', '/api/feeds/' + id, data) return api('put', './api/feeds/' + id, data)
}, },
delete: function(id) { delete: function(id) {
return api('delete', '/api/feeds/' + id) return api('delete', './api/feeds/' + id)
}, },
list_items: function(id) { list_items: function(id) {
return api('get', '/api/feeds/' + id + '/items').then(json) return api('get', './api/feeds/' + id + '/items').then(json)
}, },
refresh: function() { refresh: function() {
return api('post', '/api/feeds/refresh') return api('post', './api/feeds/refresh')
},
list_errors: function() {
return api('get', './api/feeds/errors').then(json)
}, },
}, },
folders: { folders: {
list: function() { list: function() {
return api('get', '/api/folders').then(json) return api('get', './api/folders').then(json)
}, },
create: function(data) { create: function(data) {
return api('post', '/api/folders', data).then(json) return api('post', './api/folders', data).then(json)
}, },
update: function(id, data) { update: function(id, data) {
return api('put', '/api/folders/' + id, data) return api('put', './api/folders/' + id, data)
}, },
delete: function(id) { delete: function(id) {
return api('delete', '/api/folders/' + id) return api('delete', './api/folders/' + id)
}, },
list_items: function(id) { list_items: function(id) {
return api('get', '/api/folders/' + id + '/items').then(json) return api('get', './api/folders/' + id + '/items').then(json)
} }
}, },
items: { items: {
list: function(query) { list: function(query) {
return api('get', '/api/items' + param(query)).then(json) return api('get', './api/items' + param(query)).then(json)
}, },
update: function(id, data) { update: function(id, data) {
return api('put', '/api/items/' + id, data) return api('put', './api/items/' + id, data)
}, },
mark_read: function(query) { mark_read: function(query) {
return api('put', '/api/items' + param(query)) return api('put', './api/items' + param(query))
}, },
}, },
settings: { settings: {
get: function() { get: function() {
return api('get', '/api/settings').then(json) return api('get', './api/settings').then(json)
}, },
update: function(data) { update: function(data) {
return api('put', '/api/settings', data) return api('put', './api/settings', data)
}, },
}, },
status: function() { status: function() {
return api('get', '/api/status').then(json) return api('get', './api/status').then(json)
}, },
upload_opml: function(form) { upload_opml: function(form) {
return fetch('/opml/import', { return fetch('./opml/import', {
method: 'post', method: 'post',
body: new FormData(form), body: new FormData(form),
}) })
}, },
crawl: function(url) { crawl: function(url) {
return fetch('/page?url=' + url).then(function(res) { return fetch('./page?url=' + url).then(function(res) {
return res.text() return res.text()
}) })
} }

View File

@@ -2,6 +2,11 @@
var TITLE = document.title var TITLE = document.title
function authenticated() {
return /auth=.+/g.test(document.cookie)
}
var FONTS = [ var FONTS = [
"Arial", "Arial",
"Courier New", "Courier New",
@@ -80,14 +85,21 @@ Vue.component('drag', {
function dateRepr(d) { function dateRepr(d) {
var sec = (new Date().getTime() - d.getTime()) / 1000 var sec = (new Date().getTime() - d.getTime()) / 1000
var neg = sec < 0
var out = ''
sec = Math.abs(sec)
if (sec < 2700) // less than 45 minutes if (sec < 2700) // less than 45 minutes
return Math.round(sec / 60) + 'm' out = Math.round(sec / 60) + 'm'
else if (sec < 86400) // less than 24 hours else if (sec < 86400) // less than 24 hours
return Math.round(sec / 3600) + 'h' out = Math.round(sec / 3600) + 'h'
else if (sec < 604800) // less than a week else if (sec < 604800) // less than a week
return Math.round(sec / 86400) + 'd' out = Math.round(sec / 86400) + 'd'
else else
return d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"}) out = d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"})
if (neg) return '-' + out
return out
} }
Vue.component('relative-time', { Vue.component('relative-time', {
@@ -163,6 +175,9 @@ var vm = new Vue({
'font': '', 'font': '',
'size': 1, 'size': 1,
}, },
'refreshRate': undefined,
'authenticated': authenticated(),
'feed_errors': {},
} }
}, },
computed: { computed: {
@@ -270,6 +285,10 @@ var vm = new Vue({
if (oldVal === undefined) return // do nothing, initial setup if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({item_list_width: newVal}) api.settings.update({item_list_width: newVal})
}, 1000), }, 1000),
'refreshRate': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({refresh_rate: newVal})
},
}, },
methods: { methods: {
refreshStats: function(loopMode) { refreshStats: function(loopMode) {
@@ -425,16 +444,11 @@ var vm = new Vue({
deleteFeed: function(feed) { deleteFeed: function(feed) {
if (confirm('Are you sure you want to delete ' + feed.title + '?')) { if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
api.feeds.delete(feed.id).then(function() { api.feeds.delete(feed.id).then(function() {
// note: if item list contains delete feed's entries, refresh it first. // unselect feed to prevent reading properties of null in template
for (var i = 0; i < vm.items.length; i++) { var isSelected = !vm.feedSelected
if (vm.items[i].feed_id == feed.id) { || (vm.feedSelected === 'feed:'+feed.id
vm.refreshItems().then(function() { || (feed.folder_id && vm.feedSelected === 'folder:'+feed.folder_id));
vm.refreshStats() if (isSelected) vm.feedSelected = null
vm.refreshFeeds()
})
return
}
}
vm.refreshStats() vm.refreshStats()
vm.refreshFeeds() vm.refreshFeeds()
@@ -517,6 +531,12 @@ var vm = new Vue({
showSettings: function(settings) { showSettings: function(settings) {
this.settings = settings this.settings = settings
this.$bvModal.show('settings-modal') this.$bvModal.show('settings-modal')
if (settings === 'manage') {
api.feeds.list_errors().then(function(errors) {
vm.feed_errors = errors
})
}
}, },
resizeFeedList: function(width) { resizeFeedList: function(width) {
this.feedListWidth = Math.min(Math.max(200, width), 700) this.feedListWidth = Math.min(Math.max(200, width), 700)
@@ -574,6 +594,7 @@ api.settings.get().then(function(data) {
vm.theme.name = data.theme_name vm.theme.name = data.theme_name
vm.theme.font = data.theme_font vm.theme.font = data.theme_font
vm.theme.size = data.theme_size vm.theme.size = data.theme_size
vm.refreshRate = data.refresh_rate
vm.refreshItems() vm.refreshItems()
vm.$mount('#app') vm.$mount('#app')
}) })

38
assets/login.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/anchor.png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
form {
max-width: 300px;
margin: 0 auto;
padding: 1rem;
}
form img {
width: 4rem;
height: 4rem;
display: block;
margin: 3rem auto;
}
</style>
</head>
<body>
<form action="" method="post">
<img src="./static/graphicarts/anchor.svg" alt="">
<div class="form-group">
<label for="username">Username</label>
<input name="username" class="form-control" id="username" autocomplete="off">
</div>
<div class="form-group">
<label for="password">Password</label>
<input name="password" class="form-control" id="password" type="password">
</div>
<button class="btn btn-block btn-default" type="submit">Login</button>
</form>
</body>
</html>

View File

@@ -177,6 +177,7 @@ select.form-control:not([multiple]):not([size]) {
.selectgroup-label { .selectgroup-label {
padding: .375rem .5rem; padding: .375rem .5rem;
border-radius: 4px; border-radius: 4px;
overflow-wrap: break-word;
} }
.selectgroup-label:hover { .selectgroup-label:hover {
@@ -402,6 +403,7 @@ select.form-control:not([multiple]):not([size]) {
/* content */ /* content */
.content { .content {
overflow-wrap: break-word;
line-height: 1.5; line-height: 1.5;
} }
@@ -578,3 +580,19 @@ a,
display: flex !important; display: flex !important;
} }
} }
/* styles for both mobile & tablet layout */
@media (max-width: 991.98px) {
.drag {
cursor: default;
}
.toolbar {
min-height: 3rem !important;
max-height: 3rem !important;
}
.toolbar-item,
.toolbar-search {
padding: .5rem;
}
}

21
doc/changelog.txt Normal file
View File

@@ -0,0 +1,21 @@
# upcoming
- (new) autorefresh rate
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
- (new) show feed errors in feed management modal
- (new) `-open` flag for automatically opening the server url
- (new) `-base` flag for serving urls under non-root path (thanks to @hcl)
- (new) `-auth-file` flag for authentication
- (new) `-cert-file` & `-key-file` flags for TLS
- (fix) wrapping long words in the ui to prevent vertical scroll
# v1.1 (2020-10-05)
- (new) responsive design
- (fix) server crash on favicon fetch timeout (reported by @minioin)
- (fix) handling byte order marks in feeds (reported by @ilaer)
- (fix) deleting a feed raises exception in the ui if the feed's items are shown.
# v1.0 (2020-09-24)
Initial Release

View File

@@ -1,9 +1,5 @@
# hacking # hacking
If you have any questions/suggestions/proposals,
you can reach out the author via e-mail (nkanaev@live.com)
or mastodon (https://fosstodon.org/@nkanaev).
## build ## build
Install `Go >= 1.14` and `gcc`. Get the source code: Install `Go >= 1.14` and `gcc`. Get the source code:
@@ -27,11 +23,6 @@ make build_windows # -> _output/windows/yarr.exe
go run main.go # starts a server at http://localhost:7070 go run main.go # starts a server at http://localhost:7070
``` ```
## plans
- feeds health checker
- Fever API support
## code of conduct ## code of conduct
Be excellent to each other. Party on, dudes! Be excellent to each other. Party on, dudes!

15
doc/install.md Normal file
View File

@@ -0,0 +1,15 @@
# Linux desktop
Grab the latest linux binary, then run:
```
$ sudo mv /path/to/yarr /usr/local/bin
$ sudo tee /usr/local/share/applications/yarr.desktop >/dev/null <<EOF
[Desktop Entry]
Name=yarr
Exec=yarr -open
Icon=rss
Type=Application
Categories=Internet;
EOF
```

70
doc/platform.md Normal file
View File

@@ -0,0 +1,70 @@
Incomplete & inaccurate platform-specific notes.
# MacOS Icon
The format for desktop apps is [.icns][icns].
AFAIK, the format is not open (even though it had been [reverse-engineered][icns-re]),
and I couldn't find any 3rd party tool that'd fully support it.
The easiest way for creating icon file is either via `Xcode`,
or by using built-in `iconutil` command that ships with MacOS.
The steps are provided below:
$ sips -s format png --resampleWidth 1024 source.png --out /path/to/icons/icon_512x512@2x.png
$ sips -s format png --resampleWidth 512 source.png --out /path/to/icons/icon_512x512.png
$ sips -s format png --resampleWidth 256 source.png --out /path/to/icons/icon_256x256.png
$ sips -s format png --resampleWidth 128 source.png --out /path/to/icons/icon_128x128.png
$ sips -s format png --resampleWidth 64 source.png --out /path/to/icons/icon_32x32@2x.png
$ sips -s format png --resampleWidth 32 source.png --out /path/to/icons/icon_32x32.png
$ sips -s format png --resampleWidth 16 source.png --out /path/to/icons/icon_16x16.png
$ iconutil -c icns /path/to/icons -o icon.icns
[icns]: https://en.wikipedia.org/wiki/Apple_Icon_Image_format
[icns-re]: https://www.macdisk.com/maciconen.php#RLE
# Windows Icon
Terminology:
- coff: precursor to pe format (portable executable). pe is an extension of coff.
- manifest: xml file with platform requirements needed during runtime
- https://docs.microsoft.com/en-us/windows/win32/sbscs/application-manifests
- https://www.samlogic.net/articles/manifest.htm
- rc: dsl file that describes the application metadata & resources
- https://docs.microsoft.com/en-gb/windows/win32/menurc/about-resource-files
- https://github.com/josephspurrier/goversioninfo/blob/master/testdata/rc/versioninfo.rc (sample rc)
Windows Icons are directly embedded to the binary.
To do so one needs to provide `.syso` file prior to compiling Go code,
which will be passed to the linker. So, basically `.syso` is any
[object file][obj-file] that the linker understands.
More info here: [ticket][syso-ticket] & [commit][syso-commit].
Note to self: running `go build main.go` [won't embed][syso-quirk]
.syso file if it isn't located in a package directory.
Tools to create `.syso` files:
- [windres][windres]: ships with mingw (gnu tools for windows)
- [rsrc][rsrc]: written in Go, wasn't considered at the time
due to the critical bug with icon alignment
- [goversioninfo][goversioninfo]: rsrc wrapper
with manifest file creation via json
[obj-file]: https://en.wikipedia.org/wiki/Object_file
[syso-linker]: https://github.com/golang/go/issues/23278#issuecomment-354567634
[syso-ticket]: https://github.com/golang/go/issues/1552
[syso-commit]: https://github.com/golang/go/commit/b0996334
[syso-quirk]: https://github.com/golang/go/issues/16090
[mingw]: https://en.wikipedia.org/wiki/MinGW
[coff]: https://en.wikipedia.org/wiki/COFF
[windres]: https://sourceware.org/binutils/docs/binutils/windres.html
[rsrs]: https://github.com/akavel/rsrc
[rsrc-bug]: https://github.com/akavel/rsrc/issues/12
[goversioninfo]: github.com/josephspurrier/goversioninfo
[winicon-guide]: https://docs.microsoft.com/en-us/windows/win32/uxguide/vis-icons#size-requirements
[res-vs-coff]: http://www.mingw.org/wiki/MS_resource_compiler
[versioninfo-resource]: https://docs.microsoft.com/en-us/windows/win32/menurc/versioninfo-resource

11
doc/todo.txt Normal file
View File

@@ -0,0 +1,11 @@
- add: switch to `embed` once 1.16 is out
https://tip.golang.org/pkg/embed/
- add: enclosures (podcasts etc)
https://github.com/shagr4th/yarr/commits/master
- fix: moving newly added feed makes it disappear
- fix: broken base link in https://applieddivinitystudies.com/
- fix: migrate to cascade delete
https://sqlite.org/foreignkeys.html#fk_actions
- fix: loading items (by scrolling down) is glitching while feeds are refreshing
- doc: self-hosting instructions (including certificates)
- etc: test gofeed against real-world feeds, compare results with https://pypi.org/project/feedparser/

View File

@@ -1,9 +1,12 @@
FROM golang:1.15 AS build FROM golang:alpine AS build
RUN apt install gcc -y RUN apk add build-base git
WORKDIR /src WORKDIR /src
COPY . . COPY . .
RUN make build_linux RUN make build_linux
FROM ubuntu:20.04 FROM alpine:latest
COPY --from=build /src/_output/linux/yarr /usr/bin/yarr RUN apk add --no-cache ca-certificates && \
ENTRYPOINT ["/usr/bin/yarr", "-addr", "0.0.0.0:7070"] update-ca-certificates
COPY --from=build /src/_output/linux/yarr /usr/local/bin/yarr
EXPOSE 7070
CMD ["/usr/local/bin/yarr", "-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"]

79
main.go
View File

@@ -1,25 +1,34 @@
package main package main
import ( import (
"bufio"
"flag" "flag"
"fmt" "fmt"
"github.com/nkanaev/yarr/server"
"github.com/nkanaev/yarr/storage"
"github.com/nkanaev/yarr/platform"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/nkanaev/yarr/platform"
"github.com/nkanaev/yarr/server"
"github.com/nkanaev/yarr/storage"
sdopen "github.com/skratchdot/open-golang/open"
) )
var Version string = "0.0" var Version string = "0.0"
var GitHash string = "unknown" var GitHash string = "unknown"
func main() { func main() {
var addr, storageFile string var addr, db, authfile, certfile, keyfile string
var ver bool var ver, open bool
flag.StringVar(&addr, "addr", "127.0.0.1:7070", "address to run server on") flag.StringVar(&addr, "addr", "127.0.0.1:7070", "address to run server on")
flag.StringVar(&storageFile, "db", "", "storage file path") flag.StringVar(&authfile, "auth-file", "", "path to a file containing username:password")
flag.StringVar(&server.BasePath, "base", "", "base path of the service url")
flag.StringVar(&certfile, "cert-file", "", "path to cert file for https")
flag.StringVar(&keyfile, "key-file", "", "path to key file for https")
flag.StringVar(&db, "db", "", "storage file path")
flag.BoolVar(&ver, "version", false, "print application version") flag.BoolVar(&ver, "version", false, "print application version")
flag.BoolVar(&open, "open", false, "open the server in browser")
flag.Parse() flag.Parse()
if ver { if ver {
@@ -27,6 +36,14 @@ func main() {
return return
} }
if server.BasePath != "" && !strings.HasPrefix(server.BasePath, "/") {
server.BasePath = "/" + server.BasePath
}
if server.BasePath != "" && strings.HasSuffix(server.BasePath, "/") {
server.BasePath = strings.TrimSuffix(server.BasePath, "/")
}
logger := log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lshortfile) logger := log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lshortfile)
configPath, err := os.UserConfigDir() configPath, err := os.UserConfigDir()
@@ -34,20 +51,60 @@ func main() {
logger.Fatal("Failed to get config dir: ", err) logger.Fatal("Failed to get config dir: ", err)
} }
if storageFile == "" { if db == "" {
storagePath := filepath.Join(configPath, "yarr") storagePath := filepath.Join(configPath, "yarr")
if err := os.MkdirAll(storagePath, 0755); err != nil { if err := os.MkdirAll(storagePath, 0755); err != nil {
logger.Fatal("Failed to create app config dir: ", err) logger.Fatal("Failed to create app config dir: ", err)
} }
storageFile = filepath.Join(storagePath, "storage.db") db = filepath.Join(storagePath, "storage.db")
} }
db, err := storage.New(storageFile, logger) logger.Printf("using db file %s", db)
var username, password string
if authfile != "" {
f, err := os.Open(authfile)
if err != nil {
logger.Fatal("Failed to open auth file: ", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, ":")
if len(parts) != 2 {
logger.Fatalf("Invalid auth: %v (expected `username:password`)", line)
}
username = parts[0]
password = parts[1]
break
}
}
if (certfile != "" || keyfile != "") && (certfile == "" || keyfile == "") {
logger.Fatalf("Both cert & key files are required")
}
store, err := storage.New(db, logger)
if err != nil { if err != nil {
logger.Fatal("Failed to initialise database: ", err) logger.Fatal("Failed to initialise database: ", err)
} }
srv := server.New(db, logger, addr) srv := server.New(store, logger, addr)
logger.Printf("starting server at http://%s", addr)
if certfile != "" && keyfile != "" {
srv.CertFile = certfile
srv.KeyFile = keyfile
}
if username != "" && password != "" {
srv.Username = username
srv.Password = password
}
logger.Printf("starting server at %s", srv.GetAddr())
if open {
sdopen.Run(srv.GetAddr())
}
platform.Start(srv) platform.Start(srv)
} }

View File

@@ -1,4 +1,4 @@
VERSION=1.1 VERSION=1.2
GITHASH=$(shell git rev-parse --short=8 HEAD) GITHASH=$(shell git rev-parse --short=8 HEAD)
ASSETS = assets/javascripts/* assets/stylesheets/* assets/graphicarts/* assets/index.html ASSETS = assets/javascripts/* assets/stylesheets/* assets/graphicarts/* assets/index.html

View File

@@ -20,7 +20,7 @@ func Start(s *server.Handler) {
for { for {
select { select {
case <-menuOpen.ClickedCh: case <-menuOpen.ClickedCh:
open.Run("http://" + s.Addr) open.Run(s.GetAddr())
case <-menuQuit.ClickedCh: case <-menuQuit.ClickedCh:
systray.Quit() systray.Quit()
} }

View File

@@ -8,7 +8,6 @@ yet another rss reader.
The goal of the project is to provide a desktop application accessible via web browser. The goal of the project is to provide a desktop application accessible via web browser.
Longer-term plans include a self-hosted solution for individuals. Longer-term plans include a self-hosted solution for individuals.
Support for 3rd-party applications (via Fever API) is being considered.
[download](https://github.com/nkanaev/yarr/releases/latest) [download](https://github.com/nkanaev/yarr/releases/latest)

51
server/auth.go Normal file
View File

@@ -0,0 +1,51 @@
package server
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"time"
)
func userIsAuthenticated(req *http.Request, username, password string) bool {
cookie, _ := req.Cookie("auth")
if cookie == nil {
return false
}
parts := strings.Split(cookie.Value, ":")
if len(parts) != 2 || !stringsEqual(parts[0], username) {
return false
}
return stringsEqual(parts[1], secret(username, password))
}
func userAuthenticate(rw http.ResponseWriter, username, password string) {
expires := time.Now().Add(time.Hour * 24 * 7) // 1 week
var cookiePath string
if BasePath != "" {
cookiePath = BasePath
} else {
cookiePath = "/"
}
cookie := http.Cookie{
Name: "auth",
Value: username + ":" + secret(username, password),
Expires: expires,
Path: cookiePath,
}
http.SetCookie(rw, &cookie)
}
func stringsEqual(p1, p2 string) bool {
return subtle.ConstantTimeCompare([]byte(p1), []byte(p2)) == 1
}
func secret(msg, key string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(msg))
src := mac.Sum(nil)
return hex.EncodeToString(src)
}

View File

@@ -47,6 +47,17 @@ func (c *Client) get(url string) (*http.Response, error) {
return c.httpClient.Do(req) return c.httpClient.Do(req)
} }
func (c *Client) getConditional(url, lastModified, etag string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("If-Modified-Since", lastModified)
req.Header.Set("If-None-Match", etag)
return c.httpClient.Do(req)
}
var defaultClient *Client var defaultClient *Client
func searchFeedLinks(html []byte, siteurl string) ([]FeedSource, error) { func searchFeedLinks(html []byte, siteurl string) ([]FeedSource, error) {
@@ -243,16 +254,37 @@ func convertItems(items []*gofeed.Item, feed storage.Feed) []storage.Item {
return result return result
} }
func listItems(f storage.Feed) ([]storage.Item, error) { func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
res, err := defaultClient.get(f.FeedLink) var res *http.Response
var err error
httpState := db.GetHTTPState(f.Id)
if httpState != nil {
res, err = defaultClient.getConditional(f.FeedLink, httpState.LastModified, httpState.Etag)
} else {
res, err = defaultClient.get(f.FeedLink)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer res.Body.Close() defer res.Body.Close()
if res.StatusCode == 404 {
errmsg := fmt.Sprintf("Failed to list feed items for %s (status: 404)", f.FeedLink) if res.StatusCode/100 == 4 || res.StatusCode/100 == 5 {
errmsg := fmt.Sprintf("Failed to list feed items for %s (status: %d)", f.FeedLink, res.StatusCode)
return nil, errors.New(errmsg) return nil, errors.New(errmsg)
} }
if res.StatusCode == 304 {
return nil, nil
}
lastModified := res.Header.Get("Last-Modified")
etag := res.Header.Get("Etag")
if lastModified != "" || etag != "" {
db.SetHTTPState(f.Id, lastModified, etag)
}
feedparser := gofeed.NewParser() feedparser := gofeed.NewParser()
feed, err := feedparser.Parse(res.Body) feed, err := feedparser.Parse(res.Body)
if err != nil { if err != nil {
@@ -267,7 +299,7 @@ func init() {
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
}).DialContext, }).DialContext,
DisableKeepAlives: true, DisableKeepAlives: true,
TLSHandshakeTimeout: time.Second * 10, TLSHandshakeTimeout: time.Second * 10,
} }
httpClient := &http.Client{ httpClient := &http.Client{

View File

@@ -2,9 +2,9 @@ package server
import ( import (
"bytes" "bytes"
"encoding/json"
"encoding/base64"
"compress/gzip" "compress/gzip"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"github.com/nkanaev/yarr/storage" "github.com/nkanaev/yarr/storage"
"html" "html"
@@ -22,14 +22,16 @@ import (
) )
var routes []Route = []Route{ var routes []Route = []Route{
p("/", IndexHandler), p("/", IndexHandler).ManualAuth(),
p("/static/*path", StaticHandler), p("/static/*path", StaticHandler).ManualAuth(),
p("/api/status", StatusHandler), p("/api/status", StatusHandler),
p("/api/folders", FolderListHandler), p("/api/folders", FolderListHandler),
p("/api/folders/:id", FolderHandler), p("/api/folders/:id", FolderHandler),
p("/api/feeds", FeedListHandler), p("/api/feeds", FeedListHandler),
p("/api/feeds/find", FeedHandler), p("/api/feeds/find", FeedHandler),
p("/api/feeds/refresh", FeedRefreshHandler), p("/api/feeds/refresh", FeedRefreshHandler),
p("/api/feeds/errors", FeedErrorsHandler),
p("/api/feeds/:id/icon", FeedIconHandler), p("/api/feeds/:id/icon", FeedIconHandler),
p("/api/feeds/:id", FeedHandler), p("/api/feeds/:id", FeedHandler),
p("/api/items", ItemListHandler), p("/api/items", ItemListHandler),
@@ -42,7 +44,7 @@ var routes []Route = []Route{
type asset struct { type asset struct {
etag string etag string
body string // base64(gzip(content)) body string // base64(gzip(content))
gzipped *[]byte gzipped *[]byte
decoded *string decoded *string
} }
@@ -89,6 +91,35 @@ type ItemUpdateForm struct {
} }
func IndexHandler(rw http.ResponseWriter, req *http.Request) { func IndexHandler(rw http.ResponseWriter, req *http.Request) {
h := handler(req)
if h.requiresAuth() && !userIsAuthenticated(req, h.Username, h.Password) {
if req.Method == "POST" {
username := req.FormValue("username")
password := req.FormValue("password")
if stringsEqual(username, h.Username) && stringsEqual(password, h.Password) {
userAuthenticate(rw, username, password)
http.Redirect(rw, req, req.URL.Path, http.StatusFound)
return
}
}
if assets != nil {
asset := assets["login.html"]
rw.Header().Set("Content-Type", "text/html")
rw.Header().Set("Content-Encoding", "gzip")
rw.Write(*asset.gzip())
return
} else {
f, err := os.Open("assets/login.html")
if err != nil {
handler(req).log.Print(err)
return
}
io.Copy(rw, f)
return
}
}
if assets != nil { if assets != nil {
asset := assets["index.html"] asset := assets["index.html"]
@@ -199,6 +230,11 @@ func FeedRefreshHandler(rw http.ResponseWriter, req *http.Request) {
} }
} }
func FeedErrorsHandler(rw http.ResponseWriter, req *http.Request) {
errors := db(req).GetFeedErrors()
writeJSON(rw, errors)
}
func FeedIconHandler(rw http.ResponseWriter, req *http.Request) { func FeedIconHandler(rw http.ResponseWriter, req *http.Request) {
id, err := strconv.ParseInt(Vars(req)["id"], 10, 64) id, err := strconv.ParseInt(Vars(req)["id"], 10, 64)
if err != nil { if err != nil {
@@ -243,6 +279,15 @@ func FeedListHandler(rw http.ResponseWriter, req *http.Request) {
form.FolderID, form.FolderID,
) )
db(req).CreateItems(convertItems(feed.Items, *storedFeed)) db(req).CreateItems(convertItems(feed.Items, *storedFeed))
icon, err := findFavicon(storedFeed.Link, storedFeed.FeedLink)
if icon != nil {
db(req).UpdateFeedIcon(storedFeed.Id, icon)
}
if err != nil {
handler(req).log.Printf("Failed to find favicon for %s (%d): %s", storedFeed.FeedLink, storedFeed.Id, err)
}
writeJSON(rw, map[string]string{"status": "success"}) writeJSON(rw, map[string]string{"status": "success"})
} else if sources != nil { } else if sources != nil {
writeJSON(rw, map[string]interface{}{"status": "multiple", "choice": sources}) writeJSON(rw, map[string]interface{}{"status": "multiple", "choice": sources})
@@ -348,7 +393,7 @@ func ItemListHandler(rw http.ResponseWriter, req *http.Request) {
}) })
} else if req.Method == "PUT" { } else if req.Method == "PUT" {
query := req.URL.Query() query := req.URL.Query()
filter := storage.ItemFilter{} filter := storage.MarkFilter{}
if folderID, err := strconv.ParseInt(query.Get("folder_id"), 10, 64); err == nil { if folderID, err := strconv.ParseInt(query.Get("folder_id"), 10, 64); err == nil {
filter.FolderID = &folderID filter.FolderID = &folderID
} }
@@ -372,6 +417,9 @@ func SettingsHandler(rw http.ResponseWriter, req *http.Request) {
return return
} }
if db(req).UpdateSettings(settings) { if db(req).UpdateSettings(settings) {
if _, ok := settings["refresh_rate"]; ok {
handler(req).refreshRate <- db(req).GetSettingsValueInt64("refresh_rate")
}
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} else { } else {
rw.WriteHeader(http.StatusBadRequest) rw.WriteHeader(http.StatusBadRequest)

View File

@@ -2,8 +2,8 @@ package server
import ( import (
"encoding/json" "encoding/json"
"net/http"
"log" "log"
"net/http"
) )
func writeJSON(rw http.ResponseWriter, data interface{}) { func writeJSON(rw http.ResponseWriter, data interface{}) {

View File

@@ -5,10 +5,18 @@ import (
"regexp" "regexp"
) )
var BasePath string = ""
type Route struct { type Route struct {
url string url string
urlRegex *regexp.Regexp urlRegex *regexp.Regexp
handler func(http.ResponseWriter, *http.Request) handler func(http.ResponseWriter, *http.Request)
manualAuth bool
}
func (r Route) ManualAuth() Route {
r.manualAuth = true
return r
} }
func p(path string, handler func(http.ResponseWriter, *http.Request)) Route { func p(path string, handler func(http.ResponseWriter, *http.Request)) Route {
@@ -27,13 +35,13 @@ func p(path string, handler func(http.ResponseWriter, *http.Request)) Route {
} }
} }
func getRoute(req *http.Request) (*Route, map[string]string) { func getRoute(reqPath string) (*Route, map[string]string) {
vars := make(map[string]string) vars := make(map[string]string)
for _, route := range routes { for _, route := range routes {
if route.urlRegex.MatchString(req.URL.Path) { if route.urlRegex.MatchString(reqPath) {
matches := route.urlRegex.FindStringSubmatchIndex(req.URL.Path) matches := route.urlRegex.FindStringSubmatchIndex(reqPath)
for i, key := range route.urlRegex.SubexpNames()[1:] { for i, key := range route.urlRegex.SubexpNames()[1:] {
vars[key] = req.URL.Path[matches[i*2+2]:matches[i*2+3]] vars[key] = reqPath[matches[i*2+2]:matches[i*2+3]]
} }
return &route, vars return &route, vars
} }

View File

@@ -2,48 +2,100 @@ package server
import ( import (
"context" "context"
"github.com/nkanaev/yarr/storage"
"log" "log"
"net/http" "net/http"
"runtime" "runtime"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/nkanaev/yarr/storage"
) )
type Handler struct { type Handler struct {
Addr string Addr string
db *storage.Storage db *storage.Storage
log *log.Logger log *log.Logger
feedQueue chan storage.Feed feedQueue chan storage.Feed
queueSize *int32 queueSize *int32
refreshRate chan int64
// auth
Username string
Password string
// https
CertFile string
KeyFile string
} }
func New(db *storage.Storage, logger *log.Logger, addr string) *Handler { func New(db *storage.Storage, logger *log.Logger, addr string) *Handler {
queueSize := int32(0) queueSize := int32(0)
return &Handler{ return &Handler{
db: db, db: db,
log: logger, log: logger,
feedQueue: make(chan storage.Feed, 3000), feedQueue: make(chan storage.Feed, 3000),
queueSize: &queueSize, queueSize: &queueSize,
Addr: addr, Addr: addr,
refreshRate: make(chan int64),
} }
} }
func (h *Handler) GetAddr() string {
proto := "http"
if h.CertFile != "" && h.KeyFile != "" {
proto = "https"
}
return proto + "://" + h.Addr + BasePath
}
func (h *Handler) Start() { func (h *Handler) Start() {
h.startJobs() h.startJobs()
s := &http.Server{Addr: h.Addr, Handler: h} s := &http.Server{Addr: h.Addr, Handler: h}
err := s.ListenAndServe()
var err error
if h.CertFile != "" && h.KeyFile != "" {
err = s.ListenAndServeTLS(h.CertFile, h.KeyFile)
} else {
err = s.ListenAndServe()
}
if err != http.ErrServerClosed { if err != http.ErrServerClosed {
h.log.Fatal(err) h.log.Fatal(err)
} }
} }
func unsafeMethod(method string) bool {
return method == "POST" || method == "PUT" || method == "DELETE"
}
func (h Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { func (h Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
route, vars := getRoute(req) reqPath := req.URL.Path
if BasePath != "" {
if !strings.HasPrefix(reqPath, BasePath) {
rw.WriteHeader(http.StatusNotFound)
return
}
reqPath = strings.TrimPrefix(req.URL.Path, BasePath)
if reqPath == "" {
http.Redirect(rw, req, BasePath+"/", http.StatusFound)
return
}
}
route, vars := getRoute(reqPath)
if route == nil { if route == nil {
rw.WriteHeader(http.StatusNotFound) rw.WriteHeader(http.StatusNotFound)
return return
} }
if h.requiresAuth() && !route.manualAuth {
if unsafeMethod(req.Method) && req.Header.Get("X-Requested-By") != "yarr" {
rw.WriteHeader(http.StatusUnauthorized)
return
}
if !userIsAuthenticated(req, h.Username, h.Password) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
}
ctx := context.WithValue(req.Context(), ctxHandler, &h) ctx := context.WithValue(req.Context(), ctxHandler, &h)
ctx = context.WithValue(ctx, ctxVars, vars) ctx = context.WithValue(ctx, ctxVars, vars)
route.handler(rw, req.WithContext(ctx)) route.handler(rw, req.WithContext(ctx))
@@ -53,11 +105,11 @@ func (h *Handler) startJobs() {
delTicker := time.NewTicker(time.Hour * 24) delTicker := time.NewTicker(time.Hour * 24)
syncSearchChannel := make(chan bool, 10) syncSearchChannel := make(chan bool, 10)
var syncSearchTimer *time.Timer // TODO: should this be atomic? var syncSearchTimer *time.Timer // TODO: should this be atomic?
syncSearch := func() { syncSearch := func() {
if syncSearchTimer == nil { if syncSearchTimer == nil {
syncSearchTimer = time.AfterFunc(time.Second * 2, func() { syncSearchTimer = time.AfterFunc(time.Second*2, func() {
syncSearchChannel <- true syncSearchChannel <- true
}) })
} else { } else {
@@ -69,10 +121,11 @@ func (h *Handler) startJobs() {
for { for {
select { select {
case feed := <-h.feedQueue: case feed := <-h.feedQueue:
items, err := listItems(feed) items, err := listItems(feed, h.db)
atomic.AddInt32(h.queueSize, -1) atomic.AddInt32(h.queueSize, -1)
if err != nil { if err != nil {
h.log.Printf("Failed to fetch %s (%d): %s", feed.FeedLink, feed.Id, err) h.log.Printf("Failed to fetch %s (%d): %s", feed.FeedLink, feed.Id, err)
h.db.SetFeedError(feed.Id, err)
continue continue
} }
h.db.CreateItems(items) h.db.CreateItems(items)
@@ -86,9 +139,9 @@ func (h *Handler) startJobs() {
h.log.Printf("Failed to search favicon for %s (%s): %s", feed.Link, feed.FeedLink, err) h.log.Printf("Failed to search favicon for %s (%s): %s", feed.Link, feed.FeedLink, err)
} }
} }
case <- delTicker.C: case <-delTicker.C:
h.db.DeleteOldItems() h.db.DeleteOldItems()
case <- syncSearchChannel: case <-syncSearchChannel:
h.db.SyncSearch() h.db.SyncSearch()
} }
} }
@@ -103,10 +156,42 @@ func (h *Handler) startJobs() {
} }
go h.db.DeleteOldItems() go h.db.DeleteOldItems()
go h.db.SyncSearch() go h.db.SyncSearch()
//h.fetchAllFeeds()
go func() {
var refreshTicker *time.Ticker
refreshTick := make(<-chan time.Time)
for {
select {
case <-refreshTick:
h.fetchAllFeeds()
case val := <-h.refreshRate:
if refreshTicker != nil {
refreshTicker.Stop()
if val == 0 {
refreshTick = make(<-chan time.Time)
}
}
if val > 0 {
refreshTicker = time.NewTicker(time.Duration(val) * time.Minute)
refreshTick = refreshTicker.C
}
}
}
}()
refreshRate := h.db.GetSettingsValueInt64("refresh_rate")
h.refreshRate <- refreshRate
if refreshRate > 0 {
h.fetchAllFeeds()
}
}
func (h Handler) requiresAuth() bool {
return h.Username != "" && h.Password != ""
} }
func (h *Handler) fetchAllFeeds() { func (h *Handler) fetchAllFeeds() {
h.log.Print("Refreshing all feeds")
h.db.ResetFeedErrors()
for _, feed := range h.db.ListFeeds() { for _, feed := range h.db.ListFeeds() {
h.fetchFeed(feed) h.fetchFeed(feed)
} }

View File

@@ -132,3 +132,41 @@ func (s *Storage) GetFeed(id int64) *Feed {
} }
return nil return nil
} }
func (s *Storage) ResetFeedErrors() {
if _, err := s.db.Exec(`delete from feed_errors`); err != nil {
s.log.Print(err)
}
}
func (s *Storage) SetFeedError(feedID int64, lastError error) {
_, err := s.db.Exec(`
insert into feed_errors (feed_id, error)
values (?, ?)
on conflict (feed_id) do update set error = excluded.error`,
feedID, lastError.Error(),
)
if err != nil {
s.log.Print(err)
}
}
func (s *Storage) GetFeedErrors() map[int64]string {
errors := make(map[int64]string)
rows, err := s.db.Query(`select feed_id, error from feed_errors`)
if err != nil {
s.log.Print(err)
return errors
}
for rows.Next() {
var id int64
var error string
if err = rows.Scan(&id, &error); err != nil {
s.log.Print(err)
}
errors[id] = error
}
return errors
}

72
storage/http.go Normal file
View File

@@ -0,0 +1,72 @@
package storage
import (
"time"
)
type HTTPState struct {
FeedID int64
LastRefreshed time.Time
LastModified string
Etag string
}
func (s *Storage) ListHTTPStates() map[int64]HTTPState {
result := make(map[int64]HTTPState)
rows, err := s.db.Query(`select feed_id, last_refreshed, last_modified, etag from http_states`)
if err != nil {
s.log.Print(err)
return result
}
for rows.Next() {
var state HTTPState
err = rows.Scan(
&state.FeedID,
&state.LastRefreshed,
&state.LastModified,
&state.Etag,
)
if err != nil {
s.log.Print(err)
return result
}
result[state.FeedID] = state
}
return result
}
func (s *Storage) GetHTTPState(feedID int64) *HTTPState {
row := s.db.QueryRow(`
select feed_id, last_refreshed, last_modified, etag
from http_states where feed_id = ?
`, feedID)
if row == nil {
return nil
}
var state HTTPState
row.Scan(
&state.FeedID,
&state.LastRefreshed,
&state.LastModified,
&state.Etag,
)
return &state
}
func (s *Storage) SetHTTPState(feedID int64, lastModified, etag string) {
_, err := s.db.Exec(`
insert into http_states (feed_id, last_modified, etag, last_refreshed)
values (?, ?, ?, datetime())
on conflict (feed_id) do update set last_modified = ?, etag = ?, last_refreshed = datetime()`,
// insert
feedID, lastModified, etag,
// upsert
lastModified, etag,
)
if err != nil {
s.log.Print(err)
}
}

View File

@@ -3,10 +3,10 @@ package storage
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
xhtml "golang.org/x/net/html"
"html" "html"
"strings" "strings"
"time" "time"
xhtml "golang.org/x/net/html"
) )
type ItemStatus int type ItemStatus int
@@ -64,6 +64,11 @@ type ItemFilter struct {
Search *string Search *string
} }
type MarkFilter struct {
FolderID *int64
FeedID *int64
}
func (s *Storage) CreateItems(items []Item) bool { func (s *Storage) CreateItems(items []Item) bool {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
@@ -144,16 +149,19 @@ func listQueryPredicate(filter ItemFilter) (string, []interface{}) {
if len(cond) > 0 { if len(cond) > 0 {
predicate = strings.Join(cond, " and ") predicate = strings.Join(cond, " and ")
} }
return predicate, args return predicate, args
} }
func (s *Storage) ListItems(filter ItemFilter, offset, limit int, newestFirst bool) []Item { func (s *Storage) ListItems(filter ItemFilter, offset, limit int, newestFirst bool) []Item {
predicate, args := listQueryPredicate(filter) predicate, args := listQueryPredicate(filter)
result := make([]Item, 0, 0) result := make([]Item, 0, 0)
order := "desc"
order := "date desc"
if !newestFirst { if !newestFirst {
order = "asc" order = "date asc"
} }
query := fmt.Sprintf(` query := fmt.Sprintf(`
select select
i.id, i.guid, i.feed_id, i.title, i.link, i.description, i.id, i.guid, i.feed_id, i.title, i.link, i.description,
@@ -161,7 +169,7 @@ func (s *Storage) ListItems(filter ItemFilter, offset, limit int, newestFirst bo
from items i from items i
join feeds f on f.id = i.feed_id join feeds f on f.id = i.feed_id
where %s where %s
order by i.date %s order by %s
limit %d offset %d limit %d offset %d
`, predicate, order, limit, offset) `, predicate, order, limit, offset)
rows, err := s.db.Query(query, args...) rows, err := s.db.Query(query, args...)
@@ -215,7 +223,7 @@ func (s *Storage) UpdateItemStatus(item_id int64, status ItemStatus) bool {
return err == nil return err == nil
} }
func (s *Storage) MarkItemsRead(filter ItemFilter) bool { func (s *Storage) MarkItemsRead(filter MarkFilter) bool {
cond := make([]string, 0) cond := make([]string, 0)
args := make([]interface{}, 0) args := make([]interface{}, 0)
@@ -356,7 +364,7 @@ func (s *Storage) DeleteOldItems() {
delete from items where feed_id = ? and status != ? and date_arrived < ?`, delete from items where feed_id = ? and status != ? and date_arrived < ?`,
feedId, feedId,
STARRED, STARRED,
time.Now().Add(-time.Hour*24*90), // 90 days time.Now().Add(-time.Hour*24*90), // 90 days
) )
if err != nil { if err != nil {
s.log.Print(err) s.log.Print(err)

View File

@@ -4,14 +4,15 @@ import "encoding/json"
func settingsDefaults() map[string]interface{} { func settingsDefaults() map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"filter": "", "filter": "",
"feed": "", "feed": "",
"feed_list_width": 300, "feed_list_width": 300,
"item_list_width": 300, "item_list_width": 300,
"sort_newest_first": true, "sort_newest_first": true,
"theme_name": "light", "theme_name": "light",
"theme_font": "", "theme_font": "",
"theme_size": 1, "theme_size": 1,
"refresh_rate": 0,
} }
} }
@@ -22,6 +23,9 @@ func (s *Storage) GetSettingsValue(key string) interface{} {
} }
var val []byte var val []byte
row.Scan(&val) row.Scan(&val)
if len(val) == 0 {
return nil
}
var valDecoded interface{} var valDecoded interface{}
if err := json.Unmarshal([]byte(val), &valDecoded); err != nil { if err := json.Unmarshal([]byte(val), &valDecoded); err != nil {
s.log.Print(err) s.log.Print(err)
@@ -30,6 +34,16 @@ func (s *Storage) GetSettingsValue(key string) interface{} {
return valDecoded return valDecoded
} }
func (s *Storage) GetSettingsValueInt64(key string) int64 {
val := s.GetSettingsValue(key)
if val != nil {
if fval, ok := val.(float64); ok {
return int64(fval)
}
}
return 0
}
func (s *Storage) GetSettings() map[string]interface{} { func (s *Storage) GetSettings() map[string]interface{} {
result := settingsDefaults() result := settingsDefaults()
rows, err := s.db.Query(`select key, val from settings;`) rows, err := s.db.Query(`select key, val from settings;`)

View File

@@ -61,6 +61,20 @@ create virtual table if not exists search using fts4(title, description, content
create trigger if not exists del_item_search after delete on items begin create trigger if not exists del_item_search after delete on items begin
delete from search where rowid = old.search_rowid; delete from search where rowid = old.search_rowid;
end; end;
create table if not exists http_states (
feed_id references feeds(id) unique,
last_refreshed datetime not null,
-- http header fields --
last_modified string not null,
etag string not null
);
create table if not exists feed_errors (
feed_id references feeds(id) unique,
error string
);
` `
type Storage struct { type Storage struct {
@@ -69,11 +83,8 @@ type Storage struct {
} }
func New(path string, logger *log.Logger) (*Storage, error) { func New(path string, logger *log.Logger) (*Storage, error) {
initialize := false
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) { if !os.IsNotExist(err) {
initialize = true
} else {
return nil, err return nil, err
} }
} }
@@ -85,10 +96,8 @@ func New(path string, logger *log.Logger) (*Storage, error) {
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
if initialize { if _, err := db.Exec(initQuery); err != nil {
if _, err := db.Exec(initQuery); err != nil { return nil, err
return nil, err
}
} }
return &Storage{db: db, log: logger}, nil return &Storage{db: db, log: logger}, nil
} }