From 4b6e46d9abc2d0521809ade80af1e4bbba6b9260 Mon Sep 17 00:00:00 2001
From: Gergan Penkov <gergan@users.noreply.github.com>
Date: Mon, 3 Jan 2022 04:45:12 +0100
Subject: [PATCH] Add Google Reader API implementation (experimental)

Co-authored-by: Sebastian Kempken <sebastian@kempken.io>
Co-authored-by: Gergan Penkov <gergan@gmail.com>
Co-authored-by: Dave Marquard <dave@marquard.org>
Co-authored-by: Moritz Fago <4459068+MoritzFago@users.noreply.github.com>
---
 .gitignore                                 |    1 +
 database/migrations.go                     |    9 +
 googlereader/doc.go                        |   10 +
 googlereader/handler.go                    | 1180 ++++++++++++++++++++
 googlereader/middleware.go                 |  208 ++++
 googlereader/response.go                   |  144 +++
 http/request/context.go                    |    6 +
 locale/translations/de_DE.json             |    5 +
 locale/translations/el_EL.json             |    5 +
 locale/translations/en_US.json             |    5 +
 locale/translations/es_ES.json             |    5 +
 locale/translations/fr_FR.json             |    5 +
 locale/translations/it_IT.json             |    5 +
 locale/translations/ja_JP.json             |    5 +
 locale/translations/nl_NL.json             |    5 +
 locale/translations/pl_PL.json             |    5 +
 locale/translations/pt_BR.json             |    5 +
 locale/translations/ru_RU.json             |    5 +
 locale/translations/tr_TR.json             |    7 +-
 locale/translations/zh_CN.json             |    5 +
 model/integration.go                       |    3 +
 service/httpd/httpd.go                     |    2 +
 storage/category.go                        |   49 +
 storage/entry.go                           |   20 +
 storage/integration.go                     |  215 +++-
 template/templates/views/integrations.html |   20 +
 ui/form/integration.go                     |   10 +-
 ui/integration_show.go                     |    2 +
 ui/integration_update.go                   |   13 +
 29 files changed, 1923 insertions(+), 36 deletions(-)
 create mode 100644 googlereader/doc.go
 create mode 100644 googlereader/handler.go
 create mode 100644 googlereader/middleware.go
 create mode 100644 googlereader/response.go

diff --git a/.gitignore b/.gitignore
index d75f1ee2..5e30ffea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ miniflux-*
 miniflux
 *.rpm
 *.deb
+.idea
\ No newline at end of file
diff --git a/database/migrations.go b/database/migrations.go
index a5ac6583..9803ed4d 100644
--- a/database/migrations.go
+++ b/database/migrations.go
@@ -563,4 +563,13 @@ var migrations = []func(tx *sql.Tx) error{
 		_, err = tx.Exec(sql)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		sql := `
+			ALTER TABLE integrations ADD COLUMN googlereader_enabled bool default 'f';
+			ALTER TABLE integrations ADD COLUMN googlereader_username text default '';
+			ALTER TABLE integrations ADD COLUMN googlereader_password text default '';
+			`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }
diff --git a/googlereader/doc.go b/googlereader/doc.go
new file mode 100644
index 00000000..33818622
--- /dev/null
+++ b/googlereader/doc.go
@@ -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 googlereader implements Google Reader API endpoints.
+
+*/
+package googlereader // import "miniflux.app/googlereader"
diff --git a/googlereader/handler.go b/googlereader/handler.go
new file mode 100644
index 00000000..a705f261
--- /dev/null
+++ b/googlereader/handler.go
@@ -0,0 +1,1180 @@
+package googlereader // import "miniflux.app/googlereader"
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httputil"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/gorilla/mux"
+	"miniflux.app/config"
+	"miniflux.app/http/request"
+	"miniflux.app/http/response/json"
+	"miniflux.app/http/route"
+	"miniflux.app/integration"
+	"miniflux.app/logger"
+	"miniflux.app/model"
+	mff "miniflux.app/reader/handler"
+	mfs "miniflux.app/reader/subscription"
+	"miniflux.app/storage"
+	"miniflux.app/validator"
+)
+
+type handler struct {
+	store  *storage.Storage
+	router *mux.Router
+}
+
+const (
+	// StreamPrefix is the prefix for astreams (read/starred/reading list and so on)
+	StreamPrefix = "user/-/state/com.google/"
+	// UserStreamPrefix is the user specific prefix for streams (read/starred/reading list and so on)
+	UserStreamPrefix = "user/%d/state/com.google/"
+	// LabelPrefix is the prefix for a label stream
+	LabelPrefix = "user/-/label/"
+	// UserLabelPrefix is the user specific prefix prefix for a label stream
+	UserLabelPrefix = "user/%d/label/"
+	// FeedPrefix is the prefix for a feed stream
+	FeedPrefix = "feed/"
+	// Read is the suffix for read stream
+	Read = "read"
+	// Starred is the suffix for starred stream
+	Starred = "starred"
+	// ReadingList is the suffix for reading list stream
+	ReadingList = "reading-list"
+	// KeptUnread is the suffix for kept unread stream
+	KeptUnread = "kept-unread"
+	// Broadcast is the suffix for broadcast stream
+	Broadcast = "broadcast"
+	// BroadcastFriends is the suffix for broadcast friends stream
+	BroadcastFriends = "broadcast-friends"
+	// Like is the suffix for like stream
+	Like = "like"
+	// EntryIDLong is the long entry id representation
+	EntryIDLong = "tag:google.com,2005:reader/item/%016x"
+)
+
+const (
+	// ParamItemIDs - name of the parameter with the item ids
+	ParamItemIDs = "i"
+	// ParamStreamID - name of the parameter containing the stream to be included
+	ParamStreamID = "s"
+	// ParamStreamExcludes - name of the parameter containing streams to be excluded
+	ParamStreamExcludes = "xt"
+	// ParamStreamFilters - name of the parameter containing streams to be included
+	ParamStreamFilters = "it"
+	// ParamStreamMaxItems - name of the parameter containing number of items per page/max items returned
+	ParamStreamMaxItems = "n"
+	// ParamStreamOrder - name of the parameter containing the sort criteria
+	ParamStreamOrder = "r"
+	// ParamStreamStartTime - name of the parameter containing epoch timestamp, filtering items older than
+	ParamStreamStartTime = "ot"
+	// ParamStreamStopTime - name of the parameter containing epoch timestamp, filtering items newer than
+	ParamStreamStopTime = "nt"
+	// ParamTagsRemove - name of the parameter containing tags (streams) to be removed
+	ParamTagsRemove = "r"
+	// ParamTagsAdd - name of the parameter containing tags (streams) to be added
+	ParamTagsAdd = "a"
+	// ParamSubscribeAction - name of the parameter indicating the action to take for subscription/edit
+	ParamSubscribeAction = "ac"
+	// ParamTitle - name of the parameter for the title of the subscription
+	ParamTitle = "t"
+	// ParamQuickAdd - name of the parameter for a URL being quick subscribed to
+	ParamQuickAdd = "quickadd"
+	// ParamDestination - name fo the parameter for the new name of a tag
+	ParamDestination = "dest"
+)
+
+// StreamType represents the possible stream types
+type StreamType int
+
+const (
+	// NoStream - no stream type
+	NoStream StreamType = iota
+	// ReadStream - read stream type
+	ReadStream
+	// StarredStream - starred stream type
+	StarredStream
+	// ReadingListStream - reading list stream type
+	ReadingListStream
+	// KeptUnreadStream - kept unread stream type
+	KeptUnreadStream
+	// BroadcastStream - broadcast stream type
+	BroadcastStream
+	// BroadcastFriendsStream - broadcast friends stream type
+	BroadcastFriendsStream
+	// LabelStream - label stream type
+	LabelStream
+	// FeedStream - feed stream type
+	FeedStream
+	// LikeStream - like stream type
+	LikeStream
+)
+
+// Stream defines a stream type and its id
+type Stream struct {
+	Type StreamType
+	ID   string
+}
+
+// RequestModifiers are the parsed request parameters
+type RequestModifiers struct {
+	ExcludeTargets    []Stream
+	FilterTargets     []Stream
+	Streams           []Stream
+	Count             int
+	SortDirection     string
+	StartTime         int64
+	StopTime          int64
+	ContinuationToken string
+	UserID            int64
+}
+
+func (st StreamType) String() string {
+	switch st {
+	case NoStream:
+		return "NoStream"
+	case ReadStream:
+		return "ReadStream"
+	case StarredStream:
+		return "StarredStream"
+	case ReadingListStream:
+		return "ReadingListStream"
+	case KeptUnreadStream:
+		return "KeptUnreadStream"
+	case BroadcastStream:
+		return "BroadcastStream"
+	case BroadcastFriendsStream:
+		return "BroadcastFriendsStream"
+	case LabelStream:
+		return "LabelStream"
+	case FeedStream:
+		return "FeedStream"
+	case LikeStream:
+		return "LikeStream"
+	default:
+		return st.String()
+	}
+}
+func (s Stream) String() string {
+	return fmt.Sprintf("%v - '%s'", s.Type, s.ID)
+}
+func (r RequestModifiers) String() string {
+	result := fmt.Sprintf("UserID: %d\n", r.UserID)
+	result += fmt.Sprintf("Streams: %d\n", len(r.Streams))
+	for _, s := range r.Streams {
+		result += fmt.Sprintf("         %v\n", s)
+	}
+
+	result += fmt.Sprintf("Exclusions: %d\n", len(r.ExcludeTargets))
+	for _, s := range r.ExcludeTargets {
+		result += fmt.Sprintf("            %v\n", s)
+	}
+
+	result += fmt.Sprintf("Filter: %d\n", len(r.FilterTargets))
+	for _, s := range r.FilterTargets {
+		result += fmt.Sprintf("        %v\n", s)
+	}
+	result += fmt.Sprintf("Count: %d\n", r.Count)
+	result += fmt.Sprintf("Sort Direction: %s\n", r.SortDirection)
+	result += fmt.Sprintf("Continuation Token: %s\n", r.ContinuationToken)
+	result += fmt.Sprintf("Start Time: %d\n", r.StartTime)
+	result += fmt.Sprintf("Stop Time: %d\n", r.StopTime)
+
+	return result
+}
+
+// Serve handles Google Reader API calls.
+func Serve(router *mux.Router, store *storage.Storage) {
+	handler := &handler{store, router}
+	middleware := newMiddleware(store)
+	router.HandleFunc("/accounts/ClientLogin", middleware.clientLogin).Methods(http.MethodPost).Name("ClientLogin")
+	sr := router.PathPrefix("/reader/api/0").Subrouter()
+	sr.Use(middleware.handleCORS)
+	sr.Use(middleware.apiKeyAuth)
+	sr.Methods(http.MethodOptions)
+	sr.HandleFunc("/token", middleware.token).Methods(http.MethodGet).Name("Token")
+	sr.HandleFunc("/edit-tag", handler.editTag).Methods(http.MethodPost).Name("EditTag")
+	sr.HandleFunc("/rename-tag", handler.renameTag).Methods(http.MethodPost).Name("Rename Tag")
+	sr.HandleFunc("/disable-tag", handler.disableTag).Methods(http.MethodPost).Name("Disable Tag")
+	sr.HandleFunc("/tag/list", handler.tagList).Methods(http.MethodGet).Name("TagList")
+	sr.HandleFunc("/user-info", handler.userInfo).Methods(http.MethodGet).Name("UserInfo")
+	sr.HandleFunc("/subscription/list", handler.subscriptionList).Methods(http.MethodGet).Name("SubscriptonList")
+	sr.HandleFunc("/subscription/edit", handler.editSubscription).Methods(http.MethodPost).Name("SubscriptionEdit")
+	sr.HandleFunc("/subscription/quickadd", handler.quickAdd).Methods(http.MethodPost).Name("QuickAdd")
+	sr.HandleFunc("/stream/items/ids", handler.streamItemIDs).Methods(http.MethodGet).Name("StreamItemIDs")
+	sr.HandleFunc("/stream/items/contents", handler.streamItemContents).Methods(http.MethodPost).Name("StreamItemsContents")
+	sr.PathPrefix("/").HandlerFunc(handler.serve).Methods(http.MethodPost, http.MethodGet).Name("GoogleReaderApiEndpoint")
+}
+
+func getStreamFilterModifiers(r *http.Request) (RequestModifiers, error) {
+	userID := request.UserID(r)
+
+	result := RequestModifiers{
+		SortDirection: "desc",
+		UserID:        userID,
+	}
+	streamOrder := request.QueryStringParam(r, ParamStreamOrder, "d")
+	if streamOrder == "o" {
+		result.SortDirection = "asc"
+	}
+	var err error
+	result.Streams, err = getStreams(request.QueryStringParamList(r, ParamStreamID), userID)
+	if err != nil {
+		return RequestModifiers{}, err
+	}
+	result.ExcludeTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamExcludes), userID)
+	if err != nil {
+		return RequestModifiers{}, err
+	}
+
+	result.FilterTargets, err = getStreams(request.QueryStringParamList(r, ParamStreamFilters), userID)
+	if err != nil {
+		return RequestModifiers{}, err
+	}
+
+	result.Count = request.QueryIntParam(r, ParamStreamMaxItems, 0)
+	result.StartTime = int64(request.QueryIntParam(r, ParamStreamStartTime, 0))
+	result.StopTime = int64(request.QueryIntParam(r, ParamStreamStopTime, 0))
+	return result, nil
+}
+
+func getStream(streamID string, userID int64) (Stream, error) {
+	if strings.HasPrefix(streamID, FeedPrefix) {
+		return Stream{Type: FeedStream, ID: strings.TrimPrefix(streamID, FeedPrefix)}, nil
+	} else if strings.HasPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID)) || strings.HasPrefix(streamID, StreamPrefix) {
+		id := strings.TrimPrefix(streamID, fmt.Sprintf(UserStreamPrefix, userID))
+		id = strings.TrimPrefix(id, StreamPrefix)
+		switch id {
+		case Read:
+			return Stream{ReadStream, ""}, nil
+		case Starred:
+			return Stream{StarredStream, ""}, nil
+		case ReadingList:
+			return Stream{ReadingListStream, ""}, nil
+		case KeptUnread:
+			return Stream{KeptUnreadStream, ""}, nil
+		case Broadcast:
+			return Stream{BroadcastStream, ""}, nil
+		case BroadcastFriends:
+			return Stream{BroadcastFriendsStream, ""}, nil
+		case Like:
+			return Stream{LikeStream, ""}, nil
+		default:
+			err := fmt.Errorf("uknown stream with id: %s", id)
+			return Stream{NoStream, ""}, err
+		}
+	} else if strings.HasPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID)) || strings.HasPrefix(streamID, LabelPrefix) {
+		id := strings.TrimPrefix(streamID, fmt.Sprintf(UserLabelPrefix, userID))
+		id = strings.TrimPrefix(id, LabelPrefix)
+		return Stream{LabelStream, id}, nil
+	} else if streamID == "" {
+		return Stream{NoStream, ""}, nil
+	}
+	err := fmt.Errorf("uknown stream type: %s", streamID)
+	return Stream{NoStream, ""}, err
+}
+
+func getStreams(streamIDs []string, userID int64) ([]Stream, error) {
+	streams := make([]Stream, 0)
+	for _, streamID := range streamIDs {
+		stream, err := getStream(streamID, userID)
+		if err != nil {
+			return []Stream{}, err
+		}
+		streams = append(streams, stream)
+	}
+	return streams, nil
+}
+
+func checkAndSimplifyTags(addTags []Stream, removeTags []Stream) (map[StreamType]bool, error) {
+	tags := make(map[StreamType]bool)
+	for _, s := range addTags {
+		switch s.Type {
+		case ReadStream:
+			if _, ok := tags[ReadStream]; ok {
+				return nil, fmt.Errorf(KeptUnread + " and " + Read + " should not be supplied simultaneously")
+			}
+			tags[ReadStream] = true
+		case KeptUnreadStream:
+			if _, ok := tags[ReadStream]; ok {
+				return nil, fmt.Errorf(KeptUnread + " and " + Read + " should not be supplied simultaneously")
+			}
+			tags[ReadStream] = false
+		case StarredStream:
+			tags[StarredStream] = true
+		case BroadcastStream, LikeStream:
+			logger.Info("Broadcast & Like tags are not implemented!")
+		default:
+			return nil, fmt.Errorf("unsupported tag type: %s", s.Type)
+		}
+	}
+	for _, s := range removeTags {
+		switch s.Type {
+		case ReadStream:
+			if _, ok := tags[ReadStream]; ok {
+				return nil, fmt.Errorf(KeptUnread + " and " + Read + " should not be supplied simultaneously")
+			}
+			tags[ReadStream] = false
+		case KeptUnreadStream:
+			if _, ok := tags[ReadStream]; ok {
+				return nil, fmt.Errorf(KeptUnread + " and " + Read + " should not be supplied simultaneously")
+			}
+			tags[ReadStream] = true
+		case StarredStream:
+			if _, ok := tags[StarredStream]; ok {
+				return nil, fmt.Errorf(Starred + " should not be supplied for add and remove simultaneously")
+			}
+			tags[StarredStream] = false
+		case BroadcastStream, LikeStream:
+			logger.Info("Broadcast & Like tags are not implemented!")
+		default:
+			return nil, fmt.Errorf("unsupported tag type: %s", s.Type)
+		}
+	}
+
+	return tags, nil
+}
+
+func getItemIDs(r *http.Request) ([]int64, error) {
+	items := r.Form[ParamItemIDs]
+	if len(items) == 0 {
+		return nil, fmt.Errorf("no items requested")
+	}
+
+	itemIDs := make([]int64, len(items))
+
+	for i, item := range items {
+		var itemID int64
+		_, err := fmt.Sscanf(item, EntryIDLong, &itemID)
+		if err != nil {
+			itemID, err = strconv.ParseInt(item, 16, 64)
+			if err != nil {
+				return nil, fmt.Errorf("could not parse item: %v", item)
+			}
+		}
+		itemIDs[i] = itemID
+	}
+	return itemIDs, nil
+}
+
+func checkOutputFormat(w http.ResponseWriter, r *http.Request) error {
+	var output string
+	if r.Method == http.MethodPost {
+		err := r.ParseForm()
+		if err != nil {
+			return err
+		}
+		output = r.Form.Get("output")
+	} else {
+		output = request.QueryStringParam(r, "output", "")
+	}
+	if output != "json" {
+		err := fmt.Errorf("output only as json supported")
+		return err
+	}
+	return nil
+}
+
+func (h *handler) editTag(w http.ResponseWriter, r *http.Request) {
+	userID := request.UserID(r)
+	clientIP := request.ClientIP(r)
+
+	logger.Info("[Reader][/edit-tag][ClientIP=%s] Incoming Request for userID  #%d", clientIP, userID)
+
+	err := r.ParseForm()
+	if err != nil {
+		logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+
+	addTags, err := getStreams(r.PostForm[ParamTagsAdd], userID)
+	if err != nil {
+		logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	removeTags, err := getStreams(r.PostForm[ParamTagsRemove], userID)
+	if err != nil {
+		logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	if len(addTags) == 0 && len(removeTags) == 0 {
+		err = fmt.Errorf("add or/and remove tags should be supllied")
+		logger.Error("[Reader][/edit-tag] [ClientIP=%s] ", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	tags, err := checkAndSimplifyTags(addTags, removeTags)
+	if err != nil {
+		logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+
+	itemIDs, err := getItemIDs(r)
+	if err != nil {
+		logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+
+	logger.Debug("[Reader][/edit-tag] [ClientIP=%s] itemIDs: %v", clientIP, itemIDs)
+	logger.Debug("[Reader][/edit-tag] [ClientIP=%s] tags: %v", clientIP, tags)
+	builder := h.store.NewEntryQueryBuilder(userID)
+	builder.WithEntryIDs(itemIDs)
+	builder.WithoutStatus(model.EntryStatusRemoved)
+
+	entries, err := builder.GetEntries()
+	if err != nil {
+		logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+
+	n := 0
+	readEntryIDs := make([]int64, 0)
+	unreadEntryIDs := make([]int64, 0)
+	starredEntryIDs := make([]int64, 0)
+	unstarredEntryIDs := make([]int64, 0)
+	for _, entry := range entries {
+		if read, exists := tags[ReadStream]; exists {
+			if read && entry.Status == model.EntryStatusUnread {
+				readEntryIDs = append(readEntryIDs, entry.ID)
+			} else if entry.Status == model.EntryStatusRead {
+				unreadEntryIDs = append(unreadEntryIDs, entry.ID)
+			}
+		}
+		if starred, exists := tags[StarredStream]; exists {
+			if starred && !entry.Starred {
+				starredEntryIDs = append(starredEntryIDs, entry.ID)
+				// filter the original array
+				entries[n] = entry
+				n++
+			} else if entry.Starred {
+				unstarredEntryIDs = append(unstarredEntryIDs, entry.ID)
+			}
+		}
+	}
+	entries = entries[:n]
+	if len(readEntryIDs) > 0 {
+		err = h.store.SetEntriesStatus(userID, readEntryIDs, model.EntryStatusRead)
+		if err != nil {
+			logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+			json.ServerError(w, r, err)
+			return
+		}
+	}
+
+	if len(unreadEntryIDs) > 0 {
+		err = h.store.SetEntriesStatus(userID, unreadEntryIDs, model.EntryStatusUnread)
+		if err != nil {
+			logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+			json.ServerError(w, r, err)
+			return
+		}
+	}
+
+	if len(unstarredEntryIDs) > 0 {
+		err = h.store.SetEntriesBookmarkedState(userID, unstarredEntryIDs, true)
+		if err != nil {
+			logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+			json.ServerError(w, r, err)
+			return
+		}
+	}
+
+	if len(starredEntryIDs) > 0 {
+		err = h.store.SetEntriesBookmarkedState(userID, starredEntryIDs, true)
+		if err != nil {
+			logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+			json.ServerError(w, r, err)
+			return
+		}
+	}
+
+	if len(entries) > 0 {
+		settings, err := h.store.Integration(userID)
+		if err != nil {
+			logger.Error("[Reader][/edit-tag] [ClientIP=%s] %v", clientIP, err)
+			json.ServerError(w, r, err)
+			return
+		}
+
+		for _, entry := range entries {
+			e := entry
+			go func() {
+				integration.SendEntry(e, settings)
+			}()
+		}
+	}
+
+	OK(w, r)
+}
+
+func (h *handler) quickAdd(w http.ResponseWriter, r *http.Request) {
+	userID := request.UserID(r)
+	clientIP := request.ClientIP(r)
+
+	logger.Info("[Reader][/subscription/quickadd][ClientIP=%s] Incoming Request for userID  #%d", clientIP, userID)
+
+	err := r.ParseForm()
+	if err != nil {
+		logger.Error("[Reader][/subscription/quickadd] [ClientIP=%s] %v", clientIP, err)
+		json.BadRequest(w, r, err)
+		return
+	}
+
+	url := r.Form.Get(ParamQuickAdd)
+	if !validator.IsValidURL(url) {
+		json.BadRequest(w, r, fmt.Errorf("invalid URL: %s", url))
+		return
+	}
+
+	subscriptions, s_err := mfs.FindSubscriptions(url, "", "", "", "", false, false)
+	if s_err != nil {
+		json.ServerError(w, r, s_err)
+		return
+	}
+
+	if len(subscriptions) == 0 {
+		json.OK(w, r, quickAddResponse{
+			NumResults: 0,
+		})
+		return
+	}
+
+	toSubscribe := Stream{FeedStream, subscriptions[0].URL}
+	category := Stream{NoStream, ""}
+	newFeed, err := subscribe(toSubscribe, category, "", h.store, userID)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+	json.OK(w, r, quickAddResponse{
+		NumResults: 1,
+		Query:      newFeed.FeedURL,
+		StreamID:   fmt.Sprintf(FeedPrefix+"%d", newFeed.ID),
+		StreamName: newFeed.Title,
+	})
+}
+
+func getFeed(stream Stream, store *storage.Storage, userID int64) (*model.Feed, error) {
+	feedID, err := strconv.ParseInt(stream.ID, 10, 64)
+	if err != nil {
+		return nil, err
+	}
+	return store.FeedByID(userID, feedID)
+}
+
+func getOrCreateCategory(category Stream, store *storage.Storage, userID int64) (*model.Category, error) {
+	if category.ID == "" {
+		return store.FirstCategory(userID)
+	} else if store.CategoryTitleExists(userID, category.ID) {
+		return store.CategoryByTitle(userID, category.ID)
+	} else {
+		catRequest := model.CategoryRequest{
+			Title: category.ID,
+		}
+		return store.CreateCategory(userID, &catRequest)
+	}
+}
+
+func subscribe(newFeed Stream, category Stream, title string, store *storage.Storage, userID int64) (*model.Feed, error) {
+	destCategory, err := getOrCreateCategory(category, store, userID)
+	if err != nil {
+		return nil, err
+	}
+
+	feedRequest := model.FeedCreationRequest{
+		FeedURL:    newFeed.ID,
+		CategoryID: destCategory.ID,
+	}
+	verr := validator.ValidateFeedCreation(store, userID, &feedRequest)
+	if verr != nil {
+		return nil, verr.Error()
+	}
+
+	created, err := mff.CreateFeed(store, userID, &feedRequest)
+	if err != nil {
+		return nil, err
+	}
+
+	if title != "" {
+		feedModification := model.FeedModificationRequest{
+			Title: &title,
+		}
+		feedModification.Patch(created)
+		if err := store.UpdateFeed(created); err != nil {
+			return nil, err
+		}
+	}
+
+	return created, nil
+}
+
+func unsubscribe(streams []Stream, store *storage.Storage, userID int64) error {
+	for _, stream := range streams {
+		feedID, err := strconv.ParseInt(stream.ID, 10, 64)
+		if err != nil {
+			return err
+		}
+		err = store.RemoveFeed(userID, feedID)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func rename(stream Stream, title string, store *storage.Storage, userID int64) error {
+	if title == "" {
+		return errors.New("empty title")
+	}
+	feed, err := getFeed(stream, store, userID)
+	if err != nil {
+		return err
+	}
+	feedModification := model.FeedModificationRequest{
+		Title: &title,
+	}
+	feedModification.Patch(feed)
+	return store.UpdateFeed(feed)
+}
+
+func move(stream Stream, destination Stream, store *storage.Storage, userID int64) error {
+	feed, err := getFeed(stream, store, userID)
+	if err != nil {
+		return err
+	}
+	category, err := getOrCreateCategory(destination, store, userID)
+	if err != nil {
+		return err
+	}
+	feedModification := model.FeedModificationRequest{
+		CategoryID: &category.ID,
+	}
+	feedModification.Patch(feed)
+	return store.UpdateFeed(feed)
+}
+
+func (h *handler) editSubscription(w http.ResponseWriter, r *http.Request) {
+	userID := request.UserID(r)
+	clientIP := request.ClientIP(r)
+
+	logger.Info("[Reader][/subscription/edit][ClientIP=%s] Incoming Request for userID  #%d", clientIP, userID)
+
+	err := r.ParseForm()
+	if err != nil {
+		logger.Error("[Reader][/subscription/edit] [ClientIP=%s] %v", clientIP, err)
+		json.BadRequest(w, r, err)
+		return
+	}
+
+	streamIds, err := getStreams(r.Form[ParamStreamID], userID)
+	if err != nil || len(streamIds) == 0 {
+		json.BadRequest(w, r, errors.New("no valid stream IDs provided"))
+		return
+	}
+
+	newLabel, err := getStream(r.Form.Get(ParamTagsAdd), userID)
+	if err != nil {
+		json.BadRequest(w, r, fmt.Errorf("invalid data in %s", ParamTagsAdd))
+		return
+	}
+
+	title := r.Form.Get(ParamTitle)
+
+	action := r.Form.Get(ParamSubscribeAction)
+	switch action {
+	case "subscribe":
+		_, err := subscribe(streamIds[0], newLabel, title, h.store, userID)
+		if err != nil {
+			json.ServerError(w, r, err)
+			return
+		}
+	case "unsubscribe":
+		err := unsubscribe(streamIds, h.store, userID)
+		if err != nil {
+			json.ServerError(w, r, err)
+			return
+		}
+	case "edit":
+		if title != "" {
+			err := rename(streamIds[0], title, h.store, userID)
+			if err != nil {
+				json.ServerError(w, r, err)
+				return
+			}
+		} else {
+			if newLabel.Type != LabelStream {
+				json.BadRequest(w, r, errors.New("destination must be a label"))
+				return
+			}
+			err := move(streamIds[0], newLabel, h.store, userID)
+			if err != nil {
+				json.ServerError(w, r, err)
+				return
+			}
+		}
+	default:
+		json.ServerError(w, r, fmt.Errorf("unrecognized action %s", action))
+		return
+	}
+
+	OK(w, r)
+}
+
+func (h *handler) streamItemContents(w http.ResponseWriter, r *http.Request) {
+	userID := request.UserID(r)
+	clientIP := request.ClientIP(r)
+
+	logger.Info("[Reader][/stream/items/contents][ClientIP=%s] Incoming Request for userID  #%d", clientIP, userID)
+
+	if err := checkOutputFormat(w, r); err != nil {
+		logger.Error("[Reader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+
+	err := r.ParseForm()
+	if err != nil {
+		logger.Error("[Reader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	var user *model.User
+	if user, err = h.store.UserByID(userID); err != nil {
+		logger.Error("[Reader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+
+	requestModifiers, err := getStreamFilterModifiers(r)
+	if err != nil {
+		logger.Error("[Reader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+
+	userReadingList := fmt.Sprintf(UserStreamPrefix, userID) + ReadingList
+	userRead := fmt.Sprintf(UserStreamPrefix, userID) + Read
+	userStarred := fmt.Sprintf(UserStreamPrefix, userID) + Starred
+
+	itemIDs, err := getItemIDs(r)
+	if err != nil {
+		logger.Error("[Reader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	logger.Debug("[Reader][/stream/items/contents] [ClientIP=%s] itemIDs: %v", clientIP, itemIDs)
+
+	builder := h.store.NewEntryQueryBuilder(userID)
+	builder.WithoutStatus(model.EntryStatusRemoved)
+	builder.WithEntryIDs(itemIDs)
+	builder.WithOrder(model.DefaultSortingOrder)
+	builder.WithDirection(requestModifiers.SortDirection)
+
+	entries, err := builder.GetEntries()
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+	if len(entries) == 0 {
+		err = fmt.Errorf("no items returned from the database")
+		logger.Error("[Reader][/stream/items/contents] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	result := streamContentItems{
+		Direction: "ltr",
+		ID:        fmt.Sprintf("feed/%d", entries[0].FeedID),
+		Title:     entries[0].Feed.Title,
+		Alternate: []contentHREFType{
+			{
+				HREF: entries[0].Feed.SiteURL,
+				Type: "text/html",
+			},
+		},
+		Updated: time.Now().Unix(),
+		Self: []contentHREF{
+			{
+				HREF: config.Opts.BaseURL() + route.Path(h.router, "StreamItemsContents"),
+			},
+		},
+		Author: user.Username,
+	}
+	contentItems := make([]contentItem, len(entries))
+	for i, entry := range entries {
+		enclosures := make([]contentItemEnclosure, len(entry.Enclosures))
+		for _, enclosure := range entry.Enclosures {
+			enclosures = append(enclosures, contentItemEnclosure{URL: enclosure.URL, Type: enclosure.MimeType})
+		}
+		categories := make([]string, 0)
+		categories = append(categories, userReadingList)
+		if entry.Feed.Category.Title != "" {
+			categories = append(categories, fmt.Sprintf(UserLabelPrefix, userID)+entry.Feed.Category.Title)
+		}
+		if entry.Starred {
+			categories = append(categories, userRead)
+		}
+
+		if entry.Starred {
+			categories = append(categories, userStarred)
+		}
+
+		contentItems[i] = contentItem{
+			ID:            fmt.Sprintf(EntryIDLong, entry.ID),
+			Title:         entry.Title,
+			Author:        entry.Author,
+			TimestampUsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
+			CrawlTimeMsec: fmt.Sprintf("%d", entry.Date.UnixNano()/(int64(time.Microsecond)/int64(time.Nanosecond))),
+			Published:     entry.Date.Unix(),
+			Updated:       entry.Date.Unix(),
+			Categories:    categories,
+			Canonical: []contentHREF{
+				{
+					HREF: entry.URL,
+				},
+			},
+			Alternate: []contentHREFType{
+				{
+					HREF: entry.URL,
+					Type: "text/html",
+				},
+			},
+			Content: contentItemContent{
+				Direction: "ltr",
+				Content:   entry.Content,
+			},
+			Summary: contentItemContent{
+				Direction: "ltr",
+				Content:   entry.Content,
+			},
+			Origin: contentItemOrigin{
+				StreamID: fmt.Sprintf("feed/%d", entry.FeedID),
+				Title:    entry.Feed.Title,
+				HTMLUrl:  entry.Feed.SiteURL,
+			},
+			Enclosure: enclosures,
+		}
+	}
+	result.Items = contentItems
+	json.OK(w, r, result)
+}
+
+func (h *handler) disableTag(w http.ResponseWriter, r *http.Request) {
+	userID := request.UserID(r)
+	clientIP := request.ClientIP(r)
+
+	logger.Info("[Reader][/disable-tag][ClientIP=%s] Incoming Request for userID  #%d", clientIP, userID)
+
+	err := r.ParseForm()
+	if err != nil {
+		logger.Error("[Reader][/disable-tag] [ClientIP=%s] %v", clientIP, err)
+		json.BadRequest(w, r, err)
+		return
+	}
+
+	streams, err := getStreams(r.Form[ParamStreamID], userID)
+	if err != nil {
+		json.BadRequest(w, r, fmt.Errorf("invalid data in %s", ParamStreamID))
+		return
+	}
+
+	titles := make([]string, len(streams))
+	for i, stream := range streams {
+		if stream.Type != LabelStream {
+			json.BadRequest(w, r, errors.New("only labels are supported"))
+			return
+		}
+		titles[i] = stream.ID
+	}
+
+	err = h.store.RemoveAndReplaceCategoriesByName(userID, titles)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+
+	OK(w, r)
+}
+
+func (h *handler) renameTag(w http.ResponseWriter, r *http.Request) {
+	userID := request.UserID(r)
+	clientIP := request.ClientIP(r)
+
+	logger.Info("[Reader][/rename-tag][ClientIP=%s] Incoming Request for userID  #%d", clientIP, userID)
+
+	err := r.ParseForm()
+	if err != nil {
+		logger.Error("[Reader][/rename-tag] [ClientIP=%s] %v", clientIP, err)
+		json.BadRequest(w, r, err)
+		return
+	}
+
+	source, err := getStream(r.Form.Get(ParamStreamID), userID)
+	if err != nil {
+		json.BadRequest(w, r, fmt.Errorf("invalid data in %s", ParamStreamID))
+		return
+	}
+
+	destination, err := getStream(r.Form.Get(ParamDestination), userID)
+	if err != nil {
+		json.BadRequest(w, r, fmt.Errorf("invalid data in %s", ParamDestination))
+		return
+	}
+
+	if source.Type != LabelStream || destination.Type != LabelStream {
+		json.BadRequest(w, r, errors.New("only labels supported"))
+		return
+	}
+
+	if destination.ID == "" {
+		json.BadRequest(w, r, errors.New("empty destination name"))
+		return
+	}
+
+	category, err := h.store.CategoryByTitle(userID, source.ID)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+	if category == nil {
+		json.NotFound(w, r)
+		return
+	}
+	categoryRequest := model.CategoryRequest{
+		Title: destination.ID,
+	}
+	verr := validator.ValidateCategoryModification(h.store, userID, category.ID, &categoryRequest)
+	if verr != nil {
+		json.BadRequest(w, r, verr.Error())
+		return
+	}
+	categoryRequest.Patch(category)
+	err = h.store.UpdateCategory(category)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+	OK(w, r)
+}
+
+func (h *handler) tagList(w http.ResponseWriter, r *http.Request) {
+	userID := request.UserID(r)
+	clientIP := request.ClientIP(r)
+
+	logger.Info("[Reader][tags/list][ClientIP=%s] Incoming Request for userID  #%d", clientIP, userID)
+
+	if err := checkOutputFormat(w, r); err != nil {
+		logger.Error("[Reader][OutputFormat] %v", err)
+		json.BadRequest(w, r, err)
+		return
+	}
+
+	var result tagsResponse
+	categories, err := h.store.Categories(userID)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+	result.Tags = make([]subscriptionCategory, 0)
+	result.Tags = append(result.Tags, subscriptionCategory{
+		ID: fmt.Sprintf(UserStreamPrefix, userID) + Starred,
+	})
+	for _, category := range categories {
+		result.Tags = append(result.Tags, subscriptionCategory{
+			ID:    fmt.Sprintf(UserLabelPrefix, userID) + category.Title,
+			Label: category.Title,
+			Type:  "folder",
+		})
+	}
+	json.OK(w, r, result)
+}
+
+func (h *handler) subscriptionList(w http.ResponseWriter, r *http.Request) {
+	userID := request.UserID(r)
+	clientIP := request.ClientIP(r)
+
+	logger.Info("[Reader][/subscription/list][ClientIP=%s] Incoming Request for userID  #%d", clientIP, userID)
+
+	if err := checkOutputFormat(w, r); err != nil {
+		logger.Error("[Reader][/subscription/list] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+
+	var result subscriptionsResponse
+	feeds, err := h.store.Feeds(userID)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+	result.Subscriptions = make([]subscription, 0)
+	for _, feed := range feeds {
+		result.Subscriptions = append(result.Subscriptions, subscription{
+			ID:         fmt.Sprintf(FeedPrefix+"%d", feed.ID),
+			Title:      feed.Title,
+			URL:        feed.FeedURL,
+			Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}},
+			HTMLURL:    feed.SiteURL,
+			IconURL:    "", //TODO Icons are only base64 encode in DB yet
+		})
+	}
+	json.OK(w, r, result)
+}
+
+func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
+	clientIP := request.ClientIP(r)
+	dump, _ := httputil.DumpRequest(r, true)
+	logger.Info("[Reader][UNKNOWN] [ClientIP=%s] URL: %s", clientIP, dump)
+	logger.Error("Call to Google Reader API not implemented yet!!")
+	json.OK(w, r, []string{})
+}
+
+func (h *handler) userInfo(w http.ResponseWriter, r *http.Request) {
+	clientIP := request.ClientIP(r)
+	logger.Info("[Reader][UserInfo] [ClientIP=%s] Sending", clientIP)
+
+	if err := checkOutputFormat(w, r); err != nil {
+		logger.Error("[Reader][/user-info] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+
+	user, err := h.store.UserByID(request.UserID(r))
+	if err != nil {
+		logger.Error("[Reader][/user-info] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	userInfo := userInfo{UserID: fmt.Sprint(user.ID), UserName: user.Username, UserProfileID: fmt.Sprint(user.ID), UserEmail: user.Username}
+	json.OK(w, r, userInfo)
+}
+
+func (h *handler) streamItemIDs(w http.ResponseWriter, r *http.Request) {
+	userID := request.UserID(r)
+	clientIP := request.ClientIP(r)
+
+	logger.Debug("[Reader][/stream/items/ids][ClientIP=%s] Incoming Request for userID  #%d", clientIP, userID)
+
+	if err := checkOutputFormat(w, r); err != nil {
+		err := fmt.Errorf("output only as json supported")
+		logger.Error("[Reader][/stream/items/ids] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+
+	rm, err := getStreamFilterModifiers(r)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+	logger.Info("Request Modifiers: %v", rm)
+	if len(rm.Streams) != 1 {
+		err := fmt.Errorf("only one stream type expected")
+		logger.Error("[Reader][/stream/items/ids] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	switch rm.Streams[0].Type {
+	case ReadingListStream:
+		h.handleReadingListStream(w, r, rm)
+	case StarredStream:
+		h.handleStarredStream(w, r, rm)
+	case ReadStream:
+		h.handleReadStream(w, r, rm)
+	default:
+		dump, _ := httputil.DumpRequest(r, true)
+		logger.Info("[Reader][/stream/items/ids] [ClientIP=%s] Unknown Stream: %s", clientIP, dump)
+		err := fmt.Errorf("unknown stream type")
+		logger.Error("[Reader][/stream/items/ids] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+}
+
+func (h *handler) handleReadingListStream(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
+	clientIP := request.ClientIP(r)
+
+	builder := h.store.NewEntryQueryBuilder(rm.UserID)
+	for _, s := range rm.ExcludeTargets {
+		switch s.Type {
+		case ReadStream:
+			builder.WithStatus(model.EntryStatusUnread)
+		default:
+			logger.Info("[Reader][ReadingListStreamIDs][ClientIP=%s] xt filter type: %#v", clientIP, s)
+		}
+	}
+	builder.WithLimit(rm.Count)
+	builder.WithOrder(model.DefaultSortingOrder)
+	builder.WithDirection(rm.SortDirection)
+	rawEntryIDs, err := builder.GetEntryIDs()
+	if err != nil {
+		logger.Error("[Reader][/stream/items/ids#reading-list] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	var itemRefs = make([]itemRef, 0)
+	for _, entryID := range rawEntryIDs {
+		formattedID := strconv.FormatInt(entryID, 10)
+		itemRefs = append(itemRefs, itemRef{ID: formattedID})
+	}
+	json.OK(w, r, streamIDResponse{itemRefs})
+}
+
+func (h *handler) handleStarredStream(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
+	clientIP := request.ClientIP(r)
+
+	builder := h.store.NewEntryQueryBuilder(rm.UserID)
+	builder.WithStarred()
+	builder.WithLimit(rm.Count)
+	builder.WithOrder(model.DefaultSortingOrder)
+	builder.WithDirection(rm.SortDirection)
+	rawEntryIDs, err := builder.GetEntryIDs()
+	if err != nil {
+		logger.Error("[Reader][/stream/items/ids#starred] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	var itemRefs = make([]itemRef, 0)
+	for _, entryID := range rawEntryIDs {
+		formattedID := strconv.FormatInt(entryID, 10)
+		itemRefs = append(itemRefs, itemRef{ID: formattedID})
+	}
+	json.OK(w, r, streamIDResponse{itemRefs})
+}
+
+func (h *handler) handleReadStream(w http.ResponseWriter, r *http.Request, rm RequestModifiers) {
+	clientIP := request.ClientIP(r)
+
+	builder := h.store.NewEntryQueryBuilder(rm.UserID)
+	builder.WithStatus(model.EntryStatusRead)
+	builder.WithOrder(model.DefaultSortingOrder)
+	builder.WithDirection(rm.SortDirection)
+	if rm.StartTime > 0 {
+		builder.AfterDate(time.Unix(rm.StartTime, 0))
+	}
+	if rm.StopTime > 0 {
+		builder.BeforeDate(time.Unix(rm.StopTime, 0))
+	}
+
+	rawEntryIDs, err := builder.GetEntryIDs()
+	if err != nil {
+		logger.Error("[Reader][/stream/items/ids#read] [ClientIP=%s] %v", clientIP, err)
+		json.ServerError(w, r, err)
+		return
+	}
+	var itemRefs = make([]itemRef, 0)
+	for _, entryID := range rawEntryIDs {
+		formattedID := strconv.FormatInt(entryID, 10)
+		itemRefs = append(itemRefs, itemRef{ID: formattedID})
+	}
+	json.OK(w, r, streamIDResponse{itemRefs})
+}
diff --git a/googlereader/middleware.go b/googlereader/middleware.go
new file mode 100644
index 00000000..653bcf89
--- /dev/null
+++ b/googlereader/middleware.go
@@ -0,0 +1,208 @@
+// 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 googlereader // import "miniflux.app/googlereader"
+
+import (
+	"context"
+	"crypto/hmac"
+	"crypto/sha1"
+	"encoding/hex"
+	"net/http"
+	"strings"
+
+	"miniflux.app/http/request"
+	"miniflux.app/http/response"
+	"miniflux.app/http/response/json"
+	"miniflux.app/logger"
+	"miniflux.app/model"
+	"miniflux.app/storage"
+)
+
+type middleware struct {
+	store *storage.Storage
+}
+
+func newMiddleware(s *storage.Storage) *middleware {
+	return &middleware{s}
+}
+
+func (m *middleware) clientLogin(w http.ResponseWriter, r *http.Request) {
+	clientIP := request.ClientIP(r)
+	var username, password, output string
+	var integration *model.Integration
+	err := r.ParseForm()
+	if err != nil {
+		logger.Error("[Reader][Login] [ClientIP=%s] Could not parse form", clientIP)
+		json.Unauthorized(w, r)
+		return
+	}
+	username = r.Form.Get("Email")
+	password = r.Form.Get("Passwd")
+	output = r.Form.Get("output")
+
+	if username == "" || password == "" {
+		logger.Error("[Reader][Login] [ClientIP=%s] Empty username or password", clientIP)
+		json.Unauthorized(w, r)
+		return
+	}
+
+	if err = m.store.GoogleReaderUserCheckPassword(username, password); err != nil {
+		logger.Error("[Reader][Login] [ClientIP=%s] Invalid username or password: %s", clientIP, username)
+		json.Unauthorized(w, r)
+		return
+	}
+
+	logger.Info("[Reader][Login] [ClientIP=%s] User authenticated: %s", clientIP, username)
+
+	if integration, err = m.store.GoogleReaderUserGetIntegration(username); err != nil {
+		logger.Error("[Reader][Login] [ClientIP=%s] Could not load integration: %s", clientIP, username)
+		json.Unauthorized(w, r)
+		return
+	}
+
+	m.store.SetLastLogin(integration.UserID)
+
+	token := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
+	logger.Info("[Reader][Login] [ClientIP=%s] Created token: %s", clientIP, token)
+	result := login{SID: token, LSID: token, Auth: token}
+	if output == "json" {
+		json.OK(w, r, result)
+		return
+	}
+	builder := response.New(w, r)
+	builder.WithHeader("Content-Type", "text/plain; charset=UTF-8")
+	builder.WithBody(result.String())
+	builder.Write()
+}
+
+func (m *middleware) token(w http.ResponseWriter, r *http.Request) {
+	clientIP := request.ClientIP(r)
+
+	if !request.IsAuthenticated(r) {
+		logger.Error("[Reader][Token] [ClientIP=%s] User is not authenticated", clientIP)
+		json.Unauthorized(w, r)
+		return
+	}
+	token := request.GoolgeReaderToken(r)
+	if token == "" {
+		logger.Error("[Reader][Token] [ClientIP=%s] User does not have token: %s", clientIP, request.UserID(r))
+		json.Unauthorized(w, r)
+		return
+	}
+	logger.Info("[Reader][Token] [ClientIP=%s] token: %s", clientIP, token)
+	w.Header().Add("Content-Type", "text/plain; charset=UTF-8")
+	w.WriteHeader(http.StatusOK)
+	w.Write([]byte(token))
+}
+
+func (m *middleware) handleCORS(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Access-Control-Allow-Origin", "*")
+		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+		w.Header().Set("Access-Control-Allow-Headers", "Authorization")
+		if r.Method == http.MethodOptions {
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+func (m *middleware) apiKeyAuth(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		clientIP := request.ClientIP(r)
+
+		var token string
+		if r.Method == http.MethodPost {
+			err := r.ParseForm()
+			if err != nil {
+				logger.Error("[Reader][Login] [ClientIP=%s] Could not parse form", clientIP)
+				Unauthorized(w, r)
+				return
+			}
+			token = r.Form.Get("T")
+			if token == "" {
+				logger.Error("[Reader][Auth] [ClientIP=%s] Post-Form T field is empty", clientIP)
+				Unauthorized(w, r)
+				return
+			}
+		} else {
+			authorization := r.Header.Get("Authorization")
+
+			if authorization == "" {
+				logger.Error("[Reader][Auth] [ClientIP=%s] No token provided", clientIP)
+				Unauthorized(w, r)
+				return
+			}
+			fields := strings.Fields(authorization)
+			if len(fields) != 2 {
+				logger.Error("[Reader][Auth] [ClientIP=%s] Authorization header does not have the expected structure GoogleLogin auth=xxxxxx - '%s'", clientIP, authorization)
+				Unauthorized(w, r)
+				return
+			}
+			if fields[0] != "GoogleLogin" {
+				logger.Error("[Reader][Auth] [ClientIP=%s] Authorization header does not begin with GoogleLogin - '%s'", clientIP, authorization)
+				Unauthorized(w, r)
+				return
+			}
+			auths := strings.Split(fields[1], "=")
+			if len(auths) != 2 {
+				logger.Error("[Reader][Auth] [ClientIP=%s] Authorization header does not have the expected structure GoogleLogin auth=xxxxxx - '%s'", clientIP, authorization)
+				Unauthorized(w, r)
+				return
+			}
+			if auths[0] != "auth" {
+				logger.Error("[Reader][Auth] [ClientIP=%s] Authorization header does not have the expected structure GoogleLogin auth=xxxxxx - '%s'", clientIP, authorization)
+				Unauthorized(w, r)
+				return
+			}
+			token = auths[1]
+		}
+
+		parts := strings.Split(token, "/")
+		if len(parts) != 2 {
+			logger.Error("[Reader][Auth] [ClientIP=%s] Auth token does not have the expected structure username/hash - '%s'", clientIP, token)
+			Unauthorized(w, r)
+			return
+		}
+		var integration *model.Integration
+		var user *model.User
+		var err error
+		if integration, err = m.store.GoogleReaderUserGetIntegration(parts[0]); err != nil {
+			logger.Error("[Reader][Auth] [ClientIP=%s] token: %s", clientIP, token)
+			logger.Error("[Reader][Auth] [ClientIP=%s] No user found with the given google reader username: %s", clientIP, parts[0])
+			Unauthorized(w, r)
+			return
+		}
+		expectedToken := getAuthToken(integration.GoogleReaderUsername, integration.GoogleReaderPassword)
+		if expectedToken != token {
+			logger.Error("[Reader][Auth] [ClientIP=%s] Token does not match: %s", clientIP, token)
+			Unauthorized(w, r)
+			return
+		}
+		if user, err = m.store.UserByID(integration.UserID); err != nil {
+			logger.Error("[Reader][Auth] [ClientIP=%s] No user found with the userID: %d", clientIP, integration.UserID)
+			Unauthorized(w, r)
+			return
+		}
+
+		m.store.SetLastLogin(integration.UserID)
+
+		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)
+		ctx = context.WithValue(ctx, request.GoogleReaderToken, token)
+
+		next.ServeHTTP(w, r.WithContext(ctx))
+	})
+}
+
+func getAuthToken(username, password string) string {
+	token := hex.EncodeToString(hmac.New(sha1.New, []byte(username+password)).Sum(nil))
+	token = username + "/" + token
+	return token
+}
diff --git a/googlereader/response.go b/googlereader/response.go
new file mode 100644
index 00000000..13a0eab2
--- /dev/null
+++ b/googlereader/response.go
@@ -0,0 +1,144 @@
+// 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 googlereader // import "miniflux.app/googlereader"
+
+import (
+	"fmt"
+	"net/http"
+
+	"miniflux.app/http/response"
+	"miniflux.app/logger"
+)
+
+type login struct {
+	SID  string `json:"SID,omitempty"`
+	LSID string `json:"LSID,omitempty"`
+	Auth string `json:"Auth,omitempty"`
+}
+
+func (l login) String() string {
+	return fmt.Sprintf("SID=%s\nLSID=%s\nAuth=%s\n", l.SID, l.LSID, l.Auth)
+}
+
+type userInfo struct {
+	UserID        string `json:"userId"`
+	UserName      string `json:"userName"`
+	UserProfileID string `json:"userProfileId"`
+	UserEmail     string `json:"userEmail"`
+}
+
+type subscription struct {
+	ID         string                 `json:"id"`
+	Title      string                 `json:"title"`
+	Categories []subscriptionCategory `json:"categories"`
+	URL        string                 `json:"url"`
+	HTMLURL    string                 `json:"htmlUrl"`
+	IconURL    string                 `json:"iconUrl"`
+}
+
+type quickAddResponse struct {
+	NumResults int64  `json:"numResults"`
+	Query      string `json:"query,omitempty"`
+	StreamID   string `json:"streamId,omitempty"`
+	StreamName string `json:"streamName,omitempty"`
+}
+
+type subscriptionCategory struct {
+	ID    string `json:"id"`
+	Label string `json:"label,omitempty"`
+	Type  string `json:"type,omitempty"`
+}
+type subscriptionsResponse struct {
+	Subscriptions []subscription `json:"subscriptions"`
+}
+
+type itemRef struct {
+	ID              string `json:"id"`
+	DirectStreamIDs string `json:"directStreamIds,omitempty"`
+	TimestampUsec   string `json:"timestampUsec,omitempty"`
+}
+
+type streamIDResponse struct {
+	ItemRefs []itemRef `json:"itemRefs"`
+}
+
+type tagsResponse struct {
+	Tags []subscriptionCategory `json:"tags"`
+}
+
+type streamContentItems struct {
+	Direction string            `json:"direction"`
+	ID        string            `json:"id"`
+	Title     string            `json:"title"`
+	Self      []contentHREF     `json:"self"`
+	Alternate []contentHREFType `json:"alternate"`
+	Updated   int64             `json:"updated"`
+	Items     []contentItem     `json:"items"`
+	Author    string            `json:"author"`
+}
+
+type contentItem struct {
+	ID            string                 `json:"id"`
+	Categories    []string               `json:"categories"`
+	Title         string                 `json:"title"`
+	CrawlTimeMsec string                 `json:"crawlTimeMsec"`
+	TimestampUsec string                 `json:"timestampUsec"`
+	Published     int64                  `json:"published"`
+	Updated       int64                  `json:"updated"`
+	Author        string                 `json:"author"`
+	Alternate     []contentHREFType      `json:"alternate"`
+	Summary       contentItemContent     `json:"summary"`
+	Content       contentItemContent     `json:"content"`
+	Origin        contentItemOrigin      `json:"origin"`
+	Enclosure     []contentItemEnclosure `json:"enclosure"`
+	Canonical     []contentHREF          `json:"canonical"`
+}
+
+type contentHREFType struct {
+	HREF string `json:"href"`
+	Type string `json:"type"`
+}
+
+type contentHREF struct {
+	HREF string `json:"href"`
+}
+
+type contentItemEnclosure struct {
+	URL  string `json:"url"`
+	Type string `json:"type"`
+}
+type contentItemContent struct {
+	Direction string `json:"direction"`
+	Content   string `json:"content"`
+}
+
+type contentItemOrigin struct {
+	StreamID string `json:"streamId"`
+	Title    string `json:"title"`
+	HTMLUrl  string `json:"htmlUrl"`
+}
+
+// Unauthorized sends a not authorized error to the client.
+func Unauthorized(w http.ResponseWriter, r *http.Request) {
+	logger.Error("[HTTP:Unauthorized] %s", r.URL)
+
+	builder := response.New(w, r)
+	builder.WithStatus(http.StatusUnauthorized)
+	builder.WithHeader("Content-Type", "text/plain")
+	builder.WithHeader("X-Reader-Google-Bad-Token", "true")
+	builder.WithBody("Unauthorized")
+	builder.Write()
+}
+
+// OK sends a ok response to the client.
+func OK(w http.ResponseWriter, r *http.Request) {
+	logger.Info("[HTTP:OK] %s", r.URL)
+
+	builder := response.New(w, r)
+	builder.WithStatus(http.StatusOK)
+	builder.WithHeader("Content-Type", "text/plain")
+	builder.WithBody("OK")
+	builder.Write()
+}
diff --git a/http/request/context.go b/http/request/context.go
index 5136849f..542c3d49 100644
--- a/http/request/context.go
+++ b/http/request/context.go
@@ -25,8 +25,14 @@ const (
 	FlashErrorMessageContextKey
 	PocketRequestTokenContextKey
 	ClientIPContextKey
+	GoogleReaderToken
 )
 
+// GoolgeReaderToken returns the google reader token if it exists.
+func GoolgeReaderToken(r *http.Request) string {
+	return getContextStringValue(r, GoogleReaderToken)
+}
+
 // IsAdminUser checks if the logged user is administrator.
 func IsAdminUser(r *http.Request) bool {
 	return getContextBoolValue(r, IsAdminUserContextKey)
diff --git a/locale/translations/de_DE.json b/locale/translations/de_DE.json
index a5830ff8..697e21b0 100644
--- a/locale/translations/de_DE.json
+++ b/locale/translations/de_DE.json
@@ -223,6 +223,7 @@
     "error.unlink_account_without_password": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
     "error.duplicate_linked_account": "Es ist bereits jemand mit diesem Anbieter assoziiert!",
     "error.duplicate_fever_username": "Es existiert bereits jemand mit diesem Fever Benutzernamen!",
+    "error.duplicate_googlereader_username": "Es existiert bereits jemand mit diesem Google Reader Benutzernamen!",
     "error.pocket_request_token": "Anfrage-Token konnte nicht von Pocket abgerufen werden!",
     "error.pocket_access_token": "Zugriffstoken konnte nicht von Pocket abgerufen werden!",
     "error.category_already_exists": "Diese Kategorie existiert bereits.",
@@ -308,6 +309,10 @@
     "form.integration.fever_username": "Fever Benutzername",
     "form.integration.fever_password": "Fever Passwort",
     "form.integration.fever_endpoint": "Fever API Endpunkt:",
+    "form.integration.googlereader_activate": "Google Reader API aktivieren",
+    "form.integration.googlereader_username": "Google Reader Benutzername",
+    "form.integration.googlereader_password": "Google Reader Passwort",
+    "form.integration.googlereader_endpoint": "Google Reader API Endpunkt:",
     "form.integration.pinboard_activate": "Artikel in Pinboard speichern",
     "form.integration.pinboard_token": "Pinboard API Token",
     "form.integration.pinboard_tags": "Pinboard Tags",
diff --git a/locale/translations/el_EL.json b/locale/translations/el_EL.json
index dc93a6da..4bc3a32b 100644
--- a/locale/translations/el_EL.json
+++ b/locale/translations/el_EL.json
@@ -223,6 +223,7 @@
     "error.unlink_account_without_password": "Πρέπει να ορίσετε έναν κωδικό πρόσβασης διαφορετικά δεν θα μπορείτε να συνδεθείτε ξανά.",
     "error.duplicate_linked_account": "Υπάρχει ήδη κάποιος που σχετίζεται με αυτόν τον πάροχο!",
     "error.duplicate_fever_username": "Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Fever!",
+    "error.duplicate_googlereader_username": "Υπάρχει ήδη κάποιος άλλος με το ίδιο όνομα χρήστη Google Reader!",
     "error.pocket_request_token": "Δεν είναι δυνατή η λήψη του request token από το Pocket!",
     "error.pocket_access_token": "Δεν είναι δυνατή η λήψη του access token από το Pocket!",
     "error.category_already_exists": "Αυτή η κατηγορία υπάρχει ήδη.",
@@ -308,6 +309,10 @@
     "form.integration.fever_username": "Όνομα Χρήστη Fever",
     "form.integration.fever_password": "Κωδικός Πρόσβασης Fever",
     "form.integration.fever_endpoint": "Τελικό σημείο Fever API:",
+    "form.integration.googlereader_activate": "Ενεργοποιήστε το Google Reader API",
+    "form.integration.googlereader_username": "Όνομα Χρήστη Google Reader",
+    "form.integration.googlereader_password": "Κωδικός Πρόσβασης Google Reader",
+    "form.integration.googlereader_endpoint": "Τελικό σημείο Google Reader API:",
     "form.integration.pinboard_activate": "Αποθήκευση άρθρων στο Pinboard",
     "form.integration.pinboard_token": "Pinboard API Token",
     "form.integration.pinboard_tags": "Ετικέτες Pinboard",
diff --git a/locale/translations/en_US.json b/locale/translations/en_US.json
index 2076c813..baedf569 100644
--- a/locale/translations/en_US.json
+++ b/locale/translations/en_US.json
@@ -223,6 +223,7 @@
     "error.unlink_account_without_password": "You must define a password otherwise you won't be able to login again.",
     "error.duplicate_linked_account": "There is already someone associated with this provider!",
     "error.duplicate_fever_username": "There is already someone else with the same Fever username!",
+    "error.duplicate_googlereader_username": "There is already someone else with the same Google Reader username!",
     "error.pocket_request_token": "Unable to fetch request token from Pocket!",
     "error.pocket_access_token": "Unable to fetch access token from Pocket!",
     "error.category_already_exists": "This category already exists.",
@@ -308,6 +309,10 @@
     "form.integration.fever_username": "Fever Username",
     "form.integration.fever_password": "Fever Password",
     "form.integration.fever_endpoint": "Fever API endpoint:",
+    "form.integration.googlereader_activate": "Activate Google Reader API",
+    "form.integration.googlereader_username": "Google Reader Username",
+    "form.integration.googlereader_password": "Google Reader Password",
+    "form.integration.googlereader_endpoint": "Google Reader API endpoint:",
     "form.integration.pinboard_activate": "Save articles to Pinboard",
     "form.integration.pinboard_token": "Pinboard API Token",
     "form.integration.pinboard_tags": "Pinboard Tags",
diff --git a/locale/translations/es_ES.json b/locale/translations/es_ES.json
index 00aa4d55..2fcc17e7 100644
--- a/locale/translations/es_ES.json
+++ b/locale/translations/es_ES.json
@@ -223,6 +223,7 @@
     "error.unlink_account_without_password": "Debe definir una contraseña, de lo contrario no podrá volver a iniciar sesión.",
     "error.duplicate_linked_account": "¡Ya hay alguien asociado a este servicio!",
     "error.duplicate_fever_username": "¡Ya hay alguien con el mismo nombre de usuario de Fever!",
+    "error.duplicate_googlereader_username": "¡Ya hay alguien con el mismo nombre de usuario de Google Reader!",
     "error.pocket_request_token": "Incapaz de obtener un token de solicitud de Pocket!",
     "error.pocket_access_token": "Incapaz de obtener un token de acceso de Pocket!",
     "error.category_already_exists": "Esta categoría ya existe.",
@@ -308,6 +309,10 @@
     "form.integration.fever_username": "Nombre de usuario de Fever",
     "form.integration.fever_password": "Contraseña de Fever",
     "form.integration.fever_endpoint": "Extremo de API de Fever:",
+    "form.integration.googlereader_activate": "Activar API de Google Reader",
+    "form.integration.googlereader_username": "Nombre de usuario de Google Reader",
+    "form.integration.googlereader_password": "Contraseña de Google Reader",
+    "form.integration.googlereader_endpoint": "Extremo de API de Google Reader:",
     "form.integration.pinboard_activate": "Guardar artículos a Pinboard",
     "form.integration.pinboard_token": "Token de API de Pinboard",
     "form.integration.pinboard_tags": "Etiquetas de Pinboard",
diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json
index 5910e408..cb88accc 100644
--- a/locale/translations/fr_FR.json
+++ b/locale/translations/fr_FR.json
@@ -223,6 +223,7 @@
     "error.unlink_account_without_password": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.",
     "error.duplicate_linked_account": "Il y a déjà quelqu'un d'associé avec ce provider !",
     "error.duplicate_fever_username": "Il y a déjà quelqu'un d'autre avec le même nom d'utilisateur Fever !",
+    "error.duplicate_googlereader_username": "Il y a déjà quelqu'un d'autre avec le même nom d'utilisateur Google Reader !",
     "error.pocket_request_token": "Impossible de récupérer le jeton d'accès depuis Pocket !",
     "error.pocket_access_token": "Impossible de récupérer le jeton d'accès depuis Pocket !",
     "error.category_already_exists": "Cette catégorie existe déjà.",
@@ -308,6 +309,10 @@
     "form.integration.fever_username": "Nom d'utilisateur pour l'API de Fever",
     "form.integration.fever_password": "Mot de passe pour l'API de Fever",
     "form.integration.fever_endpoint": "Point de terminaison de l'API Fever :",
+    "form.integration.googlereader_activate": "Activer l'API de Google Reader",
+    "form.integration.googlereader_username": "Nom d'utilisateur pour l'API de Google Reader",
+    "form.integration.googlereader_password": "Mot de passe pour l'API de Google Reader",
+    "form.integration.googlereader_endpoint": "Point de terminaison de l'API Google Reader:",
     "form.integration.pinboard_activate": "Sauvegarder les articles vers Pinboard",
     "form.integration.pinboard_token": "Jeton de sécurité de l'API de Pinboard",
     "form.integration.pinboard_tags": "Libellés de Pinboard",
diff --git a/locale/translations/it_IT.json b/locale/translations/it_IT.json
index c92347a9..5c5b481d 100644
--- a/locale/translations/it_IT.json
+++ b/locale/translations/it_IT.json
@@ -223,6 +223,7 @@
     "error.unlink_account_without_password": "Devi scegliere una password altrimenti la prossima volta non riuscirai ad accedere.",
     "error.duplicate_linked_account": "Esiste già un account configurato per questo servizio!",
     "error.duplicate_fever_username": "Esiste già un account Fever con lo stesso nome utente!",
+    "error.duplicate_googlereader_username": "Esiste già un account Google Reader con lo stesso nome utente!",
     "error.pocket_request_token": "Non sono riuscito ad ottenere il request token da Pocket!",
     "error.pocket_access_token": "Non sono riuscito ad ottenere l'access token da Pocket!",
     "error.category_already_exists": "Questa categoria esiste già.",
@@ -308,6 +309,10 @@
     "form.integration.fever_username": "Nome utente dell'account Fever",
     "form.integration.fever_password": "Password dell'account Fever",
     "form.integration.fever_endpoint": "Endpoint dell'API di Fever:",
+    "form.integration.googlereader_activate": "Abilita l'API di Google Reader",
+    "form.integration.googlereader_username": "Nome utente dell'account Google Reader",
+    "form.integration.googlereader_password": "Password dell'account Google Reader",
+    "form.integration.googlereader_endpoint": "Endpoint dell'API di Google Reader:",
     "form.integration.pinboard_activate": "Salva gli articoli su Pinboard",
     "form.integration.pinboard_token": "Token dell'API di Pinboard",
     "form.integration.pinboard_tags": "Tag di Pinboard",
diff --git a/locale/translations/ja_JP.json b/locale/translations/ja_JP.json
index cdb62d64..ff12ddb7 100644
--- a/locale/translations/ja_JP.json
+++ b/locale/translations/ja_JP.json
@@ -223,6 +223,7 @@
     "error.unlink_account_without_password": "パスワードを設定しなければ再びログインすることはできません。",
     "error.duplicate_linked_account": "別なユーザーが既にこのサービスの同じユーザーとリンクしています。",
     "error.duplicate_fever_username": "既に同じ名前の Fever ユーザー名が使われています!",
+    "error.duplicate_googlereader_username": "既に同じ名前の Google Reader ユーザー名が使われています!",
     "error.pocket_request_token": "Pocket の request token が取得できません!",
     "error.pocket_access_token": "Pocket の access token が取得できません!",
     "error.category_already_exists": "このカテゴリは既に存在しています。",
@@ -308,6 +309,10 @@
     "form.integration.fever_username": "Fever の ユーザー名",
     "form.integration.fever_password": "Fever の パスワード",
     "form.integration.fever_endpoint": "Fever API endpoint:",
+    "form.integration.googlereader_activate": "Google Reader API を有効にする",
+    "form.integration.googlereader_username": "Google Reader の ユーザー名",
+    "form.integration.googlereader_password": "Google Reader の パスワード",
+    "form.integration.googlereader_endpoint": "Google Reader API endpoint:",
     "form.integration.pinboard_activate": "Pinboard に記事を保存する",
     "form.integration.pinboard_token": "Pinboard の API Token",
     "form.integration.pinboard_tags": "Pinboard の Tag",
diff --git a/locale/translations/nl_NL.json b/locale/translations/nl_NL.json
index 8a9d23e7..cbf68ecb 100644
--- a/locale/translations/nl_NL.json
+++ b/locale/translations/nl_NL.json
@@ -223,6 +223,7 @@
     "error.unlink_account_without_password": "U moet een wachtwoord definiëren anders kunt u zich niet opnieuw aanmelden.",
     "error.duplicate_linked_account": "Er is al iemand geregistreerd met deze provider!",
     "error.duplicate_fever_username": "Er is al iemand met dezelfde Fever gebruikersnaam!",
+    "error.duplicate_googlereader_username": "Er is al iemand met dezelfde Google Reader gebruikersnaam!",
     "error.pocket_request_token": "Kon geen aanvraagtoken ophalen van Pocket!",
     "error.pocket_access_token": "Kon geen toegangstoken ophalen van Pocket!",
     "error.category_already_exists": "Deze categorie bestaat al.",
@@ -308,6 +309,10 @@
     "form.integration.fever_username": "Fever gebruikersnaam",
     "form.integration.fever_password": "Fever wachtwoord",
     "form.integration.fever_endpoint": "Fever URL:",
+    "form.integration.googlereader_activate": "Activeer Google Reader API",
+    "form.integration.googlereader_username": "Google Reader gebruikersnaam",
+    "form.integration.googlereader_password": "Google Reader wachtwoord",
+    "form.integration.googlereader_endpoint": "Google Reader URL:",
     "form.integration.pinboard_activate": "Artikelen opslaan naar Pinboard",
     "form.integration.pinboard_token": "Pinboard API token",
     "form.integration.pinboard_tags": "Pinboard tags",
diff --git a/locale/translations/pl_PL.json b/locale/translations/pl_PL.json
index 95134b74..facbb7e3 100644
--- a/locale/translations/pl_PL.json
+++ b/locale/translations/pl_PL.json
@@ -225,6 +225,7 @@
     "error.unlink_account_without_password": "Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.",
     "error.duplicate_linked_account": "Już ktoś jest powiązany z tym dostawcą!",
     "error.duplicate_fever_username": "Już ktoś inny używa tej nazwy użytkownika Fever!",
+    "error.duplicate_googlereader_username": "Już ktoś inny używa tej nazwy użytkownika Google Reader!",
     "error.pocket_request_token": "Nie można pobrać tokena żądania z Pocket!",
     "error.pocket_access_token": "Nie można pobrać tokena dostępu z Pocket!",
     "error.category_already_exists": "Ta kategoria już istnieje.",
@@ -310,6 +311,10 @@
     "form.integration.fever_username": "Login do Fever",
     "form.integration.fever_password": "Hasło do Fever",
     "form.integration.fever_endpoint": "Punkt końcowy API gorączka:",
+    "form.integration.googlereader_activate": "Aktywuj Google Reader API",
+    "form.integration.googlereader_username": "Login do Google Reader",
+    "form.integration.googlereader_password": "Hasło do Google Reader",
+    "form.integration.googlereader_endpoint": "Punkt końcowy API gorączka:",
     "form.integration.pinboard_activate": "Zapisz artykuł w Pinboard",
     "form.integration.pinboard_token": "Token Pinboard API",
     "form.integration.pinboard_tags": "Pinboard Tags",
diff --git a/locale/translations/pt_BR.json b/locale/translations/pt_BR.json
index 6819e20e..cb05c4e7 100644
--- a/locale/translations/pt_BR.json
+++ b/locale/translations/pt_BR.json
@@ -223,6 +223,7 @@
     "error.unlink_account_without_password": "Você deve definir uma senha, senão não será possível efetuar a sessão novamente.",
     "error.duplicate_linked_account": "Alguém já está vinculado a esse serviço!",
     "error.duplicate_fever_username": "Alguém já está utilizando esse nome de usuário do Fever!",
+    "error.duplicate_googlereader_username": "Alguém já está utilizando esse nome de usuário do Google Reader!",
     "error.pocket_request_token": "Não foi possível obter um pedido de token no Pocket!",
     "error.pocket_access_token": "Não foi possível obter um token de acesso no Pocket!",
     "error.category_already_exists": "Esta categoria já existe.",
@@ -308,6 +309,10 @@
     "form.integration.fever_username": "Nome de usuário do Fever",
     "form.integration.fever_password": "Senha do Fever",
     "form.integration.fever_endpoint": "Endpoint da API do Fever:",
+    "form.integration.googlereader_activate": "Ativar API do Google Reader",
+    "form.integration.googlereader_username": "Nome de usuário do Google Reader",
+    "form.integration.googlereader_password": "Senha do Google Reader",
+    "form.integration.googlereader_endpoint": "Endpoint da API do Google Reader:",
     "form.integration.pinboard_activate": "Salvar itens no Pinboard",
     "form.integration.pinboard_token": "Token de API do Pinboard",
     "form.integration.pinboard_tags": "Etiquetas (tags) do Pinboard",
diff --git a/locale/translations/ru_RU.json b/locale/translations/ru_RU.json
index 9e20f562..6d1bc548 100644
--- a/locale/translations/ru_RU.json
+++ b/locale/translations/ru_RU.json
@@ -225,6 +225,7 @@
     "error.unlink_account_without_password": "Вы должны установить пароль, иначе вы не сможете войти снова.",
     "error.duplicate_linked_account": "Уже есть кто-то, кто ассоциирован с этим аккаунтом!",
     "error.duplicate_fever_username": "Уже есть кто-то с таким же именем пользователя Fever!",
+    "error.duplicate_googlereader_username": "Уже есть кто-то с таким же именем пользователя Google Reader!",
     "error.pocket_request_token": "Не удается извлечь request token из Pocket!",
     "error.pocket_access_token": "Не удается извлечь access token из Pocket!",
     "error.category_already_exists": "Эта категория уже существует.",
@@ -310,6 +311,10 @@
     "form.integration.fever_username": "Имя пользователя Fever",
     "form.integration.fever_password": "Пароль Fever",
     "form.integration.fever_endpoint": "Конечная точка Fever API:",
+    "form.integration.googlereader_activate": "Активировать Google Reader API",
+    "form.integration.googlereader_username": "Имя пользователя Google Reader",
+    "form.integration.googlereader_password": "Пароль Google Reader",
+    "form.integration.googlereader_endpoint": "Конечная точка Google Reader API:",
     "form.integration.pinboard_activate": "Сохранять статьи в Pinboard",
     "form.integration.pinboard_token": "Pinboard API Token",
     "form.integration.pinboard_tags": "Теги Pinboard",
diff --git a/locale/translations/tr_TR.json b/locale/translations/tr_TR.json
index 86b89032..ae7b261a 100644
--- a/locale/translations/tr_TR.json
+++ b/locale/translations/tr_TR.json
@@ -223,6 +223,7 @@
     "error.unlink_account_without_password": "Bir şifre belirlemelisiniz, aksi takdirde tekrar oturum açamazsınız.",
     "error.duplicate_linked_account": "Bu sağlayıcıyla ilişkilendirilmiş biri zaten var!",
     "error.duplicate_fever_username": "Aynı Fever kullanıcı adına sahip başka biri zaten var!",
+    "error.duplicate_googlereader_username": "Aynı Google Reader kullanıcı adına sahip başka biri zaten var!",
     "error.pocket_request_token": "Pocket'tan istek tokeni alınamıyor!",
     "error.pocket_access_token": "Pocket'tan erişim tokeni alınamıyor!",
     "error.category_already_exists": "Bu kategori zaten mevcut.",
@@ -308,6 +309,10 @@
     "form.integration.fever_username": "Fever Kullanıcı Adı",
     "form.integration.fever_password": "Fever Parolası",
     "form.integration.fever_endpoint": "Fever API uç noktası:",
+    "form.integration.googlereader_activate": "Google Reader API'yi EtkinleÅŸtir",
+    "form.integration.googlereader_username": "Google Reader Kullanıcı Adı",
+    "form.integration.googlereader_password": "Google Reader Parolası",
+    "form.integration.googlereader_endpoint": "Google Reader API uç noktası:",
     "form.integration.pinboard_activate": "Makaleleri Pinboard'a kaydet",
     "form.integration.pinboard_token": "Pinboard API Token",
     "form.integration.pinboard_tags": "Pinboard Etiketleri",
@@ -361,4 +366,4 @@
         "%d yıl önce",
         "%d yıl önce"
     ]
-}
+}
\ No newline at end of file
diff --git a/locale/translations/zh_CN.json b/locale/translations/zh_CN.json
index eeb60a5d..4fd5b969 100644
--- a/locale/translations/zh_CN.json
+++ b/locale/translations/zh_CN.json
@@ -221,6 +221,7 @@
     "error.unlink_account_without_password": "您必须设置密码,否则您将无法再次登录。",
     "error.duplicate_linked_account": "该 Provider 已被关联!",
     "error.duplicate_fever_username": "Fever 用户名已被占用!",
+    "error.duplicate_googlereader_username": "Google Reader 用户名已被占用!",
     "error.pocket_request_token": "无法从 Pocket 获取请求令牌!",
     "error.pocket_access_token": "无法从 Pocket 获取访问令牌!",
     "error.category_already_exists": "分类已存在",
@@ -306,6 +307,10 @@
     "form.integration.fever_username": "Fever 用户名",
     "form.integration.fever_password": "Fever 密码",
     "form.integration.fever_endpoint": "Fever API 端点",
+    "form.integration.googlereader_activate": "启用 Google Reader API",
+    "form.integration.googlereader_username": "Google Reader 用户名",
+    "form.integration.googlereader_password": "Google Reader 密码",
+    "form.integration.googlereader_endpoint": "Google Reader API 端点:",
     "form.integration.pinboard_activate": "保存文章到 Pinboard",
     "form.integration.pinboard_token": "Pinboard API Token",
     "form.integration.pinboard_tags": "Pinboard 标签",
diff --git a/model/integration.go b/model/integration.go
index f60827b1..d06582ec 100644
--- a/model/integration.go
+++ b/model/integration.go
@@ -17,6 +17,9 @@ type Integration struct {
 	FeverEnabled         bool
 	FeverUsername        string
 	FeverToken           string
+	GoogleReaderEnabled  bool
+	GoogleReaderUsername string
+	GoogleReaderPassword string
 	WallabagEnabled      bool
 	WallabagURL          string
 	WallabagClientID     string
diff --git a/service/httpd/httpd.go b/service/httpd/httpd.go
index 09a0e0ef..c4464b51 100644
--- a/service/httpd/httpd.go
+++ b/service/httpd/httpd.go
@@ -16,6 +16,7 @@ import (
 	"miniflux.app/api"
 	"miniflux.app/config"
 	"miniflux.app/fever"
+	"miniflux.app/googlereader"
 	"miniflux.app/http/request"
 	"miniflux.app/logger"
 	"miniflux.app/storage"
@@ -180,6 +181,7 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
 	router.Use(middleware)
 
 	fever.Serve(router, store)
+	googlereader.Serve(router, store)
 	api.Serve(router, store, pool)
 	ui.Serve(router, store, pool)
 
diff --git a/storage/category.go b/storage/category.go
index 62b8b49a..8c2ce734 100644
--- a/storage/category.go
+++ b/storage/category.go
@@ -9,6 +9,7 @@ import (
 	"errors"
 	"fmt"
 
+	"github.com/lib/pq"
 	"miniflux.app/model"
 )
 
@@ -215,3 +216,51 @@ func (s *Storage) RemoveCategory(userID, categoryID int64) error {
 
 	return nil
 }
+
+// delete the given categories, replacing those categories with the user's first
+// category on affected feeds
+func (s *Storage) RemoveAndReplaceCategoriesByName(userid int64, titles []string) error {
+	tx, err := s.db.Begin()
+	if err != nil {
+		return errors.New("unable to begin transaction")
+	}
+
+	titleParam := pq.Array(titles)
+	var count int
+	query := "SELECT count(*) FROM categories WHERE user_id = $1 and title != ANY($2)"
+	err = tx.QueryRow(query, userid, titleParam).Scan(&count)
+	if err != nil {
+		tx.Rollback()
+		return errors.New("unable to retrieve category count")
+	}
+	if count < 1 {
+		tx.Rollback()
+		return errors.New("at least 1 category must remain after deletion")
+	}
+
+	query = `
+		WITH d_cats AS (SELECT id FROM categories WHERE user_id = $1 AND title = ANY($2)) 
+		UPDATE feeds 
+		 SET category_id = 
+		  (SELECT id 
+			FROM categories 
+			WHERE user_id = $1 AND id NOT IN (SELECT id FROM d_cats) 
+			ORDER BY title ASC 
+			LIMIT 1) 
+		WHERE user_id = $1 AND category_id IN (SELECT id FROM d_cats)
+	`
+	_, err = tx.Exec(query, userid, titleParam)
+	if err != nil {
+		tx.Rollback()
+		return fmt.Errorf("unable to replace categories: %v", err)
+	}
+
+	query = "DELETE FROM categories WHERE user_id = $1 AND title = ANY($2)"
+	_, err = tx.Exec(query, userid, titleParam)
+	if err != nil {
+		tx.Rollback()
+		return fmt.Errorf("unable to delete categories: %v", err)
+	}
+	tx.Commit()
+	return nil
+}
diff --git a/storage/entry.go b/storage/entry.go
index 4c4d0e78..5f062f7f 100644
--- a/storage/entry.go
+++ b/storage/entry.go
@@ -371,6 +371,26 @@ func (s *Storage) SetEntriesStatusCount(userID int64, entryIDs []int64, status s
 	return visible, nil
 }
 
+// SetEntriesBookmarked update the bookmarked state for the given list of entries.
+func (s *Storage) SetEntriesBookmarkedState(userID int64, entryIDs []int64, starred bool) error {
+	query := `UPDATE entries SET starred=$1, changed_at=now() WHERE user_id=$2 AND id=ANY($3)`
+	result, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs))
+	if err != nil {
+		return fmt.Errorf(`store: unable to update the bookmarked state %v: %v`, entryIDs, err)
+	}
+
+	count, err := result.RowsAffected()
+	if err != nil {
+		return fmt.Errorf(`store: unable to update these entries %v: %v`, entryIDs, err)
+	}
+
+	if count == 0 {
+		return errors.New(`store: nothing has been updated`)
+	}
+
+	return nil
+}
+
 // ToggleBookmark toggles entry bookmark value.
 func (s *Storage) ToggleBookmark(userID int64, entryID int64) error {
 	query := `UPDATE entries SET starred = NOT starred, changed_at=now() WHERE user_id=$1 AND id=$2`
diff --git a/storage/integration.go b/storage/integration.go
index a93df344..5f81a435 100644
--- a/storage/integration.go
+++ b/storage/integration.go
@@ -8,6 +8,7 @@ import (
 	"database/sql"
 	"fmt"
 
+	"golang.org/x/crypto/bcrypt"
 	"miniflux.app/model"
 )
 
@@ -19,6 +20,14 @@ func (s *Storage) HasDuplicateFeverUsername(userID int64, feverUsername string)
 	return result
 }
 
+// HasDuplicateGoogleReaderUsername checks if another user have the same googlereader  username.
+func (s *Storage) HasDuplicateGoogleReaderUsername(userID int64, googleReaderUsername string) bool {
+	query := `SELECT true FROM integrations WHERE user_id != $1 AND googlereader_username=$2`
+	var result bool
+	s.db.QueryRow(query, userID, googleReaderUsername).Scan(&result)
+	return result
+}
+
 // UserByFeverToken returns a user by using the Fever API token.
 func (s *Storage) UserByFeverToken(token string) (*model.User, error) {
 	query := `
@@ -42,6 +51,57 @@ func (s *Storage) UserByFeverToken(token string) (*model.User, error) {
 	}
 }
 
+// GoogleReaderUserCheckPassword validates the hashed password.
+func (s *Storage) GoogleReaderUserCheckPassword(username, password string) error {
+	var hash string
+
+	query := `
+		SELECT
+			googlereader_password
+		FROM integrations
+		WHERE
+			integrations.googlereader_enabled='t' AND integrations.googlereader_username=$1
+	`
+
+	err := s.db.QueryRow(query, username).Scan(&hash)
+	if err == sql.ErrNoRows {
+		return fmt.Errorf(`store: unable to find this user: %s`, username)
+	} else if err != nil {
+		return fmt.Errorf(`store: unable to fetch user: %v`, err)
+	}
+
+	if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
+		return fmt.Errorf(`store: invalid password for "%s" (%v)`, username, err)
+	}
+
+	return nil
+}
+
+// GoogleReaderUserGetIntegration returns part of the google reader parts of the integration struct.
+func (s *Storage) GoogleReaderUserGetIntegration(username string) (*model.Integration, error) {
+	var integration model.Integration
+
+	query := `
+		SELECT
+			user_id,
+			googlereader_enabled,
+			googlereader_username,
+			googlereader_password
+		FROM integrations
+		WHERE
+			integrations.googlereader_enabled='t' AND integrations.googlereader_username=$1
+	`
+
+	err := s.db.QueryRow(query, username).Scan(&integration.UserID, &integration.GoogleReaderEnabled, &integration.GoogleReaderUsername, &integration.GoogleReaderPassword)
+	if err == sql.ErrNoRows {
+		return &integration, fmt.Errorf(`store: unable to find this user: %s`, username)
+	} else if err != nil {
+		return &integration, fmt.Errorf(`store: unable to fetch user: %v`, err)
+	}
+
+	return &integration, nil
+}
+
 // Integration returns user integration settings.
 func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 	query := `
@@ -57,6 +117,9 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 			fever_enabled,
 			fever_username,
 			fever_token,
+			googlereader_enabled,
+			googlereader_username,
+			googlereader_password,
 			wallabag_enabled,
 			wallabag_url,
 			wallabag_client_id,
@@ -90,6 +153,9 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 		&integration.FeverEnabled,
 		&integration.FeverUsername,
 		&integration.FeverToken,
+		&integration.GoogleReaderEnabled,
+		&integration.GoogleReaderUsername,
+		&integration.GoogleReaderPassword,
 		&integration.WallabagEnabled,
 		&integration.WallabagURL,
 		&integration.WallabagClientID,
@@ -118,7 +184,13 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 
 // UpdateIntegration saves user integration settings.
 func (s *Storage) UpdateIntegration(integration *model.Integration) error {
-	query := `
+	var err error
+	if integration.GoogleReaderPassword != "" {
+		integration.GoogleReaderPassword, err = hashPassword(integration.GoogleReaderPassword)
+		if err != nil {
+			return err
+		}
+		query := `
 		UPDATE
 			integrations
 		SET
@@ -144,41 +216,116 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 			pocket_enabled=$20,
 			pocket_access_token=$21,
 			pocket_consumer_key=$22,
-			telegram_bot_enabled=$23,
-			telegram_bot_token=$24,
-			telegram_bot_chat_id=$25
+			googlereader_enabled=$23,
+			googlereader_username=$24,
+			googlereader_password=$25,
+			telegram_bot_enabled=$26,
+			telegram_bot_token=$27,
+			telegram_bot_chat_id=$28
 		WHERE
-			user_id=$26
+			user_id=$29
 	`
-	_, err := s.db.Exec(
-		query,
-		integration.PinboardEnabled,
-		integration.PinboardToken,
-		integration.PinboardTags,
-		integration.PinboardMarkAsUnread,
-		integration.InstapaperEnabled,
-		integration.InstapaperUsername,
-		integration.InstapaperPassword,
-		integration.FeverEnabled,
-		integration.FeverUsername,
-		integration.FeverToken,
-		integration.WallabagEnabled,
-		integration.WallabagURL,
-		integration.WallabagClientID,
-		integration.WallabagClientSecret,
-		integration.WallabagUsername,
-		integration.WallabagPassword,
-		integration.NunuxKeeperEnabled,
-		integration.NunuxKeeperURL,
-		integration.NunuxKeeperAPIKey,
-		integration.PocketEnabled,
-		integration.PocketAccessToken,
-		integration.PocketConsumerKey,
-		integration.TelegramBotEnabled,
-		integration.TelegramBotToken,
-		integration.TelegramBotChatID,
-		integration.UserID,
-	)
+		_, err = s.db.Exec(
+			query,
+			integration.PinboardEnabled,
+			integration.PinboardToken,
+			integration.PinboardTags,
+			integration.PinboardMarkAsUnread,
+			integration.InstapaperEnabled,
+			integration.InstapaperUsername,
+			integration.InstapaperPassword,
+			integration.FeverEnabled,
+			integration.FeverUsername,
+			integration.FeverToken,
+			integration.WallabagEnabled,
+			integration.WallabagURL,
+			integration.WallabagClientID,
+			integration.WallabagClientSecret,
+			integration.WallabagUsername,
+			integration.WallabagPassword,
+			integration.NunuxKeeperEnabled,
+			integration.NunuxKeeperURL,
+			integration.NunuxKeeperAPIKey,
+			integration.PocketEnabled,
+			integration.PocketAccessToken,
+			integration.PocketConsumerKey,
+			integration.GoogleReaderEnabled,
+			integration.GoogleReaderUsername,
+			integration.GoogleReaderPassword,
+			integration.TelegramBotEnabled,
+			integration.TelegramBotToken,
+			integration.TelegramBotChatID,
+			integration.UserID,
+		)
+	} else {
+		query := `
+		UPDATE
+			integrations
+		SET
+			pinboard_enabled=$1,
+			pinboard_token=$2,
+			pinboard_tags=$3,
+			pinboard_mark_as_unread=$4,
+			instapaper_enabled=$5,
+			instapaper_username=$6,
+			instapaper_password=$7,
+			fever_enabled=$8,
+			fever_username=$9,
+			fever_token=$10,
+			wallabag_enabled=$11,
+			wallabag_url=$12,
+			wallabag_client_id=$13,
+			wallabag_client_secret=$14,
+			wallabag_username=$15,
+			wallabag_password=$16,
+			nunux_keeper_enabled=$17,
+			nunux_keeper_url=$18,
+			nunux_keeper_api_key=$19,
+			pocket_enabled=$20,
+			pocket_access_token=$21,
+			pocket_consumer_key=$22,
+			googlereader_enabled=$23,
+			googlereader_username=$24,
+		    googlereader_password=$25,
+			telegram_bot_enabled=$26,
+			telegram_bot_token=$27,
+			telegram_bot_chat_id=$28
+		WHERE
+			user_id=$29
+	`
+		_, err = s.db.Exec(
+			query,
+			integration.PinboardEnabled,
+			integration.PinboardToken,
+			integration.PinboardTags,
+			integration.PinboardMarkAsUnread,
+			integration.InstapaperEnabled,
+			integration.InstapaperUsername,
+			integration.InstapaperPassword,
+			integration.FeverEnabled,
+			integration.FeverUsername,
+			integration.FeverToken,
+			integration.WallabagEnabled,
+			integration.WallabagURL,
+			integration.WallabagClientID,
+			integration.WallabagClientSecret,
+			integration.WallabagUsername,
+			integration.WallabagPassword,
+			integration.NunuxKeeperEnabled,
+			integration.NunuxKeeperURL,
+			integration.NunuxKeeperAPIKey,
+			integration.PocketEnabled,
+			integration.PocketAccessToken,
+			integration.PocketConsumerKey,
+			integration.GoogleReaderEnabled,
+			integration.GoogleReaderUsername,
+			integration.GoogleReaderPassword,
+			integration.TelegramBotEnabled,
+			integration.TelegramBotToken,
+			integration.TelegramBotChatID,
+			integration.UserID,
+		)
+	}
 
 	if err != nil {
 		return fmt.Errorf(`store: unable to update integration row: %v`, err)
diff --git a/template/templates/views/integrations.html b/template/templates/views/integrations.html
index b89e26f1..b48115cd 100644
--- a/template/templates/views/integrations.html
+++ b/template/templates/views/integrations.html
@@ -31,7 +31,27 @@
             <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
         </div>
     </div>
+    
+    <h3>Google Reader</h3>
+    <div class="form-section">
+        <label>
+            <input type="checkbox" name="googlereader_enabled" value="1" {{ if .form.GoogleReaderEnabled }}checked{{ end }}> {{ t "form.integration.googlereader_activate" }}
+        </label>
+
+        <label for="form-googlereader-username">{{ t "form.integration.googlereader_username" }}</label>
+        <input type="text" name="googlereader_username" id="form-googlereader-username" value="{{ .form.GoogleReaderUsername }}" autocomplete="username" spellcheck="false">
+
+        <label for="form-googlereader-password">{{ t "form.integration.googlereader_password" }}</label>
+        <input type="password" name="googlereader_password" id="form-googlereader-password" value="{{ .form.GoogleReaderPassword }}" autocomplete="new-password">
+
+        <p>{{ t "form.integration.googlereader_endpoint" }} <strong>{{ rootURL }}{{ route "login" }}</strong></p>
+
+        <div class="buttons">
+            <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
+        </div>
+    </div>
 
+    <!-- -->
     <h3>Pinboard</h3>
     <div class="form-section">
         <label>
diff --git a/ui/form/integration.go b/ui/form/integration.go
index b002db9e..e96f74fe 100644
--- a/ui/form/integration.go
+++ b/ui/form/integration.go
@@ -22,6 +22,9 @@ type IntegrationForm struct {
 	FeverEnabled         bool
 	FeverUsername        string
 	FeverPassword        string
+	GoogleReaderEnabled  bool
+	GoogleReaderUsername string
+	GoogleReaderPassword string
 	WallabagEnabled      bool
 	WallabagURL          string
 	WallabagClientID     string
@@ -50,6 +53,8 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
 	integration.InstapaperPassword = i.InstapaperPassword
 	integration.FeverEnabled = i.FeverEnabled
 	integration.FeverUsername = i.FeverUsername
+	integration.GoogleReaderEnabled = i.GoogleReaderEnabled
+	integration.GoogleReaderUsername = i.GoogleReaderUsername
 	integration.WallabagEnabled = i.WallabagEnabled
 	integration.WallabagURL = i.WallabagURL
 	integration.WallabagClientID = i.WallabagClientID
@@ -67,7 +72,7 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
 	integration.TelegramBotChatID = i.TelegramBotChatID
 }
 
-// NewIntegrationForm returns a new AuthForm.
+// NewIntegrationForm returns a new IntegrationForm.
 func NewIntegrationForm(r *http.Request) *IntegrationForm {
 	return &IntegrationForm{
 		PinboardEnabled:      r.FormValue("pinboard_enabled") == "1",
@@ -80,6 +85,9 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
 		FeverEnabled:         r.FormValue("fever_enabled") == "1",
 		FeverUsername:        r.FormValue("fever_username"),
 		FeverPassword:        r.FormValue("fever_password"),
+		GoogleReaderEnabled:  r.FormValue("googlereader_enabled") == "1",
+		GoogleReaderUsername: r.FormValue("googlereader_username"),
+		GoogleReaderPassword: r.FormValue("googlereader_password"),
 		WallabagEnabled:      r.FormValue("wallabag_enabled") == "1",
 		WallabagURL:          r.FormValue("wallabag_url"),
 		WallabagClientID:     r.FormValue("wallabag_client_id"),
diff --git a/ui/integration_show.go b/ui/integration_show.go
index 8a1b1257..a781806b 100644
--- a/ui/integration_show.go
+++ b/ui/integration_show.go
@@ -38,6 +38,8 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
 		InstapaperPassword:   integration.InstapaperPassword,
 		FeverEnabled:         integration.FeverEnabled,
 		FeverUsername:        integration.FeverUsername,
+		GoogleReaderEnabled:  integration.GoogleReaderEnabled,
+		GoogleReaderUsername: integration.GoogleReaderUsername,
 		WallabagEnabled:      integration.WallabagEnabled,
 		WallabagURL:          integration.WallabagURL,
 		WallabagClientID:     integration.WallabagClientID,
diff --git a/ui/integration_update.go b/ui/integration_update.go
index 251ce9e0..d5380c93 100644
--- a/ui/integration_update.go
+++ b/ui/integration_update.go
@@ -49,6 +49,19 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 		integration.FeverToken = ""
 	}
 
+	if integration.GoogleReaderUsername != "" && h.store.HasDuplicateGoogleReaderUsername(user.ID, integration.GoogleReaderUsername) {
+		sess.NewFlashErrorMessage(printer.Printf("error.duplicate_googlereader_username"))
+		html.Redirect(w, r, route.Path(h.router, "integrations"))
+		return
+	}
+
+	if integration.GoogleReaderEnabled {
+		if integrationForm.GoogleReaderPassword != "" {
+			integration.GoogleReaderPassword = integrationForm.GoogleReaderPassword
+		}
+	} else {
+		integration.GoogleReaderPassword = ""
+	}
 	err = h.store.UpdateIntegration(integration)
 	if err != nil {
 		html.ServerError(w, r, err)
-- 
GitLab