├── go.sum ├── .gitignore ├── go.mod ├── example.env ├── Dockerfile ├── README.md ├── LICENSE ├── .github └── workflows │ └── docker-image.yml └── main.go /go.sum: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module omnivore-raindrop-sync 2 | 3 | go 1.18 4 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | # duplicate this file as ".env" and fill in the below variables before building dockerfile or deploying 2 | RAINDROP_TOKEN= 3 | OMNIVORE_USERID= -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20.7-alpine 2 | 3 | WORKDIR /usr/src/omnivore-raindrop-sync 4 | 5 | COPY go.mod go.sum ./ 6 | 7 | RUN go mod download && go mod verify 8 | 9 | COPY . . 10 | 11 | RUN go build -v -o omnivore-raindrop-sync 12 | 13 | FROM alpine 14 | 15 | WORKDIR /usr/src/omnivore-raindrop-sync 16 | 17 | CMD ["./omnivore-raindrop-sync"] 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # omnivore-raindrop-sync 2 | Automatically sync Omnivore pages to Raindrop.io 3 | 4 | ## Setup 5 | 1. Go to https://app.raindrop.io/settings/integrations, create a new app, and copy the test token (we don't need a real token since this is for personal use only) 6 | 2. Go to https://omnivore.app/settings/webhooks and add a webhook pointing to the endpoint where you will host the sync application. (e.g. https://example.com/omnivore-raindrop-sync) 7 | 3. Copy `example.env` to `.env` and fill in the `RAINDROP_TOKEN` variable with your test token. 8 | 5. Fill in the `OMNIVORE_USERID` environment variable with an empty string for now. 9 | 6. Trigger a request by saving a new article in Omnivore while the sync app is running and note your actual user id in the stderr output. 10 | 7. Fill in the `OMNIVORE_USERID` environment variable with your actual user id. 11 | 12 | ## Building (Docker) 13 | 1. Run `docker build .` in the project directory. 14 | 15 | ## Building (without Docker) 16 | 1. Install Go toolchain 17 | 2. Run `go build main` 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 unappendixed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | env: 10 | REGISTRY: ghcr.io 11 | IMAGE_NAME: ${{ github.repository }} 12 | 13 | jobs: 14 | 15 | build-and-push-image: 16 | runs-on: ubuntu-latest 17 | 18 | permissions: 19 | contents: read 20 | packages: write 21 | attestations: write 22 | id-token: write 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Log in to the Container registry 29 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 30 | with: 31 | registry: ${{ env.REGISTRY }} 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Extract metadata (tags, labels) for Docker 36 | id: meta 37 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 38 | with: 39 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 40 | tags: | 41 | type=raw,value=latest,enable={{is_default_branch}} 42 | 43 | - name: Build and push Docker image 44 | id: push 45 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 46 | with: 47 | context: . 48 | push: true 49 | tags: ${{ steps.meta.outputs.tags }} 50 | labels: ${{ steps.meta.outputs.labels }} 51 | 52 | - name: Generate artifact attestation 53 | uses: actions/attest-build-provenance@v1 54 | with: 55 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 56 | subject-digest: ${{ steps.push.outputs.digest }} 57 | push-to-registry: true 58 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "time" 11 | ) 12 | 13 | type NewRaindropBookmark struct { 14 | Url string 15 | Title string 16 | UserId string 17 | } 18 | 19 | func stderrHelper(template string, v ...any) { 20 | time := time.Now().Local() 21 | timeS := fmt.Sprintf( 22 | "%v/%v/%v %v:%v:%v", 23 | time.Year(), 24 | int(time.Month()), 25 | time.Day(), time.Hour(), 26 | time.Minute(), 27 | time.Second()) 28 | s := fmt.Sprintf(template, v...) 29 | fmt.Fprintf(os.Stderr, "%v\t%v", timeS, s) 30 | } 31 | 32 | func main() { 33 | 34 | if os.Getenv("RAINDROP_TOKEN") == "" { 35 | panic("RAINDROP_TOKEN env variable is empty!") 36 | } 37 | 38 | if os.Getenv("OMNIVORE_USERID") == "" { 39 | stderrHelper("Warning: OMNIVORE_USERID variable is empty! All requests will be rejected.\n") 40 | } 41 | 42 | srv := http.Server{ 43 | Addr: ":8080", 44 | Handler: nil, 45 | } 46 | 47 | omniResponse := make(chan []byte) 48 | 49 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { handle(w, r, omniResponse) }) 50 | 51 | //TODO: add graceful shutdown logic 52 | go func() { srv.ListenAndServe() }() 53 | 54 | fmt.Printf("Server started successfully\nWaiting for requests...\n") 55 | 56 | for { 57 | 58 | data, err := parseOmnivoreResponse(<-omniResponse) 59 | 60 | if err != nil { 61 | stderrHelper("Parse Error: %v\n", err.Error()) 62 | continue 63 | } 64 | 65 | if data.UserId != os.Getenv("OMNIVORE_USERID") { 66 | stderrHelper("Rejecting request from invalid userId %q\n", data.UserId) 67 | continue 68 | } 69 | 70 | stderrHelper("Received %q\n", data.Url) 71 | 72 | valid, err := checkRaindropExists(data.Url) 73 | 74 | if err != nil { 75 | stderrHelper("Raindrop Check Response: %v\n", err.Error()) 76 | continue 77 | } 78 | 79 | if valid { 80 | err := createRaindrop(&data) 81 | if err != nil { 82 | stderrHelper("Create Raindrop Response: %v\n", err.Error()) 83 | continue 84 | } 85 | } else { 86 | stderrHelper("Bookmark already in Raindrop.io bookmarks\n") 87 | continue 88 | } 89 | stderrHelper("Successfully created Raindrop.io bookmark\n") 90 | } 91 | 92 | } 93 | 94 | // handles the request and sends the request body through ch channel 95 | func handle(w http.ResponseWriter, req *http.Request, ch chan []byte) { 96 | 97 | bytes, err := io.ReadAll(req.Body) 98 | 99 | if err != nil { 100 | fmt.Fprint(os.Stderr, err) 101 | return 102 | } 103 | 104 | ch <- bytes 105 | 106 | w.WriteHeader(http.StatusOK) 107 | } 108 | 109 | func parseOmnivoreResponse(omniBody []byte) (NewRaindropBookmark, error) { 110 | 111 | // init anonymous struct to unmarshal json body 112 | data := struct { 113 | UserId string `json:"userID"` 114 | Page struct { 115 | Url string `json:"originalUrl"` 116 | Title string 117 | } 118 | }{} 119 | 120 | err := json.Unmarshal(omniBody, &data) 121 | 122 | if err != nil { 123 | return NewRaindropBookmark{}, err 124 | } 125 | 126 | return NewRaindropBookmark{ 127 | Url: data.Page.Url, 128 | Title: data.Page.Title, 129 | UserId: data.UserId, 130 | }, nil 131 | } 132 | 133 | func createRaindrop(bookmark *NewRaindropBookmark) error { 134 | 135 | endpoint := "https://api.raindrop.io/rest/v1/raindrop" 136 | 137 | body := fmt.Sprintf(` 138 | { 139 | "link": "%v", 140 | "title": "%v", 141 | "pleaseParse": {} 142 | }`, bookmark.Url, bookmark.Title) 143 | 144 | req, err := http.NewRequest("POST", endpoint, bytes.NewReader([]byte(body))) 145 | defer req.Body.Close() 146 | 147 | if err != nil { 148 | return err 149 | } 150 | 151 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %v", os.Getenv("RAINDROP_TOKEN"))) 152 | req.Header.Add("Content-Type", "application/json") 153 | 154 | res, err := http.DefaultClient.Do(req) 155 | 156 | if err != nil { 157 | return err 158 | } else if res.StatusCode != http.StatusOK { 159 | return fmt.Errorf("Unexpected response from Raindrop.io api: %q", res.Status) 160 | } 161 | 162 | return nil 163 | } 164 | 165 | func checkRaindropExists(targetUrl string) (bool, error) { 166 | 167 | endpoint := "https://api.raindrop.io/rest/v1/import/url/exists" 168 | 169 | body := struct { 170 | Urls []string `json:"urls"` 171 | }{Urls: []string{targetUrl}} 172 | 173 | bodyString, err := json.Marshal(body) 174 | 175 | if err != nil { 176 | return false, err 177 | } 178 | 179 | token := os.Getenv("RAINDROP_TOKEN") 180 | 181 | req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(bodyString)) 182 | defer req.Body.Close() 183 | if err != nil { 184 | return false, err 185 | } 186 | 187 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %v", token)) 188 | req.Header.Add("Content-Type", "application/json") 189 | 190 | resp, err := http.DefaultClient.Do(req) 191 | 192 | if err != nil { 193 | return false, err 194 | } else if resp.StatusCode >= 400 { 195 | return false, fmt.Errorf(resp.Status) 196 | } 197 | 198 | rawResponse, err := io.ReadAll(resp.Body) 199 | 200 | if err != nil { 201 | return false, err 202 | } 203 | 204 | responseData := struct{ Result bool }{} 205 | 206 | if err := json.Unmarshal(rawResponse, &responseData); err != nil { 207 | return false, err 208 | } 209 | 210 | //raindrop api returns false if there *isn't* a duplicate, so we negate this 211 | return !responseData.Result, nil 212 | 213 | } 214 | --------------------------------------------------------------------------------