From c780470afe11e4cf872766078e9a0880489cbf02 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 9 Sep 2023 00:58:02 +0200 Subject: [PATCH 1/3] 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/3] 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 f39a762072c56bb231148398d2d1928ffcf6ff09 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 9 Sep 2023 17:20:18 +0200 Subject: [PATCH 3/3] 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;