From 408ae6a76b36cdfa93251ebd4df96341425b7b13 Mon Sep 17 00:00:00 2001 From: Daniil Tsivinsky Date: Sat, 21 Feb 2026 18:37:15 +0300 Subject: [PATCH] init --- .gitignore | 1 + Justfile | 4 ++ go.mod | 3 + main.go | 136 +++++++++++++++++++++++++++++++++++++++++++++ raindrop/api.go | 42 ++++++++++++++ raindrop/auth.go | 68 +++++++++++++++++++++++ raindrop/client.go | 36 ++++++++++++ 7 files changed, 290 insertions(+) create mode 100644 .gitignore create mode 100644 Justfile create mode 100644 go.mod create mode 100644 main.go create mode 100644 raindrop/api.go create mode 100644 raindrop/auth.go create mode 100644 raindrop/client.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..e5f0f39 --- /dev/null +++ b/Justfile @@ -0,0 +1,4 @@ +set dotenv-load := true + +dev: + CLIENT_ID=$CLIENT_ID CLIENT_SECRET=$CLIENT_SECRET REDIRECT_URI=$REDIRECT_URI AUTH_CONFIG=$AUTH_CONFIG go run . diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d6e69c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module raindrop-glance + +go 1.25.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..f0e9604 --- /dev/null +++ b/main.go @@ -0,0 +1,136 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "raindrop-glance/raindrop" +) + +func sendJSON(w http.ResponseWriter, data any, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, err.Error(), 500) + } +} + +func main() { + authConfigPath := os.Getenv("AUTH_CONFIG") + if authConfigPath == "" { + log.Fatalf("AUTH_CONFIG env is missing") + } + + if _, err := os.Stat(authConfigPath); os.IsNotExist(err) { + os.Create(authConfigPath) + os.WriteFile(authConfigPath, []byte("{}"), 0644) + } + + data, err := os.ReadFile(authConfigPath) + if err != nil { + log.Fatalf("failed to read auth config file: %v\n", err) + } + + config := new(raindrop.TokensResponse) + if err := json.Unmarshal(data, config); err != nil { + log.Fatalf("failed to decode auth config file: %v\n", err) + } + + rdClient, err := raindrop.NewClient(raindrop.ClientConfig{ + ClientId: os.Getenv("CLIENT_ID"), + ClientSecret: os.Getenv("CLIENT_SECRET"), + RedirectURI: os.Getenv("REDIRECT_URI"), + }) + if err != nil { + log.Fatalf("failed to create raindrop client: %v\n", err) + } + + if config.AccessToken != "" { + rdClient.SetApiToken(config.TokenType, config.AccessToken) + } + + mux := http.NewServeMux() + + mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) { + rdClient.OauthRedirect(w, r) + }) + + mux.HandleFunc("GET /oauth/redirect", func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "invalid request", 400) + return + } + + resp, err := rdClient.ExchangeOauthCode(code) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + config = resp + + jsonData, err := json.MarshalIndent(resp, "", " ") + if err != nil { + http.Error(w, fmt.Sprintf("failed to encode config file: %v", err), 500) + return + } + if err := os.WriteFile(authConfigPath, jsonData, 0644); err != nil { + http.Error(w, fmt.Sprintf("failed to save config file: %v", err), 500) + return + } + + rdClient.SetApiToken(resp.TokenType, resp.AccessToken) + + w.WriteHeader(200) + fmt.Fprintf(w, "ok") + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + baseURL := "https://api.raindrop.io/rest/v1" + req, err := http.NewRequest(r.Method, baseURL+r.URL.Path, r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to create request: %v", err), 500) + return + } + req.URL.RawQuery = r.URL.RawQuery + + if config.AccessToken != "" { + authHeader := fmt.Sprintf("%s %s", config.TokenType, config.AccessToken) + req.Header.Add("Authorization", authHeader) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer resp.Body.Close() + + for header, values := range resp.Header { + for _, v := range values { + w.Header().Add(header, v) + } + } + + if _, err := io.Copy(w, resp.Body); err != nil { + http.Error(w, err.Error(), 500) + } + + // data, err, status := rdClient.MakeApiRequest(r.Method, r.URL.Path, r.Body, r.URL.Query()) + // if err != nil { + // http.Error(w, err.Error(), status) + // return + // } + // w.Header().Set("Content-Type", "application/json") + // w.WriteHeader(status) + // w.Write(data) + }) + + log.Println("starting http server") + if err := http.ListenAndServe(":5000", mux); err != nil { + log.Fatalf("failed to start http server: %v\n", err) + } +} diff --git a/raindrop/api.go b/raindrop/api.go new file mode 100644 index 0000000..9fdd259 --- /dev/null +++ b/raindrop/api.go @@ -0,0 +1,42 @@ +package raindrop + +import ( + "fmt" + "io" + "net/http" + "net/url" +) + +func (c *Client) MakeApiRequest(method string, path string, body io.Reader, params url.Values) ([]byte, error, int) { + u, err := url.Parse(c.getApiURL(path)) + if err != nil { + return nil, fmt.Errorf("failed to parse url: %v", err), 500 + } + u.RawQuery = params.Encode() + + req, err := http.NewRequest(method, u.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err), 500 + } + + if c.token != nil { + req.Header.Set("Authorization", c.token.Type+" "+c.token.Value) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %v", err), resp.StatusCode + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read data from response: %v", err), 500 + } + + if resp.StatusCode >= 400 { + return data, fmt.Errorf("response failed with status %s", resp.Status), resp.StatusCode + } + + return data, nil, resp.StatusCode +} diff --git a/raindrop/auth.go b/raindrop/auth.go new file mode 100644 index 0000000..3ce91ab --- /dev/null +++ b/raindrop/auth.go @@ -0,0 +1,68 @@ +package raindrop + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +func (c *Client) OauthRedirect(w http.ResponseWriter, r *http.Request) { + authUrl, _ := url.Parse("https://raindrop.io/oauth/authorize") + params := url.Values{} + params.Set("redirect_uri", c.config.RedirectURI) + params.Set("client_id", c.config.ClientId) + params.Set("response_type", "code") + authUrl.RawQuery = params.Encode() + + http.Redirect(w, r, authUrl.String(), http.StatusFound) +} + +type TokensResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + // token lifetime in seconds + ExpiresIn int `json:"expires_in"` +} + +func (c *Client) ExchangeOauthCode(code string) (*TokensResponse, error) { + body := struct { + GrantType string `json:"grant_type"` + Code string `json:"code"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RedirectURI string `json:"redirect_uri"` + }{ + GrantType: "authorization_code", + Code: code, + ClientId: c.config.ClientId, + ClientSecret: c.config.ClientSecret, + RedirectURI: c.config.RedirectURI, + } + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to encode data to send in request: %v", err) + } + + resp, err := http.Post("https://raindrop.io/oauth/access_token", "application/json", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("failed to send request to raindrop api: %v", err) + } + defer resp.Body.Close() + + response := &TokensResponse{} + if err := json.NewDecoder(resp.Body).Decode(response); err != nil { + return nil, fmt.Errorf("failed to decode response from raindrop api: %v", err) + } + + return response, nil +} + +func (c *Client) SetApiToken(tokenType string, value string) { + c.token = &ApiToken{ + Type: tokenType, + Value: value, + } +} diff --git a/raindrop/client.go b/raindrop/client.go new file mode 100644 index 0000000..8478be3 --- /dev/null +++ b/raindrop/client.go @@ -0,0 +1,36 @@ +package raindrop + +import "fmt" + +type ClientConfig struct { + ClientId string + ClientSecret string + RedirectURI string +} + +type ApiToken struct { + Type string + Value string +} + +type Client struct { + config ClientConfig + token *ApiToken +} + +func NewClient(config ClientConfig) (*Client, error) { + if config.ClientId == "" || config.ClientSecret == "" || config.RedirectURI == "" { + return nil, fmt.Errorf("some environment variables missing") + } + return &Client{ + config: config, + }, nil +} + +func (c *Client) baseURL() string { + return "https://api.raindrop.io/rest/v1" +} + +func (c *Client) getApiURL(path string) string { + return c.baseURL() + path +}