add global notices

This commit is contained in:
sam 2023-09-09 17:20:18 +02:00
parent cb563bc00b
commit f39a762072
No known key found for this signature in database
GPG Key ID: B4EF20DDE721CAA1
12 changed files with 207 additions and 7 deletions

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

@ -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

@ -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"
@ -25,6 +27,12 @@ type MetaResponse struct {
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

@ -65,6 +65,7 @@ export interface MeUser extends User {
export interface Settings { export interface Settings {
read_changelog: string; read_changelog: string;
read_settings_notice: string; read_settings_notice: string;
read_global_notice: number;
} }
export interface Field { export interface Field {

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

@ -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>
@ -34,6 +52,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

@ -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;