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 @@ + +

Google Reader

+
+ + + + + + + +

{{ t "form.integration.googlereader_endpoint" }} {{ rootURL }}{{ route "login" }}

+ +
+ +
+
+ +

Pinboard