1
0
Fork 0
timelinize/timeline/account.go
2025-11-04 16:27:09 -07:00

249 lines
7.7 KiB
Go

/*
Timelinize
Copyright (c) 2013 Matthew Holt
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package timeline
import (
"bytes"
"context"
"encoding/gob"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Account represents an account on a data source. Some unexported
// fields require initialization from AddAccount() or LoadAccount().
type Account struct {
ID int64 `json:"id"` // DB row ID
// DataSourceID string `json:"data_source_id"` // TODO: useful?
Owner Entity `json:"owner"`
DataSource DataSource `json:"data_source"`
authorization []byte
tl *Timeline
}
func (acc *Account) fill(tl *Timeline) error {
ds, ok := dataSources[acc.DataSource.Name]
if !ok {
return fmt.Errorf("inconsistent DB: unrecognized data source ID: %s", acc.DataSource.Name)
}
acc.DataSource = ds
acc.tl = tl
return nil
}
// NewHTTPClient returns an HTTP client that is suitable for use
// with an API associated with the account's data source. If
// OAuth2 is configured for the data source, the client has OAuth2
// credentials. If a rate limit is configured, this client is
// rate limited. A sane default timeout is set, and any fields
// on the returned Client valule can be modified as needed.
func (acc Account) NewHTTPClient(ctx context.Context, oauth2 OAuth2, rl RateLimit) (*http.Client, error) {
httpClient := new(http.Client)
if oauth2.ProviderID != "" {
var err error
httpClient, err = acc.NewOAuth2HTTPClient(ctx, oauth2)
if err != nil {
return nil, err
}
}
// TODO: rate limits will likely vary depending on whether user has their own Project/App with the service, or whether they're using ours... we should look into this
if rl.RequestsPerHour > 0 {
httpClient.Transport = acc.NewRateLimitedRoundTripper(httpClient.Transport, rl)
}
httpClient.Timeout = 60 * time.Second //nolint:mnd
return httpClient, nil
}
// func (acc Account) String() string {
// return acc.DataSource.ID + "/" + acc.User.UserID
// }
// AddAccount adds a new account to the database. The account is with the
// given data source and owner. The account must not yet exist. This method
// does not attempt to authenticate with any API / hosted service.
// TODO: update godoc -- third arg is data source options as JSON
func (tl *Timeline) AddAccount(ctx context.Context, dataSourceID string, _ json.RawMessage) (Account, error) {
// ds, ok := dataSources[dataSourceID]
// if !ok {
// return Account{}, fmt.Errorf("data source not registered: %s", dataSourceID)
// }
// // datasource-specific options can be useful when interacting with it
// dsOpt, err := ds.UnmarshalOptions(dsOptJSON)
// if err != nil {
// return Account{}, fmt.Errorf("unmarshaling data source options: %w", err)
// }
// // run input through the data source's account hook (if any)
// if ds.NewAccount != nil {
// owner, err = ds.NewAccount(owner, dsOpt)
// if err != nil {
// return Account{}, fmt.Errorf("data source account hook returned error: %w", err)
// }
// }
// // add person if doesn'tl already exist
// person, err := tl.getOrMakePerson(ds.ID, owner)
// if err != nil {
// return Account{}, err
// }
// store the account
var accountID int64
err := tl.db.WritePool.QueryRowContext(ctx, `INSERT INTO accounts (data_source_id) VALUES (?) RETURNING id`,
dataSourceID).Scan(&accountID)
if err != nil {
return Account{}, fmt.Errorf("inserting into DB: %w", err)
}
// load the new account so caller can get its info (like ID)
// TODO: should we just use context.Background() here? or pass in an actual context
acct, err := tl.LoadAccount(ctx, accountID)
if err != nil {
return Account{}, fmt.Errorf("loading new account: %w", err)
}
return acct, nil
}
// AuthorizeOAuth2 performs OAuth2 authorization for the account and saves it to the DB.
func (acc *Account) AuthorizeOAuth2(ctx context.Context, oauth2 OAuth2) error {
creds, err := authorizeWithOAuth2(ctx, oauth2)
if err != nil {
return err
}
return acc.SaveAuthorization(ctx, creds)
}
// SaveAuthorization saves the credentials to the DB for this account.
func (acc *Account) SaveAuthorization(ctx context.Context, credentials []byte) error {
_, err := acc.tl.db.WritePool.ExecContext(ctx, `UPDATE accounts SET authorization=? WHERE id=?`, // TODO: LIMIT would be nice here
credentials, acc.ID)
if err != nil {
return fmt.Errorf("updating credentials in account row: %w", err)
}
return nil
}
// LoadAccount loads the account with the given ID from the database.
func (tl *Timeline) LoadAccount(ctx context.Context, id int64) (Account, error) {
var acc Account
err := tl.db.ReadPool.QueryRowContext(ctx,
`SELECT
accounts.id, accounts.authorization,
data_sources.name
FROM accounts, data_sources
WHERE accounts.id=?
AND data_sources.id = accounts.data_source_id
LIMIT 1`,
id).Scan(&acc.ID, &acc.authorization, &acc.DataSource.Name)
if err != nil {
return acc, fmt.Errorf("querying account %d from DB: %w", id, err)
}
if err := acc.fill(tl); err != nil {
return acc, fmt.Errorf("filling account: %w", err)
}
return acc, nil
}
// LoadAccounts loads all the accounts with the given IDs and/or data source(s). If the
// slices are nil, all accounts will be loaded. If the slices are empty, no accounts will be.
func (tl *Timeline) LoadAccounts(ids []int64, dataSourceIDs []string) ([]Account, error) {
if (ids != nil && len(ids) == 0) ||
(dataSourceIDs != nil && len(dataSourceIDs) == 0) {
return []Account{}, nil
}
var sb strings.Builder
sb.WriteString(`
SELECT
accounts.id, accounts.authorization,
data_sources.name
FROM accounts, data_sources
WHERE data_sources.id = accounts.data_source_id`)
args := make([]any, 0, len(ids)+len(dataSourceIDs))
if len(ids) > 0 || len(dataSourceIDs) > 0 {
sb.WriteString(" AND (")
}
for i, id := range ids {
if i > 0 {
sb.WriteString(" OR ")
}
sb.WriteString("accounts.id=?")
args = append(args, id)
}
if len(ids) > 0 && len(dataSourceIDs) > 0 {
sb.WriteString(") AND (")
}
for i, dsID := range dataSourceIDs {
if i > 0 {
sb.WriteString(" OR ")
}
sb.WriteString("data_sources.name=?")
args = append(args, dsID)
}
if len(ids) > 0 || len(dataSourceIDs) > 0 {
sb.WriteString(")")
}
accounts := []Account{}
rows, err := tl.db.ReadPool.QueryContext(tl.ctx, sb.String(), args...)
if err != nil {
return accounts, fmt.Errorf("querying accounts from DB: %w", err)
}
defer rows.Close()
for rows.Next() {
var acc Account
err := rows.Scan(&acc.ID, &acc.authorization, &acc.DataSource.Name)
if err != nil {
return accounts, fmt.Errorf("scanning row: %w", err)
}
if err := acc.fill(tl); err != nil {
return accounts, err
}
accounts = append(accounts, acc)
}
if err = rows.Err(); err != nil {
return accounts, fmt.Errorf("scanning account rows: %w", err)
}
return accounts, nil
}
// marshalGob is a convenient way to gob-encode v.
func marshalGob(v any) ([]byte, error) {
b := new(bytes.Buffer) // TODO: could pool this to improve performance a little bit
err := gob.NewEncoder(b).Encode(v)
return b.Bytes(), err
}
// unmarshalGob is a convenient way to gob-decode data into v.
func unmarshalGob(data []byte, v any) error {
return gob.NewDecoder(bytes.NewReader(data)).Decode(v)
}