mirror of
https://github.com/nkanaev/yarr.git
synced 2025-09-13 09:55:36 +00:00
Compare commits
70 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
4f1e029d0b | ||
|
9b93a959e5 | ||
|
68d269658f | ||
|
875b87b0d6 | ||
|
3e79cd7944 | ||
|
0ea8972ede | ||
|
a7113addd0 | ||
|
84df847898 | ||
|
333d9373dd | ||
|
eb90c5b9aa | ||
|
3371e1afff | ||
|
8aafb1b729 | ||
|
1a0db29aa6 | ||
|
52073e7e81 | ||
|
4f79c919f0 | ||
|
63b265fa04 | ||
|
6b01d9d7b9 | ||
|
20a0a6724a | ||
|
e79cb9e6e0 | ||
|
d7ddcc04b5 | ||
|
99684a4b2f | ||
|
6a6153ca48 | ||
|
23a4ff3af6 | ||
|
d6c2ba5812 | ||
|
fa0237b546 | ||
|
9fcaad6b2f | ||
|
edc7d56219 | ||
|
d0a2b80ecc | ||
|
e2d80af81d | ||
|
eccd383c1c | ||
|
db7a178a8d | ||
|
62e0caa950 | ||
|
46d8c98aff | ||
|
05634ebdb7 | ||
|
0e2da62081 | ||
|
94d1659ad5 | ||
|
0745c92e9a | ||
|
7c06952a7d | ||
|
e2d8ca3506 | ||
|
4f20f537c0 | ||
|
a0b42b27b3 | ||
|
288fa3979a | ||
|
e4cc96ef09 | ||
|
60a947f131 | ||
|
0226c8da23 | ||
|
b0364087ad | ||
|
40a9773beb | ||
|
790a275443 | ||
|
9b9addf3e6 | ||
|
57d2437e9c | ||
|
a13aea478e | ||
|
6def522f38 | ||
|
6a63d49823 | ||
|
b766cb4ac5 | ||
|
32ab1fefa9 | ||
|
70761c47eb | ||
|
6a09d52b85 | ||
|
fcaf23d6bc | ||
|
54c2a6458d | ||
|
05032ec428 | ||
|
e24b905adc | ||
|
f27d0c4cd7 | ||
|
11a2aa2b4a | ||
|
2eee8baa26 | ||
|
0949ffc027 | ||
|
286538c5d0 | ||
|
78844def40 | ||
|
e17ce0fb31 | ||
|
55a1c297be | ||
|
9bb7ae7902 |
1
assets/graphicarts/alert-circle.svg
Normal file
1
assets/graphicarts/alert-circle.svg
Normal 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 |
1
assets/graphicarts/log-out.svg
Normal file
1
assets/graphicarts/log-out.svg
Normal 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 |
@@ -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>
|
||||||
|
@@ -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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -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
38
assets/login.html
Normal 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>
|
@@ -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
21
doc/changelog.txt
Normal 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
|
@@ -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
15
doc/install.md
Normal 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
70
doc/platform.md
Normal 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
11
doc/todo.txt
Normal 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/
|
13
dockerfile
13
dockerfile
@@ -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
79
main.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
2
makefile
2
makefile
@@ -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
|
||||||
|
@@ -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()
|
||||||
}
|
}
|
||||||
|
@@ -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
51
server/auth.go
Normal 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)
|
||||||
|
}
|
@@ -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{
|
||||||
|
@@ -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)
|
||||||
|
@@ -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{}) {
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
|
125
server/server.go
125
server/server.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
@@ -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
72
storage/http.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@@ -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)
|
||||||
|
@@ -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;`)
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user