diff --git a/.gitignore b/.gitignore index c2448a1..f37c79a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ vite.config.ts.timestamp-* target /pronouns tmp +seed.yaml diff --git a/README.md b/README.md index c0ac541..d01b981 100644 --- a/README.md +++ b/README.md @@ -26,19 +26,24 @@ 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 -1. Create a PostgreSQL user and database (the user should own the database). +1. Create a PostgreSQL user and database (the user should own the database). 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 diff --git a/backend/db/notice.go b/backend/db/notice.go new file mode 100644 index 0000000..3f77b8f --- /dev/null +++ b/backend/db/notice.go @@ -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 +} diff --git a/backend/db/user.go b/backend/db/user.go index 80d380d..57e0a71 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -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 +} diff --git a/backend/db/user_settings.go b/backend/db/user_settings.go index 7411821..719573a 100644 --- a/backend/db/user_settings.go +++ b/backend/db/user_settings.go @@ -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 { diff --git a/backend/routes/v1/member/patch_member.go b/backend/routes/v1/member/patch_member.go index 956eb13..8d56a87 100644 --- a/backend/routes/v1/member/patch_member.go +++ b/backend/routes/v1/member/patch_member.go @@ -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 { diff --git a/backend/routes/v1/meta/meta.go b/backend/routes/v1/meta/meta.go index ebae428..66b6455 100644 --- a/backend/routes/v1/meta/meta.go +++ b/backend/routes/v1/meta/meta.go @@ -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" @@ -20,11 +22,17 @@ func Mount(srv *server.Server, r chi.Router) { } type MetaResponse struct { - GitRepository string `json:"git_repository"` - GitCommit string `json:"git_commit"` - Users MetaUsers `json:"users"` - Members int64 `json:"members"` - RequireInvite bool `json:"require_invite"` + GitRepository string `json:"git_repository"` + GitCommit string `json:"git_commit"` + 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 } diff --git a/backend/routes/v1/mod/notices.go b/backend/routes/v1/mod/notices.go new file mode 100644 index 0000000..93c9b26 --- /dev/null +++ b/backend/routes/v1/mod/notices.go @@ -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 +} diff --git a/backend/routes/v1/mod/routes.go b/backend/routes/v1/mod/routes.go index aaed170..fefda48 100644 --- a/backend/routes/v1/mod/routes.go +++ b/backend/routes/v1/mod/routes.go @@ -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()) diff --git a/backend/routes/v2/user/patch_settings.go b/backend/routes/v2/user/patch_settings.go index 777fe09..90d2ca2 100644 --- a/backend/routes/v2/user/patch_settings.go +++ b/backend/routes/v2/user/patch_settings.go @@ -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 { diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 1b3a776..8129ef7 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -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[]; diff --git a/frontend/src/lib/api/fetch.ts b/frontend/src/lib/api/fetch.ts index 8448c75..8c74667 100644 --- a/frontend/src/lib/api/fetch.ts +++ b/frontend/src/lib/api/fetch.ts @@ -11,9 +11,16 @@ export async function apiFetch( body, token, headers, - }: { method?: string; body?: any; token?: string; headers?: Record }, + version, + }: { + method?: string; + body?: any; + token?: string; + headers?: Record; + 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( return data as T; } -export const apiFetchClient = async (path: string, method = "GET", body: any = null) => { +export const apiFetchClient = async ( + path: string, + method = "GET", + body: any = null, + version = 1, +) => { try { const data = await apiFetch(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 }, + version, + }: { + method?: string; + body?: any; + token?: string; + headers?: Record; + 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) { diff --git a/frontend/src/lib/api/responses.ts b/frontend/src/lib/api/responses.ts index 5a343f8..7730b6e 100644 --- a/frontend/src/lib/api/responses.ts +++ b/frontend/src/lib/api/responses.ts @@ -11,6 +11,7 @@ export interface MetaResponse { users: MetaUsers; members: number; require_invite: boolean; + notice: { id: number; notice: string } | null; } export interface MetaUsers { diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index e00fc30..5c31360 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -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(initialUserValue); @@ -13,4 +13,10 @@ const initialThemeValue = browser export const themeStore = writable(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"; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 276d98a..68fd1de 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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; diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts index 1af3ac9..baacda0 100644 --- a/frontend/src/routes/+layout.server.ts +++ b/frontend/src/routes/+layout.server.ts @@ -22,7 +22,8 @@ export const load = (async () => { }, members: 0, require_invite: false, - }; + notice: null, + } as MetaResponse; } else { throw e; } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index cd6f3a5..f5efc97 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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( + "/users/@me/settings", + "PATCH", + { read_global_notice: data.notice!.id }, + 2, + ); + settingsStore.set({ current: true, settings: resp }); + } catch (e) { + console.log("updating settings:", e); + } + }; @@ -33,6 +51,12 @@
+ {#if data.notice && $settingsStore.current && data.notice.id > $settingsStore.settings.read_global_notice} + readNotice()}> + {@html renderUnsafeMarkdown(data.notice.notice)} + + {/if} +
{#each $toastStore as toast} diff --git a/frontend/src/routes/@[username]/ProfileLink.svelte b/frontend/src/routes/@[username]/ProfileLink.svelte index 175003c..e6f4ad1 100644 --- a/frontend/src/routes/@[username]/ProfileLink.svelte +++ b/frontend/src/routes/@[username]/ProfileLink.svelte @@ -21,7 +21,7 @@ {#if isLink} - +
  • {displayLink} diff --git a/frontend/src/routes/nav/Navigation.svelte b/frontend/src/routes/nav/Navigation.svelte index 8052181..39e01bf 100644 --- a/frontend/src/routes/nav/Navigation.svelte +++ b/frontend/src/routes/nav/Navigation.svelte @@ -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("/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( + "/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("/auth/warnings") .then((warnings) => { if (warnings.length !== 0) { @@ -157,7 +187,7 @@ {/if} - {#if changelogRead < CURRENT_CHANGELOG} + {#if $settingsStore.current && $settingsStore.settings.read_changelog < CURRENT_CHANGELOG} Changelog v{CURRENT_CHANGELOG} diff --git a/frontend/src/routes/page/terms/terms.md b/frontend/src/routes/page/terms/terms.md index c2a4f95..6f7b330 100644 --- a/frontend/src/routes/page/terms/terms.md +++ b/frontend/src/routes/page/terms/terms.md @@ -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. diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index d81c737..7e98e90 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -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( + "/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; } - }); + }; - - If you find pronouns fu useful and have the means, I would really appreciate a +{/if} + + + If you find pronouns.cc useful and have the means, I would really appreciate a donation @@ -150,9 +166,6 @@ {/if}

    {/if} - {#if error} - - {/if}
  • diff --git a/go.mod b/go.mod index 862413f..360bd54 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/scripts/cleandb/main.go b/scripts/cleandb/main.go index 994671b..317951b 100644 --- a/scripts/cleandb/main.go +++ b/scripts/cleandb/main.go @@ -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 } diff --git a/scripts/migrate/022_notices.sql b/scripts/migrate/022_notices.sql new file mode 100644 index 0000000..c73097d --- /dev/null +++ b/scripts/migrate/022_notices.sql @@ -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; diff --git a/scripts/seeddb/main.go b/scripts/seeddb/main.go index d18b354..c2e9f26 100644 --- a/scripts/seeddb/main.go +++ b/scripts/seeddb/main.go @@ -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) - u, err := pg.CreateUser(ctx, tx, "test") - if err != nil { - fmt.Println("error creating user:", err) - return err - } + 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 + } - _, err = pg.UpdateUser(ctx, tx, u.ID, ptr("testing"), ptr("This is a bio!"), nil, ptr(false), &[]string{"https://pronouns.cc"}, nil, nil, nil) - if err != nil { - fmt.Println("error setting user info:", err) - return err - } + _, err = pg.UpdateUser(ctx, tx, u.ID, su.DisplayName, su.Bio, nil, nil, &su.Links, nil, nil, nil) + if err != nil { + log.Printf("updating 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"}, - }) - if err != nil { - fmt.Println("error setting pronouns:", err) - return err - } + err = pg.SetUserNamesPronouns(ctx, tx, u.ID, db.NotNull(su.Names), db.NotNull(su.Pronouns)) + if err != nil { + log.Printf("setting names/pronouns 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"}, - }, - }, - }) - if err != nil { - fmt.Println("error setting fields:", err) - return err + err = pg.SetUserFields(ctx, tx, u.ID, db.NotNull(su.Fields)) + if err != nil { + log.Printf("setting fields for user %s: %v", su.Username, err) + return err + } + + 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 { + 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 -} diff --git a/seed.example.yaml b/seed.example.yaml new file mode 100644 index 0000000..d4a862c --- /dev/null +++ b/seed.example.yaml @@ -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