Merge remote-tracking branch 'origin/main'

This commit is contained in:
Kay Faraday 2023-09-11 03:25:26 +00:00
commit 9cc6a96691
26 changed files with 530 additions and 132 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ vite.config.ts.timestamp-*
target
/pronouns
tmp
seed.yaml

View File

@ -26,6 +26,7 @@ Requirements:
- Redis 6.0 or later
- Node.js (latest version)
- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise)
- [Air](https://github.com/cosmtrek/air) for live reloading the backend
### Setup
@ -33,12 +34,16 @@ Requirements:
For example: `create user pronouns with password 'password'; create database pronouns with owner pronouns;`
2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options.
3. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
4. Run `go run -v . web` to run the backend.
5. Copy `frontend/.env.example` into `frontend/.env` and tweak as necessary.
6. cd into the `frontend` directory and run `pnpm dev` to run the frontend.
4. Run `pnpm dev`. Alternatively, if you don't want the backend to live reload, run `go run -v . web`,
then change to the `frontend/` directory and run `pnpm dev`.
See [`docs/production.md`](/docs/production.md#configuration) for more information about keys in the backend and frontend `.env` files.
### Seeding
To seed the database with some data, create a `seed.yaml` file, then use `go run -v . database seed`.
For the file format, refer to the `Seed` struct in `scripts/seeddb`.
## License
Copyright (C) 2022 Sam <u1f320>

66
backend/db/notice.go Normal file
View File

@ -0,0 +1,66 @@
package db
import (
"context"
"time"
"emperror.dev/errors"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
)
type Notice struct {
ID int
Notice string
StartTime time.Time
EndTime time.Time
}
func (db *DB) Notices(ctx context.Context) (ns []Notice, err error) {
sql, args, err := sq.Select("*").From("notices").OrderBy("id DESC").ToSql()
if err != nil {
return nil, errors.Wrap(err, "building sql")
}
err = pgxscan.Select(ctx, db, &ns, sql, args...)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return NotNull(ns), nil
}
func (db *DB) CreateNotice(ctx context.Context, notice string, start, end time.Time) (n Notice, err error) {
sql, args, err := sq.Insert("notices").SetMap(map[string]any{
"notice": notice,
"start_time": start,
"end_time": end,
}).Suffix("RETURNING *").ToSql()
if err != nil {
return n, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &n, sql, args...)
if err != nil {
return n, errors.Wrap(err, "executing query")
}
return n, nil
}
const ErrNoNotice = errors.Sentinel("no current notice")
func (db *DB) CurrentNotice(ctx context.Context) (n Notice, err error) {
sql, args, err := sq.Select("*").From("notices").Where("end_time > ?", time.Now()).OrderBy("id DESC").Limit(1).ToSql()
if err != nil {
return n, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &n, sql, args...)
if err != nil {
if errors.Cause(err) == pgx.ErrNoRows {
return n, ErrNoNotice
}
return n, errors.Wrap(err, "executing query")
}
return n, nil
}

View File

@ -825,3 +825,24 @@ func (db *DB) CleanUser(ctx context.Context, id xid.ID) error {
}
return nil
}
const inactiveUsersSQL = `select id, snowflake_id from users
where last_active < now() - '30 days'::interval
and display_name is null and bio is null and timezone is null
and links is null and avatar is null and member_title is null
and names = '[]' and pronouns = '[]'
and (select count(m.id) from members m where user_id = users.id) = 0
and (select count(f.id) from user_fields f where user_id = users.id) = 0;`
// InactiveUsers gets the list of inactive users from the database.
// "Inactive" is defined as:
// - not logged in for 30 days or more
// - no display name, bio, avatar, names, pronouns, profile links, or profile fields
// - no members
func (db *DB) InactiveUsers(ctx context.Context, tx pgx.Tx) (us []User, err error) {
err = pgxscan.Select(ctx, tx, &us, inactiveUsersSQL)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return us, nil
}

View File

@ -10,6 +10,7 @@ import (
type UserSettings struct {
ReadChangelog string `json:"read_changelog"`
ReadSettingsNotice string `json:"read_settings_notice"`
ReadGlobalNotice int `json:"read_global_notice"`
}
func (db *DB) UpdateUserSettings(ctx context.Context, id xid.ID, us UserSettings) error {

View File

@ -58,13 +58,9 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
log.Debugf("%v/%v is not valid snowflake", chi.URLParam(r, "memberRef"), id)
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Debugf("%v/%v is valid snowflake", chi.URLParam(r, "memberRef"), id)
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {

View File

@ -4,6 +4,8 @@ import (
"net/http"
"os"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
@ -25,6 +27,12 @@ type MetaResponse struct {
Users MetaUsers `json:"users"`
Members int64 `json:"members"`
RequireInvite bool `json:"require_invite"`
Notice *MetaNotice `json:"notice"`
}
type MetaNotice struct {
ID int `json:"id"`
Notice string `json:"notice"`
}
type MetaUsers struct {
@ -39,6 +47,18 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx)
var notice *MetaNotice
if n, err := s.DB.CurrentNotice(ctx); err != nil {
if err == db.ErrNoNotice {
log.Errorf("getting notice: %v", err)
}
} else {
notice = &MetaNotice{
ID: n.ID,
Notice: n.Notice,
}
}
render.JSON(w, r, MetaResponse{
GitRepository: server.Repository,
GitCommit: server.Revision,
@ -50,6 +70,7 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
},
Members: numMembers,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
Notice: notice,
})
return nil
}

View File

@ -0,0 +1,55 @@
package mod
import (
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/aarondl/opt/omit"
"github.com/go-chi/render"
)
type createNoticeRequest struct {
Notice string `json:"notice"`
Start omit.Val[time.Time] `json:"start"`
End time.Time `json:"end"`
}
type noticeResponse struct {
ID int `json:"id"`
Notice string `json:"notice"`
StartTime time.Time `json:"start"`
EndTime time.Time `json:"end"`
}
func (s *Server) createNotice(w http.ResponseWriter, r *http.Request) error {
var req createNoticeRequest
err := render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if common.StringLength(&req.Notice) > 2000 {
return server.APIError{Code: server.ErrBadRequest, Details: "Notice is too long, max 2000 characters"}
}
start := req.Start.GetOr(time.Now())
if req.End.IsZero() {
return server.APIError{Code: server.ErrBadRequest, Details: "`end` is missing or invalid"}
}
n, err := s.DB.CreateNotice(r.Context(), req.Notice, start, req.End)
if err != nil {
return errors.Wrap(err, "creating notice")
}
render.JSON(w, r, noticeResponse{
ID: n.ID,
Notice: n.Notice,
StartTime: n.StartTime,
EndTime: n.EndTime,
})
return nil
}

View File

@ -22,6 +22,8 @@ func Mount(srv *server.Server, r chi.Router) {
r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
r.Post("/notices", server.WrapHandler(s.createNotice))
})
r.With(MustAdmin).Handle("/metrics", promhttp.Handler())

View File

@ -12,6 +12,7 @@ import (
type PatchSettingsRequest struct {
ReadChangelog omitnull.Val[string] `json:"read_changelog"`
ReadSettingsNotice omitnull.Val[string] `json:"read_settings_notice"`
ReadGlobalNotice omitnull.Val[int] `json:"read_global_notice"`
}
func (s *Server) PatchSettings(w http.ResponseWriter, r *http.Request) (err error) {
@ -34,6 +35,9 @@ func (s *Server) PatchSettings(w http.ResponseWriter, r *http.Request) (err erro
if !req.ReadSettingsNotice.IsUnset() {
u.Settings.ReadSettingsNotice = req.ReadSettingsNotice.GetOrZero()
}
if !req.ReadGlobalNotice.IsUnset() {
u.Settings.ReadGlobalNotice = req.ReadGlobalNotice.GetOrZero()
}
err = s.DB.UpdateUserSettings(ctx, u.ID, u.Settings)
if err != nil {

View File

@ -62,6 +62,12 @@ export interface MeUser extends User {
timezone: string | null;
}
export interface Settings {
read_changelog: string;
read_settings_notice: string;
read_global_notice: number;
}
export interface Field {
name: string;
entries: FieldEntry[];

View File

@ -11,9 +11,16 @@ export async function apiFetch<T>(
body,
token,
headers,
}: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
version,
}: {
method?: string;
body?: any;
token?: string;
headers?: Record<string, string>;
version?: number;
},
) {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v${version || 1}${path}`, {
method: method || "GET",
headers: {
...(token ? { Authorization: token } : {}),
@ -28,12 +35,18 @@ export async function apiFetch<T>(
return data as T;
}
export const apiFetchClient = async <T>(path: string, method = "GET", body: any = null) => {
export const apiFetchClient = async <T>(
path: string,
method = "GET",
body: any = null,
version = 1,
) => {
try {
const data = await apiFetch<T>(path, {
method,
body,
token: localStorage.getItem("pronouns-token") || undefined,
version,
});
return data;
} catch (e) {
@ -55,9 +68,16 @@ export async function fastFetch(
body,
token,
headers,
}: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
version,
}: {
method?: string;
body?: any;
token?: string;
headers?: Record<string, string>;
version?: number;
},
) {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v${version || 1}${path}`, {
method: method || "GET",
headers: {
...(token ? { Authorization: token } : {}),
@ -71,12 +91,18 @@ export async function fastFetch(
}
/** Fetches the specified path without parsing the response body. */
export const fastFetchClient = async (path: string, method = "GET", body: any = null) => {
export const fastFetchClient = async (
path: string,
method = "GET",
body: any = null,
version = 1,
) => {
try {
await fastFetch(path, {
method,
body,
token: localStorage.getItem("pronouns-token") || undefined,
version,
});
} catch (e) {
if ((e as APIError).code === ErrorCode.InvalidToken) {

View File

@ -11,6 +11,7 @@ export interface MetaResponse {
users: MetaUsers;
members: number;
require_invite: boolean;
notice: { id: number; notice: string } | null;
}
export interface MetaUsers {

View File

@ -1,7 +1,7 @@
import { writable } from "svelte/store";
import { browser } from "$app/environment";
import type { MeUser } from "./api/entities";
import type { MeUser, Settings } from "./api/entities";
const initialUserValue = null;
export const userStore = writable<MeUser | null>(initialUserValue);
@ -13,4 +13,10 @@ const initialThemeValue = browser
export const themeStore = writable<string>(initialThemeValue);
const defaultSettingsValue = {
settings: { read_changelog: "0.0.0", read_settings_notice: "0" } as Settings,
current: false,
};
export const settingsStore = writable(defaultSettingsValue);
export const CURRENT_CHANGELOG = "0.6.0";

View File

@ -7,8 +7,18 @@ const md = new MarkdownIt({
linkify: true,
}).disable(["heading", "lheading", "link", "table", "blockquote"]);
const unsafeMd = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
});
export function renderMarkdown(src: string | null) {
return src ? sanitize(md.render(src)) : null;
}
export function renderUnsafeMarkdown(src: string) {
return sanitize(unsafeMd.render(src));
}
export const charCount = (str: string) => [...str].length;

View File

@ -22,7 +22,8 @@ export const load = (async () => {
},
members: 0,
require_invite: false,
};
notice: null,
} as MetaResponse;
} else {
throw e;
}

View File

@ -10,9 +10,13 @@
import Navigation from "./nav/Navigation.svelte";
import type { LayoutData } from "./$types";
import { version } from "$app/environment";
import { settingsStore } from "$lib/store";
import { toastStore } from "$lib/toast";
import Toast from "$lib/components/Toast.svelte";
import { Icon } from "sveltestrap";
import { Alert, Icon } from "sveltestrap";
import { apiFetchClient } from "$lib/api/fetch";
import type { Settings } from "$lib/api/entities";
import { renderUnsafeMarkdown } from "$lib/utils";
export let data: LayoutData;
@ -21,6 +25,20 @@
if (versionParts.length >= 3) commit = versionParts[2].slice(1);
const versionMismatch = data.git_commit !== commit && data.git_commit !== "[unknown]";
const readNotice = async () => {
try {
const resp = await apiFetchClient<Settings>(
"/users/@me/settings",
"PATCH",
{ read_global_notice: data.notice!.id },
2,
);
settingsStore.set({ current: true, settings: resp });
} catch (e) {
console.log("updating settings:", e);
}
};
</script>
<svelte:head>
@ -33,6 +51,12 @@
<div class="flex-grow-1">
<Navigation commit={data.git_commit} />
<div class="container">
{#if data.notice && $settingsStore.current && data.notice.id > $settingsStore.settings.read_global_notice}
<Alert color="secondary" isOpen={true} toggle={() => readNotice()}>
{@html renderUnsafeMarkdown(data.notice.notice)}
</Alert>
{/if}
<slot />
<div class="position-absolute top-0 start-50 translate-middle-x">
{#each $toastStore as toast}

View File

@ -21,7 +21,7 @@
</script>
{#if isLink}
<a href={link} class="text-decoration-none">
<a href={link} class="text-decoration-none" rel="me nofollow noreferrer" target="_blank">
<li class="py-2 py-lg-0">
<Icon name="globe" aria-hidden class="text-body" />
<span class="text-decoration-underline">{displayLink}</span>

View File

@ -17,13 +17,14 @@
} from "sveltestrap";
import Logo from "./Logo.svelte";
import { userStore, themeStore, CURRENT_CHANGELOG } from "$lib/store";
import { userStore, themeStore, CURRENT_CHANGELOG, settingsStore } from "$lib/store";
import {
ErrorCode,
type APIError,
type MeUser,
type Report,
type Warning,
type Settings,
} from "$lib/api/entities";
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
import { addToast } from "$lib/toast";
@ -37,14 +38,11 @@
let isAdmin = false;
let numReports = 0;
let numWarnings = 0;
let changelogRead = "99.99.99";
$: currentUser = $userStore;
$: theme = $themeStore;
onMount(() => {
changelogRead = localStorage.getItem("changelog-read") || "0.0.0";
const localUser = localStorage.getItem("pronouns-user");
userStore.set(localUser ? JSON.parse(localUser) : null);
@ -78,6 +76,38 @@
});
}
apiFetchClient<Settings>("/users/@me/settings", "GET", null, 2)
.then((data) => {
settingsStore.set({ current: true, settings: data });
})
.catch((e) => {
console.log("getting user settings:", e);
});
// TODO: is there a cleaner way to do this? also, remove this eventually
const oldChangelogRead = localStorage.getItem("changelog-read");
const oldSettingsNotice = localStorage.getItem("alert-1681976313");
if (oldChangelogRead || oldSettingsNotice) {
localStorage.removeItem("changelog-read");
localStorage.removeItem("alert-1681976313");
apiFetchClient<Settings>(
"/users/@me/settings",
"PATCH",
{
read_changelog: oldChangelogRead ? oldChangelogRead : undefined,
read_settings_notice: oldSettingsNotice ? "1681976313" : undefined,
},
2,
)
.then((data) => {
settingsStore.set({ current: true, settings: data });
})
.catch((e) => {
console.log("updating user settings:", e);
});
}
apiFetchClient<Warning[]>("/auth/warnings")
.then((warnings) => {
if (warnings.length !== 0) {
@ -157,7 +187,7 @@
</NavLink>
</NavItem>
{/if}
{#if changelogRead < CURRENT_CHANGELOG}
{#if $settingsStore.current && $settingsStore.settings.read_changelog < CURRENT_CHANGELOG}
<NavItem>
<NavLink href="/page/changelog" active={$page.url.pathname === "/page/changelog"}>
Changelog <Badge color="secondary">v{CURRENT_CHANGELOG}</Badge>

View File

@ -17,6 +17,9 @@ We reserve the right to modify these Terms and the Privacy Policy at any time. W
You must be at least 13 years old (chronologically) and meet the minimum age required by the laws in your country to use the service.
Accounts with empty profiles, no members, and no activity within the last 30 days
will have their usernames changed to the account's ID, to free up unused usernames.
## Content
You are responsible for the content you post on the service.

View File

@ -6,12 +6,13 @@
type APIError,
MAX_MEMBERS,
MAX_FIELDS,
type Settings,
} from "$lib/api/entities";
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
import { usernameRegex } from "$lib/api/regex";
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import FallbackImage from "$lib/components/FallbackImage.svelte";
import { userStore } from "$lib/store";
import { settingsStore, userStore } from "$lib/store";
import { addToast } from "$lib/toast";
import {
Alert,
@ -91,22 +92,37 @@
}
};
const DONATE_ALERT_STORE = "alert-1681976313";
let donateAlertOpen = false;
const closeDonateAlert = () => {
donateAlertOpen = false;
localStorage.setItem(DONATE_ALERT_STORE, "dismissed");
};
const CURRENT_ALERT = "1681976313";
const closeDonateAlert = async () => {
try {
const settings = await apiFetchClient<Settings>(
"/users/@me/settings",
"PATCH",
{
read_settings_notice: CURRENT_ALERT,
},
2,
);
onMount(() => {
if (!localStorage.getItem(DONATE_ALERT_STORE)) {
donateAlertOpen = true;
settingsStore.set({ current: true, settings });
error = null;
} catch (e) {
error = e as APIError;
}
});
};
</script>
<Alert color="secondary" fade={false} isOpen={donateAlertOpen} toggle={closeDonateAlert}>
If you find pronouns fu useful and have the means, I would really appreciate a <a
{#if error}
<ErrorAlert {error} />
{/if}
<Alert
color="secondary"
fade={false}
isOpen={$settingsStore.current && $settingsStore.settings.read_settings_notice < CURRENT_ALERT}
toggle={closeDonateAlert}
>
If you find pronouns.cc useful and have the means, I would really appreciate a <a
href="https://freak.university/about#donation"
target="_blank">donation</a
>
@ -150,9 +166,6 @@
{/if}
</p>
{/if}
{#if error}
<ErrorAlert {error} />
{/if}
</div>
<div class="col-lg-4">
<p class="text-center">

1
go.mod
View File

@ -28,6 +28,7 @@ require (
go.uber.org/zap v1.24.0
golang.org/x/oauth2 v0.7.0
google.golang.org/api v0.118.0
gopkg.in/yaml.v3 v3.0.1
)
require (

View File

@ -3,6 +3,7 @@ package cleandb
import (
"context"
"fmt"
"os"
"time"
dbpkg "codeberg.org/pronounscc/pronouns.cc/backend/db"
@ -25,6 +26,8 @@ func run(c *cli.Context) error {
return err
}
changeUnusedUsernames := os.Getenv("DB_CLEAN_CHANGE_UNUSED_USERNAMES") == "true"
ctx := context.Background()
db, err := dbpkg.New()
@ -66,6 +69,41 @@ func run(c *cli.Context) error {
fmt.Printf("deleted %v expired exports\n", len(exports))
if changeUnusedUsernames {
fmt.Println("cleaning unused usernames")
tx, err := db.Begin(ctx)
if err != nil {
fmt.Printf("error starting transaction: %v\n", err)
return err
}
defer tx.Rollback(ctx)
inactiveUsers, err := db.InactiveUsers(ctx, tx)
if err != nil {
fmt.Printf("getting inactive users: %v\n", err)
return err
}
for _, u := range inactiveUsers {
err = db.UpdateUsername(ctx, tx, u.ID, fmt.Sprintf("inactive-user-%v", u.SnowflakeID))
if err != nil {
fmt.Printf("changing username for user %v: %v\n", u.SnowflakeID, err)
return err
}
}
err = tx.Commit(ctx)
if err != nil {
fmt.Printf("committing transaction: %v\n", err)
return err
}
fmt.Printf("changed usernames for %v inactive users\n", len(inactiveUsers))
} else {
fmt.Println("not cleaning unused usernames")
}
var users []dbpkg.User
err = pgxscan.Select(ctx, db, &users, `SELECT * FROM users WHERE
deleted_at IS NOT NULL AND
@ -78,7 +116,7 @@ func run(c *cli.Context) error {
}
if len(users) == 0 {
fmt.Println("there are no users pending deletion")
fmt.Println("there are no users pending deletion\nfinished cleaning database!")
return nil
}
@ -132,5 +170,6 @@ func run(c *cli.Context) error {
}
fmt.Printf("deleted %v users!\n", ct.RowsAffected())
fmt.Println("finished cleaning database!")
return nil
}

View File

@ -0,0 +1,14 @@
-- 2023-09-09: Add global notices
-- +migrate Up
create table notices (
id serial primary key,
notice text not null,
start_time timestamptz not null default now(),
end_time timestamptz not null
);
-- +migrate Down
drop table notices;

View File

@ -1,15 +1,42 @@
package seeddb
import (
"fmt"
"log"
"os"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"github.com/urfave/cli/v2"
"gopkg.in/yaml.v3"
)
type Seed struct {
Users []SeedUser `yaml:"users"`
}
type SeedUser struct {
Username string `yaml:"username"`
DisplayName *string `yaml:"displayName"`
Bio *string `yaml:"bio"`
Links []string `yaml:"links"`
Names []db.FieldEntry `yaml:"names"`
Pronouns []db.PronounEntry `yaml:"pronouns"`
Fields []db.Field `yaml:"fields"`
Members []SeedMember `yaml:"members"`
}
type SeedMember struct {
Name string `yaml:"name"`
DisplayName *string `yaml:"displayName"`
Bio string `yaml:"bio"`
Links []string `yaml:"links"`
Names []db.FieldEntry `yaml:"names"`
Pronouns []db.PronounEntry `yaml:"pronouns"`
Fields []db.Field `yaml:"fields"`
Members []SeedMember `yaml:"members"`
}
var Command = &cli.Command{
Name: "seed",
Usage: "Seed the database with test data",
@ -19,7 +46,7 @@ var Command = &cli.Command{
func run(c *cli.Context) error {
err := godotenv.Load()
if err != nil {
fmt.Println("error loading .env file:", err)
log.Println("error loading .env file:", err)
return err
}
@ -27,112 +54,94 @@ func run(c *cli.Context) error {
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
fmt.Println("error opening database:", err)
log.Println("error opening database:", err)
return err
}
defer pool.Close()
fmt.Println("opened database")
log.Println("opened database")
pg := &db.DB{Pool: pool}
// read seed file
seedFile, err := os.ReadFile("seed.yaml")
if err != nil {
log.Println("error opening seed.yaml:", err)
return err
}
var seed Seed
err = yaml.Unmarshal(seedFile, &seed)
if err != nil {
log.Println("error reading seed.yaml:", err)
return err
}
tx, err := pg.Begin(ctx)
if err != nil {
fmt.Println("error beginning transaction:", err)
log.Println("error beginning transaction:", err)
return err
}
defer tx.Rollback(ctx)
for i, su := range seed.Users {
u, err := pg.CreateUser(ctx, tx, su.Username)
if err != nil {
log.Printf("error creating user #%v/%s: %v", i+1, su.Username, err)
return err
}
u, err := pg.CreateUser(ctx, tx, "test")
_, err = pg.UpdateUser(ctx, tx, u.ID, su.DisplayName, su.Bio, nil, nil, &su.Links, nil, nil, nil)
if err != nil {
fmt.Println("error creating user:", err)
log.Printf("updating user %s: %v", su.Username, err)
return err
}
_, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil, nil, nil)
err = pg.SetUserNamesPronouns(ctx, tx, u.ID, db.NotNull(su.Names), db.NotNull(su.Pronouns))
if err != nil {
fmt.Println("error setting user info:", err)
log.Printf("setting names/pronouns for user %s: %v", su.Username, err)
return err
}
err = pg.SetUserNamesPronouns(ctx, tx, u.ID, []db.FieldEntry{
{Value: "testing 1", Status: "favourite"},
{Value: "testing 2", Status: "okay"},
}, []db.PronounEntry{
{Pronouns: "it/it/its/its/itself", DisplayText: ptr("it/its"), Status: "favourite"},
{Pronouns: "they/them/their/theirs/themself", Status: "okay"},
})
err = pg.SetUserFields(ctx, tx, u.ID, db.NotNull(su.Fields))
if err != nil {
fmt.Println("error setting pronouns:", err)
log.Printf("setting fields for user %s: %v", su.Username, err)
return err
}
err = pg.SetUserFields(ctx, tx, u.ID, []db.Field{
{
Name: "Field 1",
Entries: []db.FieldEntry{
{Value: "Favourite 1", Status: "favourite"},
{Value: "Okay 1", Status: "okay"},
{Value: "Jokingly 1", Status: "jokingly"},
{Value: "Friends only 1", Status: "friends_only"},
{Value: "Avoid 1", Status: "avoid"},
},
},
{
Name: "Field 2",
Entries: []db.FieldEntry{
{Value: "Favourite 2", Status: "favourite"},
{Value: "Okay 2", Status: "okay"},
{Value: "Jokingly 2", Status: "jokingly"},
{Value: "Friends only 2", Status: "friends_only"},
{Value: "Avoid 2", Status: "avoid"},
},
},
{
Name: "Field 3",
Entries: []db.FieldEntry{
{Value: "Favourite 3", Status: "favourite"},
{Value: "Okay 3", Status: "okay"},
{Value: "Jokingly 3", Status: "jokingly"},
{Value: "Friends only 3", Status: "friends_only"},
{Value: "Avoid 3", Status: "avoid"},
},
},
{
Name: "Field 4",
Entries: []db.FieldEntry{
{Value: "Favourite 4", Status: "favourite"},
{Value: "Okay 4", Status: "okay"},
{Value: "Jokingly 4", Status: "jokingly"},
{Value: "Friends only 4", Status: "friends_only"},
{Value: "Avoid 4", Status: "avoid"},
},
},
{
Name: "Field 5",
Entries: []db.FieldEntry{
{Value: "Favourite 5", Status: "favourite"},
{Value: "Okay 5", Status: "okay"},
{Value: "Jokingly 5", Status: "jokingly"},
{Value: "Friends only 5", Status: "friends_only"},
{Value: "Avoid 5", Status: "avoid"},
},
},
})
log.Printf("creating members for user %s", su.Username)
for _, sm := range su.Members {
m, err := pg.CreateMember(ctx, tx, u.ID, sm.Name, sm.DisplayName, sm.Bio, db.NotNull(sm.Links))
if err != nil {
fmt.Println("error setting fields:", err)
log.Printf("creating member %s: %v", sm.Name, err)
return err
}
err = pg.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(sm.Names), db.NotNull(sm.Pronouns))
if err != nil {
log.Printf("setting names/pronouns for member %s: %v", sm.Name, err)
return err
}
err = pg.SetMemberFields(ctx, tx, m.ID, db.NotNull(sm.Fields))
if err != nil {
log.Printf("setting fields for member %s: %v", sm.Name, err)
return err
}
log.Printf("created member %s", sm.Name)
}
log.Printf("created user %s", su.Username)
}
err = tx.Commit(ctx)
if err != nil {
fmt.Println("error committing transaction:", err)
log.Println("error committing transaction:", err)
return err
}
fmt.Println("Created testing user with ID", u.ID, "and name", u.Username)
log.Printf("seeded database with %d users", len(seed.Users))
return nil
}
func ptr[T any](v T) *T {
return &v
}

43
seed.example.yaml Normal file
View File

@ -0,0 +1,43 @@
users:
- username: test1
displayName: "Test 1 :3"
bio: |
*Hiiiiii*
I'm a user!
links: [https://pronouns.cc, https://codeberg.org/pronounscc/pronouns.cc]
names:
- value: Test
status: favourite
- value: Tester
status: okay
- value: Testington
status: avoid
pronouns:
- pronouns: they/them/their/theirs/themself
status: favourite
- pronouns: it/it/its/its/itself
displayText: it/its
status: okay
fields:
- name: Field 1
entries:
- value: Entry 1
status: favourite
- value: Entry 2
status: favourite
- value: Entry 3
status: friends_only
- name: Field 2
entries:
- value: Entry 4
status: avoid
- value: Entry 4
status: okay
members:
- name: member 1
- name: member 2
- name: member 3
- name: member 4
- name: member 5
- name: member 6
- name: member 7