Compare commits

...

270 Commits

Author SHA1 Message Date
Frédéric Guillot 97d1c6f02b Update ChangeLog for v2.0.18 2019-09-25 20:23:57 -07:00
Frédéric Guillot e0b600b651 Add CSS tweak and fix typo in variable name 2019-09-25 19:55:55 -07:00
Frédéric Guillot 3379429329 Log image proxy URL in debug mode 2019-09-22 11:38:45 -07:00
Frédéric Guillot bdca3372f9 Add few CSS teaks 2019-09-22 10:50:10 -07:00
Frédéric Guillot ca9cc692e4 Add Docker image variant for arm32v7 2019-09-22 10:49:42 -07:00
Frédéric Guillot afe1faf214 Add theme variants
- Use CSS variables instead of inherence
- Rename default theme to "Light - Serif"
- Rename Black theme to "Dark - Serif"
- Rename "Sans-Serif" theme to "Light - Sans Serif"
- Add "System" theme that use system preferences: Dark or Light
- Add Serif and Sans-Serif variants for each color theme
2019-09-21 20:04:42 -07:00
Frédéric Guillot d610d091fe Avoid constraint error when having duplicate entries
During feed creation, duplicated entries will generate an SQL contraint error.

This change ignore the duplicated entry to avoid showing an error.
2019-09-18 22:53:47 -07:00
Frédéric Guillot 36d7732234 Disable strict XML parsing
This change should improve parsing of broken XML feeds.

See https://golang.org/pkg/encoding/xml/#Decoder
2019-09-18 22:45:56 -07:00
Frédéric Guillot ca48f7612a Ignore invalid content type 2019-09-18 22:32:29 -07:00
Frédéric Guillot 7d50cc40de Update man page 2019-09-15 12:01:28 -07:00
Frédéric Guillot 934385ff55 Replace Travis by GitHub Actions 2019-09-15 11:48:15 -07:00
Ty Cobb fb9a1a6129 Rename cleanup config variables
The config parser logs a warning when the user uses a deprecated variable. It also ignores the value from a deprecated variable if it has already been set using the corresponding non-deprecated variable (and logs another warning).

- CLEANUP_FREQUENCY_HOURS instead of CLEANUP_FREQUENCY
- CLEANUP_ARCHIVE_READ_DAYS instead of ARCHIVE_READ_DAYS
2019-09-15 11:47:39 -07:00
Ty Cobb 3a60abbac0 Make configurable the number of days to remove old sessions 2019-09-11 20:10:33 -07:00
Frédéric Guillot 8d8f78241d Add native lazy loading for images and iframes
This feature is available only in Chrome >= 76 for now.

See https://web.dev/native-lazy-loading
2019-09-10 21:22:19 -07:00
Peter De Wachter 937492f6f5 Do not buffer responses in the image proxy
The image proxy buffered the whole image before sending it to the
browser. If the image is large and/or hosted on a slow server, this
caused a long delay before the user's browser could display anything.
2019-09-10 20:43:44 -07:00
Frédéric Guillot b94160df72 Update dependencies 2019-09-05 21:53:12 -07:00
Frédéric Guillot 456ebaf423 Add Go 1.13 to test matrix 2019-09-05 21:28:10 -07:00
Frédéric Guillot 2d27f53941 Replace link border by outline to avoid slight content shift 2019-09-05 21:22:58 -07:00
Peter De Wachter b6f3160dbc add_mailto_subject: New rewrite function
Dinosaur Comics (qwantz.com) likes to hide jokes in mailto: links, but
miniflux's sanitizer strips those out.
2019-08-19 19:42:47 -07:00
Frédéric Guillot 77125f45cc Improve import form 2019-08-15 22:21:18 -07:00
Peter De Wachter d882bbca85 Import OPML from URL 2019-08-15 21:58:01 -07:00
Frédéric Guillot ac45307da6 Add test case for parsing HTML entities 2019-08-15 21:42:13 -07:00
Peter De Wachter ea2b6e3608 addImageTitle: Fix HTML injection
This rewrite rule would change this:

    <img title="<foo>">

to this:

    <figure><img><figcaption><foo></figcaption></figure>

The image title needs to be properly escaped.
2019-08-15 21:39:41 -07:00
Peter De Wachter 3a39d110f0 Accept HTML entities when parsing XML
Every once in a while, one of my feeds would throw an XML parse error
because it used `&nbsp;` or some other HTML entity. I feel Miniflux
should be lenient here, and Go already has a handy hook to make this
work.
2019-08-15 21:26:07 -07:00
Frédéric Guillot 6d39f2a4cb Update ChangeLog for version 2.0.17 2019-08-03 14:38:14 -07:00
Frédéric Guillot f82736641f Update Docker image to Alpine Linux 3.10.1 2019-08-03 14:35:15 -07:00
Frédéric Guillot 3d19313a7f Add option to disable feeds 2019-07-26 21:24:15 -07:00
Jebbs 17aae72583 Fix regressions introduced in previous JS refactoring 2019-07-25 19:02:39 -07:00
Frédéric Guillot a634839678 Ask for confirmation before clicking on mark all as read 2019-07-17 22:00:00 -07:00
Frédéric Guillot ac3693562b Ask for confirmation before flushing history 2019-07-17 21:40:03 -07:00
Frédéric Guillot 99149d9f2a Refactor confirmation handler 2019-07-17 21:15:30 -07:00
Frédéric Guillot 2b6e17c1ef Refactoring of Javascript code 2019-07-17 20:34:11 -07:00
Matías Lang 34421dcd49 Require confirmation for marking pages as read 2019-07-17 17:44:32 -07:00
Frode Aannevik 48abf57218 Add .search margin-right 2019-07-13 13:11:39 +02:00
Frédéric Guillot 867dcb0004 Fix syntax errors in man page 2019-07-10 17:27:17 -07:00
Ilya Glotov c840268678 Sort feed categories before serialization
A function is added for feeds and its categories normalization.
The test will ensure that the order is right.
2019-07-05 20:34:49 +03:00
Pierre Dubouilh f783b135c7 Pass auth header to manifest request 2019-07-04 23:13:25 -07:00
Frédéric Guillot a04fe1aa7e Update Docker image to Alpine Linux 3.10.0 2019-06-20 21:00:59 -07:00
Frédéric Guillot d01a2dd651 Update ChangeLog 2019-06-08 21:13:44 -07:00
Frédéric Guillot ec8fa56c95 Update vendor folder 2019-06-08 18:00:10 -07:00
Frédéric Guillot 91508c50b5 Add option to toggle date/time in log messages 2019-06-08 17:48:45 -07:00
Frédéric Guillot f7b7b63e3f Add optional config file parser in addition to environment variables 2019-06-02 18:28:29 -07:00
Frédéric Guillot bb720c87c1 Make HTTP Client timeout and max body size configurable 2019-06-02 07:29:56 -07:00
Frédéric Guillot 228862fefa Refactor config package
- Parse configuration only once during startup time
- Store configuration values in a global variable
2019-06-02 06:30:08 -07:00
Ben Cohen 04d85b3c63 Flip behavior of j and k keyboard shortcuts 2019-05-26 20:02:52 +02:00
Aaron Bieber 5f487e8c79 Bump pq to v1.1.1 to bring in SCRAM-SHA-256 authentication. 2019-05-16 22:08:37 -07:00
Frédéric Guillot 0dff432337 Remove debug timer from most storage functions 2019-04-28 20:21:31 -07:00
Frédéric Guillot 4295a86e55 Add option to enable/disable keyboard shortcuts 2019-04-28 18:35:58 -07:00
Frédéric Guillot 3c8cc0b2b6 Update links to the documentation 2019-04-22 20:38:07 -07:00
Frédéric Guillot 34df45da2f Add missing translation 2019-04-05 19:31:23 -07:00
Diego Agulló a6068aae1d Improve page reload when showing unread/all entries
- Show only unread entries = refresh current page
- Show all entries = go to next page
2019-04-04 18:59:04 -07:00
Frédéric Guillot 8ee1a09dc7 Always display feed entries even when there is a feed error 2019-04-02 21:33:11 -07:00
Frédéric Guillot f593dc8463 Use loading label instead of saving when submitting login form 2019-03-26 20:52:31 -07:00
Frédéric Guillot 129f1bf3da Add support for OPML v1 import 2019-03-26 20:09:31 -07:00
Jeremy Apthorp 304b43cb30 Add 'allow-popups' to iframe sandbox permissions 2019-03-26 18:26:56 -07:00
Frédéric Guillot 3a483be258 Update ChangeLog 2019-03-16 18:14:00 -07:00
Frédéric Guillot 220d9e3d56 Move Dockerfile to main repo 2019-03-14 20:54:05 -07:00
3ddysan fc473f1d11 Add double tap detection for next/previous page navigation 2019-03-11 20:23:19 -07:00
Jacob Wahlgren 22b68eefd8 Allow users to disable auto-remove
Adds ARCHIVE_READ_DAYS="-1"
2019-03-10 10:51:21 -07:00
Frédéric Guillot 6764a420b0 Make parser compatible with Go 1.12
See changes in strings.Map(): https://golang.org/doc/go1.12#strings
2019-02-28 21:23:33 -08:00
Frédéric Guillot 45df254fe7 Add Golang 1.12 to CI 2019-02-28 20:48:33 -08:00
Frédéric Guillot f3fc8b7072 Use feed ID instead of user ID to check entry URLs presence 2019-02-28 20:43:33 -08:00
Frédéric Guillot 1634e267b8 Fix typo in stylesheet 2019-02-19 17:57:40 -08:00
Jebbs 267b706544 Sort search results by relevance 2019-02-11 22:20:07 -08:00
Frédéric Guillot ed6ae7e0d2 Use preferably the published date for Atom feeds
YouTube feeds use the published date for the original creation date.
2019-01-29 20:01:36 -08:00
e.g. pavelka c71681b197 Add Spanish translation 2019-01-23 19:45:47 -08:00
Frédéric Guillot 04b6eb509c Rename session cookies 2019-01-21 20:26:46 -08:00
Frédéric Guillot 7897d8a8ad Handle the case when application session is expired and not user session 2019-01-21 20:21:05 -08:00
Frédéric Guillot 6378ad2734 Update ChangeLog 2019-01-13 20:35:43 -08:00
Peter De Wachter 6f5ef10553 Only attempt to change password if the confirmation field is filled in
Firefox autocompletes the password field (but not the password
confirmation field) for me. This makes it annoying to use the settings
page, because miniflux thinks I'm trying to change my password and
complains that the fields don't match.
2019-01-07 18:25:59 -08:00
Frédéric Guillot 28ba09e952 Remove URL from client user agent 2019-01-07 18:08:42 -08:00
Peter De Wachter a86a7dce57 Use the appropriate message if there are no unread entries 2019-01-07 17:56:39 -08:00
Peter De Wachter 27e79a4693 Make the feed list order case-insensitive 2019-01-07 17:55:57 -08:00
Peter De Wachter 0cdcec10ca More robust Atom text handling
Miniflux couldn't deal with XHTML Summary elements.

- Make Summary an 'atomContent' field
- Define an atomContentToString function rather than inling it three times
- Also properly escape special characters in plain text fields.
2019-01-07 17:55:02 -08:00
Peter De Wachter 15505ee4a2 Make UTF-8 the default encoding for XML feeds
Consider the feed http://planet.haskell.org/atom.xml
- This is a UTF-8 encoded XML file
- No encoding declaration in the XML header
- No Unicode byte order mark
- Served with HTTP Content-Type "text/xml" (no charset parameter)

Miniflux lets charset.NewReader handle this. The charset package
implements the HTML5 character encoding algorithm, which, in this
situation, defaults to windows-1252 encoding if there are no UTF-8
characters in the first 1000 bytes. So for this feed, we get the wrong
encoding.

I inserted an explicit "utf8.Valid()" check, which fixes this problem.
2019-01-02 21:05:05 -08:00
Clar Charr 31e2669c4d Add more targets to Makefile
I put a comment above them saying that they're unsupported as they won't be tested on CI, etc., but they're still nice to have.
2019-01-02 20:31:48 -08:00
Frédéric Guillot 362fc5c2ff Add -mod=vendor in Makefile 2018-12-28 17:21:57 -08:00
Frédéric Guillot 79e7d007a4 Update man page 2018-12-28 13:55:12 -08:00
Frédéric Guillot a9fad093e6 Move healthcheck endpoint from ui package to httpd service 2018-12-28 13:41:26 -08:00
Frédéric Guillot 56efd2eb3f Add workaround for non GMT dates (RFC822, RFC850, and RFC1123)
RFC822, RFC850, and RFC1123 are supposed to be always in GMT.

This is a workaround for the one defined in PST timezone.
2018-12-26 20:24:38 -08:00
Frédéric Guillot 078cd0050a Make sure that <strong> elements are bold 2018-12-19 22:30:05 -08:00
Frédéric Guillot e367f4ca21 Show scrollbars only when necessary for <pre> elements 2018-12-16 20:12:26 -08:00
Nicola Moretto 0b63e05fe2 Add Italian translation 2018-12-16 17:28:12 -08:00
Diego Agulló cf25efb4d9 Allow to switch between unread only and all entries on category/feed views 2018-12-16 16:58:48 -08:00
Frédéric Guillot 012138179c Add function storage.UpdateFeedError() 2018-12-15 13:04:38 -08:00
Tom Matthews 8b40778ee1 Add BBC News scraping rule 2018-12-13 20:25:30 -08:00
Frédéric Guillot 6ae935309a Ignore JSON feeds from EnsureUnicode() 2018-12-12 21:37:39 -08:00
Frédéric Guillot 43ccaf52af Preserve category selection when no feed is found 2018-12-12 21:31:50 -08:00
Frédéric Guillot 82e08d0f69 Update XML encoding regex to take single quotes into consideration 2018-12-12 21:13:06 -08:00
Bryan Roth 266d97ad63 Update en_US.json
Fixing translations.
2018-12-12 20:37:52 -08:00
Frederic Guillot 6869c6fe6f Send cli errors to stderr 2018-12-09 18:05:40 -08:00
Frederic Guillot 59d8e9b95e Update dependencies 2018-12-09 17:59:39 -08:00
Frederic Guillot 61bfb3cfa8 Make password prompt compatible with Windows 2018-12-09 17:44:33 -08:00
Jebbs 87648490fd Make configurable the number of days to archive read items 2018-12-05 20:35:30 -08:00
Frédéric Guillot 3e392dc3ae Change log level to debug when starting workers 2018-12-02 21:21:18 -08:00
Frédéric Guillot 35b890aadc Do not show $DATABASE_URL warning when showing application info 2018-12-02 21:19:09 -08:00
Frédéric Guillot 1bc8535dbb Move image proxy filter to template functions 2018-12-02 21:09:53 -08:00
Frédéric Guillot 6f5d93cbbe Update scraper rule for lemonde.fr 2018-12-02 20:53:22 -08:00
Frédéric Guillot 311a133ab8 Refactor manual entry scraper 2018-12-02 20:51:06 -08:00
djbshfbzehr 52de36b158 Apply rewriter rules on manual "Fetch Original Content" 2018-12-02 20:29:44 -08:00
Frédéric Guillot b51e7ea448 Add Makefile target for current OS and architecture 2018-12-02 13:35:13 -08:00
Frédéric Guillot 154b6a3bf7 Improve Makefile
- Use -mod=vendor
- Use same naming convention for all platforms
- Move go generate to its own target
2018-12-02 11:09:06 -08:00
Frédéric Guillot 04220ca1e7 Remove date and time from log messages
We assume that logs are sent to Syslog or similar.

Syslog or systemd already adds the timetamp.
2018-12-01 18:24:01 -08:00
Frédéric Guillot 8d9e6a2c62 Add Windows build target 2018-12-01 18:11:25 -08:00
mapl e47188eab2 Update scraper rule for heise.de 2018-12-01 11:49:30 -08:00
Frédéric Guillot 0be619355f Update ChangeLog 2018-11-25 19:49:05 -08:00
Frédéric Guillot af4c68feac Add support for Systemd Socket Activation 2018-11-25 17:41:23 -08:00
Frédéric Guillot 7838870a0c Change Unix socket permission to make it accessible from other services 2018-11-25 16:13:52 -08:00
Frédéric Guillot cb037910df Archive more read entries 2018-11-25 15:01:01 -08:00
Frédéric Guillot f3bff76aa1 Make sure slice is not out of range when reading XML prolog 2018-11-24 12:17:00 -08:00
ReVanTis 70be08eaf8 Improve overall Simplified Chinese translations.
- Unified few keywords.
- Unified all symbols to Chinese Symbols.
- Remove trailing period since it's not commonly used for Chinese on websites.
- Add one space between English words and Chinese words.
2018-11-21 21:07:22 -08:00
ariddell 3d04d92aa2 Improve time since post date displays
- 15 days now is "15 days" rather than "3 weeks" ago.
- 32 days is now "1 month" rather than "2 months" ago.
- 366 days is now "1 year" rather than "2 years" ago.

Closes #267
2018-11-17 12:09:02 -08:00
Frédéric Guillot 5cd7152ae7 Simplify application HTTP middlewares 2018-11-12 10:23:39 -08:00
Frédéric Guillot 9f85f67031 Make sure the remote address is populated even when using unix socket 2018-11-11 16:42:30 -08:00
Frédéric Guillot 1315282c7f Add the possiblity to listen on Unix socket 2018-11-11 16:21:57 -08:00
Frédéric Guillot becd086865 Add config options to disable HTTP and scheduler services 2018-11-11 15:54:19 -08:00
Frédéric Guillot 487852f07e Replace daemon and scheduler package with service package 2018-11-11 15:32:48 -08:00
Frédéric Guillot ca45765c46 Improve logging messages in ui package 2018-11-11 11:47:41 -08:00
Frédéric Guillot 5a69a61d48 Move UI middlewares and routes to ui package 2018-11-11 11:29:12 -08:00
Frédéric Guillot 0925899cee Move API middleware and routes to api package 2018-11-11 10:22:47 -08:00
Frédéric Guillot a9f98adb07 Move Fever middleware and routes to fever package 2018-11-11 09:54:32 -08:00
Piotr Kubisa 25c12053a6 Typo in README.md 2018-11-11 14:51:54 +01:00
Frédéric Guillot 36564ffbcd Add link to HTML man page 2018-11-10 11:18:40 -08:00
Frédéric Guillot 2f6e241417 Add man page 2018-11-09 22:01:58 -08:00
Frédéric Guillot 186f2b11c3 Change default database connection string 2018-11-09 21:58:09 -08:00
Frédéric Guillot 3b6e44c331 Allow the scraper to parse XHTML documents
Only "text/html" was authorized before.
2018-11-03 13:44:13 -07:00
Frédéric Guillot 1ff9950a55 Remove charset=utf-8 from JSON responses
See: https://www.iana.org/assignments/media-types/application/json
2018-11-03 12:03:06 -07:00
Diego Agulló a70e9d03ff Ignore hotkeys containing Control, Alt or Meta keys
Fixes #260
2018-10-31 13:59:02 +01:00
Frédéric Guillot ae1dc1a91e Handle more encoding conversion edge cases 2018-10-29 23:00:03 -07:00
Frédéric Guillot 5ff0630726 Disable go test caching 2018-10-29 22:46:08 -07:00
Frédéric Guillot 7d1b471d88 Add test case to check different feed encoding and HTTP headers 2018-10-29 19:04:36 -07:00
Frédéric Guillot 054fc8ef99 Avoid duplication of ldflags in Makefile 2018-10-28 22:22:48 -07:00
Frédéric Guillot 03d0114dcd Fix wrong translation key for category pages 2018-10-27 17:11:05 -07:00
Frédéric Guillot b17e01a288 Update ChangeLog 2018-10-26 20:07:56 -07:00
Frédéric Guillot f6028f3863 Improve Fever middleware and handle groupID=0 2018-10-26 19:49:49 -07:00
Ashe Connor 92c98bd986 Add OpenBSD build 2018-10-23 19:29:50 -07:00
Frédéric Guillot dc8668b904 Improve logging for OAuth2 callback 2018-10-22 20:49:10 -07:00
Frédéric Guillot f06a19814b Revert "Call preventDefault() when a keyboard shortcut is executed"
This reverts commit 9440bf47a5.
2018-10-22 20:26:17 -07:00
stratmaster 86dc2bf1c9 Unify german translation 2018-10-22 17:18:26 -07:00
Frédéric Guillot 9440bf47a5 Call preventDefault() when a keyboard shortcut is executed 2018-10-21 18:32:07 -07:00
Carl Helmertz 8c65c78904 NavHandler: Make "g f" go to feed, or list of feeds
When you're reading an entry, you may want to go to the feed first,
before quickly continuing to reading all of the feeds with another "g
f".
2018-10-21 11:53:17 -07:00
Frédéric Guillot 74c35ad000 Add more details in feed storage errors to facilitate debugging 2018-10-21 11:48:29 -07:00
Frédéric Guillot 85d48c8a71 Add entries storage error to feed errors count 2018-10-21 11:44:29 -07:00
Frédéric Guillot a614f98063 Set arbitrary maximum size for tsvector column
- The max size for tsvector is 1 MiB
- We index only the first million of characters,
  it should be enough for most feed entries.
2018-10-19 21:40:59 -07:00
Frédéric Guillot 715575001a Use different variable names for localized errors 2018-10-19 21:38:21 -07:00
Carl Helmertz 15a11c3da9 Unsubscribe from feed through link or "#"
After importing old OPML files, you may discover that many feeds are
obsolete or uninteresting. You list the feeds entries and determine that
you want to unsubscribe. This needs three clicks (edit feed, delete,
confirm) and requires moving the mouse to hit the different targets.

This quickly becomes tiring, if you are up to possibly deleting hundreds
of feeds. One mediation, introduced in this commit, is to add an
unsubscribe link to each feed's entry listing view, and also adding a
keyboard shortcut.

The keyboard shortcut "#" is:
* longer than one keystroke (requires shift)
* hard to type by accident
* used in Google products (thanks for the hint @fguillot)

In an effort to try to reduce the number of accidental feed
unsubscriptions.
2018-10-19 20:05:26 -07:00
Frédéric Guillot b8f874a37d Simplify feed entries filtering
- Rename processor package to filter
- Remove boilerplate code
2018-10-14 22:33:19 -07:00
Frédéric Guillot 234b3710d4 Fix golint import in travis.yml 2018-10-14 22:23:46 -07:00
Frédéric Guillot 778346b0b0 Simplify feed fetcher
- Add browser package to handle HTTP errors
- Reduce code duplication
2018-10-14 21:43:48 -07:00
Frédéric Guillot 5870f04260 Simplify feed parser and format detection
- Avoid doing multiple buffer copies
- Move parser and format detection logic to its own package
2018-10-14 11:46:41 -07:00
Frédéric Guillot d5ff4191b6 Improve unit tests in url package 2018-10-12 18:33:59 -07:00
Frédéric Guillot c1fe84521e Fix jshint error introduced in commit d4c1677 2018-10-08 21:09:25 -07:00
Frédéric Guillot 3daef197e9 Add short cli flags -i and -v 2018-10-08 21:00:00 -07:00
Frédéric Guillot 9606126196 Convert text links and line feeds to HTML in YouTube channels 2018-10-08 20:47:10 -07:00
Frédéric Guillot d4c1677e38 Change link state when marking all entries as read 2018-10-08 18:49:15 -07:00
Frédéric Guillot 9dc38a0803 Add missing package descriptions for GoDoc 2018-10-08 17:32:17 -07:00
Frédéric Guillot 11dfcdd3d6 Fix typo in license header 2018-10-08 15:50:15 -07:00
Frédéric Guillot 1f58b37a5e Refactor HTTP response builder 2018-10-08 15:31:58 -07:00
Frédéric Guillot ddfe969d6c Improve Fever API performances when marking a feed or group as read 2018-10-07 12:50:59 -07:00
Diego Agulló 98206059fc Set focus on article link when pressing prev/next hotkeys
This enhancement offer the possibility to open articles in the background by using the browser hotkey Ctrl+Return or Cmd+Return.
2018-10-01 20:52:51 -07:00
Frédéric Guillot 9d08139f43 Improve request package and add more unit tests 2018-09-23 21:02:26 -07:00
Frédéric Guillot 844680e573 Add more unit tests for config package 2018-09-23 15:23:46 -07:00
stratmaster 8601a67a7d Add another fix for translations 2018-09-23 12:50:38 -07:00
Frédéric Guillot b1e8f534ef Simplify locale package usage (refactoring) 2018-09-22 15:04:55 -07:00
Frédéric Guillot aae9b4eb83 Add missing translations and few fixes 2018-09-22 10:48:07 -07:00
Savely Krasovsky 0e23f37f57 Translate application in Russian 2018-09-22 10:22:58 -07:00
stratmaster 37ab6eec15 Update German translation 2018-09-22 09:58:23 -07:00
Frédéric Guillot 5d75089127 Use disclosure widget <details> for advanced feed options 2018-09-21 23:11:58 -07:00
Frédéric Guillot beb7a0cfcb Use unique translation IDs instead of English text as key 2018-09-21 22:23:23 -07:00
Frédéric Guillot f244df6293 Add more unit tests for template functions 2018-09-20 19:45:56 -07:00
Savely Krasovsky d79bab2997 Fix invalid output when truncating Unicode text in templates 2018-09-20 19:11:13 -07:00
Patrick 2538eea177 Add the possibility to override default user agent for each feed 2018-09-19 18:19:24 -07:00
Frédéric Guillot 1d335390c2 Split Makefile linux targets by architecture 2018-09-16 19:14:44 -07:00
Frédéric Guillot b9fee95cf2 Add compiler, Arch, and OS to info command 2018-09-16 19:13:16 -07:00
Diego Agulló 359aea8308 Avoid line break between emoji and (un)read/(un)star links 2018-09-15 11:28:44 -07:00
Frédéric Guillot e0bfd220f7 Update ChangeLog 2018-09-11 18:29:28 -07:00
Frédéric Guillot c9f9dd3262 Store client IP address in request context 2018-09-09 15:15:14 -07:00
Frédéric Guillot c1e1506720 Revert cookie flag from strict to lax mode otherwise oauth2 won't work 2018-09-09 14:25:56 -07:00
Frédéric Guillot 46932c91a6 Use predefined Ciphers when TLS is configured 2018-09-08 21:43:45 -07:00
Frédéric Guillot 2306a4b2f6 Avoid displaying an error when shutting down the daemon 2018-09-08 21:20:24 -07:00
Adam Vigneaux 07cd7d2135 Add "Mark this page as read" to the bottom
This better matches the most common use case for the "Mark this page as
read" link, which is to click it once you have reached the bottom of the
page. Especially on mobile, it can be annoying to scroll to the top of
the page to click the link.
2018-09-08 18:40:23 -07:00
Frédéric Guillot eee1f31903 Refactor HTTP context handling 2018-09-03 14:26:40 -07:00
Frédéric Guillot 88e81d4d80 Set cookie attribute SameSite to strict mode 2018-08-29 21:17:19 -07:00
Frédéric Guillot 6137b401ee Make user creation via environment variables idempotent 2018-08-29 21:00:23 -07:00
Frédéric Guillot 41ce0a3b80 Make latest changes compatible with Heroku 2018-08-29 20:50:36 -07:00
Frédéric Guillot 9ba9858bea Use regular text version of ✔︎ instead of emoji version on iOS
See this workaround: https://stackoverflow.com/questions/32639694/ios-9-removed-the-possibility-to-change-certain-symbol-colors-using-css/38362138#38362138
2018-08-28 20:46:15 -07:00
Dave Z 5341bbcbe2 Add toggle status button to entry page 2018-08-28 20:44:34 -07:00
Frédéric Guillot 4f62a704e2 Improve readme
- Add more links to the docs
- Add screenshots
2018-08-26 17:18:17 -07:00
Frédéric Guillot a3f3f51c60 Migrate to go modules (Go 1.11) 2018-08-26 16:43:53 -07:00
Michael a9e9c347f4 Switch Travis to Go 1.11 2018-08-26 16:36:33 -07:00
Dave Z 9169fbafb2 Show count of feeds with permanent errors in header menu
Only for feeds that reach `maxParsingError` are counted (so transient errors do not trigger counter).
2018-08-26 16:18:07 -07:00
dzaikos aae62aae08 Added remote client IP to API login failure error message.
Addresses #205

Changed error level reporting on API login failure to Error from Info to match the web login reporting.
2018-08-26 09:46:00 -07:00
stratmaster 62ec185154 Add german translation string 2018-08-26 09:43:57 -07:00
Frédéric Guillot 551b73acfb Add remove button in feed edit page 2018-08-25 12:21:54 -07:00
Frédéric Guillot febce4f2e3 Split integration tests into multiple files 2018-08-25 11:55:47 -07:00
Frédéric Guillot df2bebaf3d Update scraper rule for heise.de 2018-08-25 10:33:18 -07:00
Frédéric Guillot b17e4e88f6 Expose real error messages for internal server API errors 2018-08-25 10:04:58 -07:00
Frédéric Guillot c327833314 Simplify context keys 2018-08-25 09:50:43 -07:00
dzaikos 4148d8af80 Added remote client IP to login failure error message.
Fixes #205
2018-08-25 02:47:34 -04:00
Frédéric Guillot f43a055d63 Move Golang API client here 2018-08-24 22:23:03 -07:00
Frédéric Guillot dbcc5d8a97 Use canonical imports 2018-08-24 21:56:39 -07:00
tleb 7f2612d9a6 Add Procfile
Allows to git push to heroku/self-hosted alternatives easily
2018-08-07 12:54:38 -07:00
Frédéric Guillot cf03e0e338 Create database package (refactoring) 2018-08-01 20:28:45 -07:00
Frédéric Guillot 17054b396e Update user agent with new website URL 2018-07-28 10:32:48 -07:00
stratmaster 72bc113b88 Remove white spaces in German translation 2018-07-23 13:02:01 -07:00
Frédéric Guillot 33dcbae705 Update ChangeLog 2018-07-22 21:29:30 -07:00
Frédéric Guillot cfc1f3eb69 Avoid browser caching issues when assets changes 2018-07-22 12:24:46 -07:00
Frédéric Guillot 9f6533ece9 Compress JSON, CSS and Javascript responses 2018-07-19 19:27:05 -07:00
Frédéric Guillot a291d8a38b Improve themes handling
- Store user theme in session
- Logged out users will keep their theme
- Add theme background color to web manifest and meta tag
2018-07-18 22:30:05 -07:00
Frédéric Guillot c1ab27172c Update application icon with different sizes 2018-07-18 21:30:54 -07:00
Frédéric Guillot d10312fe15 Update URLs in readme 2018-07-18 17:28:57 -07:00
neepl 5365f31e90 Add support for published tag in Atom feeds 2018-07-17 21:52:05 -07:00
Frédéric Guillot 73a6e617bb Add few tweaks to the layout
- Increase truncate length to 35 chars for feed titles
- Add bottom margin for main menu in mobile view
- Add tooltip to feed domain in feeds list
2018-07-17 19:01:28 -07:00
Dave Z 3f31744911 Prevent vertical scrolling on swipe 2018-07-17 18:48:56 -07:00
Dave Z 3b018bc6bf Show feed title instead of domain in items list 2018-07-17 18:48:28 -07:00
Frédéric Guillot 6aa02680d8 Add service worker to cache feed icons 2018-07-15 21:51:09 -07:00
Dave Z c926498d3d Make image proxy configurable
Adds IMAGE_PROXY configuration setting to change image proxy filter behaviour:

- none = No proxy
- http-only = Proxy only non-HTTPS images (default)
- all = Proxy everything
2018-07-12 17:41:09 -07:00
Frédéric Guillot 6fd6f79daf Add alt attribute for feed icons 2018-07-10 23:20:32 -07:00
Frédéric Guillot aceea56681 Update CI jshint check 2018-07-10 20:59:39 -07:00
Frédéric Guillot a786e78aca Add embedly.com to iframe whitelist 2018-07-10 20:56:54 -07:00
Frédéric Guillot 3bdb9251da Use passive event listeners for touch events
Avoid this warning in Chrome console: https://www.chromestatus.com/feature/5745543795965952
2018-07-10 20:41:27 -07:00
dzaikos 6d25e02cb5 New add_dynamic_image rewriter for JavaScript-loaded images.
Searches tags for various `data-*` attributes and sets `img` tag `src` attribute appropriately. Falls back to searching `noscript` for `img` tags.

Includes unit tests.
2018-07-09 01:22:48 -04:00
Frédéric Guillot 8ee4280461 Change feed password field type to text to avoid autocomplete with Firefox
- Using autocomplete="off" or autocomplete="new-password" doesn't change anything
- Changing the input ID doesn't change anything
- Using a different input name doesn't change anything
- Only Chrome supports autocomplete="new-password"
2018-07-08 18:21:56 -07:00
Frédéric Guillot 55a1e97778 Add base URL validation 2018-07-07 14:01:02 -07:00
Frédéric Guillot cda0efb731 Update default stylesheet name in HTML layout 2018-07-07 12:07:34 -07:00
Frédéric Guillot 364198ba4a Add integration test for search query 2018-07-07 12:02:59 -07:00
Frédéric Guillot ca30800e6a Pre-generate themes stylesheets at build time 2018-07-07 11:00:39 -07:00
Frédéric Guillot 459bb4531f Update vendor dependencies 2018-07-06 21:18:14 -07:00
Frédéric Guillot 34a3fe426b Compress HTML responses to Gzip/Deflate if supported by browser 2018-07-06 20:39:28 -07:00
Frédéric Guillot 53deb0b8cd Refactor assets bundler and split Javascript files 2018-07-05 22:18:51 -07:00
dzaikos e1c56b2e53 Processor: Do rewriter before sanitizer for entry.Content.
Addresses #163.
2018-07-06 00:17:07 -04:00
Frédéric Guillot 845cf2adba Remove timestamp from generated files 2018-07-05 12:52:29 -07:00
stratmaster 7ed8401088 Add German translation for search form 2018-07-05 10:42:49 -07:00
Frédéric Guillot de1a4aad30 Add support for protocol relative YouTube URLs 2018-07-04 22:45:44 -07:00
Frédéric Guillot 6d0dc451e4 Add search form 2018-07-04 22:05:19 -07:00
Frédéric Guillot af15412954 Add full-text search for entries and add search parameter to the API 2018-07-04 17:40:03 -07:00
Daan Sprenkels 89e5dacca9 Improve updates to dutch locale 2018-07-03 12:57:47 -07:00
dzaikos 7d4a195519 Sandbox iframes when sanitizing.
Updated iframe unit tests.

Refactored sanitizer.getExtraAttributes() to use `switch` instead of multiple `if` statements.
2018-07-03 12:55:18 -07:00
dzaikos c3628813c6 Keep consistent text size on mobile orientation change
using vendor-specific CSS `*-text-size-adjust`.
2018-07-03 12:51:24 -07:00
Dave Z d3b48b7695 Update unit test for Sans-Serif theme 2018-07-02 21:37:07 -07:00
Frédéric Guillot 29131d4ee6 Update ChangeLog 2018-07-01 11:50:43 -07:00
Frédéric Guillot cc1e4f916f Avoid Chrome to autocomplete no-login password fields
Browsers always autocomplete saved passwords even with autocomplete="off".

https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion
2018-06-30 18:05:51 -07:00
Frédéric Guillot 240f801755 Always mark the entry as read after fetching the pagination in unread mode 2018-06-30 17:35:06 -07:00
Unknown 4c7fce0233 Update German translation 2018-06-30 16:37:55 -07:00
Frédéric Guillot 5cf504745b Add cli flag to reset all feed errors 2018-06-30 14:22:45 -07:00
Frédéric Guillot 9c0f882ba0 Add specific 404 and 401 error messages 2018-06-30 12:42:12 -07:00
Frédéric Guillot a40f592aab Strip binaries to reduce size 2018-06-29 20:43:03 -07:00
Frédéric Guillot 77947282e4 Make sure we always get the pagination in unread mode 2018-06-29 20:29:04 -07:00
Dave Z 0b0f4751fa Fix incorrect data value when toggling bookmark flag on entry page 2018-06-28 18:56:04 -07:00
dzaikos 0227bb9828 Set opener to null when opening original URL with JavaScript. 2018-06-28 02:13:53 -04:00
Frédéric Guillot 9d8b5421ed Remove unnecessary style 2018-06-26 23:03:46 -07:00
Frédéric Guillot dd78fb4340 Do not ignore errored feeds when a user refresh feeds manually 2018-06-26 23:02:41 -07:00
dzaikos 45d7105ed1 Refactor AddImageTitle rewriter.
* Only processes images with `src` **and** `title` attributes (others are ignored).
* Processes **all** images in the document (not just the first one).
* Wraps the image and its title attribute in a `figure` tag with the title attribute's contents in a `figcaption` tag.

Updated xkcd rewriter unit test.

Added another xkcd rewriter unit test to check rendering of images without title tags.
2018-06-26 17:50:18 -04:00
dzaikos c9131b0e89 Improve sanitizer to remove style tag contents.
See #157.

Refactored how blacklisted tags are handled so they're easier manage in the future.
2018-06-24 19:53:23 -07:00
Dave Z d847b10e32 Improve sanitizer to remove script and noscript contents
These tags where removed but the content was rendered as escaped HTML.

See #157
2018-06-23 17:50:43 -07:00
Frédéric Guillot 7039df9af1 Improve feed and user API updates with optional values 2018-06-23 16:16:54 -07:00
stratmaster cd77ebd742 Corrected hard coded strings 2018-06-22 18:41:19 -07:00
stratmaster eb9d23c796 Update German translation 2018-06-20 14:27:36 -07:00
Frédéric Guillot bddca15b69 Add new fields for feed username/password 2018-06-19 22:58:29 -07:00
Frédéric Guillot 261695c14c Improve memory usage debug log 2018-06-19 20:55:29 -07:00
Frédéric Guillot b5c317f971 Disable keep-alive for HTTP client 2018-06-19 20:21:24 -07:00
Frédéric Guillot 7914261437 Close HTTP response body even for failed requests 2018-06-19 20:13:13 -07:00
Dave Z be95e87e0e Added Sans-Serif theme. 2018-06-19 00:57:30 -04:00
Frédéric Guillot c719cf7df0 Rewrite iframe Youtube URLs to https://www.youtube-nocookie.com 2018-06-12 18:45:09 -07:00
Frédéric Guillot 36dab8b518 Add more filters for API call /entries
New filters:

- before (unix timestamp)
- before_entry_id
- after
- after_entry_id
- starred (boolean)
2018-06-09 19:13:41 -07:00
Frédéric Guillot c5373ff2bf Rewrite individual entry pagination SQL queries 2018-06-09 13:40:20 -07:00
Frédéric Guillot bd70640794 Simplify entry query builder 2018-06-08 18:24:41 -07:00
Dave Z 2bbe21fb89 Prevent items from sticking on touchend. 2018-06-06 20:23:10 -07:00
Dave Z 084642eb0a Extended horizontal overflow to feed and category views. 2018-06-06 20:18:33 -07:00
stratmaster 11d880087d Update German translation 2018-06-05 18:36:03 -07:00
2765 changed files with 178644 additions and 891504 deletions
+95
View File
@@ -0,0 +1,95 @@
name: CI Workflow
on:
pull_request:
branches:
- master
jobs:
linters:
name: Linter Check
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: 1.13
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v1
with:
fetch-depth: 3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install linters
run: |
cd /tmp && go get -u golang.org/x/lint/golint
sudo npm install -g jshint
env:
GO111MODULE: off
- name: Run golint
run: |
export PATH=/home/runner/go/bin:$PATH
make lint
- name: Run jshint
run: jshint ui/static/js/*.js
unit-tests:
name: Unit Tests
runs-on: ${{ matrix.os }}
strategy:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
go-version: [1.11, 1.12, 1.13]
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: ${{ matrix.go-version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v1
with:
fetch-depth: 3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run unit tests
run: make test
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:9.5
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: 1.13
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v1
with:
fetch-depth: 3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install Postgres client
run: sudo apt-get install -y postgresql-client
- name: Run integration tests
run: make integration-test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PGHOST: 127.0.0.1
PGPASSWORD: postgres
+1
View File
@@ -1 +1,2 @@
miniflux-*
miniflux
-17
View File
@@ -1,17 +0,0 @@
notifications:
email: false
services:
- postgresql
addons:
postgresql: "9.4"
language: go
go:
- "1.10"
before_install:
- npm install -g jshint
- go get -u github.com/golang/lint/golint
script:
- jshint ui/static/js/app.js
- make lint
- make test
- make integration-test
+288
View File
@@ -1,3 +1,291 @@
Version 2.0.18 (Sep 25, 2019)
-----------------------------
* Add Docker image variant for arm32v7
* Add theme variants
- Use CSS variables instead of inherence
- Rename default theme to "Light - Serif"
- Rename Black theme to "Dark - Serif"
- Rename "Sans-Serif" theme to "Light - Sans Serif"
- Add "System" theme that use system preferences: Dark or Light
- Add Serif and Sans-Serif variants for each color theme
* Avoid constraint error when having duplicate entries during feed creation
* Disable strict XML parsing
* Ignore invalid content type
* Update man page
* Replace Travis by GitHub Actions
* Rename cleanup config variables and deprecate old ones
- CLEANUP_FREQUENCY_HOURS instead of CLEANUP_FREQUENCY
- CLEANUP_ARCHIVE_READ_DAYS instead of ARCHIVE_READ_DAYS
* Make configurable the number of days to remove old sessions
* Add native lazy loading for images and iframes
* Do not buffer responses in the image proxy
* Update dependencies
* Add Go 1.13 to test matrix
* Replace link border by outline to avoid slight content shift
* New rewrite function: add_mailto_subject
* Import OPML from URL
* Fix HTML injection in addImageTitle
* Accept HTML entities when parsing XML
Version 2.0.17 (Aug 3, 2019)
----------------------------
* Update Docker image to Alpine Linux 3.10.1
* Pass auth header to manifest request (crossorigin attribute)
* Sort feed categories before serialization
* Fix syntax errors in man page
* Add .search margin-right
* Ask for confirmation before flushing history, marking page as read, and mark all as read
* Add option to disable feeds
Version 2.0.16 (Jun 8, 2019)
----------------------------
* Add option to toggle date/time in log messages
* Add optional config file parser in addition to environment variables
* Make HTTP Client timeout and max body size configurable
* Refactor config package:
- Parse configuration only once during startup time
- Store configuration values in a global variable
* Flip behavior of j and k keyboard shortcuts
* Bump Postgresql client library to v1.1.1 to bring in SCRAM-SHA-256 authentication
* Add option to enable/disable keyboard shortcuts
* Add missing translation
* Improve page reload when showing unread/all entries:
- Show only unread entries = refresh current page
- Show all entries = go to next page
* Always display feed entries even when there is a feed error
* Use loading label instead of saving when submitting login form
* Add OPML v1 support during importation
* Add 'allow-popups' to iframe sandbox permissions
Version 2.0.15 (Mar 16, 2019)
-----------------------------
* Move Dockerfile to main repo
* Change location of the binary from /usr/local/bin to /usr/bin in Docker image
* Add double tap detection for next/previous page navigation
* Allow users to disable auto-remove
* Make parser compatible with Go 1.12
* Add Golang 1.12 to CI
* Use feed ID instead of user ID to check entry URLs presence
* Fix typo in stylesheet
* Sort search results by relevance
* Use preferably the published date for Atom feeds
* Add Spanish translation
* Rename session cookies
* Handle the case when application session is expired and not user session
Version 2.0.14 (Jan 13, 2019)
-----------------------------
* Only attempt to change password if the confirmation field is filled in (Firefox)
* Remove URL from client user agent
* Make the feed list order case-insensitive
* Handle XHTML Summary elements for Atom feeds
* Make UTF-8 the default encoding for XML feeds
* Add more targets to Makefile
* Add -mod=vendor in Makefile
* Move health check endpoint from ui package to httpd service
* Add workaround for non GMT dates (RFC822, RFC850, and RFC1123)
* Make sure `<strong>` elements are bold
* Show scrollbars only when necessary for <pre> elements
* Add Italian translation
* Allow to switch between unread only and all entries on category/feed views
* Add function storage.UpdateFeedError()
* Add BBC News scraping rule
* Ignore JSON feeds from EnsureUnicode()
* Preserve category selection when no feed is found
* Update XML encoding regex to take single quotes into consideration
* Send cli errors to stderr
* Update dependencies
* Make password prompt compatible with Windows
* Make configurable the number of days to archive read items
* Change log level to debug when starting workers
* Do not show $DATABASE_URL warning when showing application info
* Move image proxy filter to template functions
* Update scraper rule for lemonde.fr
* Refactor manual entry scraper
* Apply rewriter rules on manual "Fetch Original Content"
* Add Makefile target for current OS and architecture
* Improve Makefile
Version 2.0.13 (Nov 25, 2018)
-----------------------------
* Add man page
* Add support for Systemd Socket Activation (experimental)
* Add the possiblity to listen on Unix sockets
* Add config options to disable HTTP and scheduler services
* Archive more read entries in cleanup job
* Change default database connection string (backward compatible)
* Improve logging messages in ui package
* Improve overall Simplified Chinese translations
* Improve time since post date displays:
- "15 days" now is "15 days" rather than "3 weeks" ago
- "32 days" is now "1 month" rather than "2 months" ago
- "366 days" is now "1 year" rather than "2 years" ago
* Allow the scraper to parse XHTML documents
* Remove charset=utf-8 from JSON responses
* Ignore hotkeys containing Control, Alt or Meta keys
* Handle more encoding conversion edge cases
* Disable go test caching
* Avoid duplication of ldflags in Makefile
* Fix wrong translation key for category pages
* Code refactoring:
- Simplify application HTTP middlewares
- Replace daemon and scheduler package with service package
- Move UI middlewares and routes to ui package
- Move API middleware and routes to api package
- Move Fever middleware and routes to fever package
Version 2.0.12 (Oct 26, 2018)
-----------------------------
* Add OpenBSD build
* Improve logging for OAuth2 callback
* Make "g f" go to feed, or list of feeds
* Add more details in feed storage errors to facilitate debugging
* Add entries storage error to feed errors count
* Set arbitrary maximum size for tsvector column
* Unsubscribe from feed through link or "#"
* Simplify feed entries filtering
* Simplify feed fetcher
* Simplify feed parser and format detection
* Improve unit tests in url package
* Add short cli flags -i and -v
* Convert text links and line feeds to HTML in YouTube channels
* Change link state when marking all entries as read
* Add missing package descriptions for GoDoc
* Fix typo in license header
* Refactor HTTP response builder
* Improve Fever API performances when marking a feed or group as read
* Set focus on article link when pressing prev/next hotkeys
* Improve request package and add more unit tests
* Add more unit tests for config package
* Simplify locale package usage (refactoring)
* Translate application in Russian
* Use disclosure widget <details> for advanced feed options
* Use unique translation IDs instead of English text as key
* Add more unit tests for template functions
* Fix invalid output when truncating Unicode text in templates
* Add the possibility to override default user agent for each feed
* Split Makefile linux targets by architecture
* Add compiler, Arch, and OS to info command
* Avoid line break between emoji and (un)read/(un)star links
* Build Docker image for multiple architectures (amd64, arm32v6, arm64v8)
Version 2.0.11 (Sep 11, 2018)
-----------------------------
* Set cookie flag `SameSite` to Lax mode
* Use predefined ciphers when TLS is configured
* Avoid displaying an error when shutting down the daemon
* Add "Mark this page as read" to the bottom
* Store client IP address in request context
* Refactor HTTP context handling
* Make user creation via environment variables idempotent
* Use regular text version of ✔︎ instead of emoji version on iOS
* Add toggle status button to entry page
* Migrate to Go Modules and Go 1.11
* Show count of feeds with permanent errors in header menu
* Display remote client IP in logs when having a login failure (Fail2Ban)
* Add remove button in feed edit page
* Split integration tests into multiple files
* Update scraper rule for heise.de
* Expose real error messages for internal server API errors
* Move Golang API client in project source tree (the separate project is deprecated)
* Use canonical imports
* Add Procfile
* Create database package (refactoring)
* Update user agent with new website URL
* Update German translation
Version 2.0.10 (July 22, 2018)
------------------------------
* Avoid browser caching issues when assets changes
* Add Gzip/Deflate compression for HTML, JSON, CSS and Javascript responses
* Improve themes handling
* Store user theme in session
* Logged out users will keep their theme
* Add theme background color to web manifest and meta tag
* Update application icon with different sizes
* Add support for published tag in Atom feeds
* Add tooltip to feed domain in feeds list (title attribute)
* Prevent vertical scrolling on swipe
* Show feed title instead of domain in items list
* Add service worker to cache feed icons
* Make image proxy configurable via IMAGE_PROXY environment variable:
* none = No proxy
* http-only = Proxy only non-HTTPS images (default)
* all = Proxy everything
* Add alt attribute for feed icons
* Update CI jshint check
* Add embedly.com to iframe whitelist
* Use passive event listeners for touch events
* Add `add_dynamic_image` rewriter for JavaScript-loaded images
* Change feed password field type to text to avoid auto-completion with Firefox
* Using autocomplete="off" or autocomplete="new-password" doesn't change anything
* Changing the input ID doesn't change anything
* Using a different input name doesn't change anything
* Only Chrome supports autocomplete="new-password"
* Add base URL validation
* Update default stylesheet name in HTML layout
* Pre-generate themes stylesheets at build time
* Update vendor dependencies
* Refactor assets bundler and split Javascript files
* Run sanitizer after all processing and entry content rewrite
* Remove timestamp from generated files
* Add support for protocol relative YouTube URLs
* Add Postgres full-text search for entries
* Add search form in user interface
* Add search parameter to the API
* Improve Dutch locales
* Sandbox iframes when sanitizing
* Keep consistent text size on mobile orientation change
* Change permission of /etc/miniflux.conf to 600 instead of 644 in RPM package
* Add tzdata package to Docker image
* Update Docker image to Alpine Linux 3.8
Version 2.0.9 (July 1, 2018)
----------------------------
* Avoid Chrome to autocomplete no-login password fields
* Add cli flag to reset all feed errors
* Do not ignore errored feeds when a user refresh feeds manually
* Add specific 404 and 401 error messages
* Strip binaries to reduce size
* Make sure we always get the pagination in unread mode
* Fix incorrect data value when toggling bookmark flag on entry page
* Set opener to null when opening original URL with JavaScript
* Remove unnecessary style
* Refactor AddImageTitle rewriter
* Only processes images with `src` **and** `title` attributes (others are ignored)
* Processes **all** images in the document (not just the first one)
* Wraps the image and its title attribute in a `figure` tag with the title attribute's contents in a `figcaption` tag
* Improve sanitizer to remove `style`, `noscript` and `script` tag contents
* Improve feed and user API updates with optional values
* Add new fields for feed username/password
* Improve memory usage debug log
* Disable keep-alive for HTTP client
* Close HTTP response body even for failed requests
* Add Sans-Serif theme
* Rewrite iframe Youtube URLs to https://www.youtube-nocookie.com
* Add more filters for API call `/entries`:
* before (unix timestamp)
* before_entry_id (int64)
* after (unix timestamp)
* after_entry_id (int64)
* starred (boolean)
* Rewrite individual entry pagination SQL queries
* Simplify entry query builder
* Prevent items from sticking on touchend
* Extended horizontal overflow to feed and category views
* Translate missing strings
* Update German translation
Version 2.0.8 (June 4, 2018)
----------------------------
+14
View File
@@ -0,0 +1,14 @@
FROM golang:1.13-alpine3.10 as build
ENV GO111MODULE=on
WORKDIR /go/src/app
RUN apk add --no-cache --update build-base git
COPY . .
RUN make linux-__MINIFLUX_ARCH__ VERSION=__MINIFLUX_VERSION__
FROM __BASEIMAGE_ARCH__/alpine:3.10.1
EXPOSE 8080
ENV LISTEN_ADDR 0.0.0.0:8080
RUN apk --no-cache add ca-certificates tzdata
COPY --from=build /go/src/app/miniflux-linux-__MINIFLUX_ARCH__ /usr/bin/miniflux
USER nobody
CMD ["/usr/bin/miniflux"]
Generated
-157
View File
@@ -1,157 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/PuerkitoBio/goquery"
packages = ["."]
revision = "e1271ee34c6a305e38566ecd27ae374944907ee9"
version = "v1.1.0"
[[projects]]
branch = "master"
name = "github.com/andybalholm/cascadia"
packages = ["."]
revision = "349dd0209470eabd9514242c688c403c0926d266"
[[projects]]
branch = "master"
name = "github.com/golang/protobuf"
packages = ["proto"]
revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845"
[[projects]]
name = "github.com/gorilla/context"
packages = ["."]
revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
version = "v1.1"
[[projects]]
name = "github.com/gorilla/mux"
packages = ["."]
revision = "53c1911da2b537f792e7cafcb446b05ffe33b996"
version = "v1.6.1"
[[projects]]
branch = "master"
name = "github.com/lib/pq"
packages = [
".",
"hstore",
"oid"
]
revision = "83612a56d3dd153a94a629cd64925371c9adad78"
[[projects]]
branch = "master"
name = "github.com/miniflux/miniflux-go"
packages = ["."]
revision = "8863d558cbf1f20beeb640328829205971f1a632"
[[projects]]
name = "github.com/tdewolff/minify"
packages = [
".",
"css",
"js"
]
revision = "222672169d634c440a73abc47685074e1a9daa60"
version = "v2.3.4"
[[projects]]
name = "github.com/tdewolff/parse"
packages = [
".",
"buffer",
"css",
"js",
"strconv"
]
revision = "639f6272aec6b52094db77b9ec488214b0b4b1a1"
version = "v2.3.2"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = [
"acme",
"acme/autocert",
"bcrypt",
"blowfish",
"ssh/terminal"
]
revision = "1875d0a70c90e57f11972aefd42276df65e895b9"
[[projects]]
branch = "master"
name = "golang.org/x/net"
packages = [
"context",
"context/ctxhttp",
"html",
"html/atom",
"html/charset"
]
revision = "d866cfc389cec985d6fda2859936a575a55a3ab6"
[[projects]]
branch = "master"
name = "golang.org/x/oauth2"
packages = [
".",
"internal"
]
revision = "462316686f20eb6df426961c1c131bdaa5dfa68e"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = [
"unix",
"windows"
]
revision = "571f7bbbe08da2a8955aed9d4db316e78630e9a3"
[[projects]]
branch = "master"
name = "golang.org/x/text"
packages = [
"encoding",
"encoding/charmap",
"encoding/htmlindex",
"encoding/internal",
"encoding/internal/identifier",
"encoding/japanese",
"encoding/korean",
"encoding/simplifiedchinese",
"encoding/traditionalchinese",
"encoding/unicode",
"internal/gen",
"internal/tag",
"internal/utf8internal",
"language",
"runes",
"transform",
"unicode/cldr"
]
revision = "d5a9226ed7dd70cade6ccae9d37517fe14dd9fee"
[[projects]]
name = "google.golang.org/appengine"
packages = [
"internal",
"internal/base",
"internal/datastore",
"internal/log",
"internal/remote_api",
"internal/urlfetch",
"urlfetch"
]
revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
version = "v1.0.0"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "f030af166f90ac96d133b7ddf9f1f80785508462cac8cae4c65abbdb7c20474b"
solver-name = "gps-cdcl"
solver-version = 1
-58
View File
@@ -1,58 +0,0 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
[metadata.heroku]
root-package = "github.com/miniflux/miniflux"
go-version = "go1.10"
ensure = "false"
[[constraint]]
name = "github.com/PuerkitoBio/goquery"
version = "1.1.0"
[[constraint]]
name = "github.com/gorilla/mux"
version = "1.6.1"
[[constraint]]
branch = "master"
name = "github.com/lib/pq"
[[constraint]]
branch = "master"
name = "github.com/miniflux/miniflux-go"
[[constraint]]
name = "github.com/tdewolff/minify"
version = "2.3.3"
[[constraint]]
branch = "master"
name = "golang.org/x/crypto"
[[constraint]]
branch = "master"
name = "golang.org/x/net"
[[constraint]]
branch = "master"
name = "golang.org/x/oauth2"
+126 -28
View File
@@ -1,41 +1,102 @@
APP := miniflux
VERSION=$(shell git rev-parse --short HEAD)
BUILD_DATE=`date +%FT%T%z`
DOCKER_IMAGE := miniflux/miniflux
VERSION := $(shell git rev-parse --short HEAD)
BUILD_DATE := `date +%FT%T%z`
LD_FLAGS := "-s -w -X 'miniflux.app/version.Version=$(VERSION)' -X 'miniflux.app/version.BuildDate=$(BUILD_DATE)'"
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
.PHONY: linux linux-arm darwin freebsd build run clean test lint integration-test clean-integration-test
export GO111MODULE=on
linux:
@ go generate
@ GOOS=linux GOARCH=amd64 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-amd64 main.go
.PHONY: generate \
miniflux \
linux-amd64 \
linux-armv8 \
linux-armv7 \
linux-armv6 \
linux-armv5 \
linux-x86 \
darwin-amd64 \
freebsd-amd64 \
freebsd-x86 \
openbsd-amd64 \
openbsd-x86 \
netbsd-x86 \
netbsd-amd64 \
windows-amd64 \
windows-x86 \
build \
run \
clean \
test \
lint \
integration-test \
clean-integration-test \
docker-images \
docker-manifest
linux-arm:
@ go generate
@ GOOS=linux GOARCH=arm64 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv8 main.go
@ GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv7 main.go
@ GOOS=linux GOARCH=arm GOARM=6 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv6 main.go
@ GOOS=linux GOARCH=arm GOARM=5 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv5 main.go
generate:
@ go generate -mod=vendor
darwin:
@ go generate
@ GOOS=darwin GOARCH=amd64 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-darwin-amd64 main.go
miniflux: generate
@ go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP) main.go
freebsd:
@ go generate
@ GOOS=freebsd GOARCH=amd64 go build -ldflags="-X 'githug.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-freebsd-amd64 main.go
linux-amd64: generate
@ GOOS=linux GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-amd64 main.go
build: linux linux-arm darwin freebsd
linux-armv8: generate
@ GOOS=linux GOARCH=arm64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv8 main.go
run:
@ go generate
@ go run main.go -debug
linux-armv7: generate
@ GOOS=linux GOARCH=arm GOARM=7 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv7 main.go
linux-armv6: generate
@ GOOS=linux GOARCH=arm GOARM=6 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv6 main.go
linux-armv5: generate
@ GOOS=linux GOARCH=arm GOARM=5 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv5 main.go
darwin-amd64: generate
@ GOOS=darwin GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-darwin-amd64 main.go
freebsd-amd64: generate
@ GOOS=freebsd GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-amd64 main.go
openbsd-amd64: generate
@ GOOS=openbsd GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-openbsd-amd64 main.go
windows-amd64: generate
@ GOOS=windows GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-windows-amd64 main.go
build: linux-amd64 linux-armv8 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 freebsd-amd64 openbsd-amd64 windows-amd64
# NOTE: unsupported targets
netbsd-amd64: generate
@ GOOS=netbsd GOARCH=amd64 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-netbsd-amd64 main.go
linux-x86: generate
@ GOOS=linux GOARCH=386 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-linux-x86 main.go
freebsd-x86: generate
@ GOOS=freebsd GOARCH=386 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-x86 main.go
netbsd-x86: generate
@ GOOS=netbsd GOARCH=386 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-netbsd-x86 main.go
openbsd-x86: generate
@ GOOS=openbsd GOARCH=386 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-x86 main.go
windows-x86: generate
@ GOOS=windows GOARCH=386 go build -mod=vendor -ldflags=$(LD_FLAGS) -o $(APP)-windows-x86 main.go
run: generate
@ go run -mod=vendor main.go -debug
clean:
@ rm -f $(APP)-*
@ rm -f $(APP)-* $(APP)
test:
go test -cover -race ./...
go test -mod=vendor -cover -race -count=1 ./...
lint:
@ golint -set_exit_status ${PKG_LIST}
@@ -43,15 +104,52 @@ lint:
integration-test:
psql -U postgres -c 'drop database if exists miniflux_test;'
psql -U postgres -c 'create database miniflux_test;'
DATABASE_URL=$(DB_URL) go run main.go -migrate
DATABASE_URL=$(DB_URL) ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go -create-admin
go build -o miniflux-test main.go
DATABASE_URL=$(DB_URL) go run -mod=vendor main.go -migrate
DATABASE_URL=$(DB_URL) ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run -mod=vendor main.go -create-admin
go build -mod=vendor -o miniflux-test main.go
DATABASE_URL=$(DB_URL) ./miniflux-test -debug >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! echo exit | nc localhost 8080; do sleep 1; done >/dev/null
go test -v -tags=integration || cat /tmp/miniflux.log
go test -mod=vendor -v -tags=integration -count=1 miniflux.app/tests || cat /tmp/miniflux.log
clean-integration-test:
@ kill -9 `cat /tmp/miniflux.pid`
@ rm -f /tmp/miniflux.pid /tmp/miniflux.log
@ rm miniflux-test
@ psql -U postgres -c 'drop database if exists miniflux_test;'
docker-images:
for arch in amd64 arm32v6 arm64v8; do \
case $${arch} in \
amd64 ) miniflux_arch="amd64";; \
arm32v6 ) miniflux_arch="armv6";; \
arm32v7 ) miniflux_arch="armv7";; \
arm64v8 ) miniflux_arch="armv8";; \
esac ;\
cp Dockerfile Dockerfile.$${arch} && \
sed -i"" -e "s|__BASEIMAGE_ARCH__|$${arch}|g" Dockerfile.$${arch} && \
sed -i"" -e "s|__MINIFLUX_VERSION__|$(VERSION)|g" Dockerfile.$${arch} && \
sed -i"" -e "s|__MINIFLUX_ARCH__|$${miniflux_arch}|g" Dockerfile.$${arch} && \
docker build --pull -f Dockerfile.$${arch} -t $(DOCKER_IMAGE):$${arch}-$(VERSION) . && \
docker tag $(DOCKER_IMAGE):$${arch}-${VERSION} $(DOCKER_IMAGE):$${arch}-latest && \
rm -f Dockerfile.$${arch}* ;\
done
docker-manifest:
for version in $(VERSION) latest; do \
docker push $(DOCKER_IMAGE):amd64-$${version} && \
docker push $(DOCKER_IMAGE):arm32v6-$${version} && \
docker push $(DOCKER_IMAGE):arm32v7-$${version} && \
docker push $(DOCKER_IMAGE):arm64v8-$${version} && \
docker manifest create --amend $(DOCKER_IMAGE):$${version} \
$(DOCKER_IMAGE):amd64-$${version} \
$(DOCKER_IMAGE):arm32v6-$${version} \
$(DOCKER_IMAGE):arm32v7-$${version} \
$(DOCKER_IMAGE):arm64v8-$${version} && \
docker manifest annotate $(DOCKER_IMAGE):$${version} \
$(DOCKER_IMAGE):arm32v6-$${version} --os linux --arch arm --variant v6 && \
docker manifest annotate $(DOCKER_IMAGE):$${version} \
$(DOCKER_IMAGE):arm32v7-$${version} --os linux --arch arm --variant v7 && \
docker manifest annotate $(DOCKER_IMAGE):$${version} \
$(DOCKER_IMAGE):arm64v8-$${version} --os linux --arch arm64 --variant v8 && \
docker manifest push --purge $(DOCKER_IMAGE):$${version} ;\
done
+1
View File
@@ -0,0 +1 @@
web: miniflux.app
+31 -13
View File
@@ -1,8 +1,6 @@
Miniflux 2
==========
[![Build Status](https://travis-ci.org/miniflux/miniflux.svg?branch=master)](https://travis-ci.org/miniflux/miniflux)
[![GoDoc](https://godoc.org/github.com/miniflux/miniflux?status.svg)](https://godoc.org/github.com/miniflux/miniflux)
[![Documentation Status](https://readthedocs.org/projects/miniflux/badge/?version=latest)](https://docs.miniflux.net/)
[![GoDoc](https://godoc.org/miniflux.app?status.svg)](https://godoc.org/miniflux.app)
Miniflux is a minimalist and opinionated feed reader:
@@ -11,27 +9,47 @@ Miniflux is a minimalist and opinionated feed reader:
- Doesn't use any ORM
- Doesn't use any complicated framework
- Use only modern vanilla Javascript (ES6 and Fetch API)
- Single binary compiled statically without dependency
- The number of features is voluntarily limited
It's simple, fast, lightweight and super easy to install.
Miniflux 2 is a rewrite of [Miniflux 1.x](https://github.com/miniflux/miniflux-legacy) in Golang.
Official website: <https://miniflux.app>
Documentation
-------------
The Miniflux documentation is available here: <https://docs.miniflux.net/>
The Miniflux documentation is available here: <https://miniflux.app/docs/> ([Man page](https://miniflux.app/miniflux.1.html))
- [Opinionated?](https://docs.miniflux.net/en/latest/opinionated.html)
- [Features](https://docs.miniflux.net/en/latest/features.html)
- [Requirements](https://docs.miniflux.net/en/latest/requirements.html)
- [Installation Instructions](https://docs.miniflux.net/en/latest/installation.html)
- [Installation Tutorials](https://docs.miniflux.net/en/latest/tutorials.html)
- [Upgrading to a new version](https://docs.miniflux.net/en/latest/upgrade.html)
- [Configuration](https://docs.miniflux.net/en/latest/configuration.html)
- [Opinionated?](https://miniflux.app/opinionated.html)
- [Features](https://miniflux.app/features.html)
- [Requirements](https://miniflux.app/docs/requirements.html)
- [Installation Instructions](https://miniflux.app/docs/installation.html)
- [Upgrading to a New Version](https://miniflux.app/docs/upgrade.html)
- [Configuration](https://miniflux.app/docs/configuration.html)
- [Command Line Usage](https://miniflux.app/docs/cli.html)
- [User Interface Usage](https://miniflux.app/docs/ui.html)
- [Keyboard Shortcuts](https://miniflux.app/docs/keyboard_shortcuts.html)
- [Integration with External Services](https://miniflux.app/docs/services.html)
- [Rewrite and Scraper Rules](https://miniflux.app/docs/rules.html)
- [API Reference](https://miniflux.app/docs/api.html)
- [Development](https://miniflux.app/docs/development.html)
- [Internationalization](https://miniflux.app/docs/i18n.html)
- [Frequently Asked Questions](https://miniflux.app/faq.html)
Screenshots
-----------
Default theme:
![Default theme](https://miniflux.app/images/overview.png)
Dark theme when using keyboard navigation:
![Dark theme](https://miniflux.app/images/item-selection-black-theme.png)
Credits
-------
- Author: Frédéric Guillot
- Authors: Frédéric Guillot - [List of contributors](https://github.com/miniflux/miniflux/graphs/contributors)
- Distributed under Apache 2.0 License
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api // import "miniflux.app/api"
import (
"miniflux.app/reader/feed"
"miniflux.app/storage"
"github.com/gorilla/mux"
)
// Serve declares API routes for the application.
func Serve(router *mux.Router, store *storage.Storage, feedHandler *feed.Handler) {
handler := &handler{store, feedHandler}
sr := router.PathPrefix("/v1").Subrouter()
sr.Use(newMiddleware(store).serve)
sr.HandleFunc("/users", handler.createUser).Methods("POST")
sr.HandleFunc("/users", handler.users).Methods("GET")
sr.HandleFunc("/users/{userID:[0-9]+}", handler.userByID).Methods("GET")
sr.HandleFunc("/users/{userID:[0-9]+}", handler.updateUser).Methods("PUT")
sr.HandleFunc("/users/{userID:[0-9]+}", handler.removeUser).Methods("DELETE")
sr.HandleFunc("/users/{username}", handler.userByUsername).Methods("GET")
sr.HandleFunc("/me", handler.currentUser).Methods("GET")
sr.HandleFunc("/categories", handler.createCategory).Methods("POST")
sr.HandleFunc("/categories", handler.getCategories).Methods("GET")
sr.HandleFunc("/categories/{categoryID}", handler.updateCategory).Methods("PUT")
sr.HandleFunc("/categories/{categoryID}", handler.removeCategory).Methods("DELETE")
sr.HandleFunc("/discover", handler.getSubscriptions).Methods("POST")
sr.HandleFunc("/feeds", handler.createFeed).Methods("POST")
sr.HandleFunc("/feeds", handler.getFeeds).Methods("GET")
sr.HandleFunc("/feeds/{feedID}/refresh", handler.refreshFeed).Methods("PUT")
sr.HandleFunc("/feeds/{feedID}", handler.getFeed).Methods("GET")
sr.HandleFunc("/feeds/{feedID}", handler.updateFeed).Methods("PUT")
sr.HandleFunc("/feeds/{feedID}", handler.removeFeed).Methods("DELETE")
sr.HandleFunc("/feeds/{feedID}/icon", handler.feedIcon).Methods("GET")
sr.HandleFunc("/export", handler.exportFeeds).Methods("GET")
sr.HandleFunc("/import", handler.importFeeds).Methods("POST")
sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods("GET")
sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntry).Methods("GET")
sr.HandleFunc("/entries", handler.getEntries).Methods("GET")
sr.HandleFunc("/entries", handler.setEntryStatus).Methods("PUT")
sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods("GET")
sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleBookmark).Methods("PUT")
}
+33 -51
View File
@@ -2,109 +2,91 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
package api // import "miniflux.app/api"
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
"miniflux.app/http/request"
"miniflux.app/http/response/json"
)
// CreateCategory is the API handler to create a new category.
func (c *Controller) CreateCategory(w http.ResponseWriter, r *http.Request) {
func (h *handler) createCategory(w http.ResponseWriter, r *http.Request) {
category, err := decodeCategoryPayload(r.Body)
if err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
userID := request.UserID(r)
category.UserID = userID
if err := category.ValidateCategoryCreation(); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
if c, err := c.store.CategoryByTitle(userID, category.Title); err != nil || c != nil {
json.BadRequest(w, errors.New("This category already exists"))
if c, err := h.store.CategoryByTitle(userID, category.Title); err != nil || c != nil {
json.BadRequest(w, r, errors.New("This category already exists"))
return
}
err = c.store.CreateCategory(category)
if err != nil {
json.ServerError(w, errors.New("Unable to create this category"))
if err := h.store.CreateCategory(category); err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, category)
json.Created(w, r, category)
}
// UpdateCategory is the API handler to update a category.
func (c *Controller) UpdateCategory(w http.ResponseWriter, r *http.Request) {
categoryID, err := request.IntParam(r, "categoryID")
if err != nil {
json.BadRequest(w, err)
return
}
func (h *handler) updateCategory(w http.ResponseWriter, r *http.Request) {
categoryID := request.RouteInt64Param(r, "categoryID")
category, err := decodeCategoryPayload(r.Body)
if err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
ctx := context.New(r)
category.UserID = ctx.UserID()
category.UserID = request.UserID(r)
category.ID = categoryID
if err := category.ValidateCategoryModification(); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
err = c.store.UpdateCategory(category)
err = h.store.UpdateCategory(category)
if err != nil {
json.ServerError(w, errors.New("Unable to update this category"))
json.ServerError(w, r, err)
return
}
json.Created(w, category)
json.Created(w, r, category)
}
// GetCategories is the API handler to get a list of categories for a given user.
func (c *Controller) GetCategories(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
categories, err := c.store.Categories(ctx.UserID())
func (h *handler) getCategories(w http.ResponseWriter, r *http.Request) {
categories, err := h.store.Categories(request.UserID(r))
if err != nil {
json.ServerError(w, errors.New("Unable to fetch categories"))
json.ServerError(w, r, err)
return
}
json.OK(w, categories)
json.OK(w, r, categories)
}
// RemoveCategory is the API handler to remove a category.
func (c *Controller) RemoveCategory(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
categoryID, err := request.IntParam(r, "categoryID")
if err != nil {
json.BadRequest(w, err)
func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
categoryID := request.RouteInt64Param(r, "categoryID")
if !h.store.CategoryExists(userID, categoryID) {
json.NotFound(w, r)
return
}
if !c.store.CategoryExists(userID, categoryID) {
json.NotFound(w, errors.New("Category not found"))
if err := h.store.RemoveCategory(userID, categoryID); err != nil {
json.ServerError(w, r, err)
return
}
if err := c.store.RemoveCategory(userID, categoryID); err != nil {
json.ServerError(w, errors.New("Unable to remove this category"))
return
}
json.NoContent(w)
json.NoContent(w, r)
}
-21
View File
@@ -1,21 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
import (
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/storage"
)
// Controller holds all handlers for the API.
type Controller struct {
store *storage.Storage
feedHandler *feed.Handler
}
// NewController creates a new controller.
func NewController(store *storage.Storage, feedHandler *feed.Handler) *Controller {
return &Controller{store: store, feedHandler: feedHandler}
}
+4 -4
View File
@@ -1,10 +1,10 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package api implements API endpoints for Miniflux application.
Package api implements API endpoints for the application.
*/
package api
package api // import "miniflux.app/api"
+88 -86
View File
@@ -2,220 +2,222 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
package api // import "miniflux.app/api"
import (
"errors"
"net/http"
"time"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/model"
"miniflux.app/http/request"
"miniflux.app/http/response/json"
"miniflux.app/model"
"miniflux.app/storage"
)
// GetFeedEntry is the API handler to get a single feed entry.
func (c *Controller) GetFeedEntry(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
json.BadRequest(w, err)
return
}
func (h *handler) getFeedEntry(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
entryID := request.RouteInt64Param(r, "entryID")
entryID, err := request.IntParam(r, "entryID")
if err != nil {
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
builder := c.store.NewEntryQueryBuilder(userID)
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
entry, err := builder.GetEntry()
if err != nil {
json.ServerError(w, errors.New("Unable to fetch this entry from the database"))
json.ServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, errors.New("Entry not found"))
json.NotFound(w, r)
return
}
json.OK(w, entry)
json.OK(w, r, entry)
}
// GetEntry is the API handler to get a single entry.
func (c *Controller) GetEntry(w http.ResponseWriter, r *http.Request) {
entryID, err := request.IntParam(r, "entryID")
if err != nil {
json.BadRequest(w, err)
return
}
builder := c.store.NewEntryQueryBuilder(context.New(r).UserID())
func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithEntryID(entryID)
entry, err := builder.GetEntry()
if err != nil {
json.ServerError(w, errors.New("Unable to fetch this entry from the database"))
json.ServerError(w, r, err)
return
}
if entry == nil {
json.NotFound(w, errors.New("Entry not found"))
json.NotFound(w, r)
return
}
json.OK(w, entry)
json.OK(w, r, entry)
}
// GetFeedEntries is the API handler to get all feed entries.
func (c *Controller) GetFeedEntries(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
json.BadRequest(w, err)
return
}
func (h *handler) getFeedEntries(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
status := request.QueryParam(r, "status", "")
status := request.QueryStringParam(r, "status", "")
if status != "" {
if err := model.ValidateEntryStatus(status); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
}
order := request.QueryParam(r, "order", model.DefaultSortingOrder)
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
if err := model.ValidateEntryOrder(order); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
direction := request.QueryParam(r, "direction", model.DefaultSortingDirection)
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
if err := model.ValidateDirection(direction); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
limit := request.QueryIntParam(r, "limit", 100)
offset := request.QueryIntParam(r, "offset", 0)
if err := model.ValidateRange(offset, limit); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
builder := c.store.NewEntryQueryBuilder(context.New(r).UserID())
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithFeedID(feedID)
builder.WithStatus(status)
builder.WithOrder(order)
builder.WithDirection(direction)
builder.WithOffset(offset)
builder.WithLimit(limit)
configureFilters(builder, r)
entries, err := builder.GetEntries()
if err != nil {
json.ServerError(w, errors.New("Unable to fetch the list of entries"))
json.ServerError(w, r, err)
return
}
count, err := builder.CountEntries()
if err != nil {
json.ServerError(w, errors.New("Unable to count the number of entries"))
json.ServerError(w, r, err)
return
}
json.OK(w, &entriesResponse{Total: count, Entries: entries})
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
}
// GetEntries is the API handler to fetch entries.
func (c *Controller) GetEntries(w http.ResponseWriter, r *http.Request) {
status := request.QueryParam(r, "status", "")
func (h *handler) getEntries(w http.ResponseWriter, r *http.Request) {
status := request.QueryStringParam(r, "status", "")
if status != "" {
if err := model.ValidateEntryStatus(status); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
}
order := request.QueryParam(r, "order", model.DefaultSortingOrder)
order := request.QueryStringParam(r, "order", model.DefaultSortingOrder)
if err := model.ValidateEntryOrder(order); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
direction := request.QueryParam(r, "direction", model.DefaultSortingDirection)
direction := request.QueryStringParam(r, "direction", model.DefaultSortingDirection)
if err := model.ValidateDirection(direction); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
limit := request.QueryIntParam(r, "limit", 100)
offset := request.QueryIntParam(r, "offset", 0)
if err := model.ValidateRange(offset, limit); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
builder := c.store.NewEntryQueryBuilder(context.New(r).UserID())
builder := h.store.NewEntryQueryBuilder(request.UserID(r))
builder.WithStatus(status)
builder.WithOrder(order)
builder.WithDirection(direction)
builder.WithOffset(offset)
builder.WithLimit(limit)
configureFilters(builder, r)
entries, err := builder.GetEntries()
if err != nil {
json.ServerError(w, errors.New("Unable to fetch the list of entries"))
json.ServerError(w, r, err)
return
}
count, err := builder.CountEntries()
if err != nil {
json.ServerError(w, errors.New("Unable to count the number of entries"))
json.ServerError(w, r, err)
return
}
json.OK(w, &entriesResponse{Total: count, Entries: entries})
json.OK(w, r, &entriesResponse{Total: count, Entries: entries})
}
// SetEntryStatus is the API handler to change the status of entries.
func (c *Controller) SetEntryStatus(w http.ResponseWriter, r *http.Request) {
func (h *handler) setEntryStatus(w http.ResponseWriter, r *http.Request) {
entryIDs, status, err := decodeEntryStatusPayload(r.Body)
if err != nil {
json.BadRequest(w, errors.New("Invalid JSON payload"))
json.BadRequest(w , r, errors.New("Invalid JSON payload"))
return
}
if err := model.ValidateEntryStatus(status); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
if err := c.store.SetEntriesStatus(context.New(r).UserID(), entryIDs, status); err != nil {
json.ServerError(w, errors.New("Unable to change entries status"))
if err := h.store.SetEntriesStatus(request.UserID(r), entryIDs, status); err != nil {
json.ServerError(w, r, err)
return
}
json.NoContent(w)
json.NoContent(w, r)
}
// ToggleBookmark is the API handler to toggle bookmark status.
func (c *Controller) ToggleBookmark(w http.ResponseWriter, r *http.Request) {
entryID, err := request.IntParam(r, "entryID")
if err != nil {
json.BadRequest(w, err)
func (h *handler) toggleBookmark(w http.ResponseWriter, r *http.Request) {
entryID := request.RouteInt64Param(r, "entryID")
if err := h.store.ToggleBookmark(request.UserID(r), entryID); err != nil {
json.ServerError(w, r, err)
return
}
if err := c.store.ToggleBookmark(context.New(r).UserID(), entryID); err != nil {
json.ServerError(w, errors.New("Unable to toggle bookmark value"))
return
}
json.NoContent(w)
json.NoContent(w, r)
}
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0)
if beforeEntryID != 0 {
builder.BeforeEntryID(beforeEntryID)
}
afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0)
if afterEntryID != 0 {
builder.AfterEntryID(afterEntryID)
}
beforeTimestamp := request.QueryInt64Param(r, "before", 0)
if beforeTimestamp != 0 {
builder.BeforeDate(time.Unix(beforeTimestamp, 0))
}
afterTimestamp := request.QueryInt64Param(r, "after", 0)
if afterTimestamp != 0 {
builder.AfterDate(time.Unix(afterTimestamp, 0))
}
if request.HasQueryParam(r, "starred") {
builder.WithStarred()
}
searchQuery := request.QueryStringParam(r, "search", "")
if searchQuery != "" {
builder.WithSearchQuery(searchQuery)
}
}
+78 -100
View File
@@ -2,51 +2,56 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
package api // import "miniflux.app/api"
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
"miniflux.app/http/request"
"miniflux.app/http/response/json"
)
// CreateFeed is the API handler to create a new feed.
func (c *Controller) CreateFeed(w http.ResponseWriter, r *http.Request) {
feedURL, categoryID, err := decodeFeedCreationPayload(r.Body)
func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) {
feedInfo, err := decodeFeedCreationPayload(r.Body)
if err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
if feedURL == "" {
json.BadRequest(w, errors.New("The feed_url is required"))
if feedInfo.FeedURL == "" {
json.BadRequest(w, r, errors.New("The feed_url is required"))
return
}
if categoryID <= 0 {
json.BadRequest(w, errors.New("The category_id is required"))
if feedInfo.CategoryID <= 0 {
json.BadRequest(w, r, errors.New("The category_id is required"))
return
}
ctx := context.New(r)
userID := ctx.UserID()
userID := request.UserID(r)
if c.store.FeedURLExists(userID, feedURL) {
json.BadRequest(w, errors.New("This feed_url already exists"))
if h.store.FeedURLExists(userID, feedInfo.FeedURL) {
json.BadRequest(w, r, errors.New("This feed_url already exists"))
return
}
if !c.store.CategoryExists(userID, categoryID) {
json.BadRequest(w, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
if !h.store.CategoryExists(userID, feedInfo.CategoryID) {
json.BadRequest(w, r, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
return
}
feed, err := c.feedHandler.CreateFeed(userID, categoryID, feedURL, false)
feed, err := h.feedHandler.CreateFeed(
userID,
feedInfo.CategoryID,
feedInfo.FeedURL,
feedInfo.Crawler,
feedInfo.UserAgent,
feedInfo.Username,
feedInfo.Password,
)
if err != nil {
json.ServerError(w, errors.New("Unable to create this feed"))
json.ServerError(w, r, err)
return
}
@@ -54,135 +59,108 @@ func (c *Controller) CreateFeed(w http.ResponseWriter, r *http.Request) {
FeedID int64 `json:"feed_id"`
}
json.Created(w, &result{FeedID: feed.ID})
json.Created(w, r, &result{FeedID: feed.ID})
}
// RefreshFeed is the API handler to refresh a feed.
func (c *Controller) RefreshFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
func (h *handler) refreshFeed(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
userID := request.UserID(r)
if !h.store.FeedExists(userID, feedID) {
json.NotFound(w, r)
return
}
err := h.feedHandler.RefreshFeed(userID, feedID)
if err != nil {
json.BadRequest(w, err)
json.ServerError(w, r, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
if !c.store.FeedExists(userID, feedID) {
json.NotFound(w, errors.New("Unable to find this feed"))
return
}
err = c.feedHandler.RefreshFeed(userID, feedID)
if err != nil {
json.ServerError(w, errors.New("Unable to refresh this feed"))
return
}
json.NoContent(w)
json.NoContent(w, r)
}
// UpdateFeed is the API handler that is used to update a feed.
func (c *Controller) UpdateFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
feedChanges, err := decodeFeedModificationPayload(r.Body)
if err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
newFeed, err := decodeFeedModificationPayload(r.Body)
userID := request.UserID(r)
originalFeed, err := h.store.FeedByID(userID, feedID)
if err != nil {
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
if newFeed.Category != nil && newFeed.Category.ID != 0 && !c.store.CategoryExists(userID, newFeed.Category.ID) {
json.BadRequest(w, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
return
}
originalFeed, err := c.store.FeedByID(userID, feedID)
if err != nil {
json.NotFound(w, errors.New("Unable to find this feed"))
json.NotFound(w, r)
return
}
if originalFeed == nil {
json.NotFound(w, errors.New("Feed not found"))
json.NotFound(w, r)
return
}
originalFeed.Merge(newFeed)
if err := c.store.UpdateFeed(originalFeed); err != nil {
json.ServerError(w, errors.New("Unable to update this feed"))
feedChanges.Update(originalFeed)
if !h.store.CategoryExists(userID, originalFeed.Category.ID) {
json.BadRequest(w, r, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
return
}
originalFeed, err = c.store.FeedByID(userID, feedID)
if err := h.store.UpdateFeed(originalFeed); err != nil {
json.ServerError(w, r, err)
return
}
originalFeed, err = h.store.FeedByID(userID, feedID)
if err != nil {
json.ServerError(w, errors.New("Unable to fetch this feed"))
json.ServerError(w, r, err)
return
}
json.Created(w, originalFeed)
json.Created(w, r, originalFeed)
}
// GetFeeds is the API handler that get all feeds that belongs to the given user.
func (c *Controller) GetFeeds(w http.ResponseWriter, r *http.Request) {
feeds, err := c.store.Feeds(context.New(r).UserID())
func (h *handler) getFeeds(w http.ResponseWriter, r *http.Request) {
feeds, err := h.store.Feeds(request.UserID(r))
if err != nil {
json.ServerError(w, errors.New("Unable to fetch feeds from the database"))
json.ServerError(w, r, err)
return
}
json.OK(w, feeds)
json.OK(w, r, feeds)
}
// GetFeed is the API handler to get a feed.
func (c *Controller) GetFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
func (h *handler) getFeed(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
feed, err := h.store.FeedByID(request.UserID(r), feedID)
if err != nil {
json.BadRequest(w, err)
return
}
feed, err := c.store.FeedByID(context.New(r).UserID(), feedID)
if err != nil {
json.ServerError(w, errors.New("Unable to fetch this feed"))
json.ServerError(w, r, err)
return
}
if feed == nil {
json.NotFound(w, errors.New("Feed not found"))
json.NotFound(w, r)
return
}
json.OK(w, feed)
json.OK(w, r, feed)
}
// RemoveFeed is the API handler to remove a feed.
func (c *Controller) RemoveFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
json.BadRequest(w, err)
func (h *handler) removeFeed(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
userID := request.UserID(r)
if !h.store.FeedExists(userID, feedID) {
json.NotFound(w, r)
return
}
ctx := context.New(r)
userID := ctx.UserID()
if !c.store.FeedExists(userID, feedID) {
json.NotFound(w, errors.New("Feed not found"))
if err := h.store.RemoveFeed(userID, feedID); err != nil {
json.ServerError(w, r, err)
return
}
if err := c.store.RemoveFeed(userID, feedID); err != nil {
json.ServerError(w, errors.New("Unable to remove this feed"))
return
}
json.NoContent(w)
json.NoContent(w, r)
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api // import "miniflux.app/api"
import (
"miniflux.app/reader/feed"
"miniflux.app/storage"
)
type handler struct {
store *storage.Storage
feedHandler *feed.Handler
}
+12 -19
View File
@@ -2,42 +2,35 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
package api // import "miniflux.app/api"
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
"miniflux.app/http/request"
"miniflux.app/http/response/json"
)
// FeedIcon returns a feed icon.
func (c *Controller) FeedIcon(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
json.BadRequest(w, err)
func (h *handler) feedIcon(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if !h.store.HasIcon(feedID) {
json.NotFound(w, r)
return
}
if !c.store.HasIcon(feedID) {
json.NotFound(w, errors.New("This feed doesn't have any icon"))
return
}
icon, err := c.store.IconByFeedID(context.New(r).UserID(), feedID)
icon, err := h.store.IconByFeedID(request.UserID(r), feedID)
if err != nil {
json.ServerError(w, errors.New("Unable to fetch feed icon"))
json.ServerError(w, r, err)
return
}
if icon == nil {
json.NotFound(w, errors.New("This feed doesn't have any icon"))
json.NotFound(w, r)
return
}
json.OK(w, &feedIcon{
json.OK(w, r, &feedIcon{
ID: icon.ID,
MimeType: icon.MimeType,
Data: icon.DataURL(),
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api // import "miniflux.app/api"
import (
"context"
"net/http"
"miniflux.app/http/request"
"miniflux.app/http/response/json"
"miniflux.app/logger"
"miniflux.app/storage"
)
type middleware struct {
store *storage.Storage
}
func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
// BasicAuth handles HTTP basic authentication.
func (m *middleware) serve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
clientIP := request.ClientIP(r)
username, password, authOK := r.BasicAuth()
if !authOK {
logger.Debug("[API] No authentication headers sent")
json.Unauthorized(w, r)
return
}
if err := m.store.CheckPassword(username, password); err != nil {
logger.Error("[API] [ClientIP=%s] Invalid username or password: %s", clientIP, username)
json.Unauthorized(w, r)
return
}
user, err := m.store.UserByUsername(username)
if err != nil {
logger.Error("[API] %v", err)
json.ServerError(w, r, err)
return
}
if user == nil {
logger.Error("[API] [ClientIP=%s] User not found: %s", clientIP, username)
json.Unauthorized(w, r)
return
}
logger.Info("[API] User authenticated: %s", username)
m.store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
+15 -17
View File
@@ -2,38 +2,36 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
package api // import "miniflux.app/api"
import (
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/http/response/xml"
"github.com/miniflux/miniflux/reader/opml"
"miniflux.app/http/request"
"miniflux.app/http/response/json"
"miniflux.app/http/response/xml"
"miniflux.app/reader/opml"
)
// Export is the API handler that export feeds to OPML.
func (c *Controller) Export(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(c.store)
opml, err := opmlHandler.Export(context.New(r).UserID())
func (h *handler) exportFeeds(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(h.store)
opml, err := opmlHandler.Export(request.UserID(r))
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
xml.OK(w, opml)
xml.OK(w, r, opml)
}
// Import is the API handler that import an OPML file.
func (c *Controller) Import(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(c.store)
err := opmlHandler.Import(context.New(r).UserID(), r.Body)
func (h *handler) importFeeds(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(h.store)
err := opmlHandler.Import(request.UserID(r), r.Body)
defer r.Body.Close()
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
json.Created(w, map[string]string{"message": "Feeds imported successfully"})
json.Created(w, r, map[string]string{"message": "Feeds imported successfully"})
}
+153 -32
View File
@@ -2,14 +2,14 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
package api // import "miniflux.app/api"
import (
"encoding/json"
"fmt"
"io"
"github.com/miniflux/miniflux/model"
"miniflux.app/model"
)
type feedIcon struct {
@@ -23,31 +23,156 @@ type entriesResponse struct {
Entries model.Entries `json:"entries"`
}
func decodeUserPayload(r io.ReadCloser) (*model.User, error) {
var user model.User
type feedCreation struct {
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
}
decoder := json.NewDecoder(r)
type subscriptionDiscovery struct {
URL string `json:"url"`
UserAgent string `json:"user_agent"`
Username string `json:"username"`
Password string `json:"password"`
}
type feedModification struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
}
func (f *feedModification) Update(feed *model.Feed) {
if f.FeedURL != nil && *f.FeedURL != "" {
feed.FeedURL = *f.FeedURL
}
if f.SiteURL != nil && *f.SiteURL != "" {
feed.SiteURL = *f.SiteURL
}
if f.Title != nil && *f.Title != "" {
feed.Title = *f.Title
}
if f.ScraperRules != nil {
feed.ScraperRules = *f.ScraperRules
}
if f.RewriteRules != nil {
feed.RewriteRules = *f.RewriteRules
}
if f.Crawler != nil {
feed.Crawler = *f.Crawler
}
if f.UserAgent != nil {
feed.UserAgent = *f.UserAgent
}
if f.Username != nil {
feed.Username = *f.Username
}
if f.Password != nil {
feed.Password = *f.Password
}
if f.CategoryID != nil && *f.CategoryID > 0 {
feed.Category.ID = *f.CategoryID
}
if f.Disabled != nil {
feed.Disabled = *f.Disabled
}
}
type userModification struct {
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
}
func (u *userModification) Update(user *model.User) {
if u.Username != nil {
user.Username = *u.Username
}
if u.Password != nil {
user.Password = *u.Password
}
if u.IsAdmin != nil {
user.IsAdmin = *u.IsAdmin
}
if u.Theme != nil {
user.Theme = *u.Theme
}
if u.Language != nil {
user.Language = *u.Language
}
if u.Timezone != nil {
user.Timezone = *u.Timezone
}
if u.EntryDirection != nil {
user.EntryDirection = *u.EntryDirection
}
}
func decodeUserModificationPayload(r io.ReadCloser) (*userModification, error) {
defer r.Close()
var user userModification
decoder := json.NewDecoder(r)
if err := decoder.Decode(&user); err != nil {
return nil, fmt.Errorf("Unable to decode user JSON object: %v", err)
return nil, fmt.Errorf("Unable to decode user modification JSON object: %v", err)
}
return &user, nil
}
func decodeURLPayload(r io.ReadCloser) (string, error) {
type payload struct {
URL string `json:"url"`
}
var p payload
decoder := json.NewDecoder(r)
func decodeUserCreationPayload(r io.ReadCloser) (*model.User, error) {
defer r.Close()
if err := decoder.Decode(&p); err != nil {
return "", fmt.Errorf("invalid JSON payload: %v", err)
var user model.User
decoder := json.NewDecoder(r)
if err := decoder.Decode(&user); err != nil {
return nil, fmt.Errorf("Unable to decode user modification JSON object: %v", err)
}
return p.URL, nil
return &user, nil
}
func decodeURLPayload(r io.ReadCloser) (*subscriptionDiscovery, error) {
defer r.Close()
var s subscriptionDiscovery
decoder := json.NewDecoder(r)
if err := decoder.Decode(&s); err != nil {
return nil, fmt.Errorf("invalid JSON payload: %v", err)
}
return &s, nil
}
func decodeEntryStatusPayload(r io.ReadCloser) ([]int64, string, error) {
@@ -66,29 +191,25 @@ func decodeEntryStatusPayload(r io.ReadCloser) ([]int64, string, error) {
return p.EntryIDs, p.Status, nil
}
func decodeFeedCreationPayload(r io.ReadCloser) (string, int64, error) {
type payload struct {
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
}
var p payload
decoder := json.NewDecoder(r)
func decodeFeedCreationPayload(r io.ReadCloser) (*feedCreation, error) {
defer r.Close()
if err := decoder.Decode(&p); err != nil {
return "", 0, fmt.Errorf("invalid JSON payload: %v", err)
var fc feedCreation
decoder := json.NewDecoder(r)
if err := decoder.Decode(&fc); err != nil {
return nil, fmt.Errorf("invalid JSON payload: %v", err)
}
return p.FeedURL, p.CategoryID, nil
return &fc, nil
}
func decodeFeedModificationPayload(r io.ReadCloser) (*model.Feed, error) {
var feed model.Feed
decoder := json.NewDecoder(r)
func decodeFeedModificationPayload(r io.ReadCloser) (*feedModification, error) {
defer r.Close()
var feed feedModification
decoder := json.NewDecoder(r)
if err := decoder.Decode(&feed); err != nil {
return nil, fmt.Errorf("Unable to decode feed JSON object: %v", err)
return nil, fmt.Errorf("Unable to decode feed modification JSON object: %v", err)
}
return &feed, nil
+219
View File
@@ -0,0 +1,219 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api // import "miniflux.app/api"
import (
"testing"
"miniflux.app/model"
)
func TestUpdateFeedURL(t *testing.T) {
feedURL := "http://example.com/"
changes := &feedModification{FeedURL: &feedURL}
feed := &model.Feed{FeedURL: "http://example.org/"}
changes.Update(feed)
if feed.FeedURL != feedURL {
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.FeedURL, feedURL)
}
}
func TestUpdateFeedURLWithEmptyString(t *testing.T) {
feedURL := ""
changes := &feedModification{FeedURL: &feedURL}
feed := &model.Feed{FeedURL: "http://example.org/"}
changes.Update(feed)
if feed.FeedURL == feedURL {
t.Fatal(`The FeedURL should not be modified`)
}
}
func TestUpdateFeedURLWhenNotSet(t *testing.T) {
changes := &feedModification{}
feed := &model.Feed{FeedURL: "http://example.org/"}
changes.Update(feed)
if feed.FeedURL != "http://example.org/" {
t.Fatal(`The FeedURL should not be modified`)
}
}
func TestUpdateFeedSiteURL(t *testing.T) {
siteURL := "http://example.com/"
changes := &feedModification{SiteURL: &siteURL}
feed := &model.Feed{SiteURL: "http://example.org/"}
changes.Update(feed)
if feed.SiteURL != siteURL {
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.SiteURL, siteURL)
}
}
func TestUpdateFeedSiteURLWithEmptyString(t *testing.T) {
siteURL := ""
changes := &feedModification{FeedURL: &siteURL}
feed := &model.Feed{SiteURL: "http://example.org/"}
changes.Update(feed)
if feed.SiteURL == siteURL {
t.Fatal(`The FeedURL should not be modified`)
}
}
func TestUpdateFeedSiteURLWhenNotSet(t *testing.T) {
changes := &feedModification{}
feed := &model.Feed{SiteURL: "http://example.org/"}
changes.Update(feed)
if feed.SiteURL != "http://example.org/" {
t.Fatal(`The SiteURL should not be modified`)
}
}
func TestUpdateFeedTitle(t *testing.T) {
title := "Example 2"
changes := &feedModification{Title: &title}
feed := &model.Feed{Title: "Example"}
changes.Update(feed)
if feed.Title != title {
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.Title, title)
}
}
func TestUpdateFeedTitleWithEmptyString(t *testing.T) {
title := ""
changes := &feedModification{Title: &title}
feed := &model.Feed{Title: "Example"}
changes.Update(feed)
if feed.Title == title {
t.Fatal(`The Title should not be modified`)
}
}
func TestUpdateFeedTitleWhenNotSet(t *testing.T) {
changes := &feedModification{}
feed := &model.Feed{Title: "Example"}
changes.Update(feed)
if feed.Title != "Example" {
t.Fatal(`The Title should not be modified`)
}
}
func TestUpdateFeedUsername(t *testing.T) {
username := "Alice"
changes := &feedModification{Username: &username}
feed := &model.Feed{Username: "Bob"}
changes.Update(feed)
if feed.Username != username {
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.Username, username)
}
}
func TestUpdateFeedUsernameWithEmptyString(t *testing.T) {
username := ""
changes := &feedModification{Username: &username}
feed := &model.Feed{Username: "Bob"}
changes.Update(feed)
if feed.Username != "" {
t.Fatal(`The Username should be empty now`)
}
}
func TestUpdateFeedUsernameWhenNotSet(t *testing.T) {
changes := &feedModification{}
feed := &model.Feed{Username: "Alice"}
changes.Update(feed)
if feed.Username != "Alice" {
t.Fatal(`The Username should not be modified`)
}
}
func TestUpdateFeedDisabled(t *testing.T) {
valueTrue := true
valueFalse := false
scenarios := []struct {
changes *feedModification
feed *model.Feed
expected bool
}{
{&feedModification{}, &model.Feed{Disabled: true}, true},
{&feedModification{Disabled: &valueTrue}, &model.Feed{Disabled: true}, true},
{&feedModification{Disabled: &valueFalse}, &model.Feed{Disabled: true}, false},
{&feedModification{}, &model.Feed{Disabled: false}, false},
{&feedModification{Disabled: &valueTrue}, &model.Feed{Disabled: false}, true},
{&feedModification{Disabled: &valueFalse}, &model.Feed{Disabled: false}, false},
}
for _, scenario := range scenarios {
scenario.changes.Update(scenario.feed)
if scenario.feed.Disabled != scenario.expected {
t.Errorf(`Unexpected result, got %v, want: %v`,
scenario.feed.Disabled,
scenario.expected,
)
}
}
}
func TestUpdateFeedCategory(t *testing.T) {
categoryID := int64(1)
changes := &feedModification{CategoryID: &categoryID}
feed := &model.Feed{Category: &model.Category{ID: 42}}
changes.Update(feed)
if feed.Category.ID != categoryID {
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.Username, categoryID)
}
}
func TestUpdateFeedCategoryWithZero(t *testing.T) {
categoryID := int64(0)
changes := &feedModification{CategoryID: &categoryID}
feed := &model.Feed{Category: &model.Category{ID: 42}}
changes.Update(feed)
if feed.Category.ID != 42 {
t.Fatal(`The CategoryID should not be modified`)
}
}
func TestUpdateFeedCategoryWhenNotSet(t *testing.T) {
changes := &feedModification{}
feed := &model.Feed{Category: &model.Category{ID: 42}}
changes.Update(feed)
if feed.Category.ID != 42 {
t.Fatal(`The CategoryID should not be modified`)
}
}
func TestUpdateUserTheme(t *testing.T) {
theme := "Example 2"
changes := &userModification{Theme: &theme}
user := &model.User{Theme: "Example"}
changes.Update(user)
if user.Theme != theme {
t.Fatalf(`Unexpected value, got %q instead of %q`, user.Theme, theme)
}
}
func TestUserThemeWhenNotSet(t *testing.T) {
changes := &userModification{}
user := &model.User{Theme: "Example"}
changes.Update(user)
if user.Theme != "Example" {
t.Fatal(`The user Theme should not be modified`)
}
}
+17 -15
View File
@@ -2,35 +2,37 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
package api // import "miniflux.app/api"
import (
"errors"
"fmt"
"net/http"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/reader/subscription"
"miniflux.app/http/response/json"
"miniflux.app/reader/subscription"
)
// GetSubscriptions is the API handler to find subscriptions.
func (c *Controller) GetSubscriptions(w http.ResponseWriter, r *http.Request) {
websiteURL, err := decodeURLPayload(r.Body)
if err != nil {
json.BadRequest(w, err)
func (h *handler) getSubscriptions(w http.ResponseWriter, r *http.Request) {
subscriptionInfo, bodyErr := decodeURLPayload(r.Body)
if bodyErr != nil {
json.BadRequest(w, r, bodyErr)
return
}
subscriptions, err := subscription.FindSubscriptions(websiteURL)
if err != nil {
json.ServerError(w, errors.New("Unable to discover subscriptions"))
subscriptions, finderErr := subscription.FindSubscriptions(
subscriptionInfo.URL,
subscriptionInfo.UserAgent,
subscriptionInfo.Username,
subscriptionInfo.Password,
)
if finderErr != nil {
json.ServerError(w, r, finderErr)
return
}
if subscriptions == nil {
json.NotFound(w, fmt.Errorf("No subscription found"))
json.NotFound(w, r)
return
}
json.OK(w, subscriptions)
json.OK(w, r, subscriptions)
}
+70 -100
View File
@@ -2,207 +2,177 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
package api // import "miniflux.app/api"
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
"miniflux.app/http/request"
"miniflux.app/http/response/json"
)
// CurrentUser is the API handler to retrieve the authenticated user.
func (c *Controller) CurrentUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
user, err := c.store.UserByID(ctx.UserID())
func (h *handler) currentUser(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
json.OK(w, user)
json.OK(w, r, user)
}
// CreateUser is the API handler to create a new user.
func (c *Controller) CreateUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
json.Forbidden(w)
func (h *handler) createUser(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
user, err := decodeUserPayload(r.Body)
user, err := decodeUserCreationPayload(r.Body)
if err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
if err := user.ValidateUserCreation(); err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
if c.store.UserExists(user.Username) {
json.BadRequest(w, errors.New("This user already exists"))
if h.store.UserExists(user.Username) {
json.BadRequest(w, r, errors.New("This user already exists"))
return
}
err = c.store.CreateUser(user)
err = h.store.CreateUser(user)
if err != nil {
json.ServerError(w, errors.New("Unable to create this user"))
json.ServerError(w, r, err)
return
}
user.Password = ""
json.Created(w, user)
json.Created(w, r, user)
}
// UpdateUser is the API handler to update the given user.
func (c *Controller) UpdateUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
json.Forbidden(w)
func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
userID, err := request.IntParam(r, "userID")
userID := request.RouteInt64Param(r, "userID")
userChanges, err := decodeUserModificationPayload(r.Body)
if err != nil {
json.BadRequest(w, err)
json.BadRequest(w, r, err)
return
}
user, err := decodeUserPayload(r.Body)
originalUser, err := h.store.UserByID(userID)
if err != nil {
json.BadRequest(w, err)
return
}
if err := user.ValidateUserModification(); err != nil {
json.BadRequest(w, err)
return
}
originalUser, err := c.store.UserByID(userID)
if err != nil {
json.BadRequest(w, errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, r, errors.New("Unable to fetch this user from the database"))
return
}
if originalUser == nil {
json.NotFound(w, errors.New("User not found"))
json.NotFound(w, r)
return
}
originalUser.Merge(user)
if err = c.store.UpdateUser(originalUser); err != nil {
json.ServerError(w, errors.New("Unable to update this user"))
userChanges.Update(originalUser)
if err := originalUser.ValidateUserModification(); err != nil {
json.BadRequest(w, r, err)
return
}
json.Created(w, originalUser)
if err = h.store.UpdateUser(originalUser); err != nil {
json.ServerError(w, r, err)
return
}
json.Created(w, r, originalUser)
}
// Users is the API handler to get the list of users.
func (c *Controller) Users(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
json.Forbidden(w)
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
users, err := c.store.Users()
users, err := h.store.Users()
if err != nil {
json.ServerError(w, errors.New("Unable to fetch the list of users"))
json.ServerError(w, r, err)
return
}
users.UseTimezone(ctx.UserTimezone())
json.OK(w, users)
users.UseTimezone(request.UserTimezone(r))
json.OK(w, r, users)
}
// UserByID is the API handler to fetch the given user by the ID.
func (c *Controller) UserByID(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
json.Forbidden(w)
func (h *handler) userByID(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
userID, err := request.IntParam(r, "userID")
userID := request.RouteInt64Param(r, "userID")
user, err := h.store.UserByID(userID)
if err != nil {
json.BadRequest(w, err)
return
}
user, err := c.store.UserByID(userID)
if err != nil {
json.BadRequest(w, errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, r, errors.New("Unable to fetch this user from the database"))
return
}
if user == nil {
json.NotFound(w, errors.New("User not found"))
json.NotFound(w, r)
return
}
user.UseTimezone(ctx.UserTimezone())
json.OK(w, user)
user.UseTimezone(request.UserTimezone(r))
json.OK(w, r, user)
}
// UserByUsername is the API handler to fetch the given user by the username.
func (c *Controller) UserByUsername(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
json.Forbidden(w)
func (h *handler) userByUsername(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
username := request.Param(r, "username", "")
user, err := c.store.UserByUsername(username)
username := request.RouteStringParam(r, "username")
user, err := h.store.UserByUsername(username)
if err != nil {
json.BadRequest(w, errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, r, errors.New("Unable to fetch this user from the database"))
return
}
if user == nil {
json.NotFound(w, errors.New("User not found"))
json.NotFound(w, r)
return
}
json.OK(w, user)
json.OK(w, r, user)
}
// RemoveUser is the API handler to remove an existing user.
func (c *Controller) RemoveUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
json.Forbidden(w)
func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
return
}
userID, err := request.IntParam(r, "userID")
userID := request.RouteInt64Param(r, "userID")
user, err := h.store.UserByID(userID)
if err != nil {
json.BadRequest(w, err)
return
}
user, err := c.store.UserByID(userID)
if err != nil {
json.ServerError(w, errors.New("Unable to fetch this user from the database"))
json.ServerError(w, r, err)
return
}
if user == nil {
json.NotFound(w, errors.New("User not found"))
json.NotFound(w, r)
return
}
if err := c.store.RemoveUser(user.ID); err != nil {
json.BadRequest(w, errors.New("Unable to remove this user from the database"))
if err := h.store.RemoveUser(user.ID); err != nil {
json.BadRequest(w, r, errors.New("Unable to remove this user from the database"))
return
}
json.NoContent(w)
json.NoContent(w, r)
}
+13 -3
View File
@@ -2,7 +2,7 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package cli
package cli // import "miniflux.app/cli"
import (
"bufio"
@@ -14,13 +14,23 @@ import (
)
func askCredentials() (string, string) {
reader := bufio.NewReader(os.Stdin)
fd := int(os.Stdin.Fd())
if !terminal.IsTerminal(fd) {
fmt.Fprintf(os.Stderr, "This is not a terminal, exiting.\n")
os.Exit(1)
}
fmt.Print("Enter Username: ")
reader := bufio.NewReader(os.Stdin)
username, _ := reader.ReadString('\n')
fmt.Print("Enter Password: ")
bytePassword, _ := terminal.ReadPassword(0)
state, _ := terminal.GetState(fd)
defer terminal.Restore(fd, state)
bytePassword, _ := terminal.ReadPassword(fd)
fmt.Printf("\n")
return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
+101 -31
View File
@@ -2,80 +2,150 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package cli
package cli // import "miniflux.app/cli"
import (
"flag"
"fmt"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/daemon"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/storage"
"github.com/miniflux/miniflux/version"
"miniflux.app/config"
"miniflux.app/database"
"miniflux.app/logger"
"miniflux.app/storage"
"miniflux.app/version"
)
const (
flagInfoHelp = "Show application information"
flagVersionHelp = "Show application version"
flagMigrateHelp = "Run SQL migrations"
flagFlushSessionsHelp = "Flush all sessions (disconnect users)"
flagCreateAdminHelp = "Create admin user"
flagResetPasswordHelp = "Reset user password"
flagResetFeedErrorsHelp = "Clear all feed errors for all users"
flagDebugModeHelp = "Show debug logs"
flagConfigFileHelp = "Load configuration file"
flagConfigDumpHelp = "Print parsed configuration values"
)
// Parse parses command line arguments.
func Parse() {
flagInfo := flag.Bool("info", false, "Show application information")
flagVersion := flag.Bool("version", false, "Show application version")
flagMigrate := flag.Bool("migrate", false, "Migrate database schema")
flagFlushSessions := flag.Bool("flush-sessions", false, "Flush all sessions (disconnect users)")
flagCreateAdmin := flag.Bool("create-admin", false, "Create admin user")
flagResetPassword := flag.Bool("reset-password", false, "Reset user password")
flagDebugMode := flag.Bool("debug", false, "Enable debug mode (more verbose output)")
var (
err error
flagInfo bool
flagVersion bool
flagMigrate bool
flagFlushSessions bool
flagCreateAdmin bool
flagResetPassword bool
flagResetFeedErrors bool
flagDebugMode bool
flagConfigFile string
flagConfigDump bool
)
flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
flag.BoolVar(&flagInfo, "i", false, flagInfoHelp)
flag.BoolVar(&flagVersion, "version", false, flagVersionHelp)
flag.BoolVar(&flagVersion, "v", false, flagVersionHelp)
flag.BoolVar(&flagMigrate, "migrate", false, flagMigrateHelp)
flag.BoolVar(&flagFlushSessions, "flush-sessions", false, flagFlushSessionsHelp)
flag.BoolVar(&flagCreateAdmin, "create-admin", false, flagCreateAdminHelp)
flag.BoolVar(&flagResetPassword, "reset-password", false, flagResetPasswordHelp)
flag.BoolVar(&flagResetFeedErrors, "reset-feed-errors", false, flagResetFeedErrorsHelp)
flag.BoolVar(&flagDebugMode, "debug", false, flagDebugModeHelp)
flag.StringVar(&flagConfigFile, "config-file", "", flagConfigFileHelp)
flag.StringVar(&flagConfigFile, "c", "", flagConfigFileHelp)
flag.BoolVar(&flagConfigDump, "config-dump", false, flagConfigDumpHelp)
flag.Parse()
cfg := config.NewConfig()
cfg := config.NewParser()
if *flagDebugMode || cfg.HasDebugMode() {
if flagConfigFile != "" {
config.Opts, err = cfg.ParseFile(flagConfigFile)
if err != nil {
logger.Fatal("%v", err)
}
}
config.Opts, err = cfg.ParseEnvironmentVariables()
if err != nil {
logger.Fatal("%v", err)
}
if flagConfigDump {
fmt.Print(config.Opts)
return
}
if config.Opts.LogDateTime() {
logger.EnableDateTime()
}
if flagDebugMode || config.Opts.HasDebugMode() {
logger.EnableDebug()
}
store := storage.NewStorage(
cfg.DatabaseURL(),
cfg.DatabaseMaxConnections(),
)
if *flagInfo {
if flagInfo {
info()
return
}
if *flagVersion {
if flagVersion {
fmt.Println(version.Version)
return
}
if *flagMigrate {
store.Migrate()
if config.Opts.IsDefaultDatabaseURL() {
logger.Info("The default value for DATABASE_URL is used")
}
db, err := database.NewConnectionPool(
config.Opts.DatabaseURL(),
config.Opts.DatabaseMinConns(),
config.Opts.DatabaseMaxConns(),
)
if err != nil {
logger.Fatal("Unable to connect to the database: %v", err)
}
defer db.Close()
if flagMigrate {
database.Migrate(db)
return
}
if *flagFlushSessions {
store := storage.NewStorage(db)
if flagResetFeedErrors {
store.ResetFeedErrors()
return
}
if flagFlushSessions {
flushSessions(store)
return
}
if *flagCreateAdmin {
if flagCreateAdmin {
createAdmin(store)
return
}
if *flagResetPassword {
if flagResetPassword {
resetPassword(store)
return
}
// Run migrations and start the deamon.
if cfg.RunMigrations() {
store.Migrate()
if config.Opts.RunMigrations() {
database.Migrate(db)
}
// Create admin user and start the deamon.
if cfg.CreateAdmin() {
if config.Opts.CreateAdmin() {
createAdmin(store)
}
daemon.Run(cfg, store)
startDaemon(store)
}
+11 -5
View File
@@ -2,14 +2,15 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package cli
package cli // import "miniflux.app/cli"
import (
"fmt"
"os"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/storage"
"miniflux.app/logger"
"miniflux.app/model"
"miniflux.app/storage"
)
func createAdmin(store *storage.Storage) {
@@ -23,12 +24,17 @@ func createAdmin(store *storage.Storage) {
}
if err := user.ValidateUserCreation(); err != nil {
fmt.Println(err)
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
if store.UserExists(user.Username) {
logger.Info(`User %q already exists, skipping creation`, user.Username)
return
}
if err := store.CreateUser(user); err != nil {
fmt.Println(err)
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package cli // import "miniflux.app/cli"
import (
"context"
"net/http"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"miniflux.app/config"
"miniflux.app/logger"
"miniflux.app/reader/feed"
"miniflux.app/service/httpd"
"miniflux.app/service/scheduler"
"miniflux.app/storage"
"miniflux.app/worker"
)
func startDaemon(store *storage.Storage) {
logger.Info("Starting Miniflux...")
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt)
signal.Notify(stop, syscall.SIGTERM)
feedHandler := feed.NewFeedHandler(store)
pool := worker.NewPool(feedHandler, config.Opts.WorkerPoolSize())
go showProcessStatistics()
if config.Opts.HasSchedulerService() {
scheduler.Serve(store, pool)
}
var httpServer *http.Server
if config.Opts.HasHTTPService() {
httpServer = httpd.Serve(store, pool, feedHandler)
}
<-stop
logger.Info("Shutting down the process...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if httpServer != nil {
httpServer.Shutdown(ctx)
}
logger.Info("Process gracefully stopped")
}
func showProcessStatistics() {
for {
var m runtime.MemStats
runtime.ReadMemStats(&m)
logger.Debug("Sys=%vK, InUse=%vK, HeapInUse=%vK, StackSys=%vK, StackInUse=%vK, GoRoutines=%d, NumCPU=%d",
m.Sys/1024, (m.Sys-m.HeapReleased)/1024, m.HeapInuse/1024, m.StackSys/1024, m.StackInuse/1024,
runtime.NumGoroutine(), runtime.NumCPU())
time.Sleep(30 * time.Second)
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package cli implements command line arguments for Miniflux application.
*/
package cli
package cli // import "miniflux.app/cli"
+3 -3
View File
@@ -2,19 +2,19 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package cli
package cli // import "miniflux.app/cli"
import (
"fmt"
"os"
"github.com/miniflux/miniflux/storage"
"miniflux.app/storage"
)
func flushSessions(store *storage.Storage) {
fmt.Println("Flushing all sessions (disconnect users)")
if err := store.FlushAllSessions(); err != nil {
fmt.Println(err)
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
+5 -2
View File
@@ -2,17 +2,20 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package cli
package cli // import "miniflux.app/cli"
import (
"fmt"
"runtime"
"github.com/miniflux/miniflux/version"
"miniflux.app/version"
)
func info() {
fmt.Println("Version:", version.Version)
fmt.Println("Build Date:", version.BuildDate)
fmt.Println("Go Version:", runtime.Version())
fmt.Println("Compiler:", runtime.Compiler)
fmt.Println("Arch:", runtime.GOARCH)
fmt.Println("OS:", runtime.GOOS)
}
+6 -6
View File
@@ -2,36 +2,36 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package cli
package cli // import "miniflux.app/cli"
import (
"fmt"
"os"
"github.com/miniflux/miniflux/storage"
"miniflux.app/storage"
)
func resetPassword(store *storage.Storage) {
username, password := askCredentials()
user, err := store.UserByUsername(username)
if err != nil {
fmt.Println(err)
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
if user == nil {
fmt.Println("User not found!")
fmt.Fprintf(os.Stderr, "User not found!\n")
os.Exit(1)
}
user.Password = password
if err := user.ValidatePassword(); err != nil {
fmt.Println(err)
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
if err := store.UpdateUser(user); err != nil {
fmt.Println(err)
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
+50
View File
@@ -0,0 +1,50 @@
Miniflux API Client
===================
Client library for Miniflux REST API.
Installation
------------
```bash
go get -u miniflux.app/client
```
Example
-------
```go
package main
import (
"fmt"
"io/ioutil"
miniflux "miniflux.app/client"
)
func main() {
client := miniflux.New("https://api.example.org", "admin", "secret")
// Fetch all feeds.
feeds, err := client.Feeds()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(feeds)
// Backup your feeds to an OPML file.
opml, err := client.Export()
if err != nil {
fmt.Println(err)
return
}
err = ioutil.WriteFile("opml.xml", opml, 0644)
if err != nil {
fmt.Println(err)
return
}
}
```
@@ -1,8 +1,8 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package miniflux
package client // import "miniflux.app/client"
import (
"encoding/json"
@@ -13,7 +13,7 @@ import (
"strconv"
)
// Client represents a Miniflux client.
// Client holds API procedure calls.
type Client struct {
request *request
}
@@ -104,8 +104,8 @@ func (c *Client) CreateUser(username, password string, isAdmin bool) (*User, err
}
// UpdateUser updates a user in the system.
func (c *Client) UpdateUser(user *User) (*User, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/users/%d", user.ID), user)
func (c *Client) UpdateUser(userID int64, userChanges *UserModification) (*User, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/users/%d", userID), userChanges)
if err != nil {
return nil, err
}
@@ -296,8 +296,8 @@ func (c *Client) CreateFeed(url string, categoryID int64) (int64, error) {
}
// UpdateFeed updates a feed.
func (c *Client) UpdateFeed(feed *Feed) (*Feed, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d", feed.ID), feed)
func (c *Client) UpdateFeed(feedID int64, feedChanges *FeedModification) (*Feed, error) {
body, err := c.request.Put(fmt.Sprintf("/v1/feeds/%d", feedID), feedChanges)
if err != nil {
return nil, err
}
@@ -448,8 +448,8 @@ func (c *Client) ToggleBookmark(entryID int64) error {
return nil
}
// NewClient returns a new Client.
func NewClient(endpoint, username, password string) *Client {
// New returns a new Miniflux client.
func New(endpoint, username, password string) *Client {
return &Client{request: &request{endpoint: endpoint, username: username, password: password}}
}
@@ -477,6 +477,30 @@ func buildFilterQueryString(path string, filter *Filter) string {
values.Set("offset", strconv.Itoa(filter.Offset))
}
if filter.After > 0 {
values.Set("after", strconv.FormatInt(filter.After, 10))
}
if filter.AfterEntryID > 0 {
values.Set("after_entry_id", strconv.FormatInt(filter.AfterEntryID, 10))
}
if filter.Before > 0 {
values.Set("before", strconv.FormatInt(filter.Before, 10))
}
if filter.BeforeEntryID > 0 {
values.Set("before_entry_id", strconv.FormatInt(filter.BeforeEntryID, 10))
}
if filter.Starred {
values.Set("starred", "1")
}
if filter.Search != "" {
values.Set("search", filter.Search)
}
path = fmt.Sprintf("%s?%s", path, values.Encode())
}
@@ -1,8 +1,8 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package miniflux
package client // import "miniflux.app/client"
import (
"fmt"
@@ -34,6 +34,17 @@ func (u User) String() string {
return fmt.Sprintf("#%d - %s (admin=%v)", u.ID, u.Username, u.IsAdmin)
}
// UserModification is used to update a user.
type UserModification struct {
Username *string `json:"username"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
Theme *string `json:"theme"`
Language *string `json:"language"`
Timezone *string `json:"timezone"`
EntryDirection *string `json:"entry_sorting_direction"`
}
// Users represents a list of users.
type Users []User
@@ -77,10 +88,30 @@ type Feed struct {
LastModifiedHeader string `json:"last_modified_header,omitempty"`
ParsingErrorMsg string `json:"parsing_error_message,omitempty"`
ParsingErrorCount int `json:"parsing_error_count,omitempty"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
Crawler bool `json:"crawler"`
UserAgent string `json:"user_agent"`
Username string `json:"username"`
Password string `json:"password"`
Category *Category `json:"category,omitempty"`
Entries Entries `json:"entries,omitempty"`
}
// FeedModification represents changes for a feed.
type FeedModification struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
}
// FeedIcon represents the feed icon.
type FeedIcon struct {
ID int64 `json:"id"`
@@ -127,11 +158,17 @@ type Enclosures []*Enclosure
// Filter is used to filter entries.
type Filter struct {
Status string
Offset int
Limit int
Order string
Direction string
Status string
Offset int
Limit int
Order string
Direction string
Starred bool
Before int64
After int64
BeforeEntryID int64
AfterEntryID int64
Search string
}
// EntryResultSet represents the response when fetching entries.
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package client implements a client library for the Miniflux REST API.
Examples
This code snippet fetch the list of users:
import (
miniflux "miniflux.app/client"
)
client := miniflux.New("https://api.example.org", "admin", "secret")
users, err := client.Users()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(users, err)
This one discover subscriptions on a website:
subscriptions, err := client.Discover("https://example.org/")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(subscriptions)
*/
package client // import "miniflux.app/client"
@@ -1,8 +1,8 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package miniflux
package client // import "miniflux.app/client"
import (
"bytes"
@@ -18,7 +18,7 @@ import (
)
const (
userAgent = "Miniflux Client Library <https://github.com/miniflux/miniflux-go>"
userAgent = "Miniflux Client Library"
defaultTimeout = 80
)
+4 -204
View File
@@ -1,208 +1,8 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package config
package config // import "miniflux.app/config"
import (
"net/url"
"os"
"strconv"
"github.com/miniflux/miniflux/logger"
)
const (
defaultBaseURL = "http://localhost"
defaultDatabaseURL = "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"
defaultWorkerPoolSize = 5
defaultPollingFrequency = 60
defaultBatchSize = 10
defaultDatabaseMaxConns = 20
defaultListenAddr = "127.0.0.1:8080"
defaultCertFile = ""
defaultKeyFile = ""
defaultCertDomain = ""
defaultCertCache = "/tmp/cert_cache"
defaultCleanupFrequency = 24
)
// Config manages configuration parameters.
type Config struct {
IsHTTPS bool
baseURL string
rootURL string
basePath string
}
func (c *Config) get(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
func (c *Config) getInt(key string, fallback int) int {
value := os.Getenv(key)
if value == "" {
return fallback
}
v, _ := strconv.Atoi(value)
return v
}
// HasDebugMode returns true if debug mode is enabled.
func (c *Config) HasDebugMode() bool {
return c.get("DEBUG", "") != ""
}
// BaseURL returns the application base URL with path.
func (c *Config) BaseURL() string {
if c.baseURL == "" {
c.baseURL = c.get("BASE_URL", defaultBaseURL)
if c.baseURL[len(c.baseURL)-1:] == "/" {
c.baseURL = c.baseURL[:len(c.baseURL)-1]
}
}
return c.baseURL
}
// RootURL returns the base URL without path.
func (c *Config) RootURL() string {
if c.rootURL == "" {
u, _ := url.Parse(c.BaseURL())
u.Path = ""
c.rootURL = u.String()
}
return c.rootURL
}
// BasePath returns the application base path according to the base URL.
func (c *Config) BasePath() string {
if c.basePath == "" {
u, _ := url.Parse(c.BaseURL())
c.basePath = u.Path
}
return c.basePath
}
// DatabaseURL returns the database URL.
func (c *Config) DatabaseURL() string {
value, exists := os.LookupEnv("DATABASE_URL")
if !exists {
logger.Info("The environment variable DATABASE_URL is not configured (the default value is used instead)")
}
if value == "" {
value = defaultDatabaseURL
}
return value
}
// DatabaseMaxConnections returns the number of maximum database connections.
func (c *Config) DatabaseMaxConnections() int {
return c.getInt("DATABASE_MAX_CONNS", defaultDatabaseMaxConns)
}
// ListenAddr returns the listen address for the HTTP server.
func (c *Config) ListenAddr() string {
if port := os.Getenv("PORT"); port != "" {
return ":" + port
}
return c.get("LISTEN_ADDR", defaultListenAddr)
}
// CertFile returns the SSL certificate filename if any.
func (c *Config) CertFile() string {
return c.get("CERT_FILE", defaultCertFile)
}
// KeyFile returns the private key filename for custom SSL certificate.
func (c *Config) KeyFile() string {
return c.get("KEY_FILE", defaultKeyFile)
}
// CertDomain returns the domain to use for Let's Encrypt certificate.
func (c *Config) CertDomain() string {
return c.get("CERT_DOMAIN", defaultCertDomain)
}
// CertCache returns the directory to use for Let's Encrypt session cache.
func (c *Config) CertCache() string {
return c.get("CERT_CACHE", defaultCertCache)
}
// CleanupFrequency returns the interval for cleanup jobs.
func (c *Config) CleanupFrequency() int {
return c.getInt("CLEANUP_FREQUENCY", defaultCleanupFrequency)
}
// WorkerPoolSize returns the number of background worker.
func (c *Config) WorkerPoolSize() int {
return c.getInt("WORKER_POOL_SIZE", defaultWorkerPoolSize)
}
// PollingFrequency returns the interval to refresh feeds in the background.
func (c *Config) PollingFrequency() int {
return c.getInt("POLLING_FREQUENCY", defaultPollingFrequency)
}
// BatchSize returns the number of feeds to send for background processing.
func (c *Config) BatchSize() int {
return c.getInt("BATCH_SIZE", defaultBatchSize)
}
// IsOAuth2UserCreationAllowed returns true if user creation is allowed for OAuth2 users.
func (c *Config) IsOAuth2UserCreationAllowed() bool {
return c.getInt("OAUTH2_USER_CREATION", 0) == 1
}
// OAuth2ClientID returns the OAuth2 Client ID.
func (c *Config) OAuth2ClientID() string {
return c.get("OAUTH2_CLIENT_ID", "")
}
// OAuth2ClientSecret returns the OAuth2 client secret.
func (c *Config) OAuth2ClientSecret() string {
return c.get("OAUTH2_CLIENT_SECRET", "")
}
// OAuth2RedirectURL returns the OAuth2 redirect URL.
func (c *Config) OAuth2RedirectURL() string {
return c.get("OAUTH2_REDIRECT_URL", "")
}
// OAuth2Provider returns the name of the OAuth2 provider configured.
func (c *Config) OAuth2Provider() string {
return c.get("OAUTH2_PROVIDER", "")
}
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
func (c *Config) HasHSTS() bool {
return c.get("DISABLE_HSTS", "") == ""
}
// RunMigrations returns true if the environment variable RUN_MIGRATIONS is not empty.
func (c *Config) RunMigrations() bool {
return c.get("RUN_MIGRATIONS", "") != ""
}
// CreateAdmin returns true if the environment variable CREATE_ADMIN is not empty.
func (c *Config) CreateAdmin() bool {
return c.get("CREATE_ADMIN", "") != ""
}
// PocketConsumerKey returns the Pocket Consumer Key if defined as environment variable.
func (c *Config) PocketConsumerKey(defaultValue string) string {
return c.get("POCKET_CONSUMER_KEY", defaultValue)
}
// NewConfig returns a new Config.
func NewConfig() *Config {
return &Config{IsHTTPS: os.Getenv("HTTPS") != ""}
}
// Opts holds parsed configuration options.
var Opts *Options
+1195 -37
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,10 +1,10 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
// Copyright 2019 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package config handles configuration values for Miniflux application.
Package config handles configuration management for the application.
*/
package config
package config // import "miniflux.app/config"
+337
View File
@@ -0,0 +1,337 @@
// Copyright 2019 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package config // import "miniflux.app/config"
import (
"fmt"
"strings"
)
const (
defaultHTTPS = false
defaultLogDateTime = false
defaultHSTS = true
defaultHTTPService = true
defaultSchedulerService = true
defaultDebug = false
defaultBaseURL = "http://localhost"
defaultRootURL = "http://localhost"
defaultBasePath = ""
defaultWorkerPoolSize = 5
defaultPollingFrequency = 60
defaultBatchSize = 10
defaultRunMigrations = false
defaultDatabaseURL = "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
defaultDatabaseMaxConns = 20
defaultDatabaseMinConns = 1
defaultListenAddr = "127.0.0.1:8080"
defaultCertFile = ""
defaultKeyFile = ""
defaultCertDomain = ""
defaultCertCache = "/tmp/cert_cache"
defaultCleanupFrequencyHours = 24
defaultCleanupArchiveReadDays = 60
defaultCleanupRemoveSessionsDays = 30
defaultProxyImages = "http-only"
defaultCreateAdmin = false
defaultOAuth2UserCreation = false
defaultOAuth2ClientID = ""
defaultOAuth2ClientSecret = ""
defaultOAuth2RedirectURL = ""
defaultOAuth2Provider = ""
defaultPocketConsumerKey = ""
defaultHTTPClientTimeout = 20
defaultHTTPClientMaxBodySize = 15
)
// Options contains configuration options.
type Options struct {
HTTPS bool
logDateTime bool
hsts bool
httpService bool
schedulerService bool
debug bool
baseURL string
rootURL string
basePath string
databaseURL string
databaseMaxConns int
databaseMinConns int
runMigrations bool
listenAddr string
certFile string
certDomain string
certCache string
certKeyFile string
cleanupFrequencyHours int
cleanupArchiveReadDays int
cleanupRemoveSessionsDays int
pollingFrequency int
batchSize int
workerPoolSize int
createAdmin bool
proxyImages string
oauth2UserCreationAllowed bool
oauth2ClientID string
oauth2ClientSecret string
oauth2RedirectURL string
oauth2Provider string
pocketConsumerKey string
httpClientTimeout int
httpClientMaxBodySize int64
}
// NewOptions returns Options with default values.
func NewOptions() *Options {
return &Options{
HTTPS: defaultHTTPS,
logDateTime: defaultLogDateTime,
hsts: defaultHSTS,
httpService: defaultHTTPService,
schedulerService: defaultSchedulerService,
debug: defaultDebug,
baseURL: defaultBaseURL,
rootURL: defaultRootURL,
basePath: defaultBasePath,
databaseURL: defaultDatabaseURL,
databaseMaxConns: defaultDatabaseMaxConns,
databaseMinConns: defaultDatabaseMinConns,
runMigrations: defaultRunMigrations,
listenAddr: defaultListenAddr,
certFile: defaultCertFile,
certDomain: defaultCertDomain,
certCache: defaultCertCache,
certKeyFile: defaultKeyFile,
cleanupFrequencyHours: defaultCleanupFrequencyHours,
cleanupArchiveReadDays: defaultCleanupArchiveReadDays,
cleanupRemoveSessionsDays: defaultCleanupRemoveSessionsDays,
pollingFrequency: defaultPollingFrequency,
batchSize: defaultBatchSize,
workerPoolSize: defaultWorkerPoolSize,
createAdmin: defaultCreateAdmin,
proxyImages: defaultProxyImages,
oauth2UserCreationAllowed: defaultOAuth2UserCreation,
oauth2ClientID: defaultOAuth2ClientID,
oauth2ClientSecret: defaultOAuth2ClientSecret,
oauth2RedirectURL: defaultOAuth2RedirectURL,
oauth2Provider: defaultOAuth2Provider,
pocketConsumerKey: defaultPocketConsumerKey,
httpClientTimeout: defaultHTTPClientTimeout,
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
}
}
// LogDateTime returns true if the date/time should be displayed in log messages.
func (o *Options) LogDateTime() bool {
return o.logDateTime
}
// HasDebugMode returns true if debug mode is enabled.
func (o *Options) HasDebugMode() bool {
return o.debug
}
// BaseURL returns the application base URL with path.
func (o *Options) BaseURL() string {
return o.baseURL
}
// RootURL returns the base URL without path.
func (o *Options) RootURL() string {
return o.rootURL
}
// BasePath returns the application base path according to the base URL.
func (o *Options) BasePath() string {
return o.basePath
}
// IsDefaultDatabaseURL returns true if the default database URL is used.
func (o *Options) IsDefaultDatabaseURL() bool {
return o.databaseURL == defaultDatabaseURL
}
// DatabaseURL returns the database URL.
func (o *Options) DatabaseURL() string {
return o.databaseURL
}
// DatabaseMaxConns returns the maximum number of database connections.
func (o *Options) DatabaseMaxConns() int {
return o.databaseMaxConns
}
// DatabaseMinConns returns the minimum number of database connections.
func (o *Options) DatabaseMinConns() int {
return o.databaseMinConns
}
// ListenAddr returns the listen address for the HTTP server.
func (o *Options) ListenAddr() string {
return o.listenAddr
}
// CertFile returns the SSL certificate filename if any.
func (o *Options) CertFile() string {
return o.certFile
}
// CertKeyFile returns the private key filename for custom SSL certificate.
func (o *Options) CertKeyFile() string {
return o.certKeyFile
}
// CertDomain returns the domain to use for Let's Encrypt certificate.
func (o *Options) CertDomain() string {
return o.certDomain
}
// CertCache returns the directory to use for Let's Encrypt session cache.
func (o *Options) CertCache() string {
return o.certCache
}
// CleanupFrequencyHours returns the interval in hours for cleanup jobs.
func (o *Options) CleanupFrequencyHours() int {
return o.cleanupFrequencyHours
}
// CleanupArchiveReadDays returns the number of days after which marking read items as removed.
func (o *Options) CleanupArchiveReadDays() int {
return o.cleanupArchiveReadDays
}
// CleanupRemoveSessionsDays returns the number of days after which to remove sessions.
func (o *Options) CleanupRemoveSessionsDays() int {
return o.cleanupRemoveSessionsDays
}
// WorkerPoolSize returns the number of background worker.
func (o *Options) WorkerPoolSize() int {
return o.workerPoolSize
}
// PollingFrequency returns the interval to refresh feeds in the background.
func (o *Options) PollingFrequency() int {
return o.pollingFrequency
}
// BatchSize returns the number of feeds to send for background processing.
func (o *Options) BatchSize() int {
return o.batchSize
}
// IsOAuth2UserCreationAllowed returns true if user creation is allowed for OAuth2 users.
func (o *Options) IsOAuth2UserCreationAllowed() bool {
return o.oauth2UserCreationAllowed
}
// OAuth2ClientID returns the OAuth2 Client ID.
func (o *Options) OAuth2ClientID() string {
return o.oauth2ClientID
}
// OAuth2ClientSecret returns the OAuth2 client secret.
func (o *Options) OAuth2ClientSecret() string {
return o.oauth2ClientSecret
}
// OAuth2RedirectURL returns the OAuth2 redirect URL.
func (o *Options) OAuth2RedirectURL() string {
return o.oauth2RedirectURL
}
// OAuth2Provider returns the name of the OAuth2 provider configured.
func (o *Options) OAuth2Provider() string {
return o.oauth2Provider
}
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
func (o *Options) HasHSTS() bool {
return o.hsts
}
// RunMigrations returns true if the environment variable RUN_MIGRATIONS is not empty.
func (o *Options) RunMigrations() bool {
return o.runMigrations
}
// CreateAdmin returns true if the environment variable CREATE_ADMIN is not empty.
func (o *Options) CreateAdmin() bool {
return o.createAdmin
}
// ProxyImages returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
func (o *Options) ProxyImages() string {
return o.proxyImages
}
// HasHTTPService returns true if the HTTP service is enabled.
func (o *Options) HasHTTPService() bool {
return o.httpService
}
// HasSchedulerService returns true if the scheduler service is enabled.
func (o *Options) HasSchedulerService() bool {
return o.schedulerService
}
// PocketConsumerKey returns the Pocket Consumer Key if configured.
func (o *Options) PocketConsumerKey(defaultValue string) string {
if o.pocketConsumerKey != "" {
return o.pocketConsumerKey
}
return defaultValue
}
// HTTPClientTimeout returns the time limit in seconds before the HTTP client cancel the request.
func (o *Options) HTTPClientTimeout() int {
return o.httpClientTimeout
}
// HTTPClientMaxBodySize returns the number of bytes allowed for the HTTP client to transfer.
func (o *Options) HTTPClientMaxBodySize() int64 {
return o.httpClientMaxBodySize
}
func (o *Options) String() string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("LOG_DATE_TIME: %v\n", o.logDateTime))
builder.WriteString(fmt.Sprintf("DEBUG: %v\n", o.debug))
builder.WriteString(fmt.Sprintf("HTTP_SERVICE: %v\n", o.httpService))
builder.WriteString(fmt.Sprintf("SCHEDULER_SERVICE: %v\n", o.schedulerService))
builder.WriteString(fmt.Sprintf("HTTPS: %v\n", o.HTTPS))
builder.WriteString(fmt.Sprintf("HSTS: %v\n", o.hsts))
builder.WriteString(fmt.Sprintf("BASE_URL: %v\n", o.baseURL))
builder.WriteString(fmt.Sprintf("ROOT_URL: %v\n", o.rootURL))
builder.WriteString(fmt.Sprintf("BASE_PATH: %v\n", o.basePath))
builder.WriteString(fmt.Sprintf("LISTEN_ADDR: %v\n", o.listenAddr))
builder.WriteString(fmt.Sprintf("DATABASE_URL: %v\n", o.databaseURL))
builder.WriteString(fmt.Sprintf("DATABASE_MAX_CONNS: %v\n", o.databaseMaxConns))
builder.WriteString(fmt.Sprintf("DATABASE_MIN_CONNS: %v\n", o.databaseMinConns))
builder.WriteString(fmt.Sprintf("RUN_MIGRATIONS: %v\n", o.runMigrations))
builder.WriteString(fmt.Sprintf("CERT_FILE: %v\n", o.certFile))
builder.WriteString(fmt.Sprintf("KEY_FILE: %v\n", o.certKeyFile))
builder.WriteString(fmt.Sprintf("CERT_DOMAIN: %v\n", o.certDomain))
builder.WriteString(fmt.Sprintf("CERT_CACHE: %v\n", o.certCache))
builder.WriteString(fmt.Sprintf("CLEANUP_FREQUENCY_HOURS: %v\n", o.cleanupFrequencyHours))
builder.WriteString(fmt.Sprintf("CLEANUP_ARCHIVE_READ_DAYS: %v\n", o.cleanupArchiveReadDays))
builder.WriteString(fmt.Sprintf("CLEANUP_REMOVE_SESSIONS_DAYS: %v\n", o.cleanupRemoveSessionsDays))
builder.WriteString(fmt.Sprintf("WORKER_POOL_SIZE: %v\n", o.workerPoolSize))
builder.WriteString(fmt.Sprintf("POLLING_FREQUENCY: %v\n", o.pollingFrequency))
builder.WriteString(fmt.Sprintf("BATCH_SIZE: %v\n", o.batchSize))
builder.WriteString(fmt.Sprintf("PROXY_IMAGES: %v\n", o.proxyImages))
builder.WriteString(fmt.Sprintf("CREATE_ADMIN: %v\n", o.createAdmin))
builder.WriteString(fmt.Sprintf("POCKET_CONSUMER_KEY: %v\n", o.pocketConsumerKey))
builder.WriteString(fmt.Sprintf("OAUTH2_USER_CREATION: %v\n", o.oauth2UserCreationAllowed))
builder.WriteString(fmt.Sprintf("OAUTH2_CLIENT_ID: %v\n", o.oauth2ClientID))
builder.WriteString(fmt.Sprintf("OAUTH2_CLIENT_SECRET: %v\n", o.oauth2ClientSecret))
builder.WriteString(fmt.Sprintf("OAUTH2_REDIRECT_URL: %v\n", o.oauth2RedirectURL))
builder.WriteString(fmt.Sprintf("OAUTH2_PROVIDER: %v\n", o.oauth2Provider))
builder.WriteString(fmt.Sprintf("HTTP_CLIENT_TIMEOUT: %v\n", o.httpClientTimeout))
builder.WriteString(fmt.Sprintf("HTTP_CLIENT_MAX_BODY_SIZE: %v\n", o.httpClientMaxBodySize))
return builder.String()
}
+225
View File
@@ -0,0 +1,225 @@
// Copyright 2019 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package config // import "miniflux.app/config"
import (
"bufio"
"errors"
"fmt"
"io"
url_parser "net/url"
"os"
"strconv"
"strings"
"miniflux.app/logger"
)
// Parser handles configuration parsing.
type Parser struct {
opts *Options
}
// NewParser returns a new Parser.
func NewParser() *Parser {
return &Parser{
opts: NewOptions(),
}
}
// ParseEnvironmentVariables loads configuration values from environment variables.
func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
err := p.parseLines(os.Environ())
if err != nil {
return nil, err
}
return p.opts, nil
}
// ParseFile loads configuration values from a local file.
func (p *Parser) ParseFile(filename string) (*Options, error) {
fp, err := os.Open(filename)
if err != nil {
return nil, err
}
defer fp.Close()
err = p.parseLines(p.parseFileContent(fp))
if err != nil {
return nil, err
}
return p.opts, nil
}
func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
lines = append(lines, line)
}
}
return lines
}
func (p *Parser) parseLines(lines []string) (err error) {
var port string
for _, line := range lines {
fields := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(fields[0])
value := strings.TrimSpace(fields[1])
switch key {
case "LOG_DATE_TIME":
p.opts.logDateTime = parseBool(value, defaultLogDateTime)
case "DEBUG":
p.opts.debug = parseBool(value, defaultDebug)
case "BASE_URL":
p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
if err != nil {
return err
}
case "PORT":
port = value
case "LISTEN_ADDR":
p.opts.listenAddr = parseString(value, defaultListenAddr)
case "DATABASE_URL":
p.opts.databaseURL = parseString(value, defaultDatabaseURL)
case "DATABASE_MAX_CONNS":
p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
case "DATABASE_MIN_CONNS":
p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
case "RUN_MIGRATIONS":
p.opts.runMigrations = parseBool(value, defaultRunMigrations)
case "DISABLE_HSTS":
p.opts.hsts = !parseBool(value, defaultHSTS)
case "HTTPS":
p.opts.HTTPS = parseBool(value, defaultHTTPS)
case "DISABLE_SCHEDULER_SERVICE":
p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
case "DISABLE_HTTP_SERVICE":
p.opts.httpService = !parseBool(value, defaultHTTPService)
case "CERT_FILE":
p.opts.certFile = parseString(value, defaultCertFile)
case "KEY_FILE":
p.opts.certKeyFile = parseString(value, defaultKeyFile)
case "CERT_DOMAIN":
p.opts.certDomain = parseString(value, defaultCertDomain)
case "CERT_CACHE":
p.opts.certCache = parseString(value, defaultCertCache)
case "CLEANUP_FREQUENCY_HOURS":
p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
case "CLEANUP_ARCHIVE_READ_DAYS":
p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
case "CLEANUP_REMOVE_SESSIONS_DAYS":
p.opts.cleanupRemoveSessionsDays = parseInt(value, defaultCleanupRemoveSessionsDays)
case "CLEANUP_FREQUENCY":
logger.Error("[Config] CLEANUP_FREQUENCY has been deprecated in favor of CLEANUP_FREQUENCY_HOURS.")
if p.opts.cleanupFrequencyHours != defaultCleanupFrequencyHours {
logger.Error("[Config] Ignoring CLEANUP_FREQUENCY as CLEANUP_FREQUENCY_HOURS is already specified.")
} else {
p.opts.cleanupFrequencyHours = parseInt(value, defaultCleanupFrequencyHours)
}
case "ARCHIVE_READ_DAYS":
logger.Error("[Config] ARCHIVE_READ_DAYS has been deprecated in favor of CLEANUP_ARCHIVE_READ_DAYS.")
if p.opts.cleanupArchiveReadDays != defaultCleanupArchiveReadDays {
logger.Error("[Config] Ignoring ARCHIVE_READ_DAYS as CLEANUP_ARCHIVE_READ_DAYS is already specified.")
} else {
p.opts.cleanupArchiveReadDays = parseInt(value, defaultCleanupArchiveReadDays)
}
case "WORKER_POOL_SIZE":
p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
case "POLLING_FREQUENCY":
p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
case "BATCH_SIZE":
p.opts.batchSize = parseInt(value, defaultBatchSize)
case "PROXY_IMAGES":
p.opts.proxyImages = parseString(value, defaultProxyImages)
case "CREATE_ADMIN":
p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
case "POCKET_CONSUMER_KEY":
p.opts.pocketConsumerKey = parseString(value, defaultPocketConsumerKey)
case "OAUTH2_USER_CREATION":
p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
case "OAUTH2_CLIENT_ID":
p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
case "OAUTH2_CLIENT_SECRET":
p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
case "OAUTH2_REDIRECT_URL":
p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
case "OAUTH2_PROVIDER":
p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
case "HTTP_CLIENT_TIMEOUT":
p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
case "HTTP_CLIENT_MAX_BODY_SIZE":
p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
}
}
if port != "" {
p.opts.listenAddr = ":" + port
}
return nil
}
func parseBaseURL(value string) (string, string, string, error) {
if value == "" {
return defaultBaseURL, defaultRootURL, "", nil
}
if value[len(value)-1:] == "/" {
value = value[:len(value)-1]
}
url, err := url_parser.Parse(value)
if err != nil {
return "", "", "", fmt.Errorf("Invalid BASE_URL: %v", err)
}
scheme := strings.ToLower(url.Scheme)
if scheme != "https" && scheme != "http" {
return "", "", "", errors.New("Invalid BASE_URL: scheme must be http or https")
}
basePath := url.Path
url.Path = ""
return value, url.String(), basePath, nil
}
func parseBool(value string, fallback bool) bool {
if value == "" {
return fallback
}
value = strings.ToLower(value)
if value == "1" || value == "yes" || value == "true" || value == "on" {
return true
}
return false
}
func parseInt(value string, fallback int) int {
if value == "" {
return fallback
}
v, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return v
}
func parseString(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2019 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package config // import "miniflux.app/config"
import (
"testing"
)
func TestParseBoolValue(t *testing.T) {
scenarios := map[string]bool{
"": true,
"1": true,
"Yes": true,
"yes": true,
"True": true,
"true": true,
"on": true,
"false": false,
"off": false,
"invalid": false,
}
for input, expected := range scenarios {
result := parseBool(input, true)
if result != expected {
t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected)
}
}
}
func TestParseStringValueWithUnsetVariable(t *testing.T) {
if parseString("", "defaultValue") != "defaultValue" {
t.Errorf(`Unset variables should returns the default value`)
}
}
func TestParseStringValue(t *testing.T) {
if parseString("test", "defaultValue") != "test" {
t.Errorf(`Defined variables should returns the specified value`)
}
}
func TestParseIntValueWithUnsetVariable(t *testing.T) {
if parseInt("", 42) != 42 {
t.Errorf(`Unset variables should returns the default value`)
}
}
func TestParseIntValueWithInvalidInput(t *testing.T) {
if parseInt("invalid integer", 42) != 42 {
t.Errorf(`Invalid integer should returns the default value`)
}
}
func TestParseIntValue(t *testing.T) {
if parseInt("2018", 42) != 2018 {
t.Errorf(`Defined variables should returns the specified value`)
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package crypto
package crypto // import "miniflux.app/crypto"
import (
"crypto/rand"
+3 -3
View File
@@ -1,10 +1,10 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package crypto implements helpers related to cryptography.
*/
package crypto
package crypto // import "miniflux.app/crypto"
-63
View File
@@ -1,63 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package daemon
import (
"context"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/scheduler"
"github.com/miniflux/miniflux/storage"
)
// Run starts the daemon.
func Run(cfg *config.Config, store *storage.Storage) {
logger.Info("Starting Miniflux...")
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt)
signal.Notify(stop, syscall.SIGTERM)
go func() {
for {
var m runtime.MemStats
runtime.ReadMemStats(&m)
logger.Debug("Alloc=%vK, TotalAlloc=%vK, Sys=%vK, NumGC=%v, GoRoutines=%d, NumCPU=%d",
m.Alloc/1024, m.TotalAlloc/1024, m.Sys/1024, m.NumGC, runtime.NumGoroutine(), runtime.NumCPU())
time.Sleep(30 * time.Second)
}
}()
translator := locale.Load()
feedHandler := feed.NewFeedHandler(store, translator)
pool := scheduler.NewWorkerPool(feedHandler, cfg.WorkerPoolSize())
server := newServer(cfg, store, pool, feedHandler, translator)
scheduler.NewFeedScheduler(
store,
pool,
cfg.PollingFrequency(),
cfg.BatchSize(),
)
scheduler.NewCleanupScheduler(store, cfg.CleanupFrequency())
<-stop
logger.Info("Shutting down the server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx)
store.Close()
logger.Info("Server gracefully stopped")
}
-10
View File
@@ -1,10 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
/*
Package daemon handles the main application process.
*/
package daemon
-151
View File
@@ -1,151 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package daemon
import (
"net/http"
"github.com/miniflux/miniflux/api"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/fever"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/middleware"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/scheduler"
"github.com/miniflux/miniflux/storage"
"github.com/miniflux/miniflux/template"
"github.com/miniflux/miniflux/ui"
"github.com/gorilla/mux"
)
func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handler, pool *scheduler.WorkerPool, translator *locale.Translator) *mux.Router {
router := mux.NewRouter()
templateEngine := template.NewEngine(cfg, router, translator)
apiController := api.NewController(store, feedHandler)
feverController := fever.NewController(cfg, store)
uiController := ui.NewController(cfg, store, pool, feedHandler, templateEngine, translator, router)
middleware := middleware.New(cfg, store, router)
if cfg.BasePath() != "" {
router = router.PathPrefix(cfg.BasePath()).Subrouter()
}
router.Use(middleware.HeaderConfig)
router.Use(middleware.Logging)
router.Use(middleware.CommonHeaders)
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("User-agent: *\nDisallow: /"))
})
feverRouter := router.PathPrefix("/fever").Subrouter()
feverRouter.Use(middleware.FeverAuth)
feverRouter.HandleFunc("/", feverController.Handler).Name("feverEndpoint")
apiRouter := router.PathPrefix("/v1").Subrouter()
apiRouter.Use(middleware.BasicAuth)
apiRouter.HandleFunc("/users", apiController.CreateUser).Methods("POST")
apiRouter.HandleFunc("/users", apiController.Users).Methods("GET")
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.UserByID).Methods("GET")
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.UpdateUser).Methods("PUT")
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.RemoveUser).Methods("DELETE")
apiRouter.HandleFunc("/users/{username}", apiController.UserByUsername).Methods("GET")
apiRouter.HandleFunc("/me", apiController.CurrentUser).Methods("GET")
apiRouter.HandleFunc("/categories", apiController.CreateCategory).Methods("POST")
apiRouter.HandleFunc("/categories", apiController.GetCategories).Methods("GET")
apiRouter.HandleFunc("/categories/{categoryID}", apiController.UpdateCategory).Methods("PUT")
apiRouter.HandleFunc("/categories/{categoryID}", apiController.RemoveCategory).Methods("DELETE")
apiRouter.HandleFunc("/discover", apiController.GetSubscriptions).Methods("POST")
apiRouter.HandleFunc("/feeds", apiController.CreateFeed).Methods("POST")
apiRouter.HandleFunc("/feeds", apiController.GetFeeds).Methods("GET")
apiRouter.HandleFunc("/feeds/{feedID}/refresh", apiController.RefreshFeed).Methods("PUT")
apiRouter.HandleFunc("/feeds/{feedID}", apiController.GetFeed).Methods("GET")
apiRouter.HandleFunc("/feeds/{feedID}", apiController.UpdateFeed).Methods("PUT")
apiRouter.HandleFunc("/feeds/{feedID}", apiController.RemoveFeed).Methods("DELETE")
apiRouter.HandleFunc("/feeds/{feedID}/icon", apiController.FeedIcon).Methods("GET")
apiRouter.HandleFunc("/export", apiController.Export).Methods("GET")
apiRouter.HandleFunc("/import", apiController.Import).Methods("POST")
apiRouter.HandleFunc("/feeds/{feedID}/entries", apiController.GetFeedEntries).Methods("GET")
apiRouter.HandleFunc("/feeds/{feedID}/entries/{entryID}", apiController.GetFeedEntry).Methods("GET")
apiRouter.HandleFunc("/entries", apiController.GetEntries).Methods("GET")
apiRouter.HandleFunc("/entries", apiController.SetEntryStatus).Methods("PUT")
apiRouter.HandleFunc("/entries/{entryID}", apiController.GetEntry).Methods("GET")
apiRouter.HandleFunc("/entries/{entryID}/bookmark", apiController.ToggleBookmark).Methods("PUT")
uiRouter := router.NewRoute().Subrouter()
uiRouter.Use(middleware.AppSession)
uiRouter.Use(middleware.UserSession)
uiRouter.HandleFunc("/stylesheets/{name}.css", uiController.Stylesheet).Name("stylesheet").Methods("GET")
uiRouter.HandleFunc("/js", uiController.Javascript).Name("javascript").Methods("GET")
uiRouter.HandleFunc("/favicon.ico", uiController.Favicon).Name("favicon").Methods("GET")
uiRouter.HandleFunc("/icon/{filename}", uiController.AppIcon).Name("appIcon").Methods("GET")
uiRouter.HandleFunc("/manifest.json", uiController.WebManifest).Name("webManifest").Methods("GET")
uiRouter.HandleFunc("/subscribe", uiController.AddSubscription).Name("addSubscription").Methods("GET")
uiRouter.HandleFunc("/subscribe", uiController.SubmitSubscription).Name("submitSubscription").Methods("POST")
uiRouter.HandleFunc("/subscriptions", uiController.ChooseSubscription).Name("chooseSubscription").Methods("POST")
uiRouter.HandleFunc("/mark-all-as-read", uiController.MarkAllAsRead).Name("markAllAsRead").Methods("GET")
uiRouter.HandleFunc("/unread", uiController.ShowUnreadPage).Name("unread").Methods("GET")
uiRouter.HandleFunc("/history", uiController.ShowHistoryPage).Name("history").Methods("GET")
uiRouter.HandleFunc("/starred", uiController.ShowStarredPage).Name("starred").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/refresh", uiController.RefreshFeed).Name("refreshFeed").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/edit", uiController.EditFeed).Name("editFeed").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/remove", uiController.RemoveFeed).Name("removeFeed").Methods("POST")
uiRouter.HandleFunc("/feed/{feedID}/update", uiController.UpdateFeed).Name("updateFeed").Methods("POST")
uiRouter.HandleFunc("/feed/{feedID}/entries", uiController.ShowFeedEntries).Name("feedEntries").Methods("GET")
uiRouter.HandleFunc("/feeds", uiController.ShowFeedsPage).Name("feeds").Methods("GET")
uiRouter.HandleFunc("/feeds/refresh", uiController.RefreshAllFeeds).Name("refreshAllFeeds").Methods("GET")
uiRouter.HandleFunc("/unread/entry/{entryID}", uiController.ShowUnreadEntry).Name("unreadEntry").Methods("GET")
uiRouter.HandleFunc("/history/entry/{entryID}", uiController.ShowReadEntry).Name("readEntry").Methods("GET")
uiRouter.HandleFunc("/history/flush", uiController.FlushHistory).Name("flushHistory").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/entry/{entryID}", uiController.ShowFeedEntry).Name("feedEntry").Methods("GET")
uiRouter.HandleFunc("/category/{categoryID}/entry/{entryID}", uiController.ShowCategoryEntry).Name("categoryEntry").Methods("GET")
uiRouter.HandleFunc("/starred/entry/{entryID}", uiController.ShowStarredEntry).Name("starredEntry").Methods("GET")
uiRouter.HandleFunc("/entry/status", uiController.UpdateEntriesStatus).Name("updateEntriesStatus").Methods("POST")
uiRouter.HandleFunc("/entry/save/{entryID}", uiController.SaveEntry).Name("saveEntry").Methods("POST")
uiRouter.HandleFunc("/entry/download/{entryID}", uiController.FetchContent).Name("fetchContent").Methods("POST")
uiRouter.HandleFunc("/entry/bookmark/{entryID}", uiController.ToggleBookmark).Name("toggleBookmark").Methods("POST")
uiRouter.HandleFunc("/categories", uiController.CategoryList).Name("categories").Methods("GET")
uiRouter.HandleFunc("/category/create", uiController.CreateCategory).Name("createCategory").Methods("GET")
uiRouter.HandleFunc("/category/save", uiController.SaveCategory).Name("saveCategory").Methods("POST")
uiRouter.HandleFunc("/category/{categoryID}/entries", uiController.CategoryEntries).Name("categoryEntries").Methods("GET")
uiRouter.HandleFunc("/category/{categoryID}/edit", uiController.EditCategory).Name("editCategory").Methods("GET")
uiRouter.HandleFunc("/category/{categoryID}/update", uiController.UpdateCategory).Name("updateCategory").Methods("POST")
uiRouter.HandleFunc("/category/{categoryID}/remove", uiController.RemoveCategory).Name("removeCategory").Methods("POST")
uiRouter.HandleFunc("/feed/icon/{iconID}", uiController.ShowIcon).Name("icon").Methods("GET")
uiRouter.HandleFunc("/proxy/{encodedURL}", uiController.ImageProxy).Name("proxy").Methods("GET")
uiRouter.HandleFunc("/users", uiController.ShowUsers).Name("users").Methods("GET")
uiRouter.HandleFunc("/user/create", uiController.CreateUser).Name("createUser").Methods("GET")
uiRouter.HandleFunc("/user/save", uiController.SaveUser).Name("saveUser").Methods("POST")
uiRouter.HandleFunc("/users/{userID}/edit", uiController.EditUser).Name("editUser").Methods("GET")
uiRouter.HandleFunc("/users/{userID}/update", uiController.UpdateUser).Name("updateUser").Methods("POST")
uiRouter.HandleFunc("/users/{userID}/remove", uiController.RemoveUser).Name("removeUser").Methods("POST")
uiRouter.HandleFunc("/about", uiController.About).Name("about").Methods("GET")
uiRouter.HandleFunc("/settings", uiController.ShowSettings).Name("settings").Methods("GET")
uiRouter.HandleFunc("/settings", uiController.UpdateSettings).Name("updateSettings").Methods("POST")
uiRouter.HandleFunc("/bookmarklet", uiController.Bookmarklet).Name("bookmarklet").Methods("GET")
uiRouter.HandleFunc("/integrations", uiController.ShowIntegrations).Name("integrations").Methods("GET")
uiRouter.HandleFunc("/integration", uiController.UpdateIntegration).Name("updateIntegration").Methods("POST")
uiRouter.HandleFunc("/integration/pocket/authorize", uiController.PocketAuthorize).Name("pocketAuthorize").Methods("GET")
uiRouter.HandleFunc("/integration/pocket/callback", uiController.PocketCallback).Name("pocketCallback").Methods("GET")
uiRouter.HandleFunc("/sessions", uiController.ShowSessions).Name("sessions").Methods("GET")
uiRouter.HandleFunc("/sessions/{sessionID}/remove", uiController.RemoveSession).Name("removeSession").Methods("POST")
uiRouter.HandleFunc("/export", uiController.Export).Name("export").Methods("GET")
uiRouter.HandleFunc("/import", uiController.Import).Name("import").Methods("GET")
uiRouter.HandleFunc("/upload", uiController.UploadOPML).Name("uploadOPML").Methods("POST")
uiRouter.HandleFunc("/oauth2/{provider}/unlink", uiController.OAuth2Unlink).Name("oauth2Unlink").Methods("GET")
uiRouter.HandleFunc("/oauth2/{provider}/redirect", uiController.OAuth2Redirect).Name("oauth2Redirect").Methods("GET")
uiRouter.HandleFunc("/oauth2/{provider}/callback", uiController.OAuth2Callback).Name("oauth2Callback").Methods("GET")
uiRouter.HandleFunc("/login", uiController.CheckLogin).Name("checkLogin").Methods("POST")
uiRouter.HandleFunc("/logout", uiController.Logout).Name("logout").Methods("GET")
uiRouter.HandleFunc("/", uiController.ShowLoginPage).Name("login").Methods("GET")
return router
}
-71
View File
@@ -1,71 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package daemon
import (
"crypto/tls"
"net/http"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/scheduler"
"github.com/miniflux/miniflux/storage"
"golang.org/x/crypto/acme/autocert"
)
func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler, translator *locale.Translator) *http.Server {
certFile := cfg.CertFile()
keyFile := cfg.KeyFile()
certDomain := cfg.CertDomain()
certCache := cfg.CertCache()
server := &http.Server{
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
Addr: cfg.ListenAddr(),
Handler: routes(cfg, store, feedHandler, pool, translator),
}
if certDomain != "" && certCache != "" {
cfg.IsHTTPS = true
server.Addr = ":https"
certManager := autocert.Manager{
Cache: autocert.DirCache(certCache),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(certDomain),
}
// Handle http-01 challenge.
s := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
go s.ListenAndServe()
go func() {
logger.Info(`Listening on "%s" by using auto-configured certificate for "%s"`, server.Addr, certDomain)
logger.Fatal(server.Serve(certManager.Listener()).Error())
}()
} else if certFile != "" && keyFile != "" {
server.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
cfg.IsHTTPS = true
go func() {
logger.Info(`Listening on "%s" by using certificate "%s" and key "%s"`, server.Addr, certFile, keyFile)
logger.Fatal(server.ListenAndServeTLS(certFile, keyFile).Error())
}()
} else {
go func() {
logger.Info(`Listening on "%s" without TLS`, server.Addr)
logger.Fatal(server.ListenAndServe().Error())
}()
}
return server
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package database // import "miniflux.app/database"
import (
"database/sql"
// Postgresql driver import
_ "github.com/lib/pq"
)
// NewConnectionPool configures the database connection pool.
func NewConnectionPool(dsn string, minConnections, maxConnections int) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(maxConnections)
db.SetMaxIdleConns(minConnections)
return db, nil
}
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package database provides functions related to the database connection.
*/
package database // import "miniflux.app/database"
+15 -15
View File
@@ -1,23 +1,23 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package storage
package database // import "miniflux.app/database"
import (
"database/sql"
"fmt"
"strconv"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/sql"
"miniflux.app/logger"
)
const schemaVersion = 18
const schemaVersion = 25
// Migrate run database migrations.
func (s *Storage) Migrate() {
// Migrate executes database migrations.
func Migrate(db *sql.DB) {
var currentVersion int
s.db.QueryRow(`select version from schema_version`).Scan(&currentVersion)
db.QueryRow(`select version from schema_version`).Scan(&currentVersion)
fmt.Println("Current schema version:", currentVersion)
fmt.Println("Latest schema version:", schemaVersion)
@@ -25,31 +25,31 @@ func (s *Storage) Migrate() {
for version := currentVersion + 1; version <= schemaVersion; version++ {
fmt.Println("Migrating to version:", version)
tx, err := s.db.Begin()
tx, err := db.Begin()
if err != nil {
logger.Fatal("[Storage:Migrate] %v", err)
logger.Fatal("[Migrate] %v", err)
}
rawSQL := sql.SqlMap["schema_version_"+strconv.Itoa(version)]
rawSQL := SqlMap["schema_version_"+strconv.Itoa(version)]
// fmt.Println(rawSQL)
_, err = tx.Exec(rawSQL)
if err != nil {
tx.Rollback()
logger.Fatal("[Storage:Migrate] %v", err)
logger.Fatal("[Migrate] %v", err)
}
if _, err := tx.Exec(`delete from schema_version`); err != nil {
tx.Rollback()
logger.Fatal("[Storage:Migrate] %v", err)
logger.Fatal("[Migrate] %v", err)
}
if _, err := tx.Exec(`insert into schema_version (version) values($1)`, version); err != nil {
tx.Rollback()
logger.Fatal("[Storage:Migrate] %v", err)
logger.Fatal("[Migrate] %v", err)
}
if err := tx.Commit(); err != nil {
logger.Fatal("[Storage:Migrate] %v", err)
logger.Fatal("[Migrate] %v", err)
}
}
}
+22 -2
View File
@@ -1,7 +1,6 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-05-21 12:33:12.475674107 -0700 PDT m=+0.003076147
package sql
package database // import "miniflux.app/database"
var SqlMap = map[string]string{
"schema_version_1": `create table schema_version (
@@ -136,9 +135,23 @@ alter table integrations add column pocket_access_token text default '';
alter table integrations add column pocket_consumer_key text default '';
`,
"schema_version_18": `alter table user_sessions alter column ip set data type inet using ip::inet;`,
"schema_version_19": `alter table feeds add column username text default '';
alter table feeds add column password text default '';`,
"schema_version_2": `create extension if not exists hstore;
alter table users add column extra hstore;
create index users_extra_idx on users using gin(extra);
`,
"schema_version_20": `alter table entries add column document_vectors tsvector;
update entries set document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
create index document_vectors_idx on entries using gin(document_vectors);`,
"schema_version_21": `alter table feeds add column user_agent text default '';`,
"schema_version_22": `update entries set document_vectors = setweight(to_tsvector(substring(coalesce(title, '') for 1000000)), 'A') || setweight(to_tsvector(substring(coalesce(content, '') for 1000000)), 'B');`,
"schema_version_23": `alter table users add column keyboard_shortcuts boolean default 't';`,
"schema_version_24": `alter table feeds add column disabled boolean default 'f';`,
"schema_version_25": `ALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';
UPDATE users SET theme='light_serif' WHERE theme='default';
UPDATE users SET theme='light_sans_serif' WHERE theme='sansserif';
UPDATE users SET theme='dark_serif' WHERE theme='black';
`,
"schema_version_3": `create table tokens (
id text not null,
@@ -185,7 +198,14 @@ var SqlMapChecksums = map[string]string{
"schema_version_16": "9d006faca62fd7ab787f64aef0e0a5933d142466ec4cab0e096bb920d2797e34",
"schema_version_17": "b9f15d6217275fedcf6d948dd85ebe978b869bf37f42a86fd5b50a51919fa0e1",
"schema_version_18": "c0ec24847612c7f2dc326cf735baffba79391a56aedd73292371a39f38724a71",
"schema_version_19": "a83f77b41cc213d282805a5b518f15abbf96331599119f0ef4aca4be037add7b",
"schema_version_2": "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4",
"schema_version_20": "5d414c0cfc0da2863c641079afa58b7ff42dccb0f0e01c822ad435c3e3aa9201",
"schema_version_21": "77da01ee38918ff4fe33985fbb20ed3276a717a7584c2ca9ebcf4d4ab6cb6910",
"schema_version_22": "51ed5fbcae9877e57274511f0ef8c61d254ebd78dfbcbc043a2acd30f4c93ca3",
"schema_version_23": "cb3512d328436447f114e305048c0daa8af7505cfe5eab02778b0de1156081b2",
"schema_version_24": "1224754c5b9c6b4038599852bbe72656d21b09cb018d3970bd7c00f0019845bf",
"schema_version_25": "5262d2d4c88d637b6603a1fcd4f68ad257bd59bd1adf89c58a18ee87b12050d7",
"schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12",
"schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9",
"schema_version_5": "46397e2f5f2c82116786127e9f6a403e975b14d2ca7b652a48cd1ba843e6a27c",
+2
View File
@@ -0,0 +1,2 @@
alter table feeds add column username text default '';
alter table feeds add column password text default '';
+3
View File
@@ -0,0 +1,3 @@
alter table entries add column document_vectors tsvector;
update entries set document_vectors = to_tsvector(substring(title || ' ' || coalesce(content, '') for 1000000));
create index document_vectors_idx on entries using gin(document_vectors);
+1
View File
@@ -0,0 +1 @@
alter table feeds add column user_agent text default '';
+1
View File
@@ -0,0 +1 @@
update entries set document_vectors = setweight(to_tsvector(substring(coalesce(title, '') for 1000000)), 'A') || setweight(to_tsvector(substring(coalesce(content, '') for 1000000)), 'B');
+1
View File
@@ -0,0 +1 @@
alter table users add column keyboard_shortcuts boolean default 't';
+1
View File
@@ -0,0 +1 @@
alter table feeds add column disabled boolean default 'f';
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE users ALTER COLUMN theme SET DEFAULT 'light_serif';
UPDATE users SET theme='light_serif' WHERE theme='default';
UPDATE users SET theme='light_sans_serif' WHERE theme='sansserif';
UPDATE users SET theme='dark_serif' WHERE theme='black';
+4 -4
View File
@@ -1,10 +1,10 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package main implements a feed reader application.
Miniflux is a feed reader application.
*/
package main
package main // import "miniflux.app"
+3 -3
View File
@@ -1,10 +1,10 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package errors handles localized errors.
*/
package errors
package errors // import "miniflux.app/errors"
+4 -4
View File
@@ -2,12 +2,12 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package errors
package errors // import "miniflux.app/errors"
import (
"fmt"
"github.com/miniflux/miniflux/locale"
"miniflux.app/locale"
)
// LocalizedError represents an error than could be translated to another language.
@@ -22,8 +22,8 @@ func (l LocalizedError) Error() string {
}
// Localize returns the translated error message.
func (l LocalizedError) Localize(translation *locale.Language) string {
return translation.Get(l.message, l.args...)
func (l LocalizedError) Localize(printer *locale.Printer) string {
return printer.Printf(l.message, l.args...)
}
// NewLocalizedError returns a new LocalizedError.
+3 -3
View File
@@ -1,10 +1,10 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package fever implements Fever API endpoints.
*/
package fever
package fever // import "miniflux.app/fever"
+113 -248
View File
@@ -1,8 +1,8 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package fever
package fever // import "miniflux.app/fever"
import (
"net/http"
@@ -10,152 +10,51 @@ import (
"strings"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/integration"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/storage"
"miniflux.app/http/request"
"miniflux.app/http/response/json"
"miniflux.app/integration"
"miniflux.app/logger"
"miniflux.app/model"
"miniflux.app/storage"
"github.com/gorilla/mux"
)
type baseResponse struct {
Version int `json:"api_version"`
Authenticated int `json:"auth"`
LastRefresh int64 `json:"last_refreshed_on_time"`
// Serve handles Fever API calls.
func Serve(router *mux.Router, store *storage.Storage) {
handler := &handler{store}
sr := router.PathPrefix("/fever").Subrouter()
sr.Use(newMiddleware(store).serve)
sr.HandleFunc("/", handler.serve).Name("feverEndpoint")
}
func (b *baseResponse) SetCommonValues() {
b.Version = 3
b.Authenticated = 1
b.LastRefresh = time.Now().Unix()
}
/*
The default response is a JSON object containing two members:
api_version contains the version of the API responding (positive integer)
auth whether the request was successfully authenticated (boolean integer)
The API can also return XML by passing xml as the optional value of the api argument like so:
http://yourdomain.com/fever/?api=xml
The top level XML element is named response.
The response to each successfully authenticated request will have auth set to 1 and include
at least one additional member:
last_refreshed_on_time contains the time of the most recently refreshed (not updated)
feed (Unix timestamp/integer)
*/
func newBaseResponse() baseResponse {
r := baseResponse{}
r.SetCommonValues()
return r
}
type groupsResponse struct {
baseResponse
Groups []group `json:"groups"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type feedsResponse struct {
baseResponse
Feeds []feed `json:"feeds"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type faviconsResponse struct {
baseResponse
Favicons []favicon `json:"favicons"`
}
type itemsResponse struct {
baseResponse
Items []item `json:"items"`
Total int `json:"total_items"`
}
type unreadResponse struct {
baseResponse
ItemIDs string `json:"unread_item_ids"`
}
type savedResponse struct {
baseResponse
ItemIDs string `json:"saved_item_ids"`
}
type group struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type feedsGroups struct {
GroupID int64 `json:"group_id"`
FeedIDs string `json:"feed_ids"`
}
type feed struct {
ID int64 `json:"id"`
FaviconID int64 `json:"favicon_id"`
Title string `json:"title"`
URL string `json:"url"`
SiteURL string `json:"site_url"`
IsSpark int `json:"is_spark"`
LastUpdated int64 `json:"last_updated_on_time"`
}
type item struct {
ID int64 `json:"id"`
FeedID int64 `json:"feed_id"`
Title string `json:"title"`
Author string `json:"author"`
HTML string `json:"html"`
URL string `json:"url"`
IsSaved int `json:"is_saved"`
IsRead int `json:"is_read"`
CreatedAt int64 `json:"created_on_time"`
}
type favicon struct {
ID int64 `json:"id"`
Data string `json:"data"`
}
// Controller implements the Fever API.
type Controller struct {
cfg *config.Config
type handler struct {
store *storage.Storage
}
// Handler handles Fever API calls
func (c *Controller) Handler(w http.ResponseWriter, r *http.Request) {
func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
switch {
case request.HasQueryParam(r, "groups"):
c.handleGroups(w, r)
h.handleGroups(w, r)
case request.HasQueryParam(r, "feeds"):
c.handleFeeds(w, r)
h.handleFeeds(w, r)
case request.HasQueryParam(r, "favicons"):
c.handleFavicons(w, r)
h.handleFavicons(w, r)
case request.HasQueryParam(r, "unread_item_ids"):
c.handleUnreadItems(w, r)
h.handleUnreadItems(w, r)
case request.HasQueryParam(r, "saved_item_ids"):
c.handleSavedItems(w, r)
h.handleSavedItems(w, r)
case request.HasQueryParam(r, "items"):
c.handleItems(w, r)
h.handleItems(w, r)
case r.FormValue("mark") == "item":
c.handleWriteItems(w, r)
h.handleWriteItems(w, r)
case r.FormValue("mark") == "feed":
c.handleWriteFeeds(w, r)
h.handleWriteFeeds(w, r)
case r.FormValue("mark") == "group":
c.handleWriteGroups(w, r)
h.handleWriteGroups(w, r)
default:
json.OK(w, newBaseResponse())
json.OK(w, r, newBaseResponse())
}
}
@@ -179,20 +78,19 @@ The “Sparks” super group is not included in this response and is composed of
is_spark equal to 1.
*/
func (c *Controller) handleGroups(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
logger.Debug("[Fever] Fetching groups for userID=%d", userID)
categories, err := c.store.Categories(userID)
categories, err := h.store.Categories(userID)
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
feeds, err := c.store.Feeds(userID)
feeds, err := h.store.Feeds(userID)
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
@@ -201,9 +99,9 @@ func (c *Controller) handleGroups(w http.ResponseWriter, r *http.Request) {
result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
}
result.FeedsGroups = c.buildFeedGroups(feeds)
result.FeedsGroups = h.buildFeedGroups(feeds)
result.SetCommonValues()
json.OK(w, result)
json.OK(w, r, result)
}
/*
@@ -230,14 +128,13 @@ should be limited to feeds with an is_spark equal to 0.
For the Sparks super group the items should be limited to feeds with an is_spark equal to 1.
*/
func (c *Controller) handleFeeds(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
logger.Debug("[Fever] Fetching feeds for userID=%d", userID)
feeds, err := c.store.Feeds(userID)
feeds, err := h.store.Feeds(userID)
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
@@ -260,9 +157,9 @@ func (c *Controller) handleFeeds(w http.ResponseWriter, r *http.Request) {
result.Feeds = append(result.Feeds, subscripion)
}
result.FeedsGroups = c.buildFeedGroups(feeds)
result.FeedsGroups = h.buildFeedGroups(feeds)
result.SetCommonValues()
json.OK(w, result)
json.OK(w, r, result)
}
/*
@@ -284,14 +181,13 @@ A PHP/HTML example:
echo '<img src="data:'.$favicon['data'].'">';
*/
func (c *Controller) handleFavicons(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
logger.Debug("[Fever] Fetching favicons for userID=%d", userID)
icons, err := c.store.Icons(userID)
icons, err := h.store.Icons(userID)
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
@@ -304,7 +200,7 @@ func (c *Controller) handleFavicons(w http.ResponseWriter, r *http.Request) {
}
result.SetCommonValues()
json.OK(w, result)
json.OK(w, r, result)
}
/*
@@ -338,14 +234,13 @@ Three optional arguments control determine the items included in the response.
(added in API version 2)
*/
func (c *Controller) handleItems(w http.ResponseWriter, r *http.Request) {
func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
var result itemsResponse
ctx := context.New(r)
userID := ctx.UserID()
userID := request.UserID(r)
logger.Debug("[Fever] Fetching items for userID=%d", userID)
builder := c.store.NewEntryQueryBuilder(userID)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithLimit(50)
builder.WithOrder("id")
@@ -353,7 +248,7 @@ func (c *Controller) handleItems(w http.ResponseWriter, r *http.Request) {
sinceID := request.QueryIntParam(r, "since_id", 0)
if sinceID > 0 {
builder.WithGreaterThanEntryID(int64(sinceID))
builder.AfterEntryID(int64(sinceID))
}
maxID := request.QueryIntParam(r, "max_id", 0)
@@ -361,7 +256,7 @@ func (c *Controller) handleItems(w http.ResponseWriter, r *http.Request) {
builder.WithOffset(maxID)
}
csvItemIDs := request.QueryParam(r, "with_ids", "")
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
if csvItemIDs != "" {
var itemIDs []int64
@@ -376,15 +271,15 @@ func (c *Controller) handleItems(w http.ResponseWriter, r *http.Request) {
entries, err := builder.GetEntries()
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
builder = c.store.NewEntryQueryBuilder(userID)
builder = h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved)
result.Total, err = builder.CountEntries()
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
@@ -414,7 +309,7 @@ func (c *Controller) handleItems(w http.ResponseWriter, r *http.Request) {
}
result.SetCommonValues()
json.OK(w, result)
json.OK(w, r, result)
}
/*
@@ -424,16 +319,15 @@ with the remote Fever installation.
A request with the unread_item_ids argument will return one additional member:
unread_item_ids (string/comma-separated list of positive integers)
*/
func (c *Controller) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
logger.Debug("[Fever] Fetching unread items for userID=%d", userID)
builder := c.store.NewEntryQueryBuilder(userID)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread)
entries, err := builder.GetEntries()
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
@@ -445,7 +339,7 @@ func (c *Controller) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
var result unreadResponse
result.ItemIDs = strings.Join(itemIDs, ",")
result.SetCommonValues()
json.OK(w, result)
json.OK(w, r, result)
}
/*
@@ -456,17 +350,16 @@ with the remote Fever installation.
saved_item_ids (string/comma-separated list of positive integers)
*/
func (c *Controller) handleSavedItems(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
logger.Debug("[Fever] Fetching saved items for userID=%d", userID)
builder := c.store.NewEntryQueryBuilder(userID)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStarred()
entryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
@@ -477,7 +370,7 @@ func (c *Controller) handleSavedItems(w http.ResponseWriter, r *http.Request) {
result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
result.SetCommonValues()
json.OK(w, result)
json.OK(w, r, result)
}
/*
@@ -485,23 +378,22 @@ func (c *Controller) handleSavedItems(w http.ResponseWriter, r *http.Request) {
as=? where ? is replaced with read, saved or unsaved
id=? where ? is replaced with the id of the item to modify
*/
func (c *Controller) handleWriteItems(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
logger.Debug("[Fever] Receiving mark=item call for userID=%d", userID)
entryID := request.FormIntValue(r, "id")
entryID := request.FormInt64Value(r, "id")
if entryID <= 0 {
return
}
builder := c.store.NewEntryQueryBuilder(userID)
builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
entry, err := builder.GetEntry()
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
@@ -512,111 +404,89 @@ func (c *Controller) handleWriteItems(w http.ResponseWriter, r *http.Request) {
switch r.FormValue("as") {
case "read":
logger.Debug("[Fever] Mark entry #%d as read", entryID)
c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
case "unread":
logger.Debug("[Fever] Mark entry #%d as unread", entryID)
c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
case "saved", "unsaved":
logger.Debug("[Fever] Mark entry #%d as saved/unsaved", entryID)
if err := c.store.ToggleBookmark(userID, entryID); err != nil {
json.ServerError(w, err)
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
json.ServerError(w, r, err)
return
}
settings, err := c.store.Integration(userID)
settings, err := h.store.Integration(userID)
if err != nil {
json.ServerError(w, err)
json.ServerError(w, r, err)
return
}
go func() {
integration.SendEntry(c.cfg, entry, settings)
integration.SendEntry(entry, settings)
}()
}
json.OK(w, newBaseResponse())
json.OK(w, r, newBaseResponse())
}
/*
mark=? where ? is replaced with feed or group
mark=feed
as=read
id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local clients most recent items API request
*/
func (c *Controller) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Receiving mark=feed call for userID=%d", userID)
func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0)
logger.Debug("[Fever] mark=feed, userID=%d, feedID=%d, before=%v", userID, feedID, before)
feedID := request.FormIntValue(r, "id")
if feedID <= 0 {
return
}
builder := c.store.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread)
builder.WithFeedID(feedID)
go func() {
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
logger.Error("[Fever] MarkFeedAsRead failed: %v", err)
}
}()
before := request.FormIntValue(r, "before")
if before > 0 {
t := time.Unix(before, 0)
builder.Before(&t)
}
entryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, err)
return
}
err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead)
if err != nil {
json.ServerError(w, err)
return
}
json.OK(w, newBaseResponse())
json.OK(w, r, newBaseResponse())
}
/*
mark=? where ? is replaced with feed or group
mark=group
as=read
id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local clients most recent items API request
*/
func (c *Controller) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Receiving mark=group call for userID=%d", userID)
func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
groupID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0)
logger.Debug("[Fever] mark=group, userID=%d, groupID=%d, before=%v", userID, groupID, before)
groupID := request.FormIntValue(r, "id")
if groupID < 0 {
return
}
builder := c.store.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread)
builder.WithCategoryID(groupID)
go func() {
var err error
before := request.FormIntValue(r, "before")
if before > 0 {
t := time.Unix(before, 0)
builder.Before(&t)
}
if groupID == 0 {
err = h.store.MarkAllAsRead(userID)
} else {
err = h.store.MarkCategoryAsRead(userID, groupID, before)
}
entryIDs, err := builder.GetEntryIDs()
if err != nil {
json.ServerError(w, err)
return
}
if err != nil {
logger.Error("[Fever] MarkCategoryAsRead failed: %v", err)
}
}()
err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead)
if err != nil {
json.ServerError(w, err)
return
}
json.OK(w, newBaseResponse())
json.OK(w, r, newBaseResponse())
}
/*
@@ -626,7 +496,7 @@ A feeds_group object has the following members:
feed_ids (string/comma-separated list of positive integers)
*/
func (c *Controller) buildFeedGroups(feeds model.Feeds) []feedsGroups {
func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
feedsGroupedByCategory := make(map[int64][]string)
for _, feed := range feeds {
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
@@ -642,8 +512,3 @@ func (c *Controller) buildFeedGroups(feeds model.Feeds) []feedsGroups {
return result
}
// NewController returns a new Fever API.
func NewController(cfg *config.Config, store *storage.Storage) *Controller {
return &Controller{cfg, store}
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package fever // import "miniflux.app/fever"
import (
"context"
"net/http"
"miniflux.app/http/request"
"miniflux.app/http/response/json"
"miniflux.app/logger"
"miniflux.app/storage"
)
type middleware struct {
store *storage.Storage
}
func newMiddleware(s *storage.Storage) *middleware {
return &middleware{s}
}
func (m *middleware) serve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := r.FormValue("api_key")
if apiKey == "" {
logger.Info("[Fever] No API key provided")
json.OK(w, r, newAuthFailureResponse())
return
}
user, err := m.store.UserByFeverToken(apiKey)
if err != nil {
logger.Error("[Fever] %v", err)
json.OK(w, r, newAuthFailureResponse())
return
}
if user == nil {
logger.Info("[Fever] No user found with this API key")
json.OK(w, r, newAuthFailureResponse())
return
}
logger.Info("[Fever] User #%d is authenticated", user.ID)
m.store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package fever // import "miniflux.app/fever"
import (
"time"
)
type baseResponse struct {
Version int `json:"api_version"`
Authenticated int `json:"auth"`
LastRefresh int64 `json:"last_refreshed_on_time"`
}
func (b *baseResponse) SetCommonValues() {
b.Version = 3
b.Authenticated = 1
b.LastRefresh = time.Now().Unix()
}
/*
The default response is a JSON object containing two members:
api_version contains the version of the API responding (positive integer)
auth whether the request was successfully authenticated (boolean integer)
The API can also return XML by passing xml as the optional value of the api argument like so:
http://yourdomain.com/fever/?api=xml
The top level XML element is named response.
The response to each successfully authenticated request will have auth set to 1 and include
at least one additional member:
last_refreshed_on_time contains the time of the most recently refreshed (not updated)
feed (Unix timestamp/integer)
*/
func newBaseResponse() baseResponse {
r := baseResponse{}
r.SetCommonValues()
return r
}
func newAuthFailureResponse() baseResponse {
return baseResponse{Version: 3, Authenticated: 0}
}
type groupsResponse struct {
baseResponse
Groups []group `json:"groups"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type feedsResponse struct {
baseResponse
Feeds []feed `json:"feeds"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type faviconsResponse struct {
baseResponse
Favicons []favicon `json:"favicons"`
}
type itemsResponse struct {
baseResponse
Items []item `json:"items"`
Total int `json:"total_items"`
}
type unreadResponse struct {
baseResponse
ItemIDs string `json:"unread_item_ids"`
}
type savedResponse struct {
baseResponse
ItemIDs string `json:"saved_item_ids"`
}
type group struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type feedsGroups struct {
GroupID int64 `json:"group_id"`
FeedIDs string `json:"feed_ids"`
}
type feed struct {
ID int64 `json:"id"`
FaviconID int64 `json:"favicon_id"`
Title string `json:"title"`
URL string `json:"url"`
SiteURL string `json:"site_url"`
IsSpark int `json:"is_spark"`
LastUpdated int64 `json:"last_updated_on_time"`
}
type item struct {
ID int64 `json:"id"`
FeedID int64 `json:"feed_id"`
Title string `json:"title"`
Author string `json:"author"`
HTML string `json:"html"`
URL string `json:"url"`
IsSaved int `json:"is_saved"`
IsRead int `json:"is_read"`
CreatedAt int64 `json:"created_on_time"`
}
type favicon struct {
ID int64 `json:"id"`
Data string `json:"data"`
}
-10
View File
@@ -1,10 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
/*
Package filter implements a content filter to rewrite image links.
*/
package filter
-41
View File
@@ -1,41 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package filter
import (
"encoding/base64"
"strings"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/url"
"github.com/PuerkitoBio/goquery"
"github.com/gorilla/mux"
)
// ImageProxyFilter rewrites image tag URLs without HTTPS to local proxy URL
func ImageProxyFilter(router *mux.Router, data string) string {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(data))
if err != nil {
return data
}
doc.Find("img").Each(func(i int, img *goquery.Selection) {
if srcAttr, ok := img.Attr("src"); ok {
if !url.IsHTTPS(srcAttr) {
img.SetAttr("src", Proxify(router, srcAttr))
}
}
})
output, _ := doc.Find("body").First().Html()
return output
}
// Proxify returns a proxified link.
func Proxify(router *mux.Router, link string) string {
// We use base64 url encoding to avoid slash in the URL.
return route.Path(router, "proxy", "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
}
-38
View File
@@ -1,38 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package filter
import (
"net/http"
"testing"
"github.com/gorilla/mux"
)
func TestProxyFilterWithHttp(t *testing.T) {
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="http://website/folder/image.png" alt="Test"/></p>`
output := ImageProxyFilter(r, input)
expected := `<p><img src="/proxy/aHR0cDovL3dlYnNpdGUvZm9sZGVyL2ltYWdlLnBuZw==" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
}
}
func TestProxyFilterWithHttps(t *testing.T) {
r := mux.NewRouter()
r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
input := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
output := ImageProxyFilter(r, input)
expected := `<p><img src="https://website/folder/image.png" alt="Test"/></p>`
if expected != output {
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
}
}
+177 -75
View File
@@ -16,17 +16,15 @@ import (
"path/filepath"
"strings"
"text/template"
"time"
"github.com/tdewolff/minify"
"github.com/tdewolff/minify/css"
"github.com/tdewolff/minify/js"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/js"
)
const tpl = `// Code generated by go generate; DO NOT EDIT.
// {{ .Timestamp }}
package {{ .Package }}
package {{ .Package }} // import "miniflux.app/{{ .ImportPath }}"
var {{ .Map }} = map[string]string{
{{ range $constant, $content := .Files }}` + "\t" + `"{{ $constant }}": ` + "`{{ $content }}`" + `,
@@ -37,84 +35,188 @@ var {{ .Map }}Checksums = map[string]string{
{{ end }}}
`
var generatedTpl = template.Must(template.New("").Parse(tpl))
var bundleTpl = template.Must(template.New("").Parse(tpl))
type GeneratedFile struct {
Package, Map string
Timestamp time.Time
Files map[string]string
Checksums map[string]string
type Bundle struct {
Package string
Map string
ImportPath string
Files map[string]string
Checksums map[string]string
}
func normalizeBasename(filename string) string {
filename = strings.TrimSuffix(filename, filepath.Ext(filename))
return strings.Replace(filename, " ", "_", -1)
}
func generateFile(serializer, pkg, mapName, pattern, output string) {
generatedFile := &GeneratedFile{
Package: pkg,
Map: mapName,
Timestamp: time.Now(),
Files: make(map[string]string),
Checksums: make(map[string]string),
}
files, _ := filepath.Glob(pattern)
for _, file := range files {
basename := path.Base(file)
content, err := ioutil.ReadFile(file)
if err != nil {
panic(err)
}
switch serializer {
case "css":
m := minify.New()
m.AddFunc("text/css", css.Minify)
content, err = m.Bytes("text/css", content)
if err != nil {
panic(err)
}
basename = normalizeBasename(basename)
generatedFile.Files[basename] = string(content)
case "js":
m := minify.New()
m.AddFunc("text/javascript", js.Minify)
content, err = m.Bytes("text/javascript", content)
if err != nil {
panic(err)
}
basename = normalizeBasename(basename)
generatedFile.Files[basename] = string(content)
case "base64":
encodedContent := base64.StdEncoding.EncodeToString(content)
generatedFile.Files[basename] = encodedContent
default:
basename = normalizeBasename(basename)
generatedFile.Files[basename] = string(content)
}
generatedFile.Checksums[basename] = fmt.Sprintf("%x", sha256.Sum256(content))
}
f, err := os.Create(output)
func (b *Bundle) Write(filename string) {
f, err := os.Create(filename)
if err != nil {
panic(err)
}
defer f.Close()
generatedTpl.Execute(f, generatedFile)
bundleTpl.Execute(f, b)
}
func NewBundle(pkg, mapName, importPath string) *Bundle {
return &Bundle{
Package: pkg,
Map: mapName,
ImportPath: importPath,
Files: make(map[string]string),
Checksums: make(map[string]string),
}
}
func readFile(filename string) []byte {
data, err := ioutil.ReadFile(filename)
if err != nil {
panic(err)
}
return data
}
func checksum(data []byte) string {
return fmt.Sprintf("%x", sha256.Sum256(data))
}
func basename(filename string) string {
return path.Base(filename)
}
func stripExtension(filename string) string {
filename = strings.TrimSuffix(filename, path.Ext(filename))
return strings.Replace(filename, " ", "_", -1)
}
func glob(pattern string) []string {
// There is no Glob function in path package, so we have to use filepath and replace in case of Windows
files, _ := filepath.Glob(pattern)
for i := range files {
if strings.Contains(files[i], "\\") {
files[i] = strings.Replace(files[i], "\\", "/", -1)
}
}
return files
}
func concat(files []string) string {
var b strings.Builder
for _, file := range files {
b.Write(readFile(file))
}
return b.String()
}
func generateJSBundle(bundleFile string, bundleFiles map[string][]string, prefixes, suffixes map[string]string) {
bundle := NewBundle("static", "Javascripts", "ui/static")
m := minify.New()
m.AddFunc("text/javascript", js.Minify)
for name, srcFiles := range bundleFiles {
var b strings.Builder
if prefix, found := prefixes[name]; found {
b.WriteString(prefix)
}
b.WriteString(concat(srcFiles))
if suffix, found := suffixes[name]; found {
b.WriteString(suffix)
}
minifiedData, err := m.String("text/javascript", b.String())
if err != nil {
panic(err)
}
bundle.Files[name] = minifiedData
bundle.Checksums[name] = checksum([]byte(minifiedData))
}
bundle.Write(bundleFile)
}
func generateCSSBundle(bundleFile string, themes map[string][]string) {
bundle := NewBundle("static", "Stylesheets", "ui/static")
m := minify.New()
m.AddFunc("text/css", css.Minify)
for theme, srcFiles := range themes {
data := concat(srcFiles)
minifiedData, err := m.String("text/css", data)
if err != nil {
panic(err)
}
bundle.Files[theme] = minifiedData
bundle.Checksums[theme] = checksum([]byte(minifiedData))
}
bundle.Write(bundleFile)
}
func generateBinaryBundle(bundleFile string, srcFiles []string) {
bundle := NewBundle("static", "Binaries", "ui/static")
for _, srcFile := range srcFiles {
data := readFile(srcFile)
filename := basename(srcFile)
encodedData := base64.StdEncoding.EncodeToString(data)
bundle.Files[filename] = string(encodedData)
bundle.Checksums[filename] = checksum(data)
}
bundle.Write(bundleFile)
}
func generateBundle(bundleFile, pkg, mapName string, srcFiles []string) {
bundle := NewBundle(pkg, mapName, pkg)
for _, srcFile := range srcFiles {
data := readFile(srcFile)
filename := stripExtension(basename(srcFile))
bundle.Files[filename] = string(data)
bundle.Checksums[filename] = checksum(data)
}
bundle.Write(bundleFile)
}
func main() {
generateFile("none", "sql", "SqlMap", "sql/*.sql", "sql/sql.go")
generateFile("base64", "static", "Binaries", "ui/static/bin/*", "ui/static/bin.go")
generateFile("css", "static", "Stylesheets", "ui/static/css/*.css", "ui/static/css.go")
generateFile("js", "static", "Javascript", "ui/static/js/*.js", "ui/static/js.go")
generateFile("none", "template", "templateViewsMap", "template/html/*.html", "template/views.go")
generateFile("none", "template", "templateCommonMap", "template/html/common/*.html", "template/common.go")
generateFile("none", "locale", "translations", "locale/translations/*.json", "locale/translations.go")
generateJSBundle("ui/static/js.go", map[string][]string{
"app": []string{
"ui/static/js/dom_helper.js",
"ui/static/js/touch_handler.js",
"ui/static/js/keyboard_handler.js",
"ui/static/js/request_builder.js",
"ui/static/js/modal_handler.js",
"ui/static/js/app.js",
"ui/static/js/bootstrap.js",
},
"sw": []string{
"ui/static/js/sw.js",
},
}, map[string]string{
"app": "(function(){'use strict';",
"sw": "'use strict';",
}, map[string]string{
"app": "})();",
})
generateCSSBundle("ui/static/css.go", map[string][]string{
"light_serif": []string{"ui/static/css/light.css", "ui/static/css/serif.css", "ui/static/css/common.css"},
"light_sans_serif": []string{"ui/static/css/light.css", "ui/static/css/sans_serif.css", "ui/static/css/common.css"},
"dark_serif": []string{"ui/static/css/dark.css", "ui/static/css/serif.css", "ui/static/css/common.css"},
"dark_sans_serif": []string{"ui/static/css/dark.css", "ui/static/css/sans_serif.css", "ui/static/css/common.css"},
"system_serif": []string{"ui/static/css/system.css", "ui/static/css/serif.css", "ui/static/css/common.css"},
"system_sans_serif": []string{"ui/static/css/system.css", "ui/static/css/sans_serif.css", "ui/static/css/common.css"},
})
generateBinaryBundle("ui/static/bin.go", glob("ui/static/bin/*"))
generateBundle("database/sql.go", "database", "SqlMap", glob("database/sql/*.sql"))
generateBundle("template/views.go", "template", "templateViewsMap", glob("template/html/*.html"))
generateBundle("template/common.go", "template", "templateCommonMap", glob("template/html/common/*.html"))
generateBundle("locale/translations.go", "locale", "translations", glob("locale/translations/*.json"))
}
+18
View File
@@ -0,0 +1,18 @@
module miniflux.app
// +heroku goVersion go1.11
require (
github.com/PuerkitoBio/goquery v1.5.0
github.com/golang/protobuf v1.3.2 // indirect
github.com/gorilla/mux v1.7.3
github.com/lib/pq v1.2.0
github.com/tdewolff/minify/v2 v2.5.2 // indirect
golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd // indirect
google.golang.org/appengine v1.6.2 // indirect
)
go 1.11
+62
View File
@@ -0,0 +1,62 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk=
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/tdewolff/minify v2.3.6+incompatible h1:2hw5/9ZvxhWLvBUnHE06gElGYz+Jv9R4Eys0XUzItYo=
github.com/tdewolff/minify/v2 v2.5.2 h1:If/q1brvT+91oWiWnIMEGuFcwWtpB6AtLTxba78tvMs=
github.com/tdewolff/minify/v2 v2.5.2/go.mod h1:Q6mWHrmspbdRX0ZuUUoKIT8bDjVVXpIJ73ux7p7HZGg=
github.com/tdewolff/parse/v2 v2.3.9 h1:d8/K6XOLy5JVpLTG9Kx+SxA72rlm5OowFmVSVgtOlmM=
github.com/tdewolff/parse/v2 v2.3.9/go.mod h1:HansaqmN4I/U7L6/tUp0NcwT2tFO0F4EAWYGSDzkYNk=
github.com/tdewolff/test v1.0.0/go.mod h1:DiQUlutnqlEvdvhSn2LPGy4TFwRauAaYDsL+683RNX4=
github.com/tdewolff/test v1.0.4/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472 h1:Gv7RPwsi3eZ2Fgewe3CBsuOebPwO27PoXzRpJPsvSSM=
golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 h1:k7pJ2yAPLPgbskkFdhRCsA77k2fySZ1zf2zCjvQCiIM=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181031143558-9b800f95dbbc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c h1:+EXw7AwNOKzPFXMZ1yNjO40aWCh3PIquJB2fYlv9wcs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd h1:DBH9mDw0zluJT/R+nGuV3jWFWLFaHyYZWD4tOT+cjn0=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.2 h1:j8RI1yW0SkI+paT6uGwMlrMI/6zwYA6/CFil8rxOzGI=
google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+34 -22
View File
@@ -2,7 +2,7 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package client
package client // import "miniflux.app/http/client"
import (
"bytes"
@@ -18,21 +18,17 @@ import (
"strings"
"time"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/timer"
"github.com/miniflux/miniflux/version"
)
const (
// 20 seconds max.
requestTimeout = 20
// 15MB max.
maxBodySize = 1024 * 1024 * 15
"miniflux.app/config"
"miniflux.app/errors"
"miniflux.app/logger"
"miniflux.app/timer"
"miniflux.app/version"
)
var (
// DefaultUserAgent sets the User-Agent header used for any requests by miniflux.
DefaultUserAgent = "Mozilla/5.0 (compatible; Miniflux/" + version.Version + "; +https://miniflux.app)"
errInvalidCertificate = "Invalid SSL certificate (original error: %q)"
errTemporaryNetworkOperation = "This website is temporarily unreachable (original error: %q)"
errPermanentNetworkOperation = "This website is permanently unreachable (original error: %q)"
@@ -47,13 +43,16 @@ type Client struct {
authorizationHeader string
username string
password string
userAgent string
Insecure bool
}
// WithCredentials defines the username/password for HTTP Basic authentication.
func (c *Client) WithCredentials(username, password string) *Client {
c.username = username
c.password = password
if username != "" && password != "" {
c.username = username
c.password = password
}
return c
}
@@ -70,6 +69,14 @@ func (c *Client) WithCacheHeaders(etagHeader, lastModifiedHeader string) *Client
return c
}
// WithUserAgent defines the User-Agent header to use for outgoing requests.
func (c *Client) WithUserAgent(userAgent string) *Client {
if userAgent != "" {
c.userAgent = userAgent
}
return c
}
// Get execute a GET HTTP request.
func (c *Client) Get() (*Response, error) {
request, err := c.buildRequest(http.MethodGet, nil)
@@ -112,6 +119,10 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
client := c.buildClient()
resp, err := client.Do(request)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
if uerr, ok := err.(*url.Error); ok {
switch uerr.Err.(type) {
@@ -126,7 +137,7 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
case net.Error:
nerr := uerr.Err.(net.Error)
if nerr.Timeout() {
err = errors.NewLocalizedError(errRequestTimeout, requestTimeout)
err = errors.NewLocalizedError(errRequestTimeout, config.Opts.HTTPClientTimeout())
} else if nerr.Temporary() {
err = errors.NewLocalizedError(errTemporaryNetworkOperation, nerr)
}
@@ -135,9 +146,8 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
return nil, err
}
defer resp.Body.Close()
if resp.ContentLength > maxBodySize {
if resp.ContentLength > config.Opts.HTTPClientMaxBodySize() {
return nil, fmt.Errorf("client: response too large (%d bytes)", resp.ContentLength)
}
@@ -156,7 +166,7 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
ContentLength: resp.ContentLength,
}
logger.Debug("[HttpClient:%s] URL=%s, EffectiveURL=%s, Code=%d, Length=%d, Type=%s, ETag=%s, LastMod=%s, Expires=%s",
logger.Debug("[HttpClient:%s] URL=%s, EffectiveURL=%s, Code=%d, Length=%d, Type=%s, ETag=%s, LastMod=%s, Expires=%s, Auth=%v",
request.Method,
c.url,
response.EffectiveURL,
@@ -166,6 +176,7 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
response.ETag,
response.LastModified,
resp.Header.Get("Expires"),
c.username != "",
)
// Ignore caching headers for feeds that do not want any cache.
@@ -194,7 +205,7 @@ func (c *Client) buildRequest(method string, body io.Reader) (*http.Request, err
}
func (c *Client) buildClient() http.Client {
client := http.Client{Timeout: time.Duration(requestTimeout * time.Second)}
client := http.Client{Timeout: time.Duration(config.Opts.HTTPClientTimeout()) * time.Second}
if c.Insecure {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
@@ -206,7 +217,7 @@ func (c *Client) buildClient() http.Client {
func (c *Client) buildHeaders() http.Header {
headers := make(http.Header)
headers.Add("User-Agent", "Mozilla/5.0 (compatible; Miniflux/"+version.Version+"; +https://miniflux.net)")
headers.Add("User-Agent", c.userAgent)
headers.Add("Accept", "*/*")
if c.etagHeader != "" {
@@ -221,10 +232,11 @@ func (c *Client) buildHeaders() http.Header {
headers.Add("Authorization", c.authorizationHeader)
}
headers.Add("Connection", "close")
return headers
}
// New returns a new HTTP client.
func New(url string) *Client {
return &Client{url: url, Insecure: false}
return &Client{url: url, userAgent: DefaultUserAgent, Insecure: false}
}
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package client provides an HTTP client builder.
*/
package client // import "miniflux.app/http/client"
+58 -14
View File
@@ -2,17 +2,21 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package client
package client // import "miniflux.app/http/client"
import (
"bytes"
"io"
"mime"
"io/ioutil"
"regexp"
"strings"
"unicode/utf8"
"github.com/miniflux/miniflux/logger"
"golang.org/x/net/html/charset"
)
var xmlEncodingRegex = regexp.MustCompile(`<\?xml(.*)encoding=["'](.+)["'](.*)\?>`)
// Response wraps a server response.
type Response struct {
Body io.Reader
@@ -24,6 +28,16 @@ type Response struct {
ContentLength int64
}
// IsNotFound returns true if the resource doesn't exists anymore.
func (r *Response) IsNotFound() bool {
return r.StatusCode == 404 || r.StatusCode == 410
}
// IsNotAuthorized returns true if the resource require authentication.
func (r *Response) IsNotAuthorized() bool {
return r.StatusCode == 401
}
// HasServerFailure returns true if the status code represents a failure.
func (r *Response) HasServerFailure() bool {
return r.StatusCode >= 400
@@ -46,23 +60,53 @@ func (r *Response) IsModified(etag, lastModified string) bool {
return true
}
// NormalizeBodyEncoding make sure the body is encoded in UTF-8.
// EnsureUnicodeBody makes sure the body is encoded in UTF-8.
//
// If a charset other than UTF-8 is detected, we convert the document to UTF-8.
// This is used by the scraper and feed readers.
//
// Do not forget edge cases:
// - Some non-utf8 feeds specify encoding only in Content-Type, not in XML document.
func (r *Response) NormalizeBodyEncoding() (io.Reader, error) {
_, params, err := mime.ParseMediaType(r.ContentType)
if err == nil {
if enc, found := params["charset"]; found {
enc = strings.ToLower(enc)
if enc != "utf-8" && enc != "utf8" && enc != "" {
logger.Debug("[NormalizeBodyEncoding] Convert body to UTF-8 from %s", enc)
return charset.NewReader(r.Body, r.ContentType)
//
// - Feeds with encoding specified only in Content-Type header and not in XML document
// - Feeds with encoding specified in both places
// - Feeds with encoding specified only in XML document and not in HTTP header
// - Feeds with wrong encoding defined and already in UTF-8
func (r *Response) EnsureUnicodeBody() (err error) {
if r.ContentType != "" {
// JSON feeds are always in UTF-8.
if strings.Contains(r.ContentType, "json") {
return
}
if strings.Contains(r.ContentType, "xml") {
buffer, _ := ioutil.ReadAll(r.Body)
r.Body = bytes.NewReader(buffer)
// We ignore documents with encoding specified in XML prolog.
// This is going to be handled by the XML parser.
length := 1024
if len(buffer) < 1024 {
length = len(buffer)
}
if xmlEncodingRegex.Match(buffer[0:length]) {
return
}
// If no encoding is specified in the XML prolog and
// the document is valid UTF-8, nothing needs to be done.
if utf8.Valid(buffer) {
return
}
}
}
return r.Body, nil
r.Body, err = charset.NewReader(r.Body, r.ContentType)
return err
}
// String returns the response body as string.
func (r *Response) String() string {
bytes, _ := ioutil.ReadAll(r.Body)
return string(bytes)
}
+107 -14
View File
@@ -2,28 +2,64 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package client
package client // import "miniflux.app/http/client"
import "testing"
import (
"bytes"
"io/ioutil"
"strings"
"testing"
"unicode/utf8"
)
func TestHasServerFailureWith200Status(t *testing.T) {
r := &Response{StatusCode: 200}
if r.HasServerFailure() {
t.Error("200 is not a failure")
func TestIsNotFound(t *testing.T) {
scenarios := map[int]bool{
200: false,
404: true,
410: true,
}
for input, expected := range scenarios {
r := &Response{StatusCode: input}
actual := r.IsNotFound()
if actual != expected {
t.Errorf(`Unexpected result, got %v instead of %v for status code %d`, actual, expected, input)
}
}
}
func TestHasServerFailureWith404Status(t *testing.T) {
r := &Response{StatusCode: 404}
if !r.HasServerFailure() {
t.Error("404 is a failure")
func TestIsNotAuthorized(t *testing.T) {
scenarios := map[int]bool{
200: false,
401: true,
403: false,
}
for input, expected := range scenarios {
r := &Response{StatusCode: input}
actual := r.IsNotAuthorized()
if actual != expected {
t.Errorf(`Unexpected result, got %v instead of %v for status code %d`, actual, expected, input)
}
}
}
func TestHasServerFailureWith500Status(t *testing.T) {
r := &Response{StatusCode: 500}
if !r.HasServerFailure() {
t.Error("500 is a failure")
func TestHasServerFailure(t *testing.T) {
scenarios := map[int]bool{
200: false,
404: true,
500: true,
}
for input, expected := range scenarios {
r := &Response{StatusCode: input}
actual := r.HasServerFailure()
if actual != expected {
t.Errorf(`Unexpected result, got %v instead of %v for status code %d`, actual, expected, input)
}
}
}
@@ -54,3 +90,60 @@ func TestIsModifiedWithDifferentHeaders(t *testing.T) {
t.Error("The resource should be considered modified")
}
}
func TestToString(t *testing.T) {
input := `test`
r := &Response{Body: strings.NewReader(input)}
if r.String() != input {
t.Error(`Unexpected ouput`)
}
}
func TestEnsureUnicodeWithHTMLDocuments(t *testing.T) {
var unicodeTestCases = []struct {
filename, contentType string
convertedToUnicode bool
}{
{"HTTP-charset.html", "text/html; charset=iso-8859-15", true},
{"UTF-16LE-BOM.html", "", true},
{"UTF-16BE-BOM.html", "", true},
{"meta-content-attribute.html", "text/html", true},
{"meta-charset-attribute.html", "text/html", true},
{"No-encoding-declaration.html", "text/html", true},
{"HTTP-vs-UTF-8-BOM.html", "text/html; charset=iso-8859-15", true},
{"HTTP-vs-meta-content.html", "text/html; charset=iso-8859-15", true},
{"HTTP-vs-meta-charset.html", "text/html; charset=iso-8859-15", true},
{"UTF-8-BOM-vs-meta-content.html", "text/html", true},
{"UTF-8-BOM-vs-meta-charset.html", "text/html", true},
{"windows_1251.html", "text/html; charset=windows-1251", true},
{"gb2312.html", "text/html", true},
{"urdu.xml", "text/xml; charset=utf-8", true},
{"content-type-only-win-8859-1.xml", "application/xml; charset=ISO-8859-1", true},
{"rdf_utf8.xml", "application/rss+xml; charset=utf-8", true},
{"rdf_utf8.xml", "application/rss+xml; charset: utf-8", true}, // Invalid Content-Type
{"charset-content-type-xml-iso88591.xml", "application/rss+xml; charset=ISO-8859-1", false},
{"windows_1251.xml", "text/xml", false},
{"smallfile.xml", "text/xml; charset=utf-8", true},
{"single_quote_xml_encoding.xml", "text/xml; charset=utf-8", true},
}
for _, tc := range unicodeTestCases {
content, err := ioutil.ReadFile("testdata/" + tc.filename)
if err != nil {
t.Fatalf(`Unable to read file %q: %v`, tc.filename, err)
}
r := &Response{Body: bytes.NewReader(content), ContentType: tc.contentType}
parseErr := r.EnsureUnicodeBody()
if parseErr != nil {
t.Fatalf(`Unicode conversion error for %q - %q: %v`, tc.filename, tc.contentType, parseErr)
}
isUnicode := utf8.ValidString(r.String())
if isUnicode != tc.convertedToUnicode {
t.Errorf(`Unicode conversion %q - %q, got: %v, expected: %v`,
tc.filename, tc.contentType, isUnicode, tc.convertedToUnicode)
}
}
}

Some files were not shown because too many files have changed in this diff Show More