From c780470afe11e4cf872766078e9a0880489cbf02 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 9 Sep 2023 00:58:02 +0200 Subject: [PATCH 1/7] move some settings to server side --- frontend/src/lib/api/entities.ts | 5 +++ frontend/src/lib/api/fetch.ts | 38 ++++++++++++++++---- frontend/src/lib/store.ts | 8 ++++- frontend/src/routes/nav/Navigation.svelte | 40 ++++++++++++++++++--- frontend/src/routes/settings/+page.svelte | 43 +++++++++++++++-------- 5 files changed, 107 insertions(+), 27 deletions(-) diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 1b3a776..79b285a 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -62,6 +62,11 @@ export interface MeUser extends User { timezone: string | null; } +export interface Settings { + read_changelog: string; + read_settings_notice: string; +} + 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/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/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/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 163698d..92451e1 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,21 +92,36 @@ } }; - 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 error} + +{/if} + + If you find pronouns.cc useful and have the means, I would really appreciate a donation {/if} - {#if error} - - {/if}

From cb563bc00b63267b5a85105b0c07ab7603d59d19 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 9 Sep 2023 04:45:04 +0200 Subject: [PATCH 2/7] remove debug prints --- backend/routes/v1/member/patch_member.go | 4 ---- 1 file changed, 4 deletions(-) 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 { From e03c9827b98c32f3be847b23bdb9b3495ffb0b15 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 9 Sep 2023 13:41:46 +0200 Subject: [PATCH 3/7] readd rel and target attributes to profile links (fixes #93) --- frontend/src/routes/@[username]/ProfileLink.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} From f39a762072c56bb231148398d2d1928ffcf6ff09 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 9 Sep 2023 17:20:18 +0200 Subject: [PATCH 4/7] add global notices --- backend/db/notice.go | 66 ++++++++++++++++++++++++ backend/db/user_settings.go | 1 + backend/routes/v1/meta/meta.go | 31 +++++++++-- backend/routes/v1/mod/notices.go | 55 ++++++++++++++++++++ backend/routes/v1/mod/routes.go | 2 + backend/routes/v2/user/patch_settings.go | 4 ++ frontend/src/lib/api/entities.ts | 1 + frontend/src/lib/api/responses.ts | 1 + frontend/src/lib/utils.ts | 10 ++++ frontend/src/routes/+layout.server.ts | 3 +- frontend/src/routes/+layout.svelte | 26 +++++++++- scripts/migrate/022_notices.sql | 14 +++++ 12 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 backend/db/notice.go create mode 100644 backend/routes/v1/mod/notices.go create mode 100644 scripts/migrate/022_notices.sql 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_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/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 79b285a..8129ef7 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -65,6 +65,7 @@ export interface MeUser extends User { export interface Settings { read_changelog: string; read_settings_notice: string; + read_global_notice: number; } export interface Field { 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/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 e3040c1..ed696f8 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); + } + }; @@ -34,6 +52,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/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; From 153812d79ff97908aa3bf882f6acd8c02b1118e1 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 10 Sep 2023 16:49:16 +0200 Subject: [PATCH 5/7] add database seed from file --- .gitignore | 1 + README.md | 13 ++- go.mod | 1 + scripts/seeddb/main.go | 183 +++++++++++++++++++++-------------------- seed.example.yaml | 43 ++++++++++ 5 files changed, 150 insertions(+), 91 deletions(-) create mode 100644 seed.example.yaml diff --git a/.gitignore b/.gitignore index 8e5f23c..418b353 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* target 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/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/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 From 2da388df2e84bc466a61431b6a29fb08b014019a Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 10 Sep 2023 17:44:35 +0200 Subject: [PATCH 6/7] add username cleanup --- backend/db/user.go | 21 +++++++++++++++++++++ scripts/cleandb/main.go | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) 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/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 } From 727848c80179a9ccea7da778a84f69997b89cc7b Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 10 Sep 2023 17:56:04 +0200 Subject: [PATCH 7/7] update terms of service --- frontend/src/routes/page/terms/terms.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/routes/page/terms/terms.md b/frontend/src/routes/page/terms/terms.md index 05e7204..6611ff3 100644 --- a/frontend/src/routes/page/terms/terms.md +++ b/frontend/src/routes/page/terms/terms.md @@ -18,6 +18,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 and meet the minimum age required by the laws in your country to use the service. If you are under 16 years old, you need consent from your parents or legal guardian 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.