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}