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 target
/pronouns /pronouns
tmp tmp
seed.yaml

View File

@ -26,19 +26,24 @@ Requirements:
- Redis 6.0 or later - Redis 6.0 or later
- Node.js (latest version) - Node.js (latest version)
- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise) - MinIO **if using avatars, flags, or data exports** (_not_ required otherwise)
- [Air](https://github.com/cosmtrek/air) for live reloading the backend
### Setup ### 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;` 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. 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. 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. 4. Run `pnpm dev`. Alternatively, if you don't want the backend to live reload, run `go run -v . web`,
5. Copy `frontend/.env.example` into `frontend/.env` and tweak as necessary. then change to the `frontend/` directory and run `pnpm dev`.
6. cd into the `frontend` directory and run `pnpm dev` to run the frontend.
See [`docs/production.md`](/docs/production.md#configuration) for more information about keys in the backend and frontend `.env` files. 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 ## License
Copyright (C) 2022 Sam <u1f320> 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 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 { type UserSettings struct {
ReadChangelog string `json:"read_changelog"` ReadChangelog string `json:"read_changelog"`
ReadSettingsNotice string `json:"read_settings_notice"` 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 { 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 { } else {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef")) id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil { if err != nil {
log.Debugf("%v/%v is not valid snowflake", chi.URLParam(r, "memberRef"), id)
return server.APIError{Code: server.ErrMemberNotFound} 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)) m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil { if err != nil {
if err == db.ErrMemberNotFound { if err == db.ErrMemberNotFound {

View File

@ -4,6 +4,8 @@ import (
"net/http" "net/http"
"os" "os"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
@ -20,11 +22,17 @@ func Mount(srv *server.Server, r chi.Router) {
} }
type MetaResponse struct { type MetaResponse struct {
GitRepository string `json:"git_repository"` GitRepository string `json:"git_repository"`
GitCommit string `json:"git_commit"` GitCommit string `json:"git_commit"`
Users MetaUsers `json:"users"` Users MetaUsers `json:"users"`
Members int64 `json:"members"` Members int64 `json:"members"`
RequireInvite bool `json:"require_invite"` RequireInvite bool `json:"require_invite"`
Notice *MetaNotice `json:"notice"`
}
type MetaNotice struct {
ID int `json:"id"`
Notice string `json:"notice"`
} }
type MetaUsers struct { 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) 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{ render.JSON(w, r, MetaResponse{
GitRepository: server.Repository, GitRepository: server.Repository,
GitCommit: server.Revision, GitCommit: server.Revision,
@ -50,6 +70,7 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
}, },
Members: numMembers, Members: numMembers,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
Notice: notice,
}) })
return nil 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.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport)) r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
r.Post("/notices", server.WrapHandler(s.createNotice))
}) })
r.With(MustAdmin).Handle("/metrics", promhttp.Handler()) r.With(MustAdmin).Handle("/metrics", promhttp.Handler())

View File

@ -12,6 +12,7 @@ import (
type PatchSettingsRequest struct { type PatchSettingsRequest struct {
ReadChangelog omitnull.Val[string] `json:"read_changelog"` ReadChangelog omitnull.Val[string] `json:"read_changelog"`
ReadSettingsNotice omitnull.Val[string] `json:"read_settings_notice"` 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) { 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() { if !req.ReadSettingsNotice.IsUnset() {
u.Settings.ReadSettingsNotice = req.ReadSettingsNotice.GetOrZero() 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) err = s.DB.UpdateUserSettings(ctx, u.ID, u.Settings)
if err != nil { if err != nil {

View File

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

View File

@ -11,9 +11,16 @@ export async function apiFetch<T>(
body, body,
token, token,
headers, 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", method: method || "GET",
headers: { headers: {
...(token ? { Authorization: token } : {}), ...(token ? { Authorization: token } : {}),
@ -28,12 +35,18 @@ export async function apiFetch<T>(
return data as 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 { try {
const data = await apiFetch<T>(path, { const data = await apiFetch<T>(path, {
method, method,
body, body,
token: localStorage.getItem("pronouns-token") || undefined, token: localStorage.getItem("pronouns-token") || undefined,
version,
}); });
return data; return data;
} catch (e) { } catch (e) {
@ -55,9 +68,16 @@ export async function fastFetch(
body, body,
token, token,
headers, 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", method: method || "GET",
headers: { headers: {
...(token ? { Authorization: token } : {}), ...(token ? { Authorization: token } : {}),
@ -71,12 +91,18 @@ export async function fastFetch(
} }
/** Fetches the specified path without parsing the response body. */ /** 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 { try {
await fastFetch(path, { await fastFetch(path, {
method, method,
body, body,
token: localStorage.getItem("pronouns-token") || undefined, token: localStorage.getItem("pronouns-token") || undefined,
version,
}); });
} catch (e) { } catch (e) {
if ((e as APIError).code === ErrorCode.InvalidToken) { if ((e as APIError).code === ErrorCode.InvalidToken) {

View File

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

View File

@ -1,7 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import type { MeUser } from "./api/entities"; import type { MeUser, Settings } from "./api/entities";
const initialUserValue = null; const initialUserValue = null;
export const userStore = writable<MeUser | null>(initialUserValue); export const userStore = writable<MeUser | null>(initialUserValue);
@ -13,4 +13,10 @@ const initialThemeValue = browser
export const themeStore = writable<string>(initialThemeValue); 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"; export const CURRENT_CHANGELOG = "0.6.0";

View File

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

View File

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

View File

@ -10,9 +10,13 @@
import Navigation from "./nav/Navigation.svelte"; import Navigation from "./nav/Navigation.svelte";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { version } from "$app/environment"; import { version } from "$app/environment";
import { settingsStore } from "$lib/store";
import { toastStore } from "$lib/toast"; import { toastStore } from "$lib/toast";
import Toast from "$lib/components/Toast.svelte"; 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; export let data: LayoutData;
@ -21,6 +25,20 @@
if (versionParts.length >= 3) commit = versionParts[2].slice(1); if (versionParts.length >= 3) commit = versionParts[2].slice(1);
const versionMismatch = data.git_commit !== commit && data.git_commit !== "[unknown]"; 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> </script>
<svelte:head> <svelte:head>
@ -33,6 +51,12 @@
<div class="flex-grow-1"> <div class="flex-grow-1">
<Navigation commit={data.git_commit} /> <Navigation commit={data.git_commit} />
<div class="container"> <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 /> <slot />
<div class="position-absolute top-0 start-50 translate-middle-x"> <div class="position-absolute top-0 start-50 translate-middle-x">
{#each $toastStore as toast} {#each $toastStore as toast}

View File

@ -21,7 +21,7 @@
</script> </script>
{#if isLink} {#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"> <li class="py-2 py-lg-0">
<Icon name="globe" aria-hidden class="text-body" /> <Icon name="globe" aria-hidden class="text-body" />
<span class="text-decoration-underline">{displayLink}</span> <span class="text-decoration-underline">{displayLink}</span>

View File

@ -17,13 +17,14 @@
} from "sveltestrap"; } from "sveltestrap";
import Logo from "./Logo.svelte"; import Logo from "./Logo.svelte";
import { userStore, themeStore, CURRENT_CHANGELOG } from "$lib/store"; import { userStore, themeStore, CURRENT_CHANGELOG, settingsStore } from "$lib/store";
import { import {
ErrorCode, ErrorCode,
type APIError, type APIError,
type MeUser, type MeUser,
type Report, type Report,
type Warning, type Warning,
type Settings,
} from "$lib/api/entities"; } from "$lib/api/entities";
import { apiFetch, apiFetchClient } from "$lib/api/fetch"; import { apiFetch, apiFetchClient } from "$lib/api/fetch";
import { addToast } from "$lib/toast"; import { addToast } from "$lib/toast";
@ -37,14 +38,11 @@
let isAdmin = false; let isAdmin = false;
let numReports = 0; let numReports = 0;
let numWarnings = 0; let numWarnings = 0;
let changelogRead = "99.99.99";
$: currentUser = $userStore; $: currentUser = $userStore;
$: theme = $themeStore; $: theme = $themeStore;
onMount(() => { onMount(() => {
changelogRead = localStorage.getItem("changelog-read") || "0.0.0";
const localUser = localStorage.getItem("pronouns-user"); const localUser = localStorage.getItem("pronouns-user");
userStore.set(localUser ? JSON.parse(localUser) : null); 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") apiFetchClient<Warning[]>("/auth/warnings")
.then((warnings) => { .then((warnings) => {
if (warnings.length !== 0) { if (warnings.length !== 0) {
@ -157,7 +187,7 @@
</NavLink> </NavLink>
</NavItem> </NavItem>
{/if} {/if}
{#if changelogRead < CURRENT_CHANGELOG} {#if $settingsStore.current && $settingsStore.settings.read_changelog < CURRENT_CHANGELOG}
<NavItem> <NavItem>
<NavLink href="/page/changelog" active={$page.url.pathname === "/page/changelog"}> <NavLink href="/page/changelog" active={$page.url.pathname === "/page/changelog"}>
Changelog <Badge color="secondary">v{CURRENT_CHANGELOG}</Badge> 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. 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 ## Content
You are responsible for the content you post on the service. You are responsible for the content you post on the service.

View File

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

1
go.mod
View File

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

View File

@ -3,6 +3,7 @@ package cleandb
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"time" "time"
dbpkg "codeberg.org/pronounscc/pronouns.cc/backend/db" dbpkg "codeberg.org/pronounscc/pronouns.cc/backend/db"
@ -25,6 +26,8 @@ func run(c *cli.Context) error {
return err return err
} }
changeUnusedUsernames := os.Getenv("DB_CLEAN_CHANGE_UNUSED_USERNAMES") == "true"
ctx := context.Background() ctx := context.Background()
db, err := dbpkg.New() db, err := dbpkg.New()
@ -66,6 +69,41 @@ func run(c *cli.Context) error {
fmt.Printf("deleted %v expired exports\n", len(exports)) 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 var users []dbpkg.User
err = pgxscan.Select(ctx, db, &users, `SELECT * FROM users WHERE err = pgxscan.Select(ctx, db, &users, `SELECT * FROM users WHERE
deleted_at IS NOT NULL AND deleted_at IS NOT NULL AND
@ -78,7 +116,7 @@ func run(c *cli.Context) error {
} }
if len(users) == 0 { 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 return nil
} }
@ -132,5 +170,6 @@ func run(c *cli.Context) error {
} }
fmt.Printf("deleted %v users!\n", ct.RowsAffected()) fmt.Printf("deleted %v users!\n", ct.RowsAffected())
fmt.Println("finished cleaning database!")
return nil 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 package seeddb
import ( import (
"fmt" "log"
"os" "os"
"codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/urfave/cli/v2" "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{ var Command = &cli.Command{
Name: "seed", Name: "seed",
Usage: "Seed the database with test data", Usage: "Seed the database with test data",
@ -19,7 +46,7 @@ var Command = &cli.Command{
func run(c *cli.Context) error { func run(c *cli.Context) error {
err := godotenv.Load() err := godotenv.Load()
if err != nil { if err != nil {
fmt.Println("error loading .env file:", err) log.Println("error loading .env file:", err)
return err return err
} }
@ -27,112 +54,94 @@ func run(c *cli.Context) error {
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL")) pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil { if err != nil {
fmt.Println("error opening database:", err) log.Println("error opening database:", err)
return err return err
} }
defer pool.Close() defer pool.Close()
fmt.Println("opened database") log.Println("opened database")
pg := &db.DB{Pool: pool} 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) tx, err := pg.Begin(ctx)
if err != nil { if err != nil {
fmt.Println("error beginning transaction:", err) log.Println("error beginning transaction:", err)
return err return err
} }
defer tx.Rollback(ctx)
u, err := pg.CreateUser(ctx, tx, "test") for i, su := range seed.Users {
if err != nil { u, err := pg.CreateUser(ctx, tx, su.Username)
fmt.Println("error creating user:", err) if err != nil {
return err 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) _, err = pg.UpdateUser(ctx, tx, u.ID, su.DisplayName, su.Bio, nil, nil, &su.Links, nil, nil, nil)
if err != nil { if err != nil {
fmt.Println("error setting user info:", err) log.Printf("updating user %s: %v", su.Username, err)
return err return err
} }
err = pg.SetUserNamesPronouns(ctx, tx, u.ID, []db.FieldEntry{ err = pg.SetUserNamesPronouns(ctx, tx, u.ID, db.NotNull(su.Names), db.NotNull(su.Pronouns))
{Value: "testing 1", Status: "favourite"}, if err != nil {
{Value: "testing 2", Status: "okay"}, log.Printf("setting names/pronouns for user %s: %v", su.Username, err)
}, []db.PronounEntry{ return err
{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.SetUserFields(ctx, tx, u.ID, []db.Field{ err = pg.SetUserFields(ctx, tx, u.ID, db.NotNull(su.Fields))
{ if err != nil {
Name: "Field 1", log.Printf("setting fields for user %s: %v", su.Username, err)
Entries: []db.FieldEntry{ return err
{Value: "Favourite 1", Status: "favourite"}, }
{Value: "Okay 1", Status: "okay"},
{Value: "Jokingly 1", Status: "jokingly"}, log.Printf("creating members for user %s", su.Username)
{Value: "Friends only 1", Status: "friends_only"},
{Value: "Avoid 1", Status: "avoid"}, 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)
Name: "Field 2", return err
Entries: []db.FieldEntry{ }
{Value: "Favourite 2", Status: "favourite"},
{Value: "Okay 2", Status: "okay"}, err = pg.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(sm.Names), db.NotNull(sm.Pronouns))
{Value: "Jokingly 2", Status: "jokingly"}, if err != nil {
{Value: "Friends only 2", Status: "friends_only"}, log.Printf("setting names/pronouns for member %s: %v", sm.Name, err)
{Value: "Avoid 2", Status: "avoid"}, return err
}, }
},
{ err = pg.SetMemberFields(ctx, tx, m.ID, db.NotNull(sm.Fields))
Name: "Field 3", if err != nil {
Entries: []db.FieldEntry{ log.Printf("setting fields for member %s: %v", sm.Name, err)
{Value: "Favourite 3", Status: "favourite"}, return err
{Value: "Okay 3", Status: "okay"}, }
{Value: "Jokingly 3", Status: "jokingly"},
{Value: "Friends only 3", Status: "friends_only"}, log.Printf("created member %s", sm.Name)
{Value: "Avoid 3", Status: "avoid"}, }
},
}, log.Printf("created user %s", su.Username)
{
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 = tx.Commit(ctx) err = tx.Commit(ctx)
if err != nil { if err != nil {
fmt.Println("error committing transaction:", err) log.Println("error committing transaction:", err)
return 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 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