krz/michelangelo

clone: git clone https://gitbay.org/krz/michelangelo.git

main: tumblr.go · raw

  1package main
  2
  3import (
  4	"crypto/hmac"
  5	"crypto/sha1"
  6	"encoding/base64"
  7	"encoding/json"
  8	"fmt"
  9	"io"
 10	"math/rand"
 11	"net/http"
 12	"net/url"
 13	"sort"
 14	"strconv"
 15	"strings"
 16	"time"
 17)
 18
 19const apiBase = "https://api.tumblr.com/v2"
 20const oauthBase = "https://www.tumblr.com"
 21
 22type TumblrClient struct {
 23	ConsumerKey    string
 24	ConsumerSecret string
 25	Token          string
 26	TokenSecret    string
 27}
 28
 29func NewClient(ck, cs, token, secret string) *TumblrClient {
 30	return &TumblrClient{ck, cs, token, secret}
 31}
 32
 33// --- OAuth 1.0a ---
 34
 35func oauthNonce() string {
 36	b := make([]byte, 16)
 37	rand.Read(b)
 38	return base64.StdEncoding.EncodeToString(b)
 39}
 40
 41func oauthTimestamp() string {
 42	return strconv.FormatInt(time.Now().Unix(), 10)
 43}
 44
 45func hmacSha1(key, data string) string {
 46	mac := hmac.New(sha1.New, []byte(key))
 47	mac.Write([]byte(data))
 48	return base64.StdEncoding.EncodeToString(mac.Sum(nil))
 49}
 50
 51func (c *TumblrClient) oauthHeader(method, rawURL string, extraParams map[string]string) string {
 52	params := map[string]string{
 53		"oauth_consumer_key":     c.ConsumerKey,
 54		"oauth_nonce":            oauthNonce(),
 55		"oauth_signature_method": "HMAC-SHA1",
 56		"oauth_timestamp":        oauthTimestamp(),
 57		"oauth_token":            c.Token,
 58		"oauth_version":          "1.0",
 59	}
 60	for k, v := range extraParams {
 61		params[k] = v
 62	}
 63	// Build base string
 64	keys := make([]string, 0, len(params))
 65	for k := range params {
 66		keys = append(keys, k)
 67	}
 68	sort.Strings(keys)
 69	parts := make([]string, 0, len(keys))
 70	for _, k := range keys {
 71		parts = append(parts, url.QueryEscape(k)+"="+url.QueryEscape(params[k]))
 72	}
 73	paramStr := strings.Join(parts, "&")
 74	baseStr := method + "&" + url.QueryEscape(rawURL) + "&" + url.QueryEscape(paramStr)
 75	sigKey := url.QueryEscape(c.ConsumerSecret) + "&" + url.QueryEscape(c.TokenSecret)
 76	params["oauth_signature"] = hmacSha1(sigKey, baseStr)
 77
 78	// Build header
 79	headerParts := []string{}
 80	for k, v := range params {
 81		if strings.HasPrefix(k, "oauth_") {
 82			headerParts = append(headerParts, k+`="`+url.QueryEscape(v)+`"`)
 83		}
 84	}
 85	sort.Strings(headerParts)
 86	return "OAuth " + strings.Join(headerParts, ", ")
 87}
 88
 89func (c *TumblrClient) get(endpoint string, params map[string]string) ([]byte, error) {
 90	u, _ := url.Parse(apiBase + endpoint)
 91	q := u.Query()
 92	for k, v := range params {
 93		q.Set(k, v)
 94	}
 95	u.RawQuery = q.Encode()
 96	rawURL := apiBase + endpoint
 97	req, _ := http.NewRequest("GET", u.String(), nil)
 98	req.Header.Set("Authorization", c.oauthHeader("GET", rawURL, params))
 99	resp, err := http.DefaultClient.Do(req)
100	if err != nil {
101		return nil, err
102	}
103	defer resp.Body.Close()
104	return io.ReadAll(resp.Body)
105}
106
107func (c *TumblrClient) post(endpoint string, params map[string]string) ([]byte, error) {
108	form := url.Values{}
109	for k, v := range params {
110		form.Set(k, v)
111	}
112	rawURL := apiBase + endpoint
113	req, _ := http.NewRequest("POST", rawURL, strings.NewReader(form.Encode()))
114	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
115	req.Header.Set("Authorization", c.oauthHeader("POST", rawURL, params))
116	resp, err := http.DefaultClient.Do(req)
117	if err != nil {
118		return nil, err
119	}
120	defer resp.Body.Close()
121	return io.ReadAll(resp.Body)
122}
123
124// --- OAuth token exchange (no client tokens yet) ---
125
126func oauthRequest(method, rawURL string, consumerKey, consumerSecret, token, tokenSecret string, params map[string]string) ([]byte, error) {
127	c := &TumblrClient{consumerKey, consumerSecret, token, tokenSecret}
128	if method == "POST" {
129		return c.post(strings.TrimPrefix(rawURL, apiBase), params)
130	}
131	return c.get(strings.TrimPrefix(rawURL, apiBase), params)
132}
133
134func GetRequestToken(consumerKey, consumerSecret string) (string, string, error) {
135	c := &TumblrClient{ConsumerKey: consumerKey, ConsumerSecret: consumerSecret}
136	rawURL := oauthBase + "/oauth/request_token"
137	req, _ := http.NewRequest("POST", rawURL, nil)
138	req.Header.Set("Authorization", c.oauthHeader("POST", rawURL, nil))
139	resp, err := http.DefaultClient.Do(req)
140	if err != nil {
141		return "", "", err
142	}
143	defer resp.Body.Close()
144	body, _ := io.ReadAll(resp.Body)
145	vals, _ := url.ParseQuery(string(body))
146	return vals.Get("oauth_token"), vals.Get("oauth_token_secret"), nil
147}
148
149func GetAccessToken(consumerKey, consumerSecret, tmpToken, tmpSecret, verifier string) (string, string, error) {
150	c := &TumblrClient{ConsumerKey: consumerKey, ConsumerSecret: consumerSecret, Token: tmpToken, TokenSecret: tmpSecret}
151	rawURL := oauthBase + "/oauth/access_token"
152	req, _ := http.NewRequest("POST", rawURL, strings.NewReader("oauth_verifier="+url.QueryEscape(verifier)))
153	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
154	req.Header.Set("Authorization", c.oauthHeader("POST", rawURL, map[string]string{"oauth_verifier": verifier}))
155	resp, err := http.DefaultClient.Do(req)
156	if err != nil {
157		return "", "", err
158	}
159	defer resp.Body.Close()
160	body, _ := io.ReadAll(resp.Body)
161	vals, _ := url.ParseQuery(string(body))
162	return vals.Get("oauth_token"), vals.Get("oauth_token_secret"), nil
163}
164
165// --- API response types ---
166
167type Post struct {
168	ID         json.Number `json:"id"`
169	IDString   string      `json:"id_string"`
170	Type       string      `json:"type"`
171	BlogName   string      `json:"blog_name"`
172	PostURL    string      `json:"post_url"`
173	ReblogKey  string      `json:"reblog_key"`
174	NoteCount  int         `json:"note_count"`
175	Liked      bool        `json:"liked"`
176	Photos     []struct {
177		OriginalSize struct {
178			URL string `json:"url"`
179		} `json:"original_size"`
180	} `json:"photos"`
181	VideoURL     string `json:"video_url"`
182	ThumbnailURL string `json:"thumbnail_url"`
183	Caption      string `json:"caption"`
184}
185
186type UserInfo struct {
187	Response struct {
188		User struct {
189			Blogs []struct {
190				Name string `json:"name"`
191			} `json:"blogs"`
192		} `json:"user"`
193	}
194}
195
196type PostsResponse struct {
197	Response struct {
198		Posts []Post `json:"posts"`
199	}
200}
201
202type TaggedResponse struct {
203	Response []Post
204}
205
206// --- API methods ---
207
208func (c *TumblrClient) GetUserInfo() (*UserInfo, error) {
209	body, err := c.get("/user/info", nil)
210	if err != nil {
211		return nil, err
212	}
213	var result UserInfo
214	if err := json.Unmarshal(body, &result); err != nil {
215		return nil, err
216	}
217	return &result, nil
218}
219
220func (c *TumblrClient) GetDashboard(offset, limit int, postType string) ([]Post, error) {
221	params := map[string]string{
222		"offset": strconv.Itoa(offset),
223		"limit":  strconv.Itoa(limit),
224		"type":   postType,
225	}
226	body, err := c.get("/user/dashboard", params)
227	if err != nil {
228		return nil, err
229	}
230	var result PostsResponse
231	if err := json.Unmarshal(body, &result); err != nil {
232		return nil, fmt.Errorf("parse error: %w — body: %s", err, string(body))
233	}
234	return result.Response.Posts, nil
235}
236
237func (c *TumblrClient) GetBlogPosts(blogName string, offset, limit int, postType string) ([]Post, error) {
238	params := map[string]string{
239		"offset": strconv.Itoa(offset),
240		"limit":  strconv.Itoa(limit),
241		"type":   postType,
242	}
243	body, err := c.get("/blog/"+blogName+".tumblr.com/posts", params)
244	if err != nil {
245		return nil, err
246	}
247	var result PostsResponse
248	if err := json.Unmarshal(body, &result); err != nil {
249		return nil, fmt.Errorf("parse error: %w — body: %s", err, string(body))
250	}
251	return result.Response.Posts, nil
252}
253
254func (c *TumblrClient) GetTagged(tag string) ([]Post, error) {
255	body, err := c.get("/tagged", map[string]string{"tag": tag})
256	if err != nil {
257		return nil, err
258	}
259	// Tagged endpoint returns array directly under response
260	var raw struct {
261		Response []Post `json:"response"`
262	}
263	if err := json.Unmarshal(body, &raw); err != nil {
264		return nil, err
265	}
266	return raw.Response, nil
267}
268
269func (c *TumblrClient) LikePost(id, reblogKey string) error {
270	_, err := c.post("/user/like", map[string]string{"id": id, "reblog_key": reblogKey})
271	return err
272}
273
274func (c *TumblrClient) UnlikePost(id, reblogKey string) error {
275	_, err := c.post("/user/unlike", map[string]string{"id": id, "reblog_key": reblogKey})
276	return err
277}
278
279func (c *TumblrClient) ReblogPost(nativeBlog, id, reblogKey, sourceBlog string) error {
280	_, err := c.post("/blog/"+nativeBlog+".tumblr.com/post/reblog", map[string]string{
281		"id":         id,
282		"reblog_key": reblogKey,
283	})
284	return err
285}