mirror of
https://github.com/nkanaev/yarr.git
synced 2025-11-07 09:59:38 +00:00
Compare commits
11 Commits
4b3a278679
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16a7f3409c | ||
|
|
0e11cec99a | ||
|
|
c158912da4 | ||
|
|
08ad04401d | ||
|
|
a851d8ac9d | ||
|
|
5a3547e32e | ||
|
|
b24152c19a | ||
|
|
9f93298cf9 | ||
|
|
ac9b635ed8 | ||
|
|
72a1930b9e | ||
|
|
e339354cc9 |
@@ -3,7 +3,10 @@
|
|||||||
- (new) serve on unix socket (thanks to @rvighne)
|
- (new) serve on unix socket (thanks to @rvighne)
|
||||||
- (new) more auto-refresh options: 12h & 24h (thanks to @aswerkljh for suggestion)
|
- (new) more auto-refresh options: 12h & 24h (thanks to @aswerkljh for suggestion)
|
||||||
- (fix) smooth scrolling on iOS (thanks to gatheraled)
|
- (fix) smooth scrolling on iOS (thanks to gatheraled)
|
||||||
|
- (fix) displaying youtube shorts in "Read Here" (thanks to @Dean-Corso for the report)
|
||||||
|
- (etc) theme-color support (thanks to @asimpson)
|
||||||
- (etc) cookie security measures (thanks to Tom Fitzhenry)
|
- (etc) cookie security measures (thanks to Tom Fitzhenry)
|
||||||
|
- (etc) restrict access to internal IPs for page crawler (thanks to Omar Kurt)
|
||||||
|
|
||||||
# v2.5 (2025-03-26)
|
# v2.5 (2025-03-26)
|
||||||
|
|
||||||
|
|||||||
3
makefile
3
makefile
@@ -5,6 +5,7 @@ GO_TAGS = sqlite_foreign_keys sqlite_json
|
|||||||
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||||
|
|
||||||
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
||||||
|
GO_FLAGS_DEBUG = -tags "$(GO_TAGS) debug"
|
||||||
GO_FLAGS_GUI = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS)"
|
GO_FLAGS_GUI = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS)"
|
||||||
GO_FLAGS_GUI_WIN = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS) -H windowsgui"
|
GO_FLAGS_GUI_WIN = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS) -H windowsgui"
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ windows_arm64_gui: src/platform/versioninfo.rc
|
|||||||
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
||||||
|
|
||||||
serve:
|
serve:
|
||||||
go run $(GO_FLAGS) ./cmd/yarr -db local.db
|
go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db local.db
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test $(GO_FLAGS) ./...
|
go test $(GO_FLAGS) ./...
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !debug
|
||||||
|
|
||||||
package assets
|
package assets
|
||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
||||||
<link rel="manifest" href="./manifest.json" />
|
<link rel="manifest" href="./manifest.json" />
|
||||||
|
<meta name="theme-color" content="" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<script>
|
<script>
|
||||||
window.app = window.app || {}
|
window.app = window.app || {}
|
||||||
@@ -23,21 +24,21 @@
|
|||||||
<div class="p-2 toolbar d-flex align-items-center">
|
<div class="p-2 toolbar d-flex align-items-center">
|
||||||
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
|
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
|
||||||
<div class="flex-grow-1"></div>
|
<div class="flex-grow-1"></div>
|
||||||
<button class="toolbar-item"
|
<button class="toolbar-item ml-1"
|
||||||
:class="{active: filterSelected == 'unread'}"
|
:class="{active: filterSelected == 'unread'}"
|
||||||
:aria-pressed="filterSelected == 'unread'"
|
:aria-pressed="filterSelected == 'unread'"
|
||||||
title="Unread"
|
title="Unread"
|
||||||
@click="filterSelected = 'unread'">
|
@click="filterSelected = 'unread'">
|
||||||
<span class="icon">{% inline "circle-full.svg" %}</span>
|
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item"
|
<button class="toolbar-item mx-1"
|
||||||
:class="{active: filterSelected == 'starred'}"
|
:class="{active: filterSelected == 'starred'}"
|
||||||
:aria-pressed="filterSelected == 'starred'"
|
:aria-pressed="filterSelected == 'starred'"
|
||||||
title="Starred"
|
title="Starred"
|
||||||
@click="filterSelected = 'starred'">
|
@click="filterSelected = 'starred'">
|
||||||
<span class="icon">{% inline "star-full.svg" %}</span>
|
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item"
|
<button class="toolbar-item mr-1"
|
||||||
:class="{active: filterSelected == ''}"
|
:class="{active: filterSelected == ''}"
|
||||||
:aria-pressed="filterSelected == ''"
|
:aria-pressed="filterSelected == ''"
|
||||||
title="All"
|
title="All"
|
||||||
@@ -143,10 +144,8 @@
|
|||||||
</label>
|
</label>
|
||||||
<div v-for="folder in foldersWithFeeds">
|
<div v-for="folder in foldersWithFeeds">
|
||||||
<label class="selectgroup mt-1"
|
<label class="selectgroup mt-1"
|
||||||
:class="{'d-none': filterSelected
|
:class="{'d-none': mustHideFolder(folder)}"
|
||||||
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id)
|
v-if="folder.id">
|
||||||
&& !filteredFolderStats[folder.id]
|
|
||||||
&& (!itemSelectedDetails || (feedsById[itemSelectedDetails.feed_id] || {}).folder_id != folder.id)}">
|
|
||||||
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
|
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
|
||||||
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
|
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
|
||||||
<span class="icon mr-2"
|
<span class="icon mr-2"
|
||||||
@@ -160,10 +159,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
||||||
<label class="selectgroup"
|
<label class="selectgroup"
|
||||||
:class="{'d-none': filterSelected
|
:class="{'d-none': mustHideFeed(feed)}"
|
||||||
&& !(current.feed.id == feed.id)
|
|
||||||
&& !filteredFeedStats[feed.id]
|
|
||||||
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
|
|
||||||
v-for="feed in folder.feeds">
|
v-for="feed in folder.feeds">
|
||||||
<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">
|
||||||
@@ -427,6 +423,7 @@
|
|||||||
<tr><td colspan=2> </td></tr>
|
<tr><td colspan=2> </td></tr>
|
||||||
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
|
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
|
||||||
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
|
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
|
||||||
|
<tr><td><kbd>q</kbd></td> <td>close article</td></tr>
|
||||||
|
|
||||||
<tr><td colspan=2> </td></tr>
|
<tr><td colspan=2> </td></tr>
|
||||||
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
|
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ var vm = new Vue({
|
|||||||
api.feeds.list_errors().then(function(errors) {
|
api.feeds.list_errors().then(function(errors) {
|
||||||
vm.feed_errors = errors
|
vm.feed_errors = errors
|
||||||
})
|
})
|
||||||
|
this.updateMetaTheme(app.settings.theme_name)
|
||||||
},
|
},
|
||||||
data: function() {
|
data: function() {
|
||||||
var s = app.settings
|
var s = app.settings
|
||||||
@@ -249,6 +250,11 @@ var vm = new Vue({
|
|||||||
'font': s.theme_font,
|
'font': s.theme_font,
|
||||||
'size': s.theme_size,
|
'size': s.theme_size,
|
||||||
},
|
},
|
||||||
|
'themeColors': {
|
||||||
|
'night': '#0e0e0e',
|
||||||
|
'sepia': '#f4f0e5',
|
||||||
|
'light': '#fff',
|
||||||
|
},
|
||||||
'refreshRate': s.refresh_rate,
|
'refreshRate': s.refresh_rate,
|
||||||
'authenticated': app.authenticated,
|
'authenticated': app.authenticated,
|
||||||
'feed_errors': {},
|
'feed_errors': {},
|
||||||
@@ -330,6 +336,7 @@ var vm = new Vue({
|
|||||||
'theme': {
|
'theme': {
|
||||||
deep: true,
|
deep: true,
|
||||||
handler: function(theme) {
|
handler: function(theme) {
|
||||||
|
this.updateMetaTheme(theme.name)
|
||||||
document.body.classList.value = 'theme-' + theme.name
|
document.body.classList.value = 'theme-' + theme.name
|
||||||
api.settings.update({
|
api.settings.update({
|
||||||
theme_name: theme.name,
|
theme_name: theme.name,
|
||||||
@@ -405,6 +412,9 @@ var vm = new Vue({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updateMetaTheme: function(theme) {
|
||||||
|
document.querySelector("meta[name='theme-color']").content = this.themeColors[theme]
|
||||||
|
},
|
||||||
refreshStats: function(loopMode) {
|
refreshStats: function(loopMode) {
|
||||||
return api.status().then(function(data) {
|
return api.status().then(function(data) {
|
||||||
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
||||||
@@ -768,9 +778,16 @@ var vm = new Vue({
|
|||||||
// navigation helper, navigate relative to selected feed
|
// navigation helper, navigate relative to selected feed
|
||||||
navigateToFeed: function(relativePosition) {
|
navigateToFeed: function(relativePosition) {
|
||||||
let vm = this
|
let vm = this
|
||||||
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]'))
|
const navigationList = this.foldersWithFeeds
|
||||||
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' })
|
.filter(folder => !folder.id || !vm.mustHideFolder(folder))
|
||||||
.map(function(r) { return r.value })
|
.map((folder) => {
|
||||||
|
if (this.mustHideFolder(folder)) return []
|
||||||
|
const folds = folder.id ? [`folder:${folder.id}`] : []
|
||||||
|
const feeds = (folder.is_expanded || !folder.id) ? folder.feeds.filter(f => !vm.mustHideFeed(f)).map(f => `feed:${f.id}`) : []
|
||||||
|
return folds.concat(feeds)
|
||||||
|
})
|
||||||
|
.flat()
|
||||||
|
navigationList.unshift('')
|
||||||
|
|
||||||
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
|
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
|
||||||
|
|
||||||
@@ -799,6 +816,18 @@ var vm = new Vue({
|
|||||||
if (curIdx >= (this.refreshRateOptions.length - 1) && offset > 0) return
|
if (curIdx >= (this.refreshRateOptions.length - 1) && offset > 0) return
|
||||||
this.refreshRate = this.refreshRateOptions[curIdx + offset].value
|
this.refreshRate = this.refreshRateOptions[curIdx + offset].value
|
||||||
},
|
},
|
||||||
|
mustHideFolder: function (folder) {
|
||||||
|
return this.filterSelected
|
||||||
|
&& !(this.current.folder.id == folder.id || this.current.feed.folder_id == folder.id)
|
||||||
|
&& !this.filteredFolderStats[folder.id]
|
||||||
|
&& (!this.itemSelectedDetails || (this.feedsById[this.itemSelectedDetails.feed_id] || {}).folder_id != folder.id)
|
||||||
|
},
|
||||||
|
mustHideFeed: function (feed) {
|
||||||
|
return this.filterSelected
|
||||||
|
&& !(this.current.feed.id == feed.id)
|
||||||
|
&& !this.filteredFeedStats[feed.id]
|
||||||
|
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ var shortcutFunctions = {
|
|||||||
scrollBackward: function() {
|
scrollBackward: function() {
|
||||||
helperFunctions.scrollContent(-1)
|
helperFunctions.scrollContent(-1)
|
||||||
},
|
},
|
||||||
|
closeItem: function () {
|
||||||
|
vm.itemSelected = null
|
||||||
|
},
|
||||||
showAll() {
|
showAll() {
|
||||||
vm.filterSelected = ''
|
vm.filterSelected = ''
|
||||||
},
|
},
|
||||||
@@ -85,6 +88,7 @@ var keybindings = {
|
|||||||
"h": shortcutFunctions.previousFeed,
|
"h": shortcutFunctions.previousFeed,
|
||||||
"f": shortcutFunctions.scrollForward,
|
"f": shortcutFunctions.scrollForward,
|
||||||
"b": shortcutFunctions.scrollBackward,
|
"b": shortcutFunctions.scrollBackward,
|
||||||
|
"q": shortcutFunctions.closeItem,
|
||||||
"1": shortcutFunctions.showUnread,
|
"1": shortcutFunctions.showUnread,
|
||||||
"2": shortcutFunctions.showStarred,
|
"2": shortcutFunctions.showStarred,
|
||||||
"3": shortcutFunctions.showAll,
|
"3": shortcutFunctions.showAll,
|
||||||
@@ -103,6 +107,7 @@ var codebindings = {
|
|||||||
"KeyH": shortcutFunctions.previousFeed,
|
"KeyH": shortcutFunctions.previousFeed,
|
||||||
"KeyF": shortcutFunctions.scrollForward,
|
"KeyF": shortcutFunctions.scrollForward,
|
||||||
"KeyB": shortcutFunctions.scrollBackward,
|
"KeyB": shortcutFunctions.scrollBackward,
|
||||||
|
"KeyQ": shortcutFunctions.closeItem,
|
||||||
"Digit1": shortcutFunctions.showUnread,
|
"Digit1": shortcutFunctions.showUnread,
|
||||||
"Digit2": shortcutFunctions.showStarred,
|
"Digit2": shortcutFunctions.showStarred,
|
||||||
"Digit3": shortcutFunctions.showAll,
|
"Digit3": shortcutFunctions.showAll,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ func VideoIFrame(link string) string {
|
|||||||
youtubeID := ""
|
youtubeID := ""
|
||||||
if l.Host == "www.youtube.com" && l.Path == "/watch" {
|
if l.Host == "www.youtube.com" && l.Path == "/watch" {
|
||||||
youtubeID = l.Query().Get("v")
|
youtubeID = l.Query().Get("v")
|
||||||
|
} else if l.Host == "www.youtube.com" && strings.HasPrefix(l.Path, "/shorts/") {
|
||||||
|
youtubeID = strings.TrimPrefix(l.Path, "/shorts/")
|
||||||
} else if l.Host == "youtu.be" {
|
} else if l.Host == "youtu.be" {
|
||||||
youtubeID = strings.TrimLeft(l.Path, "/")
|
youtubeID = strings.TrimLeft(l.Path, "/")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build linux || freebsd
|
//go:build linux || freebsd || openbsd
|
||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
|
|||||||
@@ -513,6 +513,10 @@ func (s *Server) handlePageCrawl(c *router.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isInternalFromURL(url) {
|
||||||
|
log.Printf("attempt to access internal IP %s from %s", url, c.Req.RemoteAddr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
body, err := worker.GetBody(url)
|
body, err := worker.GetBody(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
35
src/server/util.go
Normal file
35
src/server/util.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isInternalFromURL(urlStr string) bool {
|
||||||
|
parsedURL, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
host := parsedURL.Host
|
||||||
|
|
||||||
|
// Handle "host:port" format
|
||||||
|
if strings.Contains(host, ":") {
|
||||||
|
host, _, err = net.SplitHostPort(host)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "localhost" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
|
||||||
|
}
|
||||||
31
src/server/util_test.go
Normal file
31
src/server/util_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsInternalFromURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
url string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"http://192.168.1.1:8080", true},
|
||||||
|
{"http://10.0.0.5", true},
|
||||||
|
{"http://172.16.0.1", true},
|
||||||
|
{"http://172.31.255.255", true},
|
||||||
|
{"http://172.32.0.1", false}, // outside private range
|
||||||
|
{"http://127.0.0.1", true},
|
||||||
|
{"http://127.0.0.1:7000", true},
|
||||||
|
{"http://127.0.0.1:7000/secret", true},
|
||||||
|
{"http://169.254.0.5", true},
|
||||||
|
{"http://localhost", true}, // resolves to 127.0.0.1
|
||||||
|
{"http://8.8.8.8", false},
|
||||||
|
{"http://google.com", false}, // resolves to public IPs
|
||||||
|
{"invalid-url", false}, // invalid format
|
||||||
|
{"", false}, // empty string
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
result := isInternalFromURL(test.url)
|
||||||
|
if result != test.expected {
|
||||||
|
t.Errorf("isInternalFromURL(%q) = %v; want %v", test.url, result, test.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,10 +54,14 @@ type MediaLink struct {
|
|||||||
type MediaLinks []MediaLink
|
type MediaLinks []MediaLink
|
||||||
|
|
||||||
func (m *MediaLinks) Scan(src any) error {
|
func (m *MediaLinks) Scan(src any) error {
|
||||||
if data, ok := src.([]byte); ok {
|
switch data := src.(type) {
|
||||||
|
case []byte:
|
||||||
return json.Unmarshal(data, m)
|
return json.Unmarshal(data, m)
|
||||||
|
case string:
|
||||||
|
return json.Unmarshal([]byte(data), m)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m MediaLinks) Value() (driver.Value, error) {
|
func (m MediaLinks) Value() (driver.Value, error) {
|
||||||
@@ -419,7 +423,6 @@ func (s *Storage) DeleteOldItems() {
|
|||||||
where status != ?
|
where status != ?
|
||||||
group by i.feed_id
|
group by i.feed_id
|
||||||
`, itemsKeepSize, STARRED)
|
`, itemsKeepSize, STARRED)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user