CI / test (push) Failing after 42s
People sign up on the website and link IMVU accounts by putting a code in the account's tagline, which the hub reads from IMVU's public API (the most recent proof wins; link caps per person). IMVU's age verification or Access Pass make an account adult. T5DE's proxy signs in with a device code and keeps a WebSocket open, reporting the IMVU account in use and the community products it wears; /online shows those who opted in. Moderators get a console: who's online, people, link caps, bans, roles, and the audit log. Tests run the website and API against an embedded Postgres and a fake IMVU API. Co-Authored-By: Claude Opus 5.5 <[email protected]>
530 lines
18 KiB
Go
530 lines
18 KiB
Go
// Package store is the hub's data in Postgres: people, their linked IMVU accounts, sessions, proxy sign-ins and
|
|
// the audit log. Plain SQL with pgx.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrations embed.FS
|
|
|
|
// Store is the hub's database.
|
|
type Store struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
// ErrNotFound means there's no such row.
|
|
var ErrNotFound = errors.New("not found")
|
|
|
|
// ErrTaken means a unique value (a username) is in use.
|
|
var ErrTaken = errors.New("taken")
|
|
|
|
// Open connects to Postgres and brings its schema up to date.
|
|
func Open(ctx context.Context, url string) (*Store, error) {
|
|
db, err := pgxpool.New(ctx, url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &Store{db: db}
|
|
if err := s.migrate(ctx); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Store) Close() { s.db.Close() }
|
|
|
|
// migrate applies the migrations not applied yet, in name order, each in a transaction.
|
|
func (s *Store) migrate(ctx context.Context) error {
|
|
if _, err := s.db.Exec(ctx, `create table if not exists schema_migrations (name text primary key, applied_at timestamptz not null default now())`); err != nil {
|
|
return err
|
|
}
|
|
names, err := fs.Glob(migrations, "migrations/*.sql")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
var done bool
|
|
if err := s.db.QueryRow(ctx, `select exists (select 1 from schema_migrations where name = $1)`, name).Scan(&done); err != nil {
|
|
return err
|
|
}
|
|
if done {
|
|
continue
|
|
}
|
|
sql, _ := migrations.ReadFile(name)
|
|
err := pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
|
|
if _, err := tx.Exec(ctx, string(sql)); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("migration %s: %w", name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Hash is how tokens are stored: never the token itself.
|
|
func Hash(token string) []byte {
|
|
h := sha256.Sum256([]byte(token))
|
|
return h[:]
|
|
}
|
|
|
|
func isUniqueViolation(err error) bool {
|
|
var pg *pgconn.PgError
|
|
return errors.As(err, &pg) && pg.Code == "23505"
|
|
}
|
|
|
|
// Person is a hub account: a person.
|
|
type Person struct {
|
|
ID int64
|
|
Username string
|
|
PasswordHash string
|
|
Role string
|
|
LinkCap int
|
|
CreatedAt time.Time
|
|
BannedAt *time.Time
|
|
BanReason string
|
|
}
|
|
|
|
func (p *Person) IsModerator() bool { return p.Role == "moderator" || p.Role == "admin" }
|
|
func (p *Person) IsAdmin() bool { return p.Role == "admin" }
|
|
func (p *Person) Banned() bool { return p.BannedAt != nil }
|
|
|
|
const personColumns = `id, username, password_hash, role, link_cap, created_at, banned_at, ban_reason`
|
|
|
|
func scanPerson(row pgx.Row) (*Person, error) {
|
|
var p Person
|
|
err := row.Scan(&p.ID, &p.Username, &p.PasswordHash, &p.Role, &p.LinkCap, &p.CreatedAt, &p.BannedAt, &p.BanReason)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return &p, err
|
|
}
|
|
|
|
// CreatePerson makes an account. The first account is an admin.
|
|
func (s *Store) CreatePerson(ctx context.Context, username, passwordHash string) (*Person, error) {
|
|
p, err := scanPerson(s.db.QueryRow(ctx, `insert into persons (username, password_hash, role)
|
|
values ($1, $2, case when exists (select 1 from persons) then 'user' else 'admin' end)
|
|
returning `+personColumns, username, passwordHash))
|
|
if isUniqueViolation(err) {
|
|
return nil, ErrTaken
|
|
}
|
|
return p, err
|
|
}
|
|
|
|
func (s *Store) PersonByID(ctx context.Context, id int64) (*Person, error) {
|
|
return scanPerson(s.db.QueryRow(ctx, `select `+personColumns+` from persons where id = $1`, id))
|
|
}
|
|
|
|
func (s *Store) PersonByUsername(ctx context.Context, username string) (*Person, error) {
|
|
return scanPerson(s.db.QueryRow(ctx, `select `+personColumns+` from persons where lower(username) = lower($1)`, username))
|
|
}
|
|
|
|
// People lists accounts, newest first, optionally filtered by a username search.
|
|
func (s *Store) People(ctx context.Context, search string, limit int) ([]Person, error) {
|
|
rows, err := s.db.Query(ctx, `select `+personColumns+` from persons
|
|
where $1 = '' or lower(username) like '%' || lower($1) || '%'
|
|
or id in (select person_id from imvu_links where lower(username) like '%' || lower($1) || '%')
|
|
order by id desc limit $2`, search, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Person
|
|
for rows.Next() {
|
|
p, err := scanPerson(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) SetRole(ctx context.Context, id int64, role string) error {
|
|
_, err := s.db.Exec(ctx, `update persons set role = $2 where id = $1`, id, role)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) SetLinkCap(ctx context.Context, id int64, cap int) error {
|
|
_, err := s.db.Exec(ctx, `update persons set link_cap = $2 where id = $1`, id, cap)
|
|
return err
|
|
}
|
|
|
|
// SetBanned bans (with a reason) or unbans a person; a ban also ends their sessions and sign-ins.
|
|
func (s *Store) SetBanned(ctx context.Context, id int64, banned bool, reason string) error {
|
|
return pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
|
|
if !banned {
|
|
_, err := tx.Exec(ctx, `update persons set banned_at = null, ban_reason = '' where id = $1`, id)
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(ctx, `update persons set banned_at = now(), ban_reason = $2 where id = $1`, id, reason); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(ctx, `delete from sessions where person_id = $1`, id); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.Exec(ctx, `update refresh_tokens set revoked_at = now() where person_id = $1 and revoked_at is null`, id)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// Sessions
|
|
|
|
func (s *Store) CreateSession(ctx context.Context, token string, personID int64, csrf string, expires time.Time) error {
|
|
_, err := s.db.Exec(ctx, `insert into sessions (token_hash, person_id, csrf, expires_at) values ($1, $2, $3, $4)`,
|
|
Hash(token), personID, csrf, expires)
|
|
return err
|
|
}
|
|
|
|
// Session is the person and CSRF token of a live session.
|
|
func (s *Store) Session(ctx context.Context, token string) (*Person, string, error) {
|
|
var csrf string
|
|
var p Person
|
|
err := s.db.QueryRow(ctx, `select s.csrf, `+prefixed("p", personColumns)+` from sessions s join persons p on p.id = s.person_id
|
|
where s.token_hash = $1 and s.expires_at > now()`, Hash(token)).
|
|
Scan(&csrf, &p.ID, &p.Username, &p.PasswordHash, &p.Role, &p.LinkCap, &p.CreatedAt, &p.BannedAt, &p.BanReason)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, "", ErrNotFound
|
|
}
|
|
return &p, csrf, err
|
|
}
|
|
|
|
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
|
_, err := s.db.Exec(ctx, `delete from sessions where token_hash = $1`, Hash(token))
|
|
return err
|
|
}
|
|
|
|
func prefixed(alias, columns string) string {
|
|
parts := strings.Split(columns, ", ")
|
|
for i := range parts {
|
|
parts[i] = alias + "." + parts[i]
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// Linked IMVU accounts
|
|
|
|
// Link is a linked IMVU account.
|
|
type Link struct {
|
|
IMVUID int64
|
|
PersonID int64
|
|
PairwiseID string
|
|
Username string
|
|
LinkedAt time.Time
|
|
AgeVerified bool
|
|
AccessPass bool
|
|
CheckedAt time.Time
|
|
ShowBadge bool
|
|
ShowPresence bool
|
|
}
|
|
|
|
// Adult reports whether the account may see adult content: age verified by IMVU, or with Access Pass.
|
|
func (l *Link) Adult() bool { return l.AgeVerified || l.AccessPass }
|
|
|
|
const linkColumns = `imvu_id, person_id, pairwise_id, username, linked_at, age_verified, access_pass, checked_at, show_badge, show_presence`
|
|
|
|
func scanLink(row pgx.Row) (*Link, error) {
|
|
var l Link
|
|
err := row.Scan(&l.IMVUID, &l.PersonID, &l.PairwiseID, &l.Username, &l.LinkedAt, &l.AgeVerified, &l.AccessPass, &l.CheckedAt, &l.ShowBadge, &l.ShowPresence)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return &l, err
|
|
}
|
|
|
|
func (s *Store) Links(ctx context.Context, personID int64) ([]Link, error) {
|
|
rows, err := s.db.Query(ctx, `select `+linkColumns+` from imvu_links where person_id = $1 order by linked_at`, personID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Link
|
|
for rows.Next() {
|
|
l, err := scanLink(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) LinkByIMVUID(ctx context.Context, imvuID int64) (*Link, error) {
|
|
return scanLink(s.db.QueryRow(ctx, `select `+linkColumns+` from imvu_links where imvu_id = $1`, imvuID))
|
|
}
|
|
|
|
// ErrLinkCap means the person has linked as many accounts as they may.
|
|
var ErrLinkCap = errors.New("link cap reached")
|
|
|
|
// LinkAccount links an IMVU account to a person, moving it from whoever had it (the most recent proof wins).
|
|
// It returns the person it was moved from, if any.
|
|
func (s *Store) LinkAccount(ctx context.Context, personID, imvuID int64, username, pairwiseID string, ageVerified, accessPass bool) (movedFrom int64, err error) {
|
|
err = pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
|
|
var cap, count int
|
|
if err := tx.QueryRow(ctx, `select link_cap, (select count(*) from imvu_links where person_id = $1 and imvu_id <> $2)
|
|
from persons where id = $1 for update`, personID, imvuID).Scan(&cap, &count); err != nil {
|
|
return err
|
|
}
|
|
if count >= cap {
|
|
return ErrLinkCap
|
|
}
|
|
err := tx.QueryRow(ctx, `select person_id from imvu_links where imvu_id = $1`, imvuID).Scan(&movedFrom)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return err
|
|
}
|
|
if movedFrom == personID {
|
|
movedFrom = 0
|
|
}
|
|
// A moved account gets a new pairwise ID: nothing connects its new owner to the old one.
|
|
_, err = tx.Exec(ctx, `insert into imvu_links (imvu_id, person_id, pairwise_id, username, age_verified, access_pass)
|
|
values ($1, $2, $3, $4, $5, $6)
|
|
on conflict (imvu_id) do update set person_id = excluded.person_id,
|
|
pairwise_id = case when imvu_links.person_id = excluded.person_id then imvu_links.pairwise_id else excluded.pairwise_id end,
|
|
username = excluded.username, age_verified = excluded.age_verified, access_pass = excluded.access_pass,
|
|
checked_at = now(),
|
|
linked_at = case when imvu_links.person_id = excluded.person_id then imvu_links.linked_at else now() end,
|
|
show_badge = case when imvu_links.person_id = excluded.person_id then imvu_links.show_badge else false end,
|
|
show_presence = case when imvu_links.person_id = excluded.person_id then imvu_links.show_presence else false end`,
|
|
imvuID, personID, pairwiseID, username, ageVerified, accessPass)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(ctx, `delete from link_challenges where imvu_id = $1`, imvuID)
|
|
return err
|
|
})
|
|
return movedFrom, err
|
|
}
|
|
|
|
func (s *Store) Unlink(ctx context.Context, personID, imvuID int64) error {
|
|
_, err := s.db.Exec(ctx, `delete from imvu_links where person_id = $1 and imvu_id = $2`, personID, imvuID)
|
|
return err
|
|
}
|
|
|
|
// UpdateAge records a fresh age check of a linked account.
|
|
func (s *Store) UpdateAge(ctx context.Context, imvuID int64, username string, ageVerified, accessPass bool) error {
|
|
_, err := s.db.Exec(ctx, `update imvu_links set username = $2, age_verified = $3, access_pass = $4, checked_at = now() where imvu_id = $1`,
|
|
imvuID, username, ageVerified, accessPass)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) SetLinkSettings(ctx context.Context, personID, imvuID int64, badge, presence bool) error {
|
|
_, err := s.db.Exec(ctx, `update imvu_links set show_badge = $3, show_presence = $4 where person_id = $1 and imvu_id = $2`,
|
|
personID, imvuID, badge, presence)
|
|
return err
|
|
}
|
|
|
|
// Challenge is a pending tagline code.
|
|
type Challenge struct {
|
|
IMVUID int64
|
|
Username string
|
|
Code string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// SetChallenge stores (or replaces) the code for linking an account.
|
|
func (s *Store) SetChallenge(ctx context.Context, personID, imvuID int64, username, code string) error {
|
|
_, err := s.db.Exec(ctx, `insert into link_challenges (person_id, imvu_id, username, code) values ($1, $2, $3, $4)
|
|
on conflict (person_id, imvu_id) do update set code = excluded.code, username = excluded.username, created_at = now()`,
|
|
personID, imvuID, username, code)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) Challenges(ctx context.Context, personID int64) ([]Challenge, error) {
|
|
rows, err := s.db.Query(ctx, `select imvu_id, username, code, created_at from link_challenges
|
|
where person_id = $1 and created_at > now() - interval '1 day' order by created_at`, personID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Challenge
|
|
for rows.Next() {
|
|
var c Challenge
|
|
if err := rows.Scan(&c.IMVUID, &c.Username, &c.Code, &c.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) Challenge(ctx context.Context, personID, imvuID int64) (*Challenge, error) {
|
|
var c Challenge
|
|
err := s.db.QueryRow(ctx, `select imvu_id, username, code, created_at from link_challenges
|
|
where person_id = $1 and imvu_id = $2 and created_at > now() - interval '1 day'`, personID, imvuID).
|
|
Scan(&c.IMVUID, &c.Username, &c.Code, &c.CreatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return &c, err
|
|
}
|
|
|
|
func (s *Store) DeleteChallenge(ctx context.Context, personID, imvuID int64) error {
|
|
_, err := s.db.Exec(ctx, `delete from link_challenges where person_id = $1 and imvu_id = $2`, personID, imvuID)
|
|
return err
|
|
}
|
|
|
|
// Proxy sign-in (device codes) and refresh tokens
|
|
|
|
func (s *Store) CreateDeviceCode(ctx context.Context, deviceCode, userCode string, expires time.Time) error {
|
|
_, err := s.db.Exec(ctx, `insert into device_codes (device_hash, user_code, expires_at) values ($1, $2, $3)`,
|
|
Hash(deviceCode), userCode, expires)
|
|
if isUniqueViolation(err) {
|
|
return ErrTaken
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ApproveDeviceCode lets the person's proxy waiting with userCode sign in.
|
|
func (s *Store) ApproveDeviceCode(ctx context.Context, userCode string, personID int64) error {
|
|
tag, err := s.db.Exec(ctx, `update device_codes set person_id = $2
|
|
where user_code = $1 and person_id is null and consumed_at is null and expires_at > now()`, userCode, personID)
|
|
if err == nil && tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// DeviceStatus is a waiting proxy's sign-in: approved (with the person), pending, or gone.
|
|
type DeviceStatus int
|
|
|
|
const (
|
|
DevicePending DeviceStatus = iota
|
|
DeviceApproved
|
|
DeviceGone
|
|
)
|
|
|
|
// ConsumeDeviceCode reports a device code's state; an approved one is used up by this call.
|
|
func (s *Store) ConsumeDeviceCode(ctx context.Context, deviceCode string) (DeviceStatus, int64, error) {
|
|
var personID *int64
|
|
var expires time.Time
|
|
var consumed *time.Time
|
|
err := s.db.QueryRow(ctx, `select person_id, expires_at, consumed_at from device_codes where device_hash = $1`, Hash(deviceCode)).
|
|
Scan(&personID, &expires, &consumed)
|
|
if errors.Is(err, pgx.ErrNoRows) || err == nil && (consumed != nil || time.Now().After(expires)) {
|
|
return DeviceGone, 0, nil
|
|
}
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if personID == nil {
|
|
return DevicePending, 0, nil
|
|
}
|
|
tag, err := s.db.Exec(ctx, `update device_codes set consumed_at = now() where device_hash = $1 and consumed_at is null`, Hash(deviceCode))
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return DeviceGone, 0, nil
|
|
}
|
|
return DeviceApproved, *personID, nil
|
|
}
|
|
|
|
func (s *Store) CreateRefreshToken(ctx context.Context, token string, personID int64) error {
|
|
_, err := s.db.Exec(ctx, `insert into refresh_tokens (token_hash, person_id) values ($1, $2)`, Hash(token), personID)
|
|
return err
|
|
}
|
|
|
|
// RotateRefreshToken trades a live refresh token for a new one, returning the person it's for.
|
|
func (s *Store) RotateRefreshToken(ctx context.Context, old, replacement string) (int64, error) {
|
|
var personID int64
|
|
err := pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
|
|
err := tx.QueryRow(ctx, `update refresh_tokens set revoked_at = now()
|
|
where token_hash = $1 and revoked_at is null returning person_id`, Hash(old)).Scan(&personID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(ctx, `insert into refresh_tokens (token_hash, person_id) values ($1, $2)`, Hash(replacement), personID)
|
|
return err
|
|
})
|
|
return personID, err
|
|
}
|
|
|
|
// Signins counts a person's signed-in proxies.
|
|
func (s *Store) Signins(ctx context.Context, personID int64) (int, error) {
|
|
var n int
|
|
err := s.db.QueryRow(ctx, `select count(*) from refresh_tokens where person_id = $1 and revoked_at is null`, personID).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// SignOutProxies ends all of a person's proxy sign-ins.
|
|
func (s *Store) SignOutProxies(ctx context.Context, personID int64) error {
|
|
_, err := s.db.Exec(ctx, `update refresh_tokens set revoked_at = now() where person_id = $1 and revoked_at is null`, personID)
|
|
return err
|
|
}
|
|
|
|
// Audit log
|
|
|
|
// Audit records something a person (or the hub, actor 0) did.
|
|
func (s *Store) Audit(ctx context.Context, actorID int64, action, target string, detail map[string]any) error {
|
|
var actor *int64
|
|
if actorID != 0 {
|
|
actor = &actorID
|
|
}
|
|
if detail == nil {
|
|
detail = map[string]any{}
|
|
}
|
|
_, err := s.db.Exec(ctx, `insert into audit (actor_id, action, target, detail) values ($1, $2, $3, $4)`, actor, action, target, detail)
|
|
return err
|
|
}
|
|
|
|
// AuditEntry is a line of the audit log.
|
|
type AuditEntry struct {
|
|
At time.Time
|
|
Actor string
|
|
Action string
|
|
Target string
|
|
Detail map[string]any
|
|
}
|
|
|
|
func (s *Store) AuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
|
rows, err := s.db.Query(ctx, `select a.at, coalesce(p.username, ''), a.action, a.target, a.detail
|
|
from audit a left join persons p on p.id = a.actor_id order by a.id desc limit $1`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []AuditEntry
|
|
for rows.Next() {
|
|
var e AuditEntry
|
|
if err := rows.Scan(&e.At, &e.Actor, &e.Action, &e.Target, &e.Detail); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Counts are totals for the console.
|
|
type Counts struct {
|
|
People, Links, Signins int
|
|
}
|
|
|
|
func (s *Store) Counts(ctx context.Context) (Counts, error) {
|
|
var c Counts
|
|
err := s.db.QueryRow(ctx, `select (select count(*) from persons), (select count(*) from imvu_links),
|
|
(select count(*) from refresh_tokens where revoked_at is null)`).Scan(&c.People, &c.Links, &c.Signins)
|
|
return c, err
|
|
}
|