subtree(users/wpcarro): docking briefcase at '24f5a642'
git-subtree-dir: users/wpcarro git-subtree-mainline:464bbcb15cgit-subtree-split:24f5a642afChange-Id: I6105b3762b79126b3488359c95978cadb3efa789
This commit is contained in:
commit
019f8fd211
766 changed files with 175420 additions and 0 deletions
8
users/wpcarro/tools/monzo_ynab/.envrc
Normal file
8
users/wpcarro/tools/monzo_ynab/.envrc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
source_up
|
||||
use_nix
|
||||
export monzo_client_id="$(jq -j '.monzo | .clientId' < ~/briefcase/secrets.json)"
|
||||
export monzo_client_secret="$(jq -j '.monzo | .clientSecret' < ~/briefcase/secrets.json)"
|
||||
export ynab_personal_access_token="$(jq -j '.ynab | .personalAccessToken' < ~/briefcase/secrets.json)"
|
||||
export ynab_account_id="$(jq -j '.ynab | .accountId' < ~/briefcase/secrets.json)"
|
||||
export ynab_budget_id="$(jq -j '.ynab | .budgetId' < ~/briefcase/secrets.json)"
|
||||
export store_path="$(pwd)"
|
||||
3
users/wpcarro/tools/monzo_ynab/.gitignore
vendored
Normal file
3
users/wpcarro/tools/monzo_ynab/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/ynab/fixture.json
|
||||
/monzo/fixture.json
|
||||
/kv.json
|
||||
41
users/wpcarro/tools/monzo_ynab/README.md
Normal file
41
users/wpcarro/tools/monzo_ynab/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# monzo_ynab
|
||||
|
||||
Exporting Monzo transactions to my YouNeedABudget.com (i.e. YNAB) account. YNAB
|
||||
unfortunately doesn't currently offer an Monzo integration. As a workaround and
|
||||
a practical excuse to learn Go, I decided to write one myself.
|
||||
|
||||
This job is going to run N times per 24 hours. Monzo offers webhooks for
|
||||
reacting to certain types of events. I don't expect I'll need realtime data for
|
||||
my YNAB integration. That may change, however, so it's worth noting.
|
||||
|
||||
## Installation
|
||||
|
||||
Like many other packages in this repository, `monzo_ynab` is packaged using
|
||||
Nix. To install and use, you have two options:
|
||||
|
||||
You can install using `nix-build` and then run the resulting
|
||||
`./result/bin/monzo_ynab`.
|
||||
|
||||
```shell
|
||||
> nix-build . && ./result/bin/monzo_ynab
|
||||
```
|
||||
|
||||
Or you can install using `nix-env` if you'd like to create the `monzo_ynab`
|
||||
symlink.
|
||||
|
||||
```shell
|
||||
> nix-env -f ~/briefcase/monzo_ynab -i
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
While this project is currently not deployed, my plan is to host it on Google
|
||||
Cloud and run it as a Cloud Run application. What I don't yet know is whether or
|
||||
not this is feasible or a good idea. One complication that I foresee is that the
|
||||
OAuth 2.0 login flow requires a web browser until the access token and refresh
|
||||
tokens are acquired. I'm unsure how to workaround this at the moment.
|
||||
|
||||
For more information about the general packaging and deployment strategies I'm
|
||||
currently using, refer to the [deployments][deploy] writeup.
|
||||
|
||||
[deploy]: ../deploy/README.md
|
||||
101
users/wpcarro/tools/monzo_ynab/auth.go
Normal file
101
users/wpcarro/tools/monzo_ynab/auth.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package auth
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Dependencies
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"utils"
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Constants
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
var (
|
||||
BROWSER = os.Getenv("BROWSER")
|
||||
REDIRECT_URI = "http://localhost:8080/authorization-code"
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Types
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// This is the response returned from Monzo when we exchange our authorization
|
||||
// code for an access token. While Monzo returns additional fields, I'm only
|
||||
// interested in AccessToken and RefreshToken.
|
||||
type accessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type Tokens struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresIn int
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Functions
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Returns the access token and refresh tokens for the Monzo API.
|
||||
func GetTokensFromAuthCode(authCode string, clientID string, clientSecret string) *Tokens {
|
||||
res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"client_id": {clientID},
|
||||
"client_secret": {clientSecret},
|
||||
"redirect_uri": {REDIRECT_URI},
|
||||
"code": {authCode},
|
||||
})
|
||||
utils.FailOn(err)
|
||||
defer res.Body.Close()
|
||||
payload := &accessTokenResponse{}
|
||||
json.NewDecoder(res.Body).Decode(payload)
|
||||
|
||||
return &Tokens{payload.AccessToken, payload.RefreshToken, payload.ExpiresIn}
|
||||
}
|
||||
|
||||
// Open a web browser to allow the user to authorize this application. Return
|
||||
// the authorization code sent from Monzo.
|
||||
func GetAuthCode(clientID string) string {
|
||||
// TODO(wpcarro): Consider generating a random string for the state when the
|
||||
// application starts instead of hardcoding it here.
|
||||
state := "xyz123"
|
||||
url := fmt.Sprintf(
|
||||
"https://auth.monzo.com/?client_id=%s&redirect_uri=%s&response_type=code&state=%s",
|
||||
clientID, REDIRECT_URI, state)
|
||||
exec.Command(BROWSER, url).Start()
|
||||
|
||||
authCode := make(chan string)
|
||||
go func() {
|
||||
log.Fatal(http.ListenAndServe(":8080",
|
||||
http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
// 1. Get authorization code from Monzo.
|
||||
if req.URL.Path == "/authorization-code" {
|
||||
params := req.URL.Query()
|
||||
reqState := params["state"][0]
|
||||
code := params["code"][0]
|
||||
|
||||
if reqState != state {
|
||||
log.Fatalf("Value for state returned by Monzo does not equal our state. %s != %s", reqState, state)
|
||||
}
|
||||
authCode <- code
|
||||
|
||||
fmt.Fprintf(w, "Authorized!")
|
||||
} else {
|
||||
log.Printf("Unhandled request: %v\n", *req)
|
||||
}
|
||||
})))
|
||||
}()
|
||||
result := <-authCode
|
||||
return result
|
||||
}
|
||||
12
users/wpcarro/tools/monzo_ynab/job.nix
Normal file
12
users/wpcarro/tools/monzo_ynab/job.nix
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{ depot, briefcase, ... }:
|
||||
|
||||
depot.buildGo.program {
|
||||
name = "job";
|
||||
srcs = [
|
||||
./main.go
|
||||
];
|
||||
deps = with briefcase.gopkgs; [
|
||||
kv
|
||||
utils
|
||||
];
|
||||
}
|
||||
43
users/wpcarro/tools/monzo_ynab/main.go
Normal file
43
users/wpcarro/tools/monzo_ynab/main.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Exporting Monzo transactions to my YouNeedABudget.com (i.e. YNAB)
|
||||
// account. YNAB unfortunately doesn't currently offer an Monzo integration. As
|
||||
// a workaround and a practical excuse to learn Go, I decided to write one
|
||||
// myself.
|
||||
//
|
||||
// This job is going to run N times per 24 hours. Monzo offers webhooks for
|
||||
// reacting to certain types of events. I don't expect I'll need realtime data
|
||||
// for my YNAB integration. That may change, however, so it's worth noting.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ynabAccountID = os.Getenv("ynab_account_id")
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Business Logic
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Convert a Monzo transaction struct, `tx`, into a YNAB transaction struct.
|
||||
func toYnab(tx monzoSerde.Transaction) ynabSerde.Transaction {
|
||||
return ynabSerde.Transaction{
|
||||
Id: tx.Id,
|
||||
Date: tx.Created,
|
||||
Amount: tx.Amount,
|
||||
Memo: tx.Notes,
|
||||
AccountId: ynabAccountID,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
txs := monzo.TransactionsLast24Hours()
|
||||
var ynabTxs []ynabSerde.Transaction{}
|
||||
for tx := range txs {
|
||||
append(ynabTxs, toYnab(tx))
|
||||
}
|
||||
ynab.PostTransactions(ynabTxs)
|
||||
os.Exit(0)
|
||||
}
|
||||
52
users/wpcarro/tools/monzo_ynab/monzo/client.go
Normal file
52
users/wpcarro/tools/monzo_ynab/monzo/client.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package monzoClient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"monzoSerde"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"tokens"
|
||||
"utils"
|
||||
)
|
||||
|
||||
const (
|
||||
accountID = "pizza"
|
||||
)
|
||||
|
||||
type Client struct{}
|
||||
|
||||
// Ensure that the token server is running and return a new instance of a Client
|
||||
// struct.
|
||||
func Create() *Client {
|
||||
tokens.StartServer()
|
||||
time.Sleep(time.Second * 1)
|
||||
return &Client{}
|
||||
}
|
||||
|
||||
// Returns a slice of transactions from the last 24 hours.
|
||||
func (c *Client) Transactions24Hours() []monzoSerde.Transaction {
|
||||
token := tokens.AccessToken()
|
||||
form := url.Values{"account_id": {accountID}}
|
||||
client := http.Client{}
|
||||
req, _ := http.NewRequest("POST", "https://api.monzo.com/transactions",
|
||||
strings.NewReader(form.Encode()))
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Add("User-Agent", "monzo-ynab")
|
||||
res, err := client.Do(req)
|
||||
|
||||
utils.DebugRequest(req)
|
||||
utils.DebugResponse(res)
|
||||
|
||||
if err != nil {
|
||||
utils.DebugRequest(req)
|
||||
utils.DebugResponse(res)
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
return []monzoSerde.Transaction{}
|
||||
}
|
||||
82
users/wpcarro/tools/monzo_ynab/monzo/serde.go
Normal file
82
users/wpcarro/tools/monzo_ynab/monzo/serde.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// This package hosts the serialization and deserialization logic for all of the
|
||||
// data types with which our application interacts from the Monzo API.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TxMetadata struct {
|
||||
FasterPayment string `json:"faster_payment"`
|
||||
FpsPaymentId string `json:"fps_payment_id"`
|
||||
Insertion string `json:"insertion"`
|
||||
Notes string `json:"notes"`
|
||||
Trn string `json:"trn"`
|
||||
}
|
||||
|
||||
type TxCounterparty struct {
|
||||
AccountNumber string `json:"account_number"`
|
||||
Name string `json:"name"`
|
||||
SortCode string `json:"sort_code"`
|
||||
UserId string `json:"user_id"`
|
||||
}
|
||||
|
||||
type Transaction struct {
|
||||
Id string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
Description string `json:"description"`
|
||||
Amount int `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Notes string `json:"notes"`
|
||||
Metadata TxMetadata
|
||||
AccountBalance int `json:"account_balance"`
|
||||
International interface{} `json:"international"`
|
||||
Category string `json:"category"`
|
||||
IsLoad bool `json:"is_load"`
|
||||
Settled time.Time `json:"settled"`
|
||||
LocalAmount int `json:"local_amount"`
|
||||
LocalCurrency string `json:"local_currency"`
|
||||
Updated time.Time `json:"updated"`
|
||||
AccountId string `json:"account_id"`
|
||||
UserId string `json:"user_id"`
|
||||
Counterparty TxCounterparty `json:"counterparty"`
|
||||
Scheme string `json:"scheme"`
|
||||
DedupeId string `json:"dedupe_id"`
|
||||
Originator bool `json:"originator"`
|
||||
IncludeInSpending bool `json:"include_in_spending"`
|
||||
CanBeExcludedFromBreakdown bool `json:"can_be_excluded_from_breakdown"`
|
||||
CanBeMadeSubscription bool `json:"can_be_made_subscription"`
|
||||
CanSplitTheBill bool `json:"can_split_the_bill"`
|
||||
CanAddToTab bool `json:"can_add_to_tab"`
|
||||
AmountIsPending bool `json:"amount_is_pending"`
|
||||
// Fees interface{} `json:"fees"`
|
||||
// Merchant interface `json:"merchant"`
|
||||
// Labels interface{} `json:"labels"`
|
||||
// Attachments interface{} `json:"attachments"`
|
||||
// Categories interface{} `json:"categories"`
|
||||
}
|
||||
|
||||
// Attempts to encode a Monzo transaction struct into a string.
|
||||
func serializeTx(tx *Transaction) (string, error) {
|
||||
x, err := json.Marshal(tx)
|
||||
return string(x), err
|
||||
}
|
||||
|
||||
// Attempts to parse a string encoding a transaction presumably sent from a
|
||||
// Monzo server.
|
||||
func deserializeTx(x string) (*Transaction, error) {
|
||||
target := &Transaction{}
|
||||
err := json.Unmarshal([]byte(x), target)
|
||||
return target, err
|
||||
}
|
||||
|
||||
func main() {
|
||||
b, _ := ioutil.ReadFile("./fixture.json")
|
||||
tx := string(b)
|
||||
target, _ := deserializeTx(tx)
|
||||
out, _ := serializeTx(target)
|
||||
fmt.Println(out)
|
||||
}
|
||||
80
users/wpcarro/tools/monzo_ynab/requests.txt
Normal file
80
users/wpcarro/tools/monzo_ynab/requests.txt
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
################################################################################
|
||||
# YNAB
|
||||
################################################################################
|
||||
:ynab = https://api.youneedabudget.com/v1
|
||||
:ynab-access-token := (getenv "ynab_personal_access_token")
|
||||
:ynab-budget-id := (getenv "ynab_budget_id")
|
||||
:ynab-account-id := (getenv "ynab_account_id")
|
||||
|
||||
# Test
|
||||
GET :ynab/budgets
|
||||
Authorization: Bearer :ynab-access-token
|
||||
|
||||
# List transactions
|
||||
GET :ynab/budgets/:ynab-budget-id/transactions
|
||||
Authorization: Bearer :ynab-access-token
|
||||
|
||||
# Post transactions
|
||||
POST :ynab/budgets/:ynab-budget-id/transactions
|
||||
Authorization: Bearer :ynab-access-token
|
||||
Content-Type: application/json
|
||||
{
|
||||
"transactions": [
|
||||
{
|
||||
"account_id": ":ynab-account-id",
|
||||
"date": "2019-12-30",
|
||||
"amount": 10000,
|
||||
"payee_name": "Richard Stallman",
|
||||
"memo": "Not so free software after all...",
|
||||
"cleared": "cleared",
|
||||
"approved": true,
|
||||
"flag_color": "red",
|
||||
"import_id": "xyz-123"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
################################################################################
|
||||
# Monzo
|
||||
################################################################################
|
||||
:monzo = https://api.monzo.com
|
||||
:monzo-access-token := (getenv "monzo_cached_access_token")
|
||||
:monzo-refresh-token := (getenv "monzo_cached_refresh_token")
|
||||
:monzo-client-id := (getenv "monzo_client_id")
|
||||
:monzo-client-secret := (getenv "monzo_client_secret")
|
||||
:monzo-account-id := (getenv "monzo_account_id")
|
||||
|
||||
# List transactions
|
||||
GET :monzo/transactions
|
||||
Authorization: Bearer :monzo-access-token
|
||||
account_id==:monzo-account-id
|
||||
|
||||
# Refresh access token
|
||||
# According from the docs, the access token expires in 6 hours.
|
||||
POST :monzo/oauth2/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Authorization: Bearer :monzo-access-token
|
||||
grant_type=refresh_token&client_id=:monzo-client-id&client_secret=:monzo-client-secret&refresh_token=:monzo-refresh-token
|
||||
|
||||
################################################################################
|
||||
# Tokens server
|
||||
################################################################################
|
||||
:tokens = http://localhost:4242
|
||||
|
||||
# Get tokens
|
||||
GET :tokens/tokens
|
||||
|
||||
# Get application state for debugging purposes
|
||||
GET :tokens/state
|
||||
|
||||
# Force refresh tokens
|
||||
POST :tokens/refresh-tokens
|
||||
|
||||
# Set tokens
|
||||
POST :tokens/set-tokens
|
||||
Content-Type: application/json
|
||||
{
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_in": 120
|
||||
}
|
||||
10
users/wpcarro/tools/monzo_ynab/shell.nix
Normal file
10
users/wpcarro/tools/monzo_ynab/shell.nix
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
let
|
||||
briefcase = import <briefcase> {};
|
||||
pkgs = briefcase.third_party.pkgs;
|
||||
in pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.go
|
||||
pkgs.goimports
|
||||
pkgs.godef
|
||||
];
|
||||
}
|
||||
283
users/wpcarro/tools/monzo_ynab/tokens.go
Normal file
283
users/wpcarro/tools/monzo_ynab/tokens.go
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
// Creating a Tokens server to manage my access and refresh tokens. Keeping this
|
||||
// as a separate server allows me to develop and use the access tokens without
|
||||
// going through client authorization.
|
||||
package main
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Dependencies
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
import (
|
||||
"auth"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"kv"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
"utils"
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Types
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// This is the response from Monzo's API after we request an access token
|
||||
// refresh.
|
||||
type refreshTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ClientId string `json:"client_id"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// This is the shape of the request from clients wishing to set state of the
|
||||
// server.
|
||||
type setTokensRequest struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// This is our application state.
|
||||
type state struct {
|
||||
accessToken string `json:"access_token"`
|
||||
refreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type readMsg struct {
|
||||
sender chan state
|
||||
}
|
||||
|
||||
type writeMsg struct {
|
||||
state state
|
||||
sender chan bool
|
||||
}
|
||||
|
||||
type channels struct {
|
||||
reads chan readMsg
|
||||
writes chan writeMsg
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Top-level Definitions
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
var chans = &channels{
|
||||
reads: make(chan readMsg),
|
||||
writes: make(chan writeMsg),
|
||||
}
|
||||
|
||||
var (
|
||||
monzoClientId = os.Getenv("monzo_client_id")
|
||||
monzoClientSecret = os.Getenv("monzo_client_secret")
|
||||
storePath = os.Getenv("store_path")
|
||||
)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Print the access and refresh tokens for debugging.
|
||||
func logTokens(access string, refresh string) {
|
||||
log.Printf("Access: %s\n", access)
|
||||
log.Printf("Refresh: %s\n", refresh)
|
||||
}
|
||||
|
||||
func (state *state) String() string {
|
||||
return fmt.Sprintf("state{\n\taccessToken: \"%s\",\n\trefreshToken: \"%s\"\n}\n", state.accessToken, state.refreshToken)
|
||||
}
|
||||
|
||||
// Schedule a token refresh for `expiresIn` seconds using the provided
|
||||
// `refreshToken`. This will update the application state with the access token
|
||||
// and schedule an additional token refresh for the newly acquired tokens.
|
||||
func scheduleTokenRefresh(expiresIn int, refreshToken string) {
|
||||
duration := time.Second * time.Duration(expiresIn)
|
||||
timestamp := time.Now().Local().Add(duration)
|
||||
// TODO(wpcarro): Consider adding a more human readable version that will
|
||||
// log the number of hours, minutes, etc. until the next refresh.
|
||||
log.Printf("Scheduling token refresh for %v\n", timestamp)
|
||||
time.Sleep(duration)
|
||||
log.Println("Refreshing tokens now...")
|
||||
accessToken, refreshToken := refreshTokens(refreshToken)
|
||||
log.Println("Successfully refreshed tokens.")
|
||||
logTokens(accessToken, refreshToken)
|
||||
setState(accessToken, refreshToken)
|
||||
}
|
||||
|
||||
// Exchange existing credentials for a new access token and `refreshToken`. Also
|
||||
// schedule the next refresh. This function returns the newly acquired access
|
||||
// token and refresh token.
|
||||
func refreshTokens(refreshToken string) (string, string) {
|
||||
// TODO(wpcarro): Support retries with exponential backoff.
|
||||
res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"client_id": {monzoClientId},
|
||||
"client_secret": {monzoClientSecret},
|
||||
"refresh_token": {refreshToken},
|
||||
})
|
||||
if res.StatusCode != http.StatusOK {
|
||||
// TODO(wpcarro): Considering panicking here.
|
||||
utils.DebugResponse(res)
|
||||
}
|
||||
if err != nil {
|
||||
utils.DebugResponse(res)
|
||||
log.Fatal("The request to Monzo to refresh our access token failed.", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
payload := &refreshTokenResponse{}
|
||||
err = json.NewDecoder(res.Body).Decode(payload)
|
||||
if err != nil {
|
||||
log.Fatal("Could not decode the JSON response from Monzo.", err)
|
||||
}
|
||||
|
||||
go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken)
|
||||
|
||||
// Interestingly, JSON decoding into the refreshTokenResponse can success
|
||||
// even if the decoder doesn't populate any of the fields in the
|
||||
// refreshTokenResponse struct. From what I read, it isn't possible to make
|
||||
// these fields as required using an annotation, so this guard must suffice
|
||||
// for now.
|
||||
if payload.AccessToken == "" || payload.RefreshToken == "" {
|
||||
log.Fatal("JSON parsed correctly but failed to populate token fields.")
|
||||
}
|
||||
|
||||
return payload.AccessToken, payload.RefreshToken
|
||||
}
|
||||
|
||||
func persistTokens(access string, refresh string) {
|
||||
log.Println("Persisting tokens...")
|
||||
kv.Set(storePath, "monzoAccessToken", access)
|
||||
kv.Set(storePath, "monzoRefreshToken", refresh)
|
||||
log.Println("Successfully persisted tokens.")
|
||||
}
|
||||
|
||||
// Listen for SIGINT and SIGTERM signals. When received, persist the access and
|
||||
// refresh tokens and shutdown the server.
|
||||
func handleInterrupts() {
|
||||
// Gracefully handle interruptions.
|
||||
sigs := make(chan os.Signal, 1)
|
||||
done := make(chan bool)
|
||||
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
sig := <-sigs
|
||||
log.Printf("Received signal to shutdown. %v\n", sig)
|
||||
state := getState()
|
||||
persistTokens(state.accessToken, state.refreshToken)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
<-done
|
||||
log.Println("Exiting...")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Set `accessToken` and `refreshToken` on application state.
|
||||
func setState(accessToken string, refreshToken string) {
|
||||
msg := writeMsg{state{accessToken, refreshToken}, make(chan bool)}
|
||||
chans.writes <- msg
|
||||
<-msg.sender
|
||||
}
|
||||
|
||||
// Return our application state.
|
||||
func getState() state {
|
||||
msg := readMsg{make(chan state)}
|
||||
chans.reads <- msg
|
||||
return <-msg.sender
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Main
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func main() {
|
||||
// Manage application state.
|
||||
go func() {
|
||||
state := &state{}
|
||||
for {
|
||||
select {
|
||||
case msg := <-chans.reads:
|
||||
log.Println("Reading from state...")
|
||||
log.Println(state)
|
||||
msg.sender <- *state
|
||||
case msg := <-chans.writes:
|
||||
log.Println("Writing to state.")
|
||||
log.Printf("Old: %s\n", state)
|
||||
*state = msg.state
|
||||
log.Printf("New: %s\n", state)
|
||||
// As an attempt to maintain consistency between application
|
||||
// state and persisted state, everytime we write to the
|
||||
// application state, we will write to the store.
|
||||
persistTokens(state.accessToken, state.refreshToken)
|
||||
msg.sender <- true
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Retrieve cached tokens from store.
|
||||
accessToken := fmt.Sprintf("%v", kv.Get(storePath, "monzoAccessToken"))
|
||||
refreshToken := fmt.Sprintf("%v", kv.Get(storePath, "monzoRefreshToken"))
|
||||
|
||||
log.Println("Attempting to retrieve cached credentials...")
|
||||
logTokens(accessToken, refreshToken)
|
||||
|
||||
if accessToken == "" || refreshToken == "" {
|
||||
log.Println("Cached credentials are absent. Authorizing client...")
|
||||
authCode := auth.GetAuthCode(monzoClientId)
|
||||
tokens := auth.GetTokensFromAuthCode(authCode, monzoClientId, monzoClientSecret)
|
||||
setState(tokens.AccessToken, tokens.RefreshToken)
|
||||
go scheduleTokenRefresh(tokens.ExpiresIn, tokens.RefreshToken)
|
||||
} else {
|
||||
setState(accessToken, refreshToken)
|
||||
// If we have tokens, they may be expiring soon. We don't know because
|
||||
// we aren't storing the expiration timestamp in the state or in the
|
||||
// store. Until we have that information, and to be safe, let's refresh
|
||||
// the tokens.
|
||||
go scheduleTokenRefresh(0, refreshToken)
|
||||
}
|
||||
|
||||
// Gracefully handle shutdowns.
|
||||
go handleInterrupts()
|
||||
|
||||
// Listen to inbound requests.
|
||||
fmt.Println("Listening on http://localhost:4242 ...")
|
||||
log.Fatal(http.ListenAndServe(":4242",
|
||||
http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Path == "/refresh-tokens" && req.Method == "POST" {
|
||||
state := getState()
|
||||
go scheduleTokenRefresh(0, state.refreshToken)
|
||||
fmt.Fprintf(w, "Done.")
|
||||
} else if req.URL.Path == "/set-tokens" && req.Method == "POST" {
|
||||
// Parse
|
||||
payload := &setTokensRequest{}
|
||||
err := json.NewDecoder(req.Body).Decode(payload)
|
||||
if err != nil {
|
||||
log.Fatal("Could not decode the user's JSON request.", err)
|
||||
}
|
||||
|
||||
// Update application state
|
||||
setState(payload.AccessToken, payload.RefreshToken)
|
||||
|
||||
// Refresh tokens
|
||||
go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken)
|
||||
|
||||
// Ack
|
||||
fmt.Fprintf(w, "Done.")
|
||||
} else if req.URL.Path == "/state" && req.Method == "GET" {
|
||||
// TODO(wpcarro): Ensure that this returns serialized state.
|
||||
w.Header().Set("Content-type", "application/json")
|
||||
state := getState()
|
||||
payload, _ := json.Marshal(state)
|
||||
io.WriteString(w, string(payload))
|
||||
} else {
|
||||
log.Printf("Unhandled request: %v\n", *req)
|
||||
}
|
||||
})))
|
||||
}
|
||||
23
users/wpcarro/tools/monzo_ynab/tokens.nix
Normal file
23
users/wpcarro/tools/monzo_ynab/tokens.nix
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{ depot, briefcase, ... }:
|
||||
|
||||
let
|
||||
auth = depot.buildGo.package {
|
||||
name = "auth";
|
||||
srcs = [
|
||||
./auth.go
|
||||
];
|
||||
deps = with briefcase.gopkgs; [
|
||||
utils
|
||||
];
|
||||
};
|
||||
in depot.buildGo.program {
|
||||
name = "token-server";
|
||||
srcs = [
|
||||
./tokens.go
|
||||
];
|
||||
deps = with briefcase.gopkgs; [
|
||||
kv
|
||||
utils
|
||||
auth
|
||||
];
|
||||
}
|
||||
24
users/wpcarro/tools/monzo_ynab/ynab/client.go
Normal file
24
users/wpcarro/tools/monzo_ynab/ynab/client.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"serde"
|
||||
)
|
||||
|
||||
// See requests.txt for more details.
|
||||
func PostTransactions(accountID string, txs []serde.Transaction{}) error {
|
||||
return map[string]string{
|
||||
"transactions": [
|
||||
{
|
||||
"account_id": accountID,
|
||||
"date": "2019-12-30",
|
||||
"amount": 10000,
|
||||
"payee_name": "Richard Stallman",
|
||||
"memo": "Not so free software after all...",
|
||||
"cleared": "cleared",
|
||||
"approved": true,
|
||||
"flag_color": "red",
|
||||
"import_id": "xyz-123"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
52
users/wpcarro/tools/monzo_ynab/ynab/serde.go
Normal file
52
users/wpcarro/tools/monzo_ynab/ynab/serde.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// This package hosts the serialization and deserialization logic for all of the
|
||||
// data types with which our application interacts from the YNAB API.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Transaction struct {
|
||||
Id string `json:"id"`
|
||||
Date time.Time `json:"date"`
|
||||
Amount int `json:"amount"`
|
||||
Memo string `json:"memo"`
|
||||
Cleared string `json:"cleared"`
|
||||
Approved bool `json:"approved"`
|
||||
FlagColor string `json:"flag_color"`
|
||||
AccountId string `json:"account_id"`
|
||||
AccountName string `json:"account_name"`
|
||||
PayeeId string `json:"payeed_id"`
|
||||
PayeeName string `json:"payee_name"`
|
||||
CategoryId string `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Deleted bool `json:"deleted"`
|
||||
// TransferAccountId interface{} `json:"transfer_account_id"`
|
||||
// TransferTransactionId interface{} `json:"transfer_transaction_id"`
|
||||
// MatchedTransactionId interface{} `json:"matched_transaction_id"`
|
||||
// ImportId interface{} `json:"import_id"`
|
||||
// Subtransactions interface{} `json:"subtransactions"`
|
||||
}
|
||||
|
||||
// Attempts to encode a YNAB transaction into a string.
|
||||
func serializeTx(tx *Transaction) (string, error) {
|
||||
x, err := json.Marshal(tx)
|
||||
return string(x), err
|
||||
}
|
||||
|
||||
// Attempts to parse a string encoding a transaction presumably sent from a
|
||||
// YNAB server.
|
||||
func deserializeTx(x string) (*Transaction, error) {
|
||||
target := &Transaction{}
|
||||
err := json.Unmarshal([]byte(x), target)
|
||||
return target, err
|
||||
}
|
||||
|
||||
func main() {
|
||||
target, _ := deserializeTx(tx)
|
||||
out, _ := serializeTx(target)
|
||||
fmt.Println(out)
|
||||
fmt.Println(ynabOut)
|
||||
}
|
||||
202
users/wpcarro/tools/rfcToKindle/LICENSE
Normal file
202
users/wpcarro/tools/rfcToKindle/LICENSE
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
30
users/wpcarro/tools/rfcToKindle/README.md
Normal file
30
users/wpcarro/tools/rfcToKindle/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# rfcToKindle
|
||||
|
||||
Wirelessly transfer RFC documents to your Kindle to device for an alternative
|
||||
medium for reading.
|
||||
|
||||
## Installation
|
||||
|
||||
`rfcToKindle` makes use of [`buildGo.nix`][2] to package itself. If you're
|
||||
using [Nix][1], you can install `rfcToKindle` using `nix-env`:
|
||||
|
||||
```shell
|
||||
> nix-env -f https://github.com/wpcarro/rfcToKindle -i
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```shell
|
||||
> rfcToKindle -document rfc6479 -recipient username@kindle.com
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
This uses `sendgmr` to send the file to the Kindle. Make sure:
|
||||
1. That `sendgmr` is installed and available on $PATH.
|
||||
2. That it is configured to work with your preferred email address.
|
||||
3. That the email address `sendgmr` is configured to use is whitelisted in
|
||||
your Kindle "Personal Document Settings".
|
||||
|
||||
[1]: https://nixos.org/nix/
|
||||
[2]: https://git.tazj.in/tree/nix/buildGo
|
||||
11
users/wpcarro/tools/rfcToKindle/default.nix
Normal file
11
users/wpcarro/tools/rfcToKindle/default.nix
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{ depot, ... }:
|
||||
|
||||
# TODO: This doesn't depend on `sendgmr` at the moment, but it should. As such,
|
||||
# it's an imcomplete packaging.
|
||||
depot.buildGo.program {
|
||||
name = "rfcToKindle";
|
||||
srcs = [
|
||||
./main.go
|
||||
];
|
||||
deps = [];
|
||||
}
|
||||
89
users/wpcarro/tools/rfcToKindle/main.go
Normal file
89
users/wpcarro/tools/rfcToKindle/main.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Author: wpcarro@gmail.com
|
||||
//
|
||||
// Wirelessly transfer RFC documents to your Kindle to device for an alternative
|
||||
// medium for reading.
|
||||
//
|
||||
// Usage:
|
||||
// ```shell
|
||||
// > go run rfcToKindle.go -document rfc6479 -recipient username@kindle.com
|
||||
// ```
|
||||
//
|
||||
// This uses `sendgmr` to send the file to the Kindle. Make sure:
|
||||
// 1. That `sendgmr` is installed and available on $PATH.
|
||||
// 2. That it is configured to work with your preferred email address.
|
||||
// 3. That the email address `sendgmr` is configured to use is whitelisted in
|
||||
// your Kindle "Personal Document Settings".
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
document := flag.String("document", "", "(Required) The name of the document to fetch. For example \"RFC6479\".")
|
||||
recipient := flag.String("recipient", "", "(Required) The email address of the Kindle device.")
|
||||
subject := flag.String("subject", "", "(Optional) The email address of the Kindle device.")
|
||||
flag.Parse()
|
||||
|
||||
if *document == "" {
|
||||
// TODO: Is log.Fatal the best function to use here?
|
||||
log.Fatal("-document cannot be empty. See -help for more information.")
|
||||
}
|
||||
|
||||
if *recipient == "" {
|
||||
log.Fatal("-recipient cannot be empty. See -help for more information.")
|
||||
}
|
||||
|
||||
*document = strings.ToLower(*document)
|
||||
|
||||
url := fmt.Sprintf("https://www.ietf.org/rfc/%s.txt", *document)
|
||||
resp, err := http.Get(url)
|
||||
fmt.Printf("Downloading %s ... ", url)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
f, err := ioutil.TempFile("", fmt.Sprintf("%s-*.txt", *document))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// TODO: Verify if this is cleaning up or not.
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("done.")
|
||||
|
||||
if *subject == "" {
|
||||
*subject = fmt.Sprintf("%s - Sent from rfcToKindle.go", *document)
|
||||
}
|
||||
|
||||
// Although I couldn't find it documented anywhere, the email sent to the
|
||||
// Kindle must have a body, even if the body isn't used for anything.
|
||||
fmt.Printf("Emailing %s to %s ... ", f.Name(), *recipient)
|
||||
cmd := exec.Command("sendgmr",
|
||||
fmt.Sprintf("--to=%s", *recipient),
|
||||
fmt.Sprintf("--body_file=%s", f.Name()),
|
||||
fmt.Sprintf("--subject=%s", *subject),
|
||||
fmt.Sprintf("--attachment_files=%s", f.Name()))
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("done.")
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
2
users/wpcarro/tools/run/.envrc
Normal file
2
users/wpcarro/tools/run/.envrc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
source_up
|
||||
use_nix
|
||||
30
users/wpcarro/tools/run/README.md
Normal file
30
users/wpcarro/tools/run/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# run
|
||||
|
||||
Simplify the commands you call to run scripts on the command line.
|
||||
|
||||
```shell
|
||||
> run path/to/file.py
|
||||
> run path/to/file.ts
|
||||
```
|
||||
|
||||
## How?
|
||||
|
||||
Define a run.json configuration mapping commands to filename extensions like
|
||||
so:
|
||||
```json
|
||||
{
|
||||
".ts": "npx ts-node $file",
|
||||
".py": "python3 $file"
|
||||
}
|
||||
```
|
||||
|
||||
Then call `run path/to/some/file.ts` on the command line, and `npx ts-node
|
||||
file.ts` will run.
|
||||
|
||||
## Installation
|
||||
|
||||
Install `run` using Nix.
|
||||
|
||||
```shell
|
||||
> nix-env -iA briefcase.run
|
||||
```
|
||||
11
users/wpcarro/tools/run/default.nix
Normal file
11
users/wpcarro/tools/run/default.nix
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{ pkgs, depot, briefcase, ... }:
|
||||
|
||||
depot.buildGo.program {
|
||||
name = "run";
|
||||
srcs = [
|
||||
./main.go
|
||||
];
|
||||
deps = with briefcase.gopkgs; [
|
||||
utils
|
||||
];
|
||||
}
|
||||
49
users/wpcarro/tools/run/main.go
Normal file
49
users/wpcarro/tools/run/main.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
log.Fatal("You can only call run with a single file at a time.")
|
||||
}
|
||||
|
||||
rulesPath := utils.Resolve("run.json", []string{"/home/wpcarro/.config/run/run.json"})
|
||||
b, err := ioutil.ReadFile(rulesPath)
|
||||
if err != nil {
|
||||
log.Fatal("Could not locate a run.json file: ", err)
|
||||
}
|
||||
rules := map[string]string{}
|
||||
err = json.Unmarshal(b, &rules)
|
||||
if err != nil {
|
||||
log.Fatal("Could not decode run.json as JSON: ", err)
|
||||
}
|
||||
|
||||
fileName := os.Args[1]
|
||||
ext := filepath.Ext(fileName)
|
||||
cmd, ok := rules[ext]
|
||||
|
||||
if !ok {
|
||||
log.Fatalf("No rules for extension, %s, have been defined.", ext)
|
||||
}
|
||||
|
||||
// TODO(wpcarro): Support more sophisticated parsing than just string
|
||||
// splitting. To handle 'cases like this'.
|
||||
tokens := strings.Split(strings.Replace(cmd, "$file", fileName, 1), " ")
|
||||
c := exec.Command(tokens[0], tokens[1:]...)
|
||||
err = c.Start()
|
||||
// TODO(wpcarro): Forward STDERR and STDOUT.
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(c.Wait())
|
||||
}
|
||||
10
users/wpcarro/tools/run/shell.nix
Normal file
10
users/wpcarro/tools/run/shell.nix
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
let
|
||||
briefcase = import <briefcase> {};
|
||||
pkgs = briefcase.third_party.pkgs;
|
||||
in pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
go
|
||||
goimports
|
||||
godef
|
||||
];
|
||||
}
|
||||
98
users/wpcarro/tools/simple_vim/config.vim
Normal file
98
users/wpcarro/tools/simple_vim/config.vim
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
" My barebones vimrc without any Vundle dependencies.
|
||||
"
|
||||
" I'm attempting to optimize the following:
|
||||
" - Minimize dependencies
|
||||
" - Maximize ergonomics
|
||||
" - Maximize Tmux compatibility
|
||||
" - Minimize shadowing of existing Vim KBDs
|
||||
"
|
||||
" Warning: This is currently unstable as it is a work-in-progress.
|
||||
"
|
||||
" Author: William Carroll <wpcarro@gmail.com>
|
||||
|
||||
" Use <Space> as the leader key.
|
||||
let mapleader = " "
|
||||
nnoremap <leader>ev :tabnew<CR>:edit ~/.vimrc<CR>
|
||||
nnoremap <leader>sv :source ~/.vimrc<CR>
|
||||
nnoremap <leader>w :w<CR>
|
||||
nnoremap <leader>h :help
|
||||
|
||||
" increment,decrement numbers
|
||||
nnoremap + <C-a>
|
||||
" TODO: Restore with better KBD
|
||||
" nnoremap - <C-x>
|
||||
|
||||
" Visit the CWD
|
||||
nnoremap - :e .<CR>
|
||||
|
||||
" Turn line numbers on.
|
||||
set number
|
||||
|
||||
" Easily create vertical, horizontal window splits.
|
||||
nnoremap sh :vsplit<CR>
|
||||
nnoremap sj :split<CR>:wincmd j<CR>
|
||||
nnoremap sk :split<CR>
|
||||
nnoremap sl :vsplit<CR>:wincmd l<CR>
|
||||
|
||||
" Move across window splits.
|
||||
" TODO: Change to <M-{h,j,k,l}>.
|
||||
nnoremap <C-h> :wincmd h<CR>
|
||||
nnoremap <C-j> :wincmd j<CR>
|
||||
nnoremap <C-k> :wincmd k<CR>
|
||||
nnoremap <C-l> :wincmd l<CR>
|
||||
|
||||
" TODO: Support these.
|
||||
" nnoremap <M-q> :q<CR>
|
||||
" nnoremap <M-h> :wincmd h<CR>
|
||||
" nnoremap <M-j> :wincmd j<CR>
|
||||
" nnoremap <M-k> :wincmd k<CR>
|
||||
" nnoremap <M-l> :wincmd l<CR>
|
||||
|
||||
" Use <Enter> instead of G to support:
|
||||
" 20<Enter> - to jump to line 20
|
||||
" d20<Enter> - to delete from the current line until line 20
|
||||
" <C-v>20<Enter> - to select from the current line until line 20
|
||||
nnoremap <Enter> G
|
||||
onoremap <Enter> G
|
||||
vnoremap <Enter> G
|
||||
|
||||
" Easily change modes on keyboards that don't have CapsLock mapped to <Esc>
|
||||
inoremap jk <ESC>
|
||||
|
||||
" CRUD tabs.
|
||||
nnoremap <TAB> :tabnext<CR>
|
||||
nnoremap <S-TAB> :tabprevious<CR>
|
||||
nnoremap <C-t> :tabnew<CR>:edit .<CR>
|
||||
nnoremap <C-w> :tabclose<CR>
|
||||
" TODO: Re-enable these once <M-{h,j,k,l}> are supported.
|
||||
" nnoremap <C-l> :+tabmove<CR>
|
||||
" nnoremap <C-h> :-tabmove<CR>
|
||||
|
||||
" Use H,L to goto beggining,end of a line.
|
||||
" Swaps the keys to ensure original functionality of H,L are preserved.
|
||||
nnoremap H ^
|
||||
nnoremap L $
|
||||
nnoremap ^ H
|
||||
nnoremap $ L
|
||||
|
||||
" Use H,L in visual mode too
|
||||
vnoremap H ^
|
||||
vnoremap L $
|
||||
vnoremap ^ H
|
||||
vnoremap $ L
|
||||
|
||||
" Emacs hybrid mode
|
||||
" TODO: model this after tpope's rsi.vim (Readline-style insertion)
|
||||
cnoremap <C-g> <C-c>
|
||||
cnoremap <C-a> <C-b>
|
||||
inoremap <C-a> <C-o>^
|
||||
inoremap <C-e> <C-o>$
|
||||
inoremap <C-b> <C-o>h
|
||||
inoremap <C-f> <C-o>l
|
||||
|
||||
" Indenting
|
||||
" The following three settings are based on option 2 of `:help tabstop`
|
||||
set tabstop=4
|
||||
set shiftwidth=4
|
||||
set expandtab
|
||||
set autoindent
|
||||
15
users/wpcarro/tools/simple_vim/default.nix
Normal file
15
users/wpcarro/tools/simple_vim/default.nix
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{ pkgs, ... }:
|
||||
|
||||
let
|
||||
configVim = builtins.path {
|
||||
path = ./config.vim;
|
||||
name = "config.vim";
|
||||
};
|
||||
|
||||
script = pkgs.writeShellScriptBin "simple_vim" ''
|
||||
${pkgs.vim}/bin/vim -u ${configVim}
|
||||
'';
|
||||
in pkgs.stdenv.mkDerivation {
|
||||
name = "simple_vim";
|
||||
buildInputs = [ script ];
|
||||
}
|
||||
14
users/wpcarro/tools/symlinkManager/README.md
Normal file
14
users/wpcarro/tools/symlinkManager/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Dotfile Symlink Manager
|
||||
|
||||
Find and delete all symlinks to the dotfiles defined in `$BRIEFCASE`.
|
||||
|
||||
Oftentimes I corrupt the state of my configuration files. The intention with
|
||||
this script is to help me clean things up when this happens. An example workflow
|
||||
might look like:
|
||||
|
||||
```shell
|
||||
> symlink-mgr --audit
|
||||
> symlink-mgr --seriously
|
||||
> briefcase # changes directory to $BRIEFCASE
|
||||
> make install
|
||||
```
|
||||
11
users/wpcarro/tools/symlinkManager/default.nix
Normal file
11
users/wpcarro/tools/symlinkManager/default.nix
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{ depot, briefcase, ... }:
|
||||
|
||||
depot.buildGo.program {
|
||||
name = "symlink-mgr";
|
||||
srcs = [
|
||||
./main.go
|
||||
];
|
||||
deps = with briefcase.gopkgs; [
|
||||
utils
|
||||
];
|
||||
}
|
||||
82
users/wpcarro/tools/symlinkManager/main.go
Normal file
82
users/wpcarro/tools/symlinkManager/main.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"utils"
|
||||
)
|
||||
|
||||
var hostnames = map[string]string{
|
||||
os.Getenv("DESKTOP"): "desktop",
|
||||
os.Getenv("LAPTOP"): "work_laptop",
|
||||
}
|
||||
|
||||
func main() {
|
||||
audit := flag.Bool("audit", false, "Output all symlinks that would be deleted. This is the default behavior. This option is mutually exclusive with the --seriously option.")
|
||||
seriously := flag.Bool("seriously", false, "Actually delete the symlinks. This option is mutually exclusive with the --audit option.")
|
||||
repoName := flag.String("repo-name", "briefcase", "The name of the repository.")
|
||||
deviceOnly := flag.Bool("device-only", false, "Only output the device-specific dotfiles.")
|
||||
flag.Parse()
|
||||
|
||||
if !*audit && !*seriously {
|
||||
log.Fatal(errors.New("Either -audit or -seriously needs to be set."))
|
||||
}
|
||||
if *audit == *seriously {
|
||||
log.Fatal(errors.New("Arguments -audit and -seriously are mutually exclusive"))
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
utils.FailOn(err)
|
||||
count := 0
|
||||
|
||||
err = filepath.Walk(home, func(path string, info os.FileInfo, err error) error {
|
||||
if utils.IsSymlink(info.Mode()) {
|
||||
dest, err := os.Readlink(path)
|
||||
utils.FailOn(err)
|
||||
|
||||
var predicate func(string) bool
|
||||
|
||||
if *deviceOnly {
|
||||
predicate = func(dest string) bool {
|
||||
var hostname string
|
||||
hostname, err = os.Hostname()
|
||||
utils.FailOn(err)
|
||||
seeking, ok := hostnames[hostname]
|
||||
if !ok {
|
||||
log.Fatal(fmt.Sprintf("Hostname \"%s\" not supported in the hostnames map.", hostname))
|
||||
}
|
||||
return strings.Contains(dest, *repoName) && strings.Contains(dest, seeking)
|
||||
}
|
||||
} else {
|
||||
predicate = func(dest string) bool {
|
||||
return strings.Contains(dest, *repoName)
|
||||
}
|
||||
}
|
||||
|
||||
if predicate(dest) {
|
||||
if *audit {
|
||||
fmt.Printf("%s -> %s\n", path, dest)
|
||||
} else if *seriously {
|
||||
fmt.Printf("rm %s\n", path)
|
||||
err = os.Remove(path)
|
||||
utils.FailOn(err)
|
||||
}
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
utils.FailOn(err)
|
||||
if *audit {
|
||||
fmt.Printf("Would have deleted %d symlinks.\n", count)
|
||||
} else if *seriously {
|
||||
fmt.Printf("Successfully deleted %d symlinks.\n", count)
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
2
users/wpcarro/tools/url-blocker/.envrc
Normal file
2
users/wpcarro/tools/url-blocker/.envrc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
source_up
|
||||
use_nix
|
||||
205
users/wpcarro/tools/url-blocker/Main.hs
Normal file
205
users/wpcarro/tools/url-blocker/Main.hs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
module Main ( main ) where
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Dependencies
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
import qualified Data.Maybe as Maybe
|
||||
import qualified Data.Time.Clock as Clock
|
||||
import qualified Data.Time.Calendar as Calendar
|
||||
import qualified Data.Time.LocalTime as LocalTime
|
||||
import qualified Data.ByteString.Lazy as LazyByteString
|
||||
import qualified Data.Aeson as Aeson
|
||||
import qualified Data.Either.Combinators as Either
|
||||
import qualified Data.HashMap.Strict as HashMap
|
||||
import qualified Data.Text as Text
|
||||
import qualified Data.Text.IO as TextIO
|
||||
import qualified Data.Text.Read as TextRead
|
||||
import qualified Data.List as List
|
||||
|
||||
import GHC.Generics
|
||||
import Data.Aeson ((.:))
|
||||
import Data.Text (Text)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Types
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
newtype URL = URL { getURL :: Text } deriving (Show, Eq, Generic)
|
||||
|
||||
newtype IPAddress = IPAddress { getIPAddress :: Text } deriving (Show)
|
||||
|
||||
newtype Domain = Domain { getDomain :: Text } deriving (Show)
|
||||
|
||||
newtype Hour = Hour { getHour :: Int } deriving (Show, Eq, Generic)
|
||||
|
||||
newtype Minute = Minute { getMinute :: Int } deriving (Show, Eq, Generic)
|
||||
|
||||
data EtcHostsEntry = EtcHostsEntry { ip :: IPAddress
|
||||
, domains :: [Domain]
|
||||
} deriving (Show)
|
||||
|
||||
-- | Write these in terms of your system's local time (i.e. `date`).
|
||||
data TimeSlot = TimeSlot { beg :: (Hour, Minute)
|
||||
, end :: (Hour, Minute)
|
||||
} deriving (Show, Eq, Generic)
|
||||
|
||||
data Allowance = Allowance { day :: Calendar.DayOfWeek
|
||||
, timeslots :: [TimeSlot]
|
||||
} deriving (Show, Eq, Generic)
|
||||
|
||||
data Rule = Rule { urls :: [URL]
|
||||
, allowed :: [Allowance]
|
||||
} deriving (Show, Eq, Generic)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Instances
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
instance Aeson.FromJSON TimeSlot where
|
||||
parseJSON = Aeson.withText "timeslot" $ \x -> do
|
||||
let [a, b] = Text.splitOn "-" x
|
||||
[ah, am] = Text.splitOn ":" a
|
||||
[bh, bm] = Text.splitOn ":" b
|
||||
case extractTimeSlot ah am bh bm of
|
||||
Left s -> fail s
|
||||
Right x -> pure x
|
||||
where
|
||||
extractTimeSlot :: Text -> Text -> Text -> Text -> Either String TimeSlot
|
||||
extractTimeSlot ah am bh bm = do
|
||||
(begh, _) <- TextRead.decimal ah
|
||||
(begm, _) <- TextRead.decimal am
|
||||
(endh, _) <- TextRead.decimal bh
|
||||
(endm, _) <- TextRead.decimal bm
|
||||
pure $ TimeSlot{ beg = (Hour begh, Minute begm)
|
||||
, end = (Hour endh, Minute endm)
|
||||
}
|
||||
|
||||
instance Aeson.FromJSON Allowance where
|
||||
parseJSON = Aeson.withObject "allowance" $ \x -> do
|
||||
day <- x .: "day"
|
||||
timeslots <- x .: "timeslots"
|
||||
pure $ Allowance{day, timeslots}
|
||||
|
||||
instance Aeson.FromJSON URL where
|
||||
parseJSON = Aeson.withText "URL" $ \x -> do
|
||||
pure $ URL { getURL = x }
|
||||
|
||||
instance Aeson.FromJSON Rule where
|
||||
parseJSON = Aeson.withObject "rule" $ \x -> do
|
||||
urls <- x .: "urls"
|
||||
allowed <- x .: "allowed"
|
||||
pure Rule{urls, allowed}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
-- Functions
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | Pipe operator
|
||||
(|>) :: a -> (a -> b) -> b
|
||||
(|>) a f = f a
|
||||
infixl 1 |>
|
||||
|
||||
-- | Returns True if the current time falls within any of the `timeslots`.
|
||||
isWithinTimeSlot :: LocalTime.LocalTime -> [TimeSlot] -> Bool
|
||||
isWithinTimeSlot date timeslots =
|
||||
List.any withinTimeSlot timeslots
|
||||
where
|
||||
withinTimeSlot :: TimeSlot -> Bool
|
||||
withinTimeSlot TimeSlot{ beg = (Hour ah, Minute am)
|
||||
, end = (Hour bh, Minute bm)
|
||||
} =
|
||||
let LocalTime.TimeOfDay{LocalTime.todHour, LocalTime.todMin} =
|
||||
LocalTime.localTimeOfDay date
|
||||
in (todHour > ah) && (todMin > am) && (todHour < bh) && (todMin < bm)
|
||||
|
||||
-- | Returns True if `day` is the same day as today.
|
||||
isToday :: LocalTime.LocalTime -> Calendar.DayOfWeek -> Bool
|
||||
isToday date day = today == day
|
||||
where
|
||||
today = Calendar.dayOfWeek (LocalTime.localDay date)
|
||||
|
||||
-- | Returns True if a list of none of the `allowances` are valid.
|
||||
shouldBeBlocked :: LocalTime.LocalTime -> [Allowance] -> Bool
|
||||
shouldBeBlocked _ [] = True
|
||||
shouldBeBlocked date allowances = do
|
||||
case filter (isToday date . day) allowances of
|
||||
[Allowance{timeslots}] -> not $ isWithinTimeSlot date timeslots
|
||||
[] -> True
|
||||
-- Error when more than one rule per day
|
||||
_ -> True
|
||||
|
||||
-- | Maps an EtcHostsEntry to the line of text url-blocker will append to /etc/hosts.
|
||||
serializeEtcHostEntry :: EtcHostsEntry -> Text
|
||||
serializeEtcHostEntry EtcHostsEntry{ip, domains} =
|
||||
(getIPAddress ip) <> "\t" <> (Text.unwords $ fmap getDomain domains)
|
||||
|
||||
-- | Create an EtcHostsEntry mapping the URLs in `rule` to 127.0.0.1 if the
|
||||
-- URLs should be blocked.
|
||||
maybeBlockURL :: LocalTime.LocalTime -> Rule -> Maybe EtcHostsEntry
|
||||
maybeBlockURL date Rule{urls, allowed} =
|
||||
if shouldBeBlocked date allowed then
|
||||
Just $ EtcHostsEntry { ip = IPAddress "127.0.0.1"
|
||||
, domains = fmap (Domain . getURL) urls
|
||||
}
|
||||
else
|
||||
Nothing
|
||||
|
||||
-- | Read and parse the rules.json file.
|
||||
-- TODO(wpcarro): Properly handle errors for file not found.
|
||||
-- TODO(wpcarro): Properly handle errors for parse failures.
|
||||
-- TODO(wpcarro): How can we resolve the $HOME directory when this is run as
|
||||
-- root?
|
||||
getRules :: IO [Rule]
|
||||
getRules = do
|
||||
contents <- LazyByteString.readFile "/home/wpcarro/.config/url-blocker/rules.json"
|
||||
let payload = Aeson.eitherDecode contents
|
||||
pure $ Either.fromRight [] payload
|
||||
|
||||
-- | Informational header added to /etc/hosts before the entries that
|
||||
-- url-blocker adds.
|
||||
urlBlockerHeader :: Text
|
||||
urlBlockerHeader =
|
||||
Text.unlines [ "################################################################################"
|
||||
, "# Added by url-blocker."
|
||||
, "#"
|
||||
, "# Warning: url-blocker will remove anything that you add beneath this header."
|
||||
, "################################################################################"
|
||||
]
|
||||
|
||||
-- | Removes all entries that url-blocker may have added to /etc/hosts.
|
||||
removeURLBlockerEntries :: Text -> Text
|
||||
removeURLBlockerEntries etcHosts =
|
||||
case Text.breakOn urlBlockerHeader etcHosts of
|
||||
(etcHosts', _) -> etcHosts'
|
||||
|
||||
-- | Appends the newly created `entries` to `etcHosts`.
|
||||
addURLBlockerEntries :: Text -> Text -> Text
|
||||
addURLBlockerEntries entries etcHosts =
|
||||
Text.unlines [ etcHosts
|
||||
, urlBlockerHeader
|
||||
, entries
|
||||
]
|
||||
|
||||
-- | This script reads the current /etc/hosts, removes any entries that
|
||||
-- url-blocker may have added in a previous run, and adds new entries to block
|
||||
-- URLs according to the rules.json file.
|
||||
main :: IO ()
|
||||
main = do
|
||||
rules <- getRules
|
||||
tz <- LocalTime.getCurrentTimeZone
|
||||
ct <- Clock.getCurrentTime
|
||||
let date = LocalTime.utcToLocalTime tz ct
|
||||
entries = rules
|
||||
|> fmap (maybeBlockURL date)
|
||||
|> Maybe.catMaybes
|
||||
|> fmap serializeEtcHostEntry
|
||||
|> Text.unlines
|
||||
existingEtcHosts <- TextIO.readFile "/etc/hosts"
|
||||
existingEtcHosts
|
||||
|> removeURLBlockerEntries
|
||||
|> addURLBlockerEntries entries
|
||||
|> \x -> writeFile "/etc/hosts" (Text.unpack x)
|
||||
47
users/wpcarro/tools/url-blocker/README.md
Normal file
47
users/wpcarro/tools/url-blocker/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# url-blocker
|
||||
|
||||
`url-blocker` blocks the URLs that you want to block when you want it to block
|
||||
them.
|
||||
|
||||
Let's say that you don't want to visit Twitter during the work week. Create the
|
||||
file `~/.config/url-blocker/rules.json` with the following contents and
|
||||
`url-blocker` will take care of the rest.
|
||||
|
||||
```json
|
||||
# ~/.config/url-blocker/rules.json
|
||||
[
|
||||
{
|
||||
"urls": [
|
||||
"twitter.com",
|
||||
"www.twitter.com",
|
||||
],
|
||||
"allowed": [
|
||||
{
|
||||
"day": "Saturday",
|
||||
"timeslots": [
|
||||
"00:00-11:59"
|
||||
]
|
||||
},
|
||||
{
|
||||
"day": "Sunday",
|
||||
"timeslots": [
|
||||
"00:00-11:59"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```shell
|
||||
$ nix-env -iA 'briefcase.tools.url-blocker'
|
||||
```
|
||||
|
||||
## How does it work?
|
||||
|
||||
`systemd` is intended to run `url-blocker` once every minute. `url-blocker` will
|
||||
read `/etc/hosts` and map the URLs defined in `rules.json` to `127.0.0.1` when
|
||||
you want them blocked. Because `systemd` run once every minute, `/etc/hosts`
|
||||
should be current to the minute as well.
|
||||
33
users/wpcarro/tools/url-blocker/default.nix
Normal file
33
users/wpcarro/tools/url-blocker/default.nix
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{ pkgs, ... }:
|
||||
|
||||
let
|
||||
ghc = pkgs.haskellPackages.ghcWithPackages (hpkgs: [
|
||||
hpkgs.time
|
||||
hpkgs.aeson
|
||||
hpkgs.either
|
||||
]);
|
||||
|
||||
# This is the systemd service unit
|
||||
service = pkgs.stdenv.mkDerivation {
|
||||
name = "url-blocker";
|
||||
src = builtins.path { path = ./.; name = "url-blocker"; };
|
||||
buildPhase = ''
|
||||
${ghc}/bin/ghc Main.hs
|
||||
'';
|
||||
installPhase = ''
|
||||
mv ./Main $out
|
||||
'';
|
||||
};
|
||||
|
||||
# This is the systemd timer unit.
|
||||
# Run once every minute.
|
||||
# Give root privilege.
|
||||
systemdUnit = {
|
||||
systemd = {
|
||||
timers.simple-timer = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
partOf = [];
|
||||
};
|
||||
};
|
||||
};
|
||||
in null
|
||||
28
users/wpcarro/tools/url-blocker/rules.json
Normal file
28
users/wpcarro/tools/url-blocker/rules.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[
|
||||
{
|
||||
"urls": [
|
||||
"facebook.com",
|
||||
"www.facebook.com",
|
||||
"twitter.com",
|
||||
"www.twitter.com",
|
||||
"youtube.com",
|
||||
"www.youtube.com",
|
||||
"instagram.com",
|
||||
"www.instagram.com"
|
||||
],
|
||||
"allowed": []
|
||||
},
|
||||
{
|
||||
"urls": [
|
||||
"chat.googleplex.com"
|
||||
],
|
||||
"allowed": [
|
||||
{
|
||||
"day": "Sunday",
|
||||
"timeslots": [
|
||||
"18:35-18:39"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
10
users/wpcarro/tools/url-blocker/shell.nix
Normal file
10
users/wpcarro/tools/url-blocker/shell.nix
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
let
|
||||
briefcase = import <briefcase> {};
|
||||
in briefcase.buildHaskell.shell {
|
||||
deps = hpkgs: with hpkgs; [
|
||||
time
|
||||
aeson
|
||||
either
|
||||
hspec
|
||||
];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue