forked from mirrors/pronouns.cc
add global notices
This commit is contained in:
parent
cb563bc00b
commit
f39a762072
12 changed files with 207 additions and 7 deletions
66
backend/db/notice.go
Normal file
66
backend/db/notice.go
Normal 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
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
55
backend/routes/v1/mod/notices.go
Normal file
55
backend/routes/v1/mod/notices.go
Normal 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
|
||||||
|
}
|
|
@ -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())
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
14
scripts/migrate/022_notices.sql
Normal file
14
scripts/migrate/022_notices.sql
Normal 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;
|
Loading…
Reference in a new issue