mirror of
https://github.com/miniflux/v2.git
synced 2025-08-11 17:51:01 +00:00
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>
This commit is contained in:
parent
2935aaef45
commit
4b6e46d9ab
29 changed files with 1923 additions and 36 deletions
10
googlereader/doc.go
Normal file
10
googlereader/doc.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
|
||||
Package googlereader implements Google Reader API endpoints.
|
||||
|
||||
*/
|
||||
package googlereader // import "miniflux.app/googlereader"
|
1180
googlereader/handler.go
Normal file
1180
googlereader/handler.go
Normal file
File diff suppressed because it is too large
Load diff
208
googlereader/middleware.go
Normal file
208
googlereader/middleware.go
Normal file
|
@ -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
|
||||
}
|
144
googlereader/response.go
Normal file
144
googlereader/response.go
Normal file
|
@ -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()
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue