chore: reorganize backend and frontend modules

- remove the unused internal cron package
- split API service implementations by responsibility
- clarify frontend shared-module ownership
This commit is contained in:
boojack
2026-07-12 17:52:41 +08:00
parent c9b356b46a
commit 3fe145083f
60 changed files with 3811 additions and 6563 deletions
-1
View File
@@ -1 +0,0 @@
Fork from https://github.com/robfig/cron
-96
View File
@@ -1,96 +0,0 @@
package cron
import (
"errors"
"fmt"
"runtime"
"sync"
"time"
)
// JobWrapper decorates the given Job with some behavior.
type JobWrapper func(Job) Job
// Chain is a sequence of JobWrappers that decorates submitted jobs with
// cross-cutting behaviors like logging or synchronization.
type Chain struct {
wrappers []JobWrapper
}
// NewChain returns a Chain consisting of the given JobWrappers.
func NewChain(c ...JobWrapper) Chain {
return Chain{c}
}
// Then decorates the given job with all JobWrappers in the chain.
//
// This:
//
// NewChain(m1, m2, m3).Then(job)
//
// is equivalent to:
//
// m1(m2(m3(job)))
func (c Chain) Then(j Job) Job {
for i := range c.wrappers {
j = c.wrappers[len(c.wrappers)-i-1](j)
}
return j
}
// Recover panics in wrapped jobs and log them with the provided logger.
func Recover(logger Logger) JobWrapper {
return func(j Job) Job {
return FuncJob(func() {
defer func() {
if r := recover(); r != nil {
const size = 64 << 10
buf := make([]byte, size)
buf = buf[:runtime.Stack(buf, false)]
err, ok := r.(error)
if !ok {
err = errors.New("panic: " + fmt.Sprint(r))
}
logger.Error(err, "panic", "stack", "...\n"+string(buf))
}
}()
j.Run()
})
}
}
// DelayIfStillRunning serializes jobs, delaying subsequent runs until the
// previous one is complete. Jobs running after a delay of more than a minute
// have the delay logged at Info.
func DelayIfStillRunning(logger Logger) JobWrapper {
return func(j Job) Job {
var mu sync.Mutex
return FuncJob(func() {
start := time.Now()
mu.Lock()
defer mu.Unlock()
if dur := time.Since(start); dur > time.Minute {
logger.Info("delay", "duration", dur)
}
j.Run()
})
}
}
// SkipIfStillRunning skips an invocation of the Job if a previous invocation is
// still running. It logs skips to the given logger at Info level.
func SkipIfStillRunning(logger Logger) JobWrapper {
return func(j Job) Job {
var ch = make(chan struct{}, 1)
ch <- struct{}{}
return FuncJob(func() {
select {
case v := <-ch:
defer func() { ch <- v }()
j.Run()
default:
logger.Info("skip")
}
})
}
}
-252
View File
@@ -1,252 +0,0 @@
//nolint:all
package cron
import (
"io"
"log"
"reflect"
"sync"
"testing"
"time"
)
func waitFor(t *testing.T, timeout time.Duration, fn func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if fn() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("condition not met before timeout")
}
func appendingJob(slice *[]int, value int) Job {
var m sync.Mutex
return FuncJob(func() {
m.Lock()
*slice = append(*slice, value)
m.Unlock()
})
}
func appendingWrapper(slice *[]int, value int) JobWrapper {
return func(j Job) Job {
return FuncJob(func() {
appendingJob(slice, value).Run()
j.Run()
})
}
}
func TestChain(t *testing.T) {
var nums []int
var (
append1 = appendingWrapper(&nums, 1)
append2 = appendingWrapper(&nums, 2)
append3 = appendingWrapper(&nums, 3)
append4 = appendingJob(&nums, 4)
)
NewChain(append1, append2, append3).Then(append4).Run()
if !reflect.DeepEqual(nums, []int{1, 2, 3, 4}) {
t.Error("unexpected order of calls:", nums)
}
}
func TestChainRecover(t *testing.T) {
panickingJob := FuncJob(func() {
panic("panickingJob panics")
})
t.Run("panic exits job by default", func(*testing.T) {
defer func() {
if err := recover(); err == nil {
t.Errorf("panic expected, but none received")
}
}()
NewChain().Then(panickingJob).
Run()
})
t.Run("Recovering JobWrapper recovers", func(*testing.T) {
NewChain(Recover(PrintfLogger(log.New(io.Discard, "", 0)))).
Then(panickingJob).
Run()
})
t.Run("composed with the *IfStillRunning wrappers", func(*testing.T) {
NewChain(Recover(PrintfLogger(log.New(io.Discard, "", 0)))).
Then(panickingJob).
Run()
})
}
type countJob struct {
m sync.Mutex
started int
done int
delay time.Duration
}
func (j *countJob) Run() {
j.m.Lock()
j.started++
j.m.Unlock()
time.Sleep(j.delay)
j.m.Lock()
j.done++
j.m.Unlock()
}
func (j *countJob) Started() int {
defer j.m.Unlock()
j.m.Lock()
return j.started
}
func (j *countJob) Done() int {
defer j.m.Unlock()
j.m.Lock()
return j.done
}
func TestChainDelayIfStillRunning(t *testing.T) {
t.Run("runs immediately", func(*testing.T) {
var j countJob
wrappedJob := NewChain(DelayIfStillRunning(DiscardLogger)).Then(&j)
go wrappedJob.Run()
waitFor(t, 100*time.Millisecond, func() bool { return j.Done() == 1 })
if c := j.Done(); c != 1 {
t.Errorf("expected job run once, immediately, got %d", c)
}
})
t.Run("second run immediate if first done", func(*testing.T) {
var j countJob
wrappedJob := NewChain(DelayIfStillRunning(DiscardLogger)).Then(&j)
go func() {
go wrappedJob.Run()
time.Sleep(time.Millisecond)
go wrappedJob.Run()
}()
waitFor(t, 100*time.Millisecond, func() bool { return j.Done() == 2 })
if c := j.Done(); c != 2 {
t.Errorf("expected job run twice, immediately, got %d", c)
}
})
t.Run("second run delayed if first not done", func(*testing.T) {
var j countJob
j.delay = 10 * time.Millisecond
wrappedJob := NewChain(DelayIfStillRunning(DiscardLogger)).Then(&j)
go func() {
go wrappedJob.Run()
time.Sleep(time.Millisecond)
go wrappedJob.Run()
}()
waitFor(t, 100*time.Millisecond, func() bool { return j.Started() == 1 })
started, done := j.Started(), j.Done()
if done != 0 {
t.Error("expected first job started, but not finished, got", started, done)
}
waitFor(t, 200*time.Millisecond, func() bool { return j.Done() == 2 })
started, done = j.Started(), j.Done()
if started != 2 || done != 2 {
t.Error("expected both jobs done, got", started, done)
}
})
}
func TestChainSkipIfStillRunning(t *testing.T) {
t.Run("runs immediately", func(*testing.T) {
var j countJob
wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j)
go wrappedJob.Run()
time.Sleep(2 * time.Millisecond) // Give the job 2ms to complete.
if c := j.Done(); c != 1 {
t.Errorf("expected job run once, immediately, got %d", c)
}
})
t.Run("second run immediate if first done", func(*testing.T) {
var j countJob
wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j)
go func() {
go wrappedJob.Run()
time.Sleep(time.Millisecond)
go wrappedJob.Run()
}()
time.Sleep(3 * time.Millisecond) // Give both jobs 3ms to complete.
if c := j.Done(); c != 2 {
t.Errorf("expected job run twice, immediately, got %d", c)
}
})
t.Run("second run skipped if first not done", func(*testing.T) {
var j countJob
j.delay = 10 * time.Millisecond
wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j)
go func() {
go wrappedJob.Run()
time.Sleep(time.Millisecond)
go wrappedJob.Run()
}()
// After 5ms, the first job is still in progress, and the second job was
// already skipped.
time.Sleep(5 * time.Millisecond)
started, done := j.Started(), j.Done()
if started != 1 || done != 0 {
t.Error("expected first job started, but not finished, got", started, done)
}
// Verify that the first job completes and second does not run.
time.Sleep(25 * time.Millisecond)
started, done = j.Started(), j.Done()
if started != 1 || done != 1 {
t.Error("expected second job skipped, got", started, done)
}
})
t.Run("skip 10 jobs on rapid fire", func(*testing.T) {
var j countJob
j.delay = 10 * time.Millisecond
wrappedJob := NewChain(SkipIfStillRunning(DiscardLogger)).Then(&j)
for i := 0; i < 11; i++ {
go wrappedJob.Run()
}
time.Sleep(200 * time.Millisecond)
done := j.Done()
if done != 1 {
t.Error("expected 1 jobs executed, 10 jobs dropped, got", done)
}
})
t.Run("different jobs independent", func(*testing.T) {
var j1, j2 countJob
j1.delay = 10 * time.Millisecond
j2.delay = 10 * time.Millisecond
chain := NewChain(SkipIfStillRunning(DiscardLogger))
wrappedJob1 := chain.Then(&j1)
wrappedJob2 := chain.Then(&j2)
for i := 0; i < 11; i++ {
go wrappedJob1.Run()
go wrappedJob2.Run()
}
time.Sleep(100 * time.Millisecond)
var (
done1 = j1.Done()
done2 = j2.Done()
)
if done1 != 1 || done2 != 1 {
t.Error("expected both jobs executed once, got", done1, "and", done2)
}
})
}
-27
View File
@@ -1,27 +0,0 @@
package cron
import "time"
// ConstantDelaySchedule represents a simple recurring duty cycle, e.g. "Every 5 minutes".
// It does not support jobs more frequent than once a second.
type ConstantDelaySchedule struct {
Delay time.Duration
}
// Every returns a crontab Schedule that activates once every duration.
// Delays of less than a second are not supported (will round up to 1 second).
// Any fields less than a Second are truncated.
func Every(duration time.Duration) ConstantDelaySchedule {
if duration < time.Second {
duration = time.Second
}
return ConstantDelaySchedule{
Delay: duration - time.Duration(duration.Nanoseconds())%time.Second,
}
}
// Next returns the next time this should be run.
// This rounds so that the next activation time will be on the second.
func (schedule ConstantDelaySchedule) Next(t time.Time) time.Time {
return t.Add(schedule.Delay - time.Duration(t.Nanosecond())*time.Nanosecond)
}
-55
View File
@@ -1,55 +0,0 @@
//nolint:all
package cron
import (
"testing"
"time"
)
func TestConstantDelayNext(t *testing.T) {
tests := []struct {
time string
delay time.Duration
expected string
}{
// Simple cases
{"Mon Jul 9 14:45 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"},
{"Mon Jul 9 14:59 2012", 15 * time.Minute, "Mon Jul 9 15:14 2012"},
{"Mon Jul 9 14:59:59 2012", 15 * time.Minute, "Mon Jul 9 15:14:59 2012"},
// Wrap around hours
{"Mon Jul 9 15:45 2012", 35 * time.Minute, "Mon Jul 9 16:20 2012"},
// Wrap around days
{"Mon Jul 9 23:46 2012", 14 * time.Minute, "Tue Jul 10 00:00 2012"},
{"Mon Jul 9 23:45 2012", 35 * time.Minute, "Tue Jul 10 00:20 2012"},
{"Mon Jul 9 23:35:51 2012", 44*time.Minute + 24*time.Second, "Tue Jul 10 00:20:15 2012"},
{"Mon Jul 9 23:35:51 2012", 25*time.Hour + 44*time.Minute + 24*time.Second, "Thu Jul 11 01:20:15 2012"},
// Wrap around months
{"Mon Jul 9 23:35 2012", 91*24*time.Hour + 25*time.Minute, "Thu Oct 9 00:00 2012"},
// Wrap around minute, hour, day, month, and year
{"Mon Dec 31 23:59:45 2012", 15 * time.Second, "Tue Jan 1 00:00:00 2013"},
// Round to nearest second on the delay
{"Mon Jul 9 14:45 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"},
// Round up to 1 second if the duration is less.
{"Mon Jul 9 14:45:00 2012", 15 * time.Millisecond, "Mon Jul 9 14:45:01 2012"},
// Round to nearest second when calculating the next time.
{"Mon Jul 9 14:45:00.005 2012", 15 * time.Minute, "Mon Jul 9 15:00 2012"},
// Round to nearest second for both.
{"Mon Jul 9 14:45:00.005 2012", 15*time.Minute + 50*time.Nanosecond, "Mon Jul 9 15:00 2012"},
}
for _, c := range tests {
actual := Every(c.delay).Next(getTime(c.time))
expected := getTime(c.expected)
if actual != expected {
t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.delay, expected, actual)
}
}
}
-349
View File
@@ -1,349 +0,0 @@
package cron
import (
"context"
"slices"
"sync"
"time"
)
// Cron keeps track of any number of entries, invoking the associated func as
// specified by the schedule. It may be started, stopped, and the entries may
// be inspected while running.
type Cron struct {
entries []*Entry
chain Chain
stop chan struct{}
add chan *Entry
remove chan EntryID
snapshot chan chan []Entry
running bool
logger Logger
runningMu sync.Mutex
location *time.Location
parser ScheduleParser
nextID EntryID
jobWaiter sync.WaitGroup
}
// ScheduleParser is an interface for schedule spec parsers that return a Schedule.
type ScheduleParser interface {
Parse(spec string) (Schedule, error)
}
// Job is an interface for submitted cron jobs.
type Job interface {
Run()
}
// Schedule describes a job's duty cycle.
type Schedule interface {
// Next returns the next activation time, later than the given time.
// Next is invoked initially, and then each time the job is run.
Next(time.Time) time.Time
}
// EntryID identifies an entry within a Cron instance.
type EntryID int
// Entry consists of a schedule and the func to execute on that schedule.
type Entry struct {
// ID is the cron-assigned ID of this entry, which may be used to look up a
// snapshot or remove it.
ID EntryID
// Schedule on which this job should be run.
Schedule Schedule
// Next time the job will run, or the zero time if Cron has not been
// started or this entry's schedule is unsatisfiable
Next time.Time
// Prev is the last time this job was run, or the zero time if never.
Prev time.Time
// WrappedJob is the thing to run when the Schedule is activated.
WrappedJob Job
// Job is the thing that was submitted to cron.
// It is kept around so that user code that needs to get at the job later,
// e.g. via Entries() can do so.
Job Job
}
// Valid returns true if this is not the zero entry.
func (e Entry) Valid() bool { return e.ID != 0 }
// New returns a new Cron job runner, modified by the given options.
//
// Available Settings
//
// Time Zone
// Description: The time zone in which schedules are interpreted
// Default: time.Local
//
// Parser
// Description: Parser converts cron spec strings into cron.Schedules.
// Default: Accepts this spec: https://en.wikipedia.org/wiki/Cron
//
// Chain
// Description: Wrap submitted jobs to customize behavior.
// Default: A chain that recovers panics and logs them to stderr.
//
// See "cron.With*" to modify the default behavior.
func New(opts ...Option) *Cron {
c := &Cron{
entries: nil,
chain: NewChain(),
add: make(chan *Entry),
stop: make(chan struct{}),
snapshot: make(chan chan []Entry),
remove: make(chan EntryID),
running: false,
runningMu: sync.Mutex{},
logger: DefaultLogger,
location: time.Local,
parser: standardParser,
}
for _, opt := range opts {
opt(c)
}
return c
}
// FuncJob is a wrapper that turns a func() into a cron.Job.
type FuncJob func()
func (f FuncJob) Run() { f() }
// AddFunc adds a func to the Cron to be run on the given schedule.
// The spec is parsed using the time zone of this Cron instance as the default.
// An opaque ID is returned that can be used to later remove it.
func (c *Cron) AddFunc(spec string, cmd func()) (EntryID, error) {
return c.AddJob(spec, FuncJob(cmd))
}
// AddJob adds a Job to the Cron to be run on the given schedule.
// The spec is parsed using the time zone of this Cron instance as the default.
// An opaque ID is returned that can be used to later remove it.
func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error) {
schedule, err := c.parser.Parse(spec)
if err != nil {
return 0, err
}
return c.Schedule(schedule, cmd), nil
}
// Schedule adds a Job to the Cron to be run on the given schedule.
// The job is wrapped with the configured Chain.
func (c *Cron) Schedule(schedule Schedule, cmd Job) EntryID {
c.runningMu.Lock()
defer c.runningMu.Unlock()
c.nextID++
entry := &Entry{
ID: c.nextID,
Schedule: schedule,
WrappedJob: c.chain.Then(cmd),
Job: cmd,
}
if !c.running {
c.entries = append(c.entries, entry)
} else {
c.add <- entry
}
return entry.ID
}
// Entries returns a snapshot of the cron entries.
func (c *Cron) Entries() []Entry {
c.runningMu.Lock()
defer c.runningMu.Unlock()
if c.running {
replyChan := make(chan []Entry, 1)
c.snapshot <- replyChan
return <-replyChan
}
return c.entrySnapshot()
}
// Location gets the time zone location.
func (c *Cron) Location() *time.Location {
return c.location
}
// Entry returns a snapshot of the given entry, or nil if it couldn't be found.
func (c *Cron) Entry(id EntryID) Entry {
for _, entry := range c.Entries() {
if id == entry.ID {
return entry
}
}
return Entry{}
}
// Remove an entry from being run in the future.
func (c *Cron) Remove(id EntryID) {
c.runningMu.Lock()
defer c.runningMu.Unlock()
if c.running {
c.remove <- id
} else {
c.removeEntry(id)
}
}
// Start the cron scheduler in its own goroutine, or no-op if already started.
func (c *Cron) Start() {
c.runningMu.Lock()
defer c.runningMu.Unlock()
if c.running {
return
}
c.running = true
go c.runScheduler()
}
// Run the cron scheduler, or no-op if already running.
func (c *Cron) Run() {
c.runningMu.Lock()
if c.running {
c.runningMu.Unlock()
return
}
c.running = true
c.runningMu.Unlock()
c.runScheduler()
}
// runScheduler runs the scheduler.. this is private just due to the need to synchronize
// access to the 'running' state variable.
func (c *Cron) runScheduler() {
c.logger.Info("start")
// Figure out the next activation times for each entry.
now := c.now()
for _, entry := range c.entries {
entry.Next = entry.Schedule.Next(now)
c.logger.Info("schedule", "now", now, "entry", entry.ID, "next", entry.Next)
}
for {
// Determine the next entry to run.
slices.SortFunc(c.entries, func(a, b *Entry) int {
switch {
case a.Next.IsZero() && b.Next.IsZero():
return 0
case a.Next.IsZero():
return 1
case b.Next.IsZero():
return -1
case a.Next.Before(b.Next):
return -1
case b.Next.Before(a.Next):
return 1
default:
return 0
}
})
var timer *time.Timer
if len(c.entries) == 0 || c.entries[0].Next.IsZero() {
// If there are no entries yet, just sleep - it still handles new entries
// and stop requests.
timer = time.NewTimer(100000 * time.Hour)
} else {
timer = time.NewTimer(c.entries[0].Next.Sub(now))
}
for {
select {
case now = <-timer.C:
now = now.In(c.location)
c.logger.Info("wake", "now", now)
// Run every entry whose next time was less than now
for _, e := range c.entries {
if e.Next.After(now) || e.Next.IsZero() {
break
}
c.startJob(e.WrappedJob)
e.Prev = e.Next
e.Next = e.Schedule.Next(now)
c.logger.Info("run", "now", now, "entry", e.ID, "next", e.Next)
}
case newEntry := <-c.add:
timer.Stop()
now = c.now()
newEntry.Next = newEntry.Schedule.Next(now)
c.entries = append(c.entries, newEntry)
c.logger.Info("added", "now", now, "entry", newEntry.ID, "next", newEntry.Next)
case replyChan := <-c.snapshot:
replyChan <- c.entrySnapshot()
continue
case <-c.stop:
timer.Stop()
c.logger.Info("stop")
return
case id := <-c.remove:
timer.Stop()
now = c.now()
c.removeEntry(id)
c.logger.Info("removed", "entry", id)
}
break
}
}
}
// startJob runs the given job in a new goroutine.
func (c *Cron) startJob(j Job) {
c.jobWaiter.Go(func() {
j.Run()
})
}
// now returns current time in c location.
func (c *Cron) now() time.Time {
return time.Now().In(c.location)
}
// Stop stops the cron scheduler if it is running; otherwise it does nothing.
// A context is returned so the caller can wait for running jobs to complete.
func (c *Cron) Stop() context.Context {
c.runningMu.Lock()
defer c.runningMu.Unlock()
if c.running {
c.stop <- struct{}{}
c.running = false
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
c.jobWaiter.Wait()
cancel()
}()
return ctx
}
// entrySnapshot returns a copy of the current cron entry list.
func (c *Cron) entrySnapshot() []Entry {
var entries = make([]Entry, len(c.entries))
for i, e := range c.entries {
entries[i] = *e
}
return entries
}
func (c *Cron) removeEntry(id EntryID) {
var entries []*Entry
for _, e := range c.entries {
if e.ID != id {
entries = append(entries, e)
}
}
c.entries = entries
}
-705
View File
@@ -1,705 +0,0 @@
//nolint:all
package cron
import (
"bytes"
"fmt"
"log"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// Many tests schedule a job for every second, and then wait at most a second
// for it to run. This amount is just slightly larger than 1 second to
// compensate for a few milliseconds of runtime.
const OneSecond = 1*time.Second + 50*time.Millisecond
type syncWriter struct {
wr bytes.Buffer
m sync.Mutex
}
func (sw *syncWriter) Write(data []byte) (n int, err error) {
sw.m.Lock()
n, err = sw.wr.Write(data)
sw.m.Unlock()
return
}
func (sw *syncWriter) String() string {
sw.m.Lock()
defer sw.m.Unlock()
return sw.wr.String()
}
func newBufLogger(sw *syncWriter) Logger {
return PrintfLogger(log.New(sw, "", log.LstdFlags))
}
func TestFuncPanicRecovery(t *testing.T) {
var buf syncWriter
cron := New(WithParser(secondParser),
WithChain(Recover(newBufLogger(&buf))))
cron.Start()
defer cron.Stop()
cron.AddFunc("* * * * * ?", func() {
panic("YOLO")
})
select {
case <-time.After(OneSecond):
if !strings.Contains(buf.String(), "YOLO") {
t.Error("expected a panic to be logged, got none")
}
return
}
}
type DummyJob struct{}
func (DummyJob) Run() {
panic("YOLO")
}
func TestJobPanicRecovery(t *testing.T) {
var job DummyJob
var buf syncWriter
cron := New(WithParser(secondParser),
WithChain(Recover(newBufLogger(&buf))))
cron.Start()
defer cron.Stop()
cron.AddJob("* * * * * ?", job)
select {
case <-time.After(OneSecond):
if !strings.Contains(buf.String(), "YOLO") {
t.Error("expected a panic to be logged, got none")
}
return
}
}
// Start and stop cron with no entries.
func TestNoEntries(t *testing.T) {
cron := newWithSeconds()
cron.Start()
select {
case <-time.After(OneSecond):
t.Fatal("expected cron will be stopped immediately")
case <-stop(cron):
}
}
// Start, stop, then add an entry. Verify entry doesn't run.
func TestStopCausesJobsToNotRun(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(1)
cron := newWithSeconds()
cron.Start()
cron.Stop()
cron.AddFunc("* * * * * ?", func() { wg.Done() })
select {
case <-time.After(OneSecond):
// No job ran!
case <-wait(wg):
t.Fatal("expected stopped cron does not run any job")
}
}
// Add a job, start cron, expect it runs.
func TestAddBeforeRunning(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(1)
cron := newWithSeconds()
cron.AddFunc("* * * * * ?", func() { wg.Done() })
cron.Start()
defer cron.Stop()
// Give cron 2 seconds to run our job (which is always activated).
select {
case <-time.After(OneSecond):
t.Fatal("expected job runs")
case <-wait(wg):
}
}
// Start cron, add a job, expect it runs.
func TestAddWhileRunning(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(1)
cron := newWithSeconds()
cron.Start()
defer cron.Stop()
cron.AddFunc("* * * * * ?", func() { wg.Done() })
select {
case <-time.After(OneSecond):
t.Fatal("expected job runs")
case <-wait(wg):
}
}
// Test for #34. Adding a job after calling start results in multiple job invocations
func TestAddWhileRunningWithDelay(t *testing.T) {
cron := newWithSeconds()
cron.Start()
defer cron.Stop()
time.Sleep(5 * time.Second)
var calls int64
cron.AddFunc("* * * * * *", func() { atomic.AddInt64(&calls, 1) })
<-time.After(OneSecond)
if atomic.LoadInt64(&calls) != 1 {
t.Errorf("called %d times, expected 1\n", calls)
}
}
// Add a job, remove a job, start cron, expect nothing runs.
func TestRemoveBeforeRunning(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(1)
cron := newWithSeconds()
id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() })
cron.Remove(id)
cron.Start()
defer cron.Stop()
select {
case <-time.After(OneSecond):
// Success, shouldn't run
case <-wait(wg):
t.FailNow()
}
}
// Start cron, add a job, remove it, expect it doesn't run.
func TestRemoveWhileRunning(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(1)
cron := newWithSeconds()
cron.Start()
defer cron.Stop()
id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() })
cron.Remove(id)
select {
case <-time.After(OneSecond):
case <-wait(wg):
t.FailNow()
}
}
// Test timing with Entries.
func TestSnapshotEntries(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(1)
cron := New()
cron.AddFunc("@every 2s", func() { wg.Done() })
cron.Start()
defer cron.Stop()
// Cron should fire in 2 seconds. After 1 second, call Entries.
select {
case <-time.After(OneSecond):
cron.Entries()
}
// Even though Entries was called, the cron should fire at the 2 second mark.
select {
case <-time.After(OneSecond):
t.Error("expected job runs at 2 second mark")
case <-wait(wg):
}
}
// Test that the entries are correctly sorted.
// Add a bunch of long-in-the-future entries, and an immediate entry, and ensure
// that the immediate entry runs immediately.
// Also: Test that multiple jobs run in the same instant.
func TestMultipleEntries(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(2)
cron := newWithSeconds()
cron.AddFunc("0 0 0 1 1 ?", func() {})
cron.AddFunc("* * * * * ?", func() { wg.Done() })
id1, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() })
id2, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() })
cron.AddFunc("0 0 0 31 12 ?", func() {})
cron.AddFunc("* * * * * ?", func() { wg.Done() })
cron.Remove(id1)
cron.Start()
cron.Remove(id2)
defer cron.Stop()
select {
case <-time.After(OneSecond):
t.Error("expected job run in proper order")
case <-wait(wg):
}
}
// Test running the same job twice.
func TestRunningJobTwice(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(2)
cron := newWithSeconds()
cron.AddFunc("0 0 0 1 1 ?", func() {})
cron.AddFunc("0 0 0 31 12 ?", func() {})
cron.AddFunc("* * * * * ?", func() { wg.Done() })
cron.Start()
defer cron.Stop()
select {
case <-time.After(2 * OneSecond):
t.Error("expected job fires 2 times")
case <-wait(wg):
}
}
func TestRunningMultipleSchedules(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(2)
cron := newWithSeconds()
cron.AddFunc("0 0 0 1 1 ?", func() {})
cron.AddFunc("0 0 0 31 12 ?", func() {})
cron.AddFunc("* * * * * ?", func() { wg.Done() })
cron.Schedule(Every(time.Minute), FuncJob(func() {}))
cron.Schedule(Every(time.Second), FuncJob(func() { wg.Done() }))
cron.Schedule(Every(time.Hour), FuncJob(func() {}))
cron.Start()
defer cron.Stop()
select {
case <-time.After(2 * OneSecond):
t.Error("expected job fires 2 times")
case <-wait(wg):
}
}
// Test that the cron is run in the local time zone (as opposed to UTC).
func TestLocalTimezone(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(2)
now := time.Now()
// FIX: Issue #205
// This calculation doesn't work in seconds 58 or 59.
// Take the easy way out and sleep.
if now.Second() >= 58 {
time.Sleep(2 * time.Second)
now = time.Now()
}
spec := fmt.Sprintf("%d,%d %d %d %d %d ?",
now.Second()+1, now.Second()+2, now.Minute(), now.Hour(), now.Day(), now.Month())
cron := newWithSeconds()
cron.AddFunc(spec, func() { wg.Done() })
cron.Start()
defer cron.Stop()
select {
case <-time.After(OneSecond * 2):
t.Error("expected job fires 2 times")
case <-wait(wg):
}
}
// Test that the cron is run in the given time zone (as opposed to local).
func TestNonLocalTimezone(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(2)
loc, err := time.LoadLocation("Atlantic/Cape_Verde")
if err != nil {
fmt.Printf("Failed to load time zone Atlantic/Cape_Verde: %+v", err)
t.Fail()
}
now := time.Now().In(loc)
// FIX: Issue #205
// This calculation doesn't work in seconds 58 or 59.
// Take the easy way out and sleep.
if now.Second() >= 58 {
time.Sleep(2 * time.Second)
now = time.Now().In(loc)
}
spec := fmt.Sprintf("%d,%d %d %d %d %d ?",
now.Second()+1, now.Second()+2, now.Minute(), now.Hour(), now.Day(), now.Month())
cron := New(WithLocation(loc), WithParser(secondParser))
cron.AddFunc(spec, func() { wg.Done() })
cron.Start()
defer cron.Stop()
select {
case <-time.After(OneSecond * 2):
t.Error("expected job fires 2 times")
case <-wait(wg):
}
}
// Test that calling stop before start silently returns without
// blocking the stop channel.
func TestStopWithoutStart(t *testing.T) {
cron := New()
cron.Stop()
}
type testJob struct {
wg *sync.WaitGroup
name string
}
func (t testJob) Run() {
t.wg.Done()
}
// Test that adding an invalid job spec returns an error
func TestInvalidJobSpec(t *testing.T) {
cron := New()
_, err := cron.AddJob("this will not parse", nil)
if err == nil {
t.Errorf("expected an error with invalid spec, got nil")
}
}
// Test blocking run method behaves as Start()
func TestBlockingRun(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(1)
cron := newWithSeconds()
cron.AddFunc("* * * * * ?", func() { wg.Done() })
var unblockChan = make(chan struct{})
go func() {
cron.Run()
close(unblockChan)
}()
defer cron.Stop()
select {
case <-time.After(OneSecond):
t.Error("expected job fires")
case <-unblockChan:
t.Error("expected that Run() blocks")
case <-wait(wg):
}
}
// Test that double-running is a no-op
func TestStartNoop(t *testing.T) {
var tickChan = make(chan struct{}, 2)
cron := newWithSeconds()
cron.AddFunc("* * * * * ?", func() {
tickChan <- struct{}{}
})
cron.Start()
defer cron.Stop()
// Wait for the first firing to ensure the runner is going
<-tickChan
cron.Start()
<-tickChan
// Fail if this job fires again in a short period, indicating a double-run
select {
case <-time.After(time.Millisecond):
case <-tickChan:
t.Error("expected job fires exactly twice")
}
}
// Simple test using Runnables.
func TestJob(t *testing.T) {
wg := &sync.WaitGroup{}
wg.Add(1)
cron := newWithSeconds()
cron.AddJob("0 0 0 30 Feb ?", testJob{wg, "job0"})
cron.AddJob("0 0 0 1 1 ?", testJob{wg, "job1"})
job2, _ := cron.AddJob("* * * * * ?", testJob{wg, "job2"})
cron.AddJob("1 0 0 1 1 ?", testJob{wg, "job3"})
cron.Schedule(Every(5*time.Second+5*time.Nanosecond), testJob{wg, "job4"})
job5 := cron.Schedule(Every(5*time.Minute), testJob{wg, "job5"})
// Test getting an Entry pre-Start.
if actualName := cron.Entry(job2).Job.(testJob).name; actualName != "job2" {
t.Error("wrong job retrieved:", actualName)
}
if actualName := cron.Entry(job5).Job.(testJob).name; actualName != "job5" {
t.Error("wrong job retrieved:", actualName)
}
cron.Start()
defer cron.Stop()
select {
case <-time.After(OneSecond):
t.FailNow()
case <-wait(wg):
}
// Ensure the entries are in the right order.
expecteds := []string{"job2", "job4", "job5", "job1", "job3", "job0"}
var actuals []string
for _, entry := range cron.Entries() {
actuals = append(actuals, entry.Job.(testJob).name)
}
for i, expected := range expecteds {
if actuals[i] != expected {
t.Fatalf("Jobs not in the right order. (expected) %s != %s (actual)", expecteds, actuals)
}
}
// Test getting Entries.
if actualName := cron.Entry(job2).Job.(testJob).name; actualName != "job2" {
t.Error("wrong job retrieved:", actualName)
}
if actualName := cron.Entry(job5).Job.(testJob).name; actualName != "job5" {
t.Error("wrong job retrieved:", actualName)
}
}
// Issue #206
// Ensure that the next run of a job after removing an entry is accurate.
func TestScheduleAfterRemoval(t *testing.T) {
var wg1 sync.WaitGroup
var wg2 sync.WaitGroup
wg1.Add(1)
wg2.Add(1)
// The first time this job is run, set a timer and remove the other job
// 750ms later. Correct behavior would be to still run the job again in
// 250ms, but the bug would cause it to run instead 1s later.
var calls int
var mu sync.Mutex
cron := newWithSeconds()
hourJob := cron.Schedule(Every(time.Hour), FuncJob(func() {}))
cron.Schedule(Every(time.Second), FuncJob(func() {
mu.Lock()
defer mu.Unlock()
switch calls {
case 0:
wg1.Done()
calls++
case 1:
time.Sleep(750 * time.Millisecond)
cron.Remove(hourJob)
calls++
case 2:
calls++
wg2.Done()
case 3:
panic("unexpected 3rd call")
}
}))
cron.Start()
defer cron.Stop()
// the first run might be any length of time 0 - 1s, since the schedule
// rounds to the second. wait for the first run to true up.
wg1.Wait()
select {
case <-time.After(2 * OneSecond):
t.Error("expected job fires 2 times")
case <-wait(&wg2):
}
}
type ZeroSchedule struct{}
func (*ZeroSchedule) Next(time.Time) time.Time {
return time.Time{}
}
// Tests that job without time does not run
func TestJobWithZeroTimeDoesNotRun(t *testing.T) {
cron := newWithSeconds()
var calls int64
cron.AddFunc("* * * * * *", func() { atomic.AddInt64(&calls, 1) })
cron.Schedule(new(ZeroSchedule), FuncJob(func() { t.Error("expected zero task will not run") }))
cron.Start()
defer cron.Stop()
<-time.After(OneSecond)
if atomic.LoadInt64(&calls) != 1 {
t.Errorf("called %d times, expected 1\n", calls)
}
}
func TestStopAndWait(t *testing.T) {
t.Run("nothing running, returns immediately", func(*testing.T) {
cron := newWithSeconds()
cron.Start()
ctx := cron.Stop()
select {
case <-ctx.Done():
case <-time.After(time.Millisecond):
t.Error("context was not done immediately")
}
})
t.Run("repeated calls to Stop", func(*testing.T) {
cron := newWithSeconds()
cron.Start()
_ = cron.Stop()
time.Sleep(time.Millisecond)
ctx := cron.Stop()
select {
case <-ctx.Done():
case <-time.After(time.Millisecond):
t.Error("context was not done immediately")
}
})
t.Run("a couple fast jobs added, still returns immediately", func(*testing.T) {
cron := newWithSeconds()
cron.AddFunc("* * * * * *", func() {})
cron.Start()
cron.AddFunc("* * * * * *", func() {})
cron.AddFunc("* * * * * *", func() {})
cron.AddFunc("* * * * * *", func() {})
time.Sleep(time.Second)
ctx := cron.Stop()
select {
case <-ctx.Done():
case <-time.After(time.Millisecond):
t.Error("context was not done immediately")
}
})
t.Run("a couple fast jobs and a slow job added, waits for slow job", func(*testing.T) {
cron := newWithSeconds()
cron.AddFunc("* * * * * *", func() {})
cron.Start()
cron.AddFunc("* * * * * *", func() { time.Sleep(2 * time.Second) })
cron.AddFunc("* * * * * *", func() {})
time.Sleep(time.Second)
ctx := cron.Stop()
// Verify that it is not done for at least 750ms
select {
case <-ctx.Done():
t.Error("context was done too quickly immediately")
case <-time.After(750 * time.Millisecond):
// expected, because the job sleeping for 1 second is still running
}
// Verify that it IS done in the next 500ms (giving 250ms buffer)
select {
case <-ctx.Done():
// expected
case <-time.After(1500 * time.Millisecond):
t.Error("context not done after job should have completed")
}
})
t.Run("repeated calls to stop, waiting for completion and after", func(*testing.T) {
cron := newWithSeconds()
cron.AddFunc("* * * * * *", func() {})
cron.AddFunc("* * * * * *", func() { time.Sleep(3 * time.Second) })
cron.Start()
cron.AddFunc("* * * * * *", func() {})
time.Sleep(time.Second)
ctx := cron.Stop()
ctx2 := cron.Stop()
// Verify that it is not done for at least 1500ms.
// The 3-second sleep job starts within 1s of cron.Start(), so it always has
// at least 2s remaining when Stop() is called, well above this threshold.
select {
case <-ctx.Done():
t.Error("context was done too quickly immediately")
case <-ctx2.Done():
t.Error("context2 was done too quickly immediately")
case <-time.After(1500 * time.Millisecond):
// expected, because the job sleeping for 3 seconds is still running
}
// Verify that it IS done in the next 2s (giving 500ms buffer).
// After the 1500ms wait above, at most 1500ms of the job remain.
select {
case <-ctx.Done():
// expected
case <-time.After(2 * time.Second):
t.Error("context not done after job should have completed")
}
// Verify that ctx2 is also done.
select {
case <-ctx2.Done():
// expected
case <-time.After(time.Millisecond):
t.Error("context2 not done even though context1 is")
}
// Verify that a new context retrieved from stop is immediately done.
ctx3 := cron.Stop()
select {
case <-ctx3.Done():
// expected
case <-time.After(time.Millisecond):
t.Error("context not done even when cron Stop is completed")
}
})
}
func TestMultiThreadedStartAndStop(t *testing.T) {
cron := New()
go cron.Run()
time.Sleep(2 * time.Millisecond)
cron.Stop()
}
func wait(wg *sync.WaitGroup) chan bool {
ch := make(chan bool)
go func() {
wg.Wait()
ch <- true
}()
return ch
}
func stop(cron *Cron) chan bool {
ch := make(chan bool)
go func() {
cron.Stop()
ch <- true
}()
return ch
}
// newWithSeconds returns a Cron with the seconds field enabled.
func newWithSeconds() *Cron {
return New(WithParser(secondParser), WithChain())
}
-86
View File
@@ -1,86 +0,0 @@
package cron
import (
"io"
"log"
"os"
"strings"
"time"
)
// DefaultLogger is used by Cron if none is specified.
var DefaultLogger = PrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags))
// DiscardLogger can be used by callers to discard all log messages.
var DiscardLogger = PrintfLogger(log.New(io.Discard, "", 0))
// Logger is the interface used in this package for logging, so that any backend
// can be plugged in. It is a subset of the github.com/go-logr/logr interface.
type Logger interface {
// Info logs routine messages about cron's operation.
Info(msg string, keysAndValues ...interface{})
// Error logs an error condition.
Error(err error, msg string, keysAndValues ...interface{})
}
// PrintfLogger wraps a Printf-based logger (such as the standard library "log")
// into an implementation of the Logger interface which logs errors only.
func PrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger {
return printfLogger{l, false}
}
// VerbosePrintfLogger wraps a Printf-based logger (such as the standard library
// "log") into an implementation of the Logger interface which logs everything.
func VerbosePrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger {
return printfLogger{l, true}
}
type printfLogger struct {
logger interface{ Printf(string, ...interface{}) }
logInfo bool
}
func (pl printfLogger) Info(msg string, keysAndValues ...interface{}) {
if pl.logInfo {
keysAndValues = formatTimes(keysAndValues)
pl.logger.Printf(
formatString(len(keysAndValues)),
append([]interface{}{msg}, keysAndValues...)...)
}
}
func (pl printfLogger) Error(err error, msg string, keysAndValues ...interface{}) {
keysAndValues = formatTimes(keysAndValues)
pl.logger.Printf(
formatString(len(keysAndValues)+2),
append([]interface{}{msg, "error", err}, keysAndValues...)...)
}
// formatString returns a logfmt-like format string for the number of
// key/values.
func formatString(numKeysAndValues int) string {
var sb strings.Builder
sb.WriteString("%s")
if numKeysAndValues > 0 {
sb.WriteString(", ")
}
for i := 0; i < numKeysAndValues/2; i++ {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString("%v=%v")
}
return sb.String()
}
// formatTimes formats any time.Time values as RFC3339.
func formatTimes(keysAndValues []interface{}) []interface{} {
var formattedArgs []interface{}
for _, arg := range keysAndValues {
if t, ok := arg.(time.Time); ok {
arg = t.Format(time.RFC3339)
}
formattedArgs = append(formattedArgs, arg)
}
return formattedArgs
}
-45
View File
@@ -1,45 +0,0 @@
package cron
import (
"time"
)
// Option represents a modification to the default behavior of a Cron.
type Option func(*Cron)
// WithLocation overrides the timezone of the cron instance.
func WithLocation(loc *time.Location) Option {
return func(c *Cron) {
c.location = loc
}
}
// WithSeconds overrides the parser used for interpreting job schedules to
// include a seconds field as the first one.
func WithSeconds() Option {
return WithParser(NewParser(
Second | Minute | Hour | Dom | Month | Dow | Descriptor,
))
}
// WithParser overrides the parser used for interpreting job schedules.
func WithParser(p ScheduleParser) Option {
return func(c *Cron) {
c.parser = p
}
}
// WithChain specifies Job wrappers to apply to all jobs added to this cron.
// Refer to the Chain* functions in this package for provided wrappers.
func WithChain(wrappers ...JobWrapper) Option {
return func(c *Cron) {
c.chain = NewChain(wrappers...)
}
}
// WithLogger uses the provided logger.
func WithLogger(logger Logger) Option {
return func(c *Cron) {
c.logger = logger
}
}
-43
View File
@@ -1,43 +0,0 @@
//nolint:all
package cron
import (
"log"
"strings"
"testing"
"time"
)
func TestWithLocation(t *testing.T) {
c := New(WithLocation(time.UTC))
if c.location != time.UTC {
t.Errorf("expected UTC, got %v", c.location)
}
}
func TestWithParser(t *testing.T) {
var parser = NewParser(Dow)
c := New(WithParser(parser))
if c.parser != parser {
t.Error("expected provided parser")
}
}
func TestWithVerboseLogger(t *testing.T) {
var buf syncWriter
var logger = log.New(&buf, "", log.LstdFlags)
c := New(WithLogger(VerbosePrintfLogger(logger)))
if c.logger.(printfLogger).logger != logger {
t.Error("expected provided logger")
}
c.AddFunc("@every 1s", func() {})
c.Start()
time.Sleep(OneSecond)
c.Stop()
out := buf.String()
if !strings.Contains(out, "schedule,") ||
!strings.Contains(out, "run,") {
t.Error("expected to see some actions, got:", out)
}
}
-446
View File
@@ -1,446 +0,0 @@
package cron
import (
"math"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
)
// Configuration options for creating a parser. Most options specify which
// fields should be included, while others enable features. If a field is not
// included the parser will assume a default value. These options do not change
// the order fields are parsed in.
type ParseOption int
const (
Second ParseOption = 1 << iota // Seconds field, default 0
SecondOptional // Optional seconds field, default 0
Minute // Minutes field, default 0
Hour // Hours field, default 0
Dom // Day of month field, default *
Month // Month field, default *
Dow // Day of week field, default *
DowOptional // Optional day of week field, default *
Descriptor // Allow descriptors such as @monthly, @weekly, etc.
)
const (
secondIndex = iota
minuteIndex
hourIndex
domIndex
monthIndex
dowIndex
)
var places = []ParseOption{
Second,
Minute,
Hour,
Dom,
Month,
Dow,
}
var defaults = []string{
"0",
"0",
"0",
"*",
"*",
"*",
}
// A custom Parser that can be configured.
type Parser struct {
options ParseOption
}
// NewParser creates a Parser with custom options.
//
// It panics if more than one Optional is given, since it would be impossible to
// correctly infer which optional is provided or missing in general.
//
// Examples
//
// // Standard parser without descriptors
// specParser := NewParser(Minute | Hour | Dom | Month | Dow)
// sched, err := specParser.Parse("0 0 15 */3 *")
//
// // Same as above, just excludes time fields
// specParser := NewParser(Dom | Month | Dow)
// sched, err := specParser.Parse("15 */3 *")
//
// // Same as above, just makes Dow optional
// specParser := NewParser(Dom | Month | DowOptional)
// sched, err := specParser.Parse("15 */3")
func NewParser(options ParseOption) Parser {
optionals := 0
if options&DowOptional > 0 {
optionals++
}
if options&SecondOptional > 0 {
optionals++
}
if optionals > 1 {
panic("multiple optionals may not be configured")
}
return Parser{options}
}
// Parse returns a new crontab schedule representing the given spec.
// It returns a descriptive error if the spec is not valid.
// It accepts crontab specs and features configured by NewParser.
func (p Parser) Parse(spec string) (Schedule, error) {
if len(spec) == 0 {
return nil, errors.New("empty spec string")
}
// Extract timezone if present
var loc = time.Local
if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") {
var err error
i := strings.Index(spec, " ")
eq := strings.Index(spec, "=")
if loc, err = time.LoadLocation(spec[eq+1 : i]); err != nil {
return nil, errors.Wrap(err, "provided bad location")
}
spec = strings.TrimSpace(spec[i:])
}
// Handle named schedules (descriptors), if configured
if strings.HasPrefix(spec, "@") {
if p.options&Descriptor == 0 {
return nil, errors.New("descriptors not enabled")
}
return parseDescriptor(spec, loc)
}
// Split on whitespace.
fields := strings.Fields(spec)
// Validate & fill in any omitted or optional fields
var err error
fields, err = normalizeFields(fields, p.options)
if err != nil {
return nil, err
}
field := func(field string, r bounds) uint64 {
if err != nil {
return 0
}
var bits uint64
bits, err = getField(field, r)
return bits
}
var (
second = field(fields[0], seconds)
minute = field(fields[1], minutes)
hour = field(fields[2], hours)
dayofmonth = field(fields[3], dom)
month = field(fields[4], months)
dayofweek = field(fields[5], dow)
)
if err != nil {
return nil, err
}
return &SpecSchedule{
Second: second,
Minute: minute,
Hour: hour,
Dom: dayofmonth,
Month: month,
Dow: dayofweek,
Location: loc,
}, nil
}
// normalizeFields takes a subset set of the time fields and returns the full set
// with defaults (zeroes) populated for unset fields.
//
// As part of performing this function, it also validates that the provided
// fields are compatible with the configured options.
func normalizeFields(fields []string, options ParseOption) ([]string, error) {
// Validate optionals & add their field to options
optionals := 0
if options&SecondOptional > 0 {
options |= Second
optionals++
}
if options&DowOptional > 0 {
options |= Dow
optionals++
}
if optionals > 1 {
return nil, errors.New("multiple optionals may not be configured")
}
// Figure out how many fields we need
max := 0
for _, place := range places {
if options&place > 0 {
max++
}
}
min := max - optionals
// Validate number of fields
if count := len(fields); count < min || count > max {
if min == max {
return nil, errors.New("incorrect number of fields")
}
return nil, errors.New("incorrect number of fields, expected " + strconv.Itoa(min) + "-" + strconv.Itoa(max))
}
// Populate the optional field if not provided
if min < max && len(fields) == min {
switch {
case options&DowOptional > 0:
fields = append(fields, defaults[dowIndex])
case options&SecondOptional > 0:
fields = append([]string{defaults[secondIndex]}, fields...)
default:
return nil, errors.New("unexpected optional field")
}
}
// Populate all fields not part of options with their defaults
n := 0
expandedFields := make([]string, len(places))
copy(expandedFields, defaults)
for i, place := range places {
if options&place > 0 {
expandedFields[i] = fields[n]
n++
}
}
return expandedFields, nil
}
var standardParser = NewParser(
Minute | Hour | Dom | Month | Dow | Descriptor,
)
// ParseStandard returns a new crontab schedule representing the given
// standardSpec (https://en.wikipedia.org/wiki/Cron). It requires 5 entries
// representing: minute, hour, day of month, month and day of week, in that
// order. It returns a descriptive error if the spec is not valid.
//
// It accepts
// - Standard crontab specs, e.g. "* * * * ?"
// - Descriptors, e.g. "@midnight", "@every 1h30m"
func ParseStandard(standardSpec string) (Schedule, error) {
return standardParser.Parse(standardSpec)
}
// getField returns an Int with the bits set representing all of the times that
// the field represents or error parsing field value. A "field" is a comma-separated
// list of "ranges".
func getField(field string, r bounds) (uint64, error) {
var bits uint64
ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' })
for _, expr := range ranges {
bit, err := getRange(expr, r)
if err != nil {
return bits, err
}
bits |= bit
}
return bits, nil
}
// getRange returns the bits indicated by the given expression:
//
// number | number "-" number [ "/" number ]
//
// or error parsing range.
func getRange(expr string, r bounds) (uint64, error) {
var (
start, end, step uint
rangeAndStep = strings.Split(expr, "/")
lowAndHigh = strings.Split(rangeAndStep[0], "-")
singleDigit = len(lowAndHigh) == 1
err error
)
var extra uint64
if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" {
start = r.min
end = r.max
extra = starBit
} else {
start, err = parseIntOrName(lowAndHigh[0], r.names)
if err != nil {
return 0, err
}
switch len(lowAndHigh) {
case 1:
end = start
case 2:
end, err = parseIntOrName(lowAndHigh[1], r.names)
if err != nil {
return 0, err
}
default:
return 0, errors.New("too many hyphens: " + expr)
}
}
switch len(rangeAndStep) {
case 1:
step = 1
case 2:
step, err = mustParseInt(rangeAndStep[1])
if err != nil {
return 0, err
}
// Special handling: "N/step" means "N-max/step".
if singleDigit {
end = r.max
}
if step > 1 {
extra = 0
}
default:
return 0, errors.New("too many slashes: " + expr)
}
if start < r.min {
return 0, errors.New("beginning of range below minimum: " + expr)
}
if end > r.max {
return 0, errors.New("end of range above maximum: " + expr)
}
if start > end {
return 0, errors.New("beginning of range after end: " + expr)
}
if step == 0 {
return 0, errors.New("step cannot be zero: " + expr)
}
return getBits(start, end, step) | extra, nil
}
// parseIntOrName returns the (possibly-named) integer contained in expr.
func parseIntOrName(expr string, names map[string]uint) (uint, error) {
if names != nil {
if namedInt, ok := names[strings.ToLower(expr)]; ok {
return namedInt, nil
}
}
return mustParseInt(expr)
}
// mustParseInt parses the given expression as an int or returns an error.
func mustParseInt(expr string) (uint, error) {
num, err := strconv.Atoi(expr)
if err != nil {
return 0, errors.Wrap(err, "failed to parse number")
}
if num < 0 {
return 0, errors.New("number must be positive")
}
return uint(num), nil
}
// getBits sets all bits in the range [min, max], modulo the given step size.
func getBits(min, max, step uint) uint64 {
var bits uint64
// If step is 1, use shifts.
if step == 1 {
return ^(math.MaxUint64 << (max + 1)) & (math.MaxUint64 << min)
}
// Else, use a simple loop.
for i := min; i <= max; i += step {
bits |= 1 << i
}
return bits
}
// all returns all bits within the given bounds.
func all(r bounds) uint64 {
return getBits(r.min, r.max, 1) | starBit
}
// parseDescriptor returns a predefined schedule for the expression, or error if none matches.
func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) {
switch descriptor {
case "@yearly", "@annually":
return &SpecSchedule{
Second: 1 << seconds.min,
Minute: 1 << minutes.min,
Hour: 1 << hours.min,
Dom: 1 << dom.min,
Month: 1 << months.min,
Dow: all(dow),
Location: loc,
}, nil
case "@monthly":
return &SpecSchedule{
Second: 1 << seconds.min,
Minute: 1 << minutes.min,
Hour: 1 << hours.min,
Dom: 1 << dom.min,
Month: all(months),
Dow: all(dow),
Location: loc,
}, nil
case "@weekly":
return &SpecSchedule{
Second: 1 << seconds.min,
Minute: 1 << minutes.min,
Hour: 1 << hours.min,
Dom: all(dom),
Month: all(months),
Dow: 1 << dow.min,
Location: loc,
}, nil
case "@daily", "@midnight":
return &SpecSchedule{
Second: 1 << seconds.min,
Minute: 1 << minutes.min,
Hour: 1 << hours.min,
Dom: all(dom),
Month: all(months),
Dow: all(dow),
Location: loc,
}, nil
case "@hourly":
return &SpecSchedule{
Second: 1 << seconds.min,
Minute: 1 << minutes.min,
Hour: all(hours),
Dom: all(dom),
Month: all(months),
Dow: all(dow),
Location: loc,
}, nil
default:
// Continue to check @every prefix below
}
const every = "@every "
if strings.HasPrefix(descriptor, every) {
duration, err := time.ParseDuration(descriptor[len(every):])
if err != nil {
return nil, errors.Wrap(err, "failed to parse duration")
}
return Every(duration), nil
}
return nil, errors.New("unrecognized descriptor: " + descriptor)
}
-384
View File
@@ -1,384 +0,0 @@
//nolint:all
package cron
import (
"reflect"
"strings"
"testing"
"time"
)
var secondParser = NewParser(Second | Minute | Hour | Dom | Month | DowOptional | Descriptor)
func TestRange(t *testing.T) {
zero := uint64(0)
ranges := []struct {
expr string
min, max uint
expected uint64
err string
}{
{"5", 0, 7, 1 << 5, ""},
{"0", 0, 7, 1 << 0, ""},
{"7", 0, 7, 1 << 7, ""},
{"5-5", 0, 7, 1 << 5, ""},
{"5-6", 0, 7, 1<<5 | 1<<6, ""},
{"5-7", 0, 7, 1<<5 | 1<<6 | 1<<7, ""},
{"5-6/2", 0, 7, 1 << 5, ""},
{"5-7/2", 0, 7, 1<<5 | 1<<7, ""},
{"5-7/1", 0, 7, 1<<5 | 1<<6 | 1<<7, ""},
{"*", 1, 3, 1<<1 | 1<<2 | 1<<3 | starBit, ""},
{"*/2", 1, 3, 1<<1 | 1<<3, ""},
{"5--5", 0, 0, zero, "too many hyphens"},
{"jan-x", 0, 0, zero, `failed to parse number: strconv.Atoi: parsing "jan": invalid syntax`},
{"2-x", 1, 5, zero, `failed to parse number: strconv.Atoi: parsing "x": invalid syntax`},
{"*/-12", 0, 0, zero, "number must be positive"},
{"*//2", 0, 0, zero, "too many slashes"},
{"1", 3, 5, zero, "below minimum"},
{"6", 3, 5, zero, "above maximum"},
{"5-3", 3, 5, zero, "beginning of range after end: 5-3"},
{"*/0", 0, 0, zero, "step cannot be zero: */0"},
}
for _, c := range ranges {
actual, err := getRange(c.expr, bounds{c.min, c.max, nil})
if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) {
t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
}
if len(c.err) == 0 && err != nil {
t.Errorf("%s => unexpected error %v", c.expr, err)
}
if actual != c.expected {
t.Errorf("%s => expected %d, got %d", c.expr, c.expected, actual)
}
}
}
func TestField(t *testing.T) {
fields := []struct {
expr string
min, max uint
expected uint64
}{
{"5", 1, 7, 1 << 5},
{"5,6", 1, 7, 1<<5 | 1<<6},
{"5,6,7", 1, 7, 1<<5 | 1<<6 | 1<<7},
{"1,5-7/2,3", 1, 7, 1<<1 | 1<<5 | 1<<7 | 1<<3},
}
for _, c := range fields {
actual, _ := getField(c.expr, bounds{c.min, c.max, nil})
if actual != c.expected {
t.Errorf("%s => expected %d, got %d", c.expr, c.expected, actual)
}
}
}
func TestAll(t *testing.T) {
allBits := []struct {
r bounds
expected uint64
}{
{minutes, 0xfffffffffffffff}, // 0-59: 60 ones
{hours, 0xffffff}, // 0-23: 24 ones
{dom, 0xfffffffe}, // 1-31: 31 ones, 1 zero
{months, 0x1ffe}, // 1-12: 12 ones, 1 zero
{dow, 0x7f}, // 0-6: 7 ones
}
for _, c := range allBits {
actual := all(c.r) // all() adds the starBit, so compensate for that..
if c.expected|starBit != actual {
t.Errorf("%d-%d/%d => expected %b, got %b",
c.r.min, c.r.max, 1, c.expected|starBit, actual)
}
}
}
func TestBits(t *testing.T) {
bits := []struct {
min, max, step uint
expected uint64
}{
{0, 0, 1, 0x1},
{1, 1, 1, 0x2},
{1, 5, 2, 0x2a}, // 101010
{1, 4, 2, 0xa}, // 1010
}
for _, c := range bits {
actual := getBits(c.min, c.max, c.step)
if c.expected != actual {
t.Errorf("%d-%d/%d => expected %b, got %b",
c.min, c.max, c.step, c.expected, actual)
}
}
}
func TestParseScheduleErrors(t *testing.T) {
var tests = []struct{ expr, err string }{
{"* 5 j * * *", `failed to parse number: strconv.Atoi: parsing "j": invalid syntax`},
{"@every Xm", "failed to parse duration"},
{"@unrecognized", "unrecognized descriptor"},
{"* * * *", "incorrect number of fields, expected 5-6"},
{"", "empty spec string"},
}
for _, c := range tests {
actual, err := secondParser.Parse(c.expr)
if err == nil || !strings.Contains(err.Error(), c.err) {
t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
}
if actual != nil {
t.Errorf("expected nil schedule on error, got %v", actual)
}
}
}
func TestParseSchedule(t *testing.T) {
tokyo, _ := time.LoadLocation("Asia/Tokyo")
entries := []struct {
parser Parser
expr string
expected Schedule
}{
{secondParser, "0 5 * * * *", every5min(time.Local)},
{standardParser, "5 * * * *", every5min(time.Local)},
{secondParser, "CRON_TZ=UTC 0 5 * * * *", every5min(time.UTC)},
{standardParser, "CRON_TZ=UTC 5 * * * *", every5min(time.UTC)},
{secondParser, "CRON_TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo)},
{secondParser, "@every 5m", ConstantDelaySchedule{5 * time.Minute}},
{secondParser, "@midnight", midnight(time.Local)},
{secondParser, "TZ=UTC @midnight", midnight(time.UTC)},
{secondParser, "TZ=Asia/Tokyo @midnight", midnight(tokyo)},
{secondParser, "@yearly", annual(time.Local)},
{secondParser, "@annually", annual(time.Local)},
{
parser: secondParser,
expr: "* 5 * * * *",
expected: &SpecSchedule{
Second: all(seconds),
Minute: 1 << 5,
Hour: all(hours),
Dom: all(dom),
Month: all(months),
Dow: all(dow),
Location: time.Local,
},
},
}
for _, c := range entries {
actual, err := c.parser.Parse(c.expr)
if err != nil {
t.Errorf("%s => unexpected error %v", c.expr, err)
}
if !reflect.DeepEqual(actual, c.expected) {
t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)
}
}
}
func TestOptionalSecondSchedule(t *testing.T) {
parser := NewParser(SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor)
entries := []struct {
expr string
expected Schedule
}{
{"0 5 * * * *", every5min(time.Local)},
{"5 5 * * * *", every5min5s(time.Local)},
{"5 * * * *", every5min(time.Local)},
}
for _, c := range entries {
actual, err := parser.Parse(c.expr)
if err != nil {
t.Errorf("%s => unexpected error %v", c.expr, err)
}
if !reflect.DeepEqual(actual, c.expected) {
t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)
}
}
}
func TestNormalizeFields(t *testing.T) {
tests := []struct {
name string
input []string
options ParseOption
expected []string
}{
{
"AllFields_NoOptional",
[]string{"0", "5", "*", "*", "*", "*"},
Second | Minute | Hour | Dom | Month | Dow | Descriptor,
[]string{"0", "5", "*", "*", "*", "*"},
},
{
"AllFields_SecondOptional_Provided",
[]string{"0", "5", "*", "*", "*", "*"},
SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor,
[]string{"0", "5", "*", "*", "*", "*"},
},
{
"AllFields_SecondOptional_NotProvided",
[]string{"5", "*", "*", "*", "*"},
SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor,
[]string{"0", "5", "*", "*", "*", "*"},
},
{
"SubsetFields_NoOptional",
[]string{"5", "15", "*"},
Hour | Dom | Month,
[]string{"0", "0", "5", "15", "*", "*"},
},
{
"SubsetFields_DowOptional_Provided",
[]string{"5", "15", "*", "4"},
Hour | Dom | Month | DowOptional,
[]string{"0", "0", "5", "15", "*", "4"},
},
{
"SubsetFields_DowOptional_NotProvided",
[]string{"5", "15", "*"},
Hour | Dom | Month | DowOptional,
[]string{"0", "0", "5", "15", "*", "*"},
},
{
"SubsetFields_SecondOptional_NotProvided",
[]string{"5", "15", "*"},
SecondOptional | Hour | Dom | Month,
[]string{"0", "0", "5", "15", "*", "*"},
},
}
for _, test := range tests {
t.Run(test.name, func(*testing.T) {
actual, err := normalizeFields(test.input, test.options)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !reflect.DeepEqual(actual, test.expected) {
t.Errorf("expected %v, got %v", test.expected, actual)
}
})
}
}
func TestNormalizeFields_Errors(t *testing.T) {
tests := []struct {
name string
input []string
options ParseOption
err string
}{
{
"TwoOptionals",
[]string{"0", "5", "*", "*", "*", "*"},
SecondOptional | Minute | Hour | Dom | Month | DowOptional,
"",
},
{
"TooManyFields",
[]string{"0", "5", "*", "*"},
SecondOptional | Minute | Hour,
"",
},
{
"NoFields",
[]string{},
SecondOptional | Minute | Hour,
"",
},
{
"TooFewFields",
[]string{"*"},
SecondOptional | Minute | Hour,
"",
},
}
for _, test := range tests {
t.Run(test.name, func(*testing.T) {
actual, err := normalizeFields(test.input, test.options)
if err == nil {
t.Errorf("expected an error, got none. results: %v", actual)
}
if !strings.Contains(err.Error(), test.err) {
t.Errorf("expected error %q, got %q", test.err, err.Error())
}
})
}
}
func TestStandardSpecSchedule(t *testing.T) {
entries := []struct {
expr string
expected Schedule
err string
}{
{
expr: "5 * * * *",
expected: &SpecSchedule{1 << seconds.min, 1 << 5, all(hours), all(dom), all(months), all(dow), time.Local},
},
{
expr: "@every 5m",
expected: ConstantDelaySchedule{time.Duration(5) * time.Minute},
},
{
expr: "5 j * * *",
err: `failed to parse number: strconv.Atoi: parsing "j": invalid syntax`,
},
{
expr: "* * * *",
err: "incorrect number of fields",
},
}
for _, c := range entries {
actual, err := ParseStandard(c.expr)
if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) {
t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
}
if len(c.err) == 0 && err != nil {
t.Errorf("%s => unexpected error %v", c.expr, err)
}
if !reflect.DeepEqual(actual, c.expected) {
t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)
}
}
}
func TestNoDescriptorParser(t *testing.T) {
parser := NewParser(Minute | Hour)
_, err := parser.Parse("@every 1m")
if err == nil {
t.Error("expected an error, got none")
}
}
func every5min(loc *time.Location) *SpecSchedule {
return &SpecSchedule{1 << 0, 1 << 5, all(hours), all(dom), all(months), all(dow), loc}
}
func every5min5s(loc *time.Location) *SpecSchedule {
return &SpecSchedule{1 << 5, 1 << 5, all(hours), all(dom), all(months), all(dow), loc}
}
func midnight(loc *time.Location) *SpecSchedule {
return &SpecSchedule{1, 1, 1, all(dom), all(months), all(dow), loc}
}
func annual(loc *time.Location) *SpecSchedule {
return &SpecSchedule{
Second: 1 << seconds.min,
Minute: 1 << minutes.min,
Hour: 1 << hours.min,
Dom: 1 << dom.min,
Month: 1 << months.min,
Dow: all(dow),
Location: loc,
}
}
-188
View File
@@ -1,188 +0,0 @@
package cron
import "time"
// SpecSchedule specifies a duty cycle (to the second granularity), based on a
// traditional crontab specification. It is computed initially and stored as bit sets.
type SpecSchedule struct {
Second, Minute, Hour, Dom, Month, Dow uint64
// Override location for this schedule.
Location *time.Location
}
// bounds provides a range of acceptable values (plus a map of name to value).
type bounds struct {
min, max uint
names map[string]uint
}
// The bounds for each field.
var (
seconds = bounds{0, 59, nil}
minutes = bounds{0, 59, nil}
hours = bounds{0, 23, nil}
dom = bounds{1, 31, nil}
months = bounds{1, 12, map[string]uint{
"jan": 1,
"feb": 2,
"mar": 3,
"apr": 4,
"may": 5,
"jun": 6,
"jul": 7,
"aug": 8,
"sep": 9,
"oct": 10,
"nov": 11,
"dec": 12,
}}
dow = bounds{0, 6, map[string]uint{
"sun": 0,
"mon": 1,
"tue": 2,
"wed": 3,
"thu": 4,
"fri": 5,
"sat": 6,
}}
)
const (
// Set the top bit if a star was included in the expression.
starBit = 1 << 63
)
// Next returns the next time this schedule is activated, greater than the given
// time. If no time can be found to satisfy the schedule, return the zero time.
func (s *SpecSchedule) Next(t time.Time) time.Time {
// General approach
//
// For Month, Day, Hour, Minute, Second:
// Check if the time value matches. If yes, continue to the next field.
// If the field doesn't match the schedule, then increment the field until it matches.
// While incrementing the field, a wrap-around brings it back to the beginning
// of the field list (since it is necessary to re-verify previous field
// values)
// Convert the given time into the schedule's timezone, if one is specified.
// Save the original timezone so we can convert back after we find a time.
// Note that schedules without a time zone specified (time.Local) are treated
// as local to the time provided.
origLocation := t.Location()
loc := s.Location
if loc == time.Local {
loc = t.Location()
}
if s.Location != time.Local {
t = t.In(s.Location)
}
// Start at the earliest possible time (the upcoming second).
t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond)
// This flag indicates whether a field has been incremented.
added := false
// If no time is found within five years, return zero.
yearLimit := t.Year() + 5
WRAP:
if t.Year() > yearLimit {
return time.Time{}
}
// Find the first applicable month.
// If it's this month, then do nothing.
for 1<<uint(t.Month())&s.Month == 0 {
// If we have to add a month, reset the other parts to 0.
if !added {
added = true
// Otherwise, set the date at the beginning (since the current time is irrelevant).
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, loc)
}
t = t.AddDate(0, 1, 0)
// Wrapped around.
if t.Month() == time.January {
goto WRAP
}
}
// Now get a day in that month.
//
// NOTE: This causes issues for daylight savings regimes where midnight does
// not exist. For example: Sao Paulo has DST that transforms midnight on
// 11/3 into 1am. Handle that by noticing when the Hour ends up != 0.
for !dayMatches(s, t) {
if !added {
added = true
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
t = t.AddDate(0, 0, 1)
// Notice if the hour is no longer midnight due to DST.
// Add an hour if it's 23, subtract an hour if it's 1.
if t.Hour() != 0 {
if t.Hour() > 12 {
t = t.Add(time.Duration(24-t.Hour()) * time.Hour)
} else {
t = t.Add(time.Duration(-t.Hour()) * time.Hour)
}
}
if t.Day() == 1 {
goto WRAP
}
}
for 1<<uint(t.Hour())&s.Hour == 0 {
if !added {
added = true
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, loc)
}
t = t.Add(1 * time.Hour)
if t.Hour() == 0 {
goto WRAP
}
}
for 1<<uint(t.Minute())&s.Minute == 0 {
if !added {
added = true
t = t.Truncate(time.Minute)
}
t = t.Add(1 * time.Minute)
if t.Minute() == 0 {
goto WRAP
}
}
for 1<<uint(t.Second())&s.Second == 0 {
if !added {
added = true
t = t.Truncate(time.Second)
}
t = t.Add(1 * time.Second)
if t.Second() == 0 {
goto WRAP
}
}
return t.In(origLocation)
}
// dayMatches returns true if the schedule's day-of-week and day-of-month
// restrictions are satisfied by the given time.
func dayMatches(s *SpecSchedule, t time.Time) bool {
var (
domMatch = 1<<uint(t.Day())&s.Dom > 0
dowMatch = 1<<uint(t.Weekday())&s.Dow > 0
)
if s.Dom&starBit > 0 || s.Dow&starBit > 0 {
return domMatch && dowMatch
}
return domMatch || dowMatch
}
-301
View File
@@ -1,301 +0,0 @@
//nolint:all
package cron
import (
"strings"
"testing"
"time"
)
func TestActivation(t *testing.T) {
tests := []struct {
time, spec string
expected bool
}{
// Every fifteen minutes.
{"Mon Jul 9 15:00 2012", "0/15 * * * *", true},
{"Mon Jul 9 15:45 2012", "0/15 * * * *", true},
{"Mon Jul 9 15:40 2012", "0/15 * * * *", false},
// Every fifteen minutes, starting at 5 minutes.
{"Mon Jul 9 15:05 2012", "5/15 * * * *", true},
{"Mon Jul 9 15:20 2012", "5/15 * * * *", true},
{"Mon Jul 9 15:50 2012", "5/15 * * * *", true},
// Named months
{"Sun Jul 15 15:00 2012", "0/15 * * Jul *", true},
{"Sun Jul 15 15:00 2012", "0/15 * * Jun *", false},
// Everything set.
{"Sun Jul 15 08:30 2012", "30 08 ? Jul Sun", true},
{"Sun Jul 15 08:30 2012", "30 08 15 Jul ?", true},
{"Mon Jul 16 08:30 2012", "30 08 ? Jul Sun", false},
{"Mon Jul 16 08:30 2012", "30 08 15 Jul ?", false},
// Predefined schedules
{"Mon Jul 9 15:00 2012", "@hourly", true},
{"Mon Jul 9 15:04 2012", "@hourly", false},
{"Mon Jul 9 15:00 2012", "@daily", false},
{"Mon Jul 9 00:00 2012", "@daily", true},
{"Mon Jul 9 00:00 2012", "@weekly", false},
{"Sun Jul 8 00:00 2012", "@weekly", true},
{"Sun Jul 8 01:00 2012", "@weekly", false},
{"Sun Jul 8 00:00 2012", "@monthly", false},
{"Sun Jul 1 00:00 2012", "@monthly", true},
// Test interaction of DOW and DOM.
// If both are restricted, then only one needs to match.
{"Sun Jul 15 00:00 2012", "* * 1,15 * Sun", true},
{"Fri Jun 15 00:00 2012", "* * 1,15 * Sun", true},
{"Wed Aug 1 00:00 2012", "* * 1,15 * Sun", true},
{"Sun Jul 15 00:00 2012", "* * */10 * Sun", true}, // verifies #70
// However, if one has a star, then both need to match.
{"Sun Jul 15 00:00 2012", "* * * * Mon", false},
{"Mon Jul 9 00:00 2012", "* * 1,15 * *", false},
{"Sun Jul 15 00:00 2012", "* * 1,15 * *", true},
{"Sun Jul 15 00:00 2012", "* * */2 * Sun", true},
}
for _, test := range tests {
sched, err := ParseStandard(test.spec)
if err != nil {
t.Error(err)
continue
}
actual := sched.Next(getTime(test.time).Add(-1 * time.Second))
expected := getTime(test.time)
if test.expected && expected != actual || !test.expected && expected == actual {
t.Errorf("Fail evaluating %s on %s: (expected) %s != %s (actual)",
test.spec, test.time, expected, actual)
}
}
}
func TestNext(t *testing.T) {
runs := []struct {
time, spec string
expected string
}{
// Simple cases
{"Mon Jul 9 14:45 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
{"Mon Jul 9 14:59 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
{"Mon Jul 9 14:59:59 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
// Wrap around hours
{"Mon Jul 9 15:45 2012", "0 20-35/15 * * * *", "Mon Jul 9 16:20 2012"},
// Wrap around days
{"Mon Jul 9 23:46 2012", "0 */15 * * * *", "Tue Jul 10 00:00 2012"},
{"Mon Jul 9 23:45 2012", "0 20-35/15 * * * *", "Tue Jul 10 00:20 2012"},
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * * * *", "Tue Jul 10 00:20:15 2012"},
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 * * *", "Tue Jul 10 01:20:15 2012"},
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 10-12 * * *", "Tue Jul 10 10:20:15 2012"},
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 */2 * *", "Thu Jul 11 01:20:15 2012"},
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * 9-20 * *", "Wed Jul 10 00:20:15 2012"},
{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * 9-20 Jul *", "Wed Jul 10 00:20:15 2012"},
// Wrap around months
{"Mon Jul 9 23:35 2012", "0 0 0 9 Apr-Oct ?", "Thu Aug 9 00:00 2012"},
{"Mon Jul 9 23:35 2012", "0 0 0 */5 Apr,Aug,Oct Mon", "Tue Aug 1 00:00 2012"},
{"Mon Jul 9 23:35 2012", "0 0 0 */5 Oct Mon", "Mon Oct 1 00:00 2012"},
// Wrap around years
{"Mon Jul 9 23:35 2012", "0 0 0 * Feb Mon", "Mon Feb 4 00:00 2013"},
{"Mon Jul 9 23:35 2012", "0 0 0 * Feb Mon/2", "Fri Feb 1 00:00 2013"},
// Wrap around minute, hour, day, month, and year
{"Mon Dec 31 23:59:45 2012", "0 * * * * *", "Tue Jan 1 00:00:00 2013"},
// Leap year
{"Mon Jul 9 23:35 2012", "0 0 0 29 Feb ?", "Mon Feb 29 00:00 2016"},
// Daylight savings time 2am EST (-5) -> 3am EDT (-4)
{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 30 2 11 Mar ?", "2013-03-11T02:30:00-0400"},
// hourly job
{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T01:00:00-0500"},
{"2012-03-11T01:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T03:00:00-0400"},
{"2012-03-11T03:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T04:00:00-0400"},
{"2012-03-11T04:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T05:00:00-0400"},
// hourly job using CRON_TZ
{"2012-03-11T00:00:00-0500", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T01:00:00-0500"},
{"2012-03-11T01:00:00-0500", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T03:00:00-0400"},
{"2012-03-11T03:00:00-0400", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T04:00:00-0400"},
{"2012-03-11T04:00:00-0400", "CRON_TZ=America/New_York 0 0 * * * ?", "2012-03-11T05:00:00-0400"},
// 1am nightly job
{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-03-11T01:00:00-0500"},
{"2012-03-11T01:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-03-12T01:00:00-0400"},
// 2am nightly job (skipped)
{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 2 * * ?", "2012-03-12T02:00:00-0400"},
// Daylight savings time 2am EDT (-4) => 1am EST (-5)
{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 30 2 04 Nov ?", "2012-11-04T02:30:00-0500"},
{"2012-11-04T01:45:00-0400", "TZ=America/New_York 0 30 1 04 Nov ?", "2012-11-04T01:30:00-0500"},
// hourly job
{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T01:00:00-0400"},
{"2012-11-04T01:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T01:00:00-0500"},
{"2012-11-04T01:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T02:00:00-0500"},
// 1am nightly job (runs twice)
{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 1 * * ?", "2012-11-04T01:00:00-0400"},
{"2012-11-04T01:00:00-0400", "TZ=America/New_York 0 0 1 * * ?", "2012-11-04T01:00:00-0500"},
{"2012-11-04T01:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-11-05T01:00:00-0500"},
// 2am nightly job
{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 2 * * ?", "2012-11-04T02:00:00-0500"},
{"2012-11-04T02:00:00-0500", "TZ=America/New_York 0 0 2 * * ?", "2012-11-05T02:00:00-0500"},
// 3am nightly job
{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 3 * * ?", "2012-11-04T03:00:00-0500"},
{"2012-11-04T03:00:00-0500", "TZ=America/New_York 0 0 3 * * ?", "2012-11-05T03:00:00-0500"},
// hourly job
{"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 * * * ?", "2012-11-04T01:00:00-0400"},
{"TZ=America/New_York 2012-11-04T01:00:00-0400", "0 0 * * * ?", "2012-11-04T01:00:00-0500"},
{"TZ=America/New_York 2012-11-04T01:00:00-0500", "0 0 * * * ?", "2012-11-04T02:00:00-0500"},
// 1am nightly job (runs twice)
{"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 1 * * ?", "2012-11-04T01:00:00-0400"},
{"TZ=America/New_York 2012-11-04T01:00:00-0400", "0 0 1 * * ?", "2012-11-04T01:00:00-0500"},
{"TZ=America/New_York 2012-11-04T01:00:00-0500", "0 0 1 * * ?", "2012-11-05T01:00:00-0500"},
// 2am nightly job
{"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 2 * * ?", "2012-11-04T02:00:00-0500"},
{"TZ=America/New_York 2012-11-04T02:00:00-0500", "0 0 2 * * ?", "2012-11-05T02:00:00-0500"},
// 3am nightly job
{"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 3 * * ?", "2012-11-04T03:00:00-0500"},
{"TZ=America/New_York 2012-11-04T03:00:00-0500", "0 0 3 * * ?", "2012-11-05T03:00:00-0500"},
// Unsatisfiable
{"Mon Jul 9 23:35 2012", "0 0 0 30 Feb ?", ""},
{"Mon Jul 9 23:35 2012", "0 0 0 31 Apr ?", ""},
// Monthly job
{"TZ=America/New_York 2012-11-04T00:00:00-0400", "0 0 3 3 * ?", "2012-12-03T03:00:00-0500"},
// Test the scenario of DST resulting in midnight not being a valid time.
// https://github.com/robfig/cron/issues/157
{"2018-10-17T05:00:00-0400", "TZ=America/Sao_Paulo 0 0 9 10 * ?", "2018-11-10T06:00:00-0500"},
{"2018-02-14T05:00:00-0500", "TZ=America/Sao_Paulo 0 0 9 22 * ?", "2018-02-22T07:00:00-0500"},
}
for _, c := range runs {
sched, err := secondParser.Parse(c.spec)
if err != nil {
t.Error(err)
continue
}
actual := sched.Next(getTime(c.time))
expected := getTime(c.expected)
if !actual.Equal(expected) {
t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.spec, expected, actual)
}
}
}
func TestErrors(t *testing.T) {
invalidSpecs := []string{
"xyz",
"60 0 * * *",
"0 60 * * *",
"0 0 * * XYZ",
}
for _, spec := range invalidSpecs {
_, err := ParseStandard(spec)
if err == nil {
t.Error("expected an error parsing: ", spec)
}
}
}
func getTime(value string) time.Time {
if value == "" {
return time.Time{}
}
var location = time.Local
if strings.HasPrefix(value, "TZ=") {
parts := strings.Fields(value)
loc, err := time.LoadLocation(parts[0][len("TZ="):])
if err != nil {
panic("could not parse location:" + err.Error())
}
location = loc
value = parts[1]
}
var layouts = []string{
"Mon Jan 2 15:04 2006",
"Mon Jan 2 15:04:05 2006",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, value, location); err == nil {
return t
}
}
if t, err := time.ParseInLocation("2006-01-02T15:04:05-0700", value, location); err == nil {
return t
}
panic("could not parse time value " + value)
}
func TestNextWithTz(t *testing.T) {
runs := []struct {
time, spec string
expected string
}{
// Failing tests
{"2016-01-03T13:09:03+0530", "14 14 * * *", "2016-01-03T14:14:00+0530"},
{"2016-01-03T04:09:03+0530", "14 14 * * ?", "2016-01-03T14:14:00+0530"},
// Passing tests
{"2016-01-03T14:09:03+0530", "14 14 * * *", "2016-01-03T14:14:00+0530"},
{"2016-01-03T14:00:00+0530", "14 14 * * ?", "2016-01-03T14:14:00+0530"},
}
for _, c := range runs {
sched, err := ParseStandard(c.spec)
if err != nil {
t.Error(err)
continue
}
actual := sched.Next(getTimeTZ(c.time))
expected := getTimeTZ(c.expected)
if !actual.Equal(expected) {
t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.spec, expected, actual)
}
}
}
func getTimeTZ(value string) time.Time {
if value == "" {
return time.Time{}
}
t, err := time.Parse("Mon Jan 2 15:04 2006", value)
if err != nil {
t, err = time.Parse("Mon Jan 2 15:04:05 2006", value)
if err != nil {
t, err = time.Parse("2006-01-02T15:04:05-0700", value)
if err != nil {
panic(err)
}
}
}
return t
}
// https://github.com/robfig/cron/issues/144
func TestSlash0NoHang(t *testing.T) {
schedule := "TZ=America/New_York 15/0 * * * *"
_, err := ParseStandard(schedule)
if err == nil {
t.Error("expected an error on 0 increment")
}
}
-365
View File
@@ -1,35 +1,22 @@
package v1
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"image"
"io"
"log/slog"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/internal/filter"
"github.com/usememos/memos/internal/motionphoto"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/storage/s3"
"github.com/usememos/memos/internal/util"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
@@ -414,236 +401,6 @@ func (s *APIV1Service) BatchDeleteAttachments(ctx context.Context, request *v1pb
return &emptypb.Empty{}, nil
}
func convertAttachmentFromStore(attachment *store.Attachment) *v1pb.Attachment {
attachmentMessage := &v1pb.Attachment{
Name: fmt.Sprintf("%s%s", AttachmentNamePrefix, attachment.UID),
CreateTime: timestamppb.New(time.Unix(attachment.CreatedTs, 0)),
Filename: attachment.Filename,
Type: attachment.Type,
Size: attachment.Size,
MotionMedia: convertMotionMediaFromStore(getAttachmentMotionMedia(attachment)),
}
if attachment.MemoUID != nil && *attachment.MemoUID != "" {
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, *attachment.MemoUID)
attachmentMessage.Memo = &memoName
}
if attachment.StorageType == storepb.AttachmentStorageType_EXTERNAL || attachment.StorageType == storepb.AttachmentStorageType_S3 {
attachmentMessage.ExternalLink = attachment.Reference
}
return attachmentMessage
}
// SaveAttachmentBlob saves the blob of attachment based on the storage config.
func SaveAttachmentBlob(ctx context.Context, profile *profile.Profile, stores *store.Store, create *store.Attachment) error {
instanceStorageSetting, err := stores.GetInstanceStorageSetting(ctx)
if err != nil {
return errors.Wrap(err, "Failed to find instance storage setting")
}
if instanceStorageSetting.StorageType == storepb.InstanceStorageSetting_LOCAL {
filepathTemplate := "assets/{timestamp}_{uuid}_{filename}"
if instanceStorageSetting.FilepathTemplate != "" {
filepathTemplate = instanceStorageSetting.FilepathTemplate
}
internalPath := filepathTemplate
if !strings.Contains(internalPath, "{filename}") {
internalPath = filepath.Join(internalPath, "{filename}")
}
internalPath = replaceFilenameWithPathTemplate(internalPath, create.Filename)
internalPath = filepath.ToSlash(internalPath)
// Ensure the directory exists.
osPath := filepath.FromSlash(internalPath)
if !filepath.IsAbs(osPath) {
osPath = filepath.Join(profile.Data, osPath)
}
osPath = ensureUniqueLocalAttachmentPath(osPath, create.UID)
internalPath = filepath.ToSlash(osPath)
if !filepath.IsAbs(filepath.FromSlash(internalPath)) {
internalPath, err = filepath.Rel(profile.Data, osPath)
if err != nil {
return errors.Wrap(err, "Failed to get relative path")
}
internalPath = filepath.ToSlash(internalPath)
}
dir := filepath.Dir(osPath)
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
return errors.Wrap(err, "Failed to create directory")
}
// Write the blob to the file.
if err := os.WriteFile(osPath, create.Blob, 0644); err != nil {
return errors.Wrap(err, "Failed to write file")
}
create.Reference = internalPath
create.Blob = nil
create.StorageType = storepb.AttachmentStorageType_LOCAL
} else if instanceStorageSetting.StorageType == storepb.InstanceStorageSetting_S3 {
s3Config := instanceStorageSetting.S3Config
if s3Config == nil {
return errors.Errorf("No activated external storage found")
}
s3Client, err := s3.NewClient(ctx, s3Config)
if err != nil {
return errors.Wrap(err, "Failed to create s3 client")
}
filepathTemplate := instanceStorageSetting.FilepathTemplate
if !strings.Contains(filepathTemplate, "{filename}") {
filepathTemplate = filepath.Join(filepathTemplate, "{filename}")
}
filepathTemplate = replaceFilenameWithPathTemplate(filepathTemplate, create.Filename)
key, err := s3Client.UploadObject(ctx, filepathTemplate, create.Type, bytes.NewReader(create.Blob))
if err != nil {
return errors.Wrap(err, "Failed to upload via s3 client")
}
presignURL, err := s3Client.PresignGetObject(ctx, key)
if err != nil {
return errors.Wrap(err, "Failed to presign via s3 client")
}
create.Reference = presignURL
create.Blob = nil
create.StorageType = storepb.AttachmentStorageType_S3
payload := ensureAttachmentPayload(create.Payload)
payload.Payload = &storepb.AttachmentPayload_S3Object_{
S3Object: &storepb.AttachmentPayload_S3Object{
S3Config: s3Config,
Key: key,
LastPresignedTime: timestamppb.New(time.Now()),
},
}
create.Payload = payload
}
return nil
}
func (s *APIV1Service) GetAttachmentBlob(attachment *store.Attachment) ([]byte, error) {
// For local storage, read the file from the local disk.
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
attachmentPath := filepath.FromSlash(attachment.Reference)
if !filepath.IsAbs(attachmentPath) {
attachmentPath = filepath.Join(s.Profile.Data, attachmentPath)
}
file, err := os.Open(attachmentPath)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.Wrap(err, "file not found")
}
return nil, errors.Wrap(err, "failed to open the file")
}
defer file.Close()
blob, err := io.ReadAll(file)
if err != nil {
return nil, errors.Wrap(err, "failed to read the file")
}
return blob, nil
}
// For S3 storage, download the file from S3.
if attachment.StorageType == storepb.AttachmentStorageType_S3 {
if attachment.Payload == nil {
return nil, errors.New("attachment payload is missing")
}
s3Object := attachment.Payload.GetS3Object()
if s3Object == nil {
return nil, errors.New("S3 object payload is missing")
}
if s3Object.S3Config == nil {
return nil, errors.New("S3 config is missing")
}
if s3Object.Key == "" {
return nil, errors.New("S3 object key is missing")
}
s3Client, err := s3.NewClient(context.Background(), s3Object.S3Config)
if err != nil {
return nil, errors.Wrap(err, "failed to create S3 client")
}
blob, err := s3Client.GetObject(context.Background(), s3Object.Key)
if err != nil {
return nil, errors.Wrap(err, "failed to get object from S3")
}
return blob, nil
}
// For database storage, return the blob from the database.
return attachment.Blob, nil
}
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
func replaceFilenameWithPathTemplate(path, filename string) string {
t := time.Now()
path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
switch s {
case "{filename}":
return filename
case "{timestamp}":
return fmt.Sprintf("%d", t.Unix())
case "{year}":
return fmt.Sprintf("%d", t.Year())
case "{month}":
return fmt.Sprintf("%02d", t.Month())
case "{day}":
return fmt.Sprintf("%02d", t.Day())
case "{hour}":
return fmt.Sprintf("%02d", t.Hour())
case "{minute}":
return fmt.Sprintf("%02d", t.Minute())
case "{second}":
return fmt.Sprintf("%02d", t.Second())
case "{uuid}":
return util.GenUUID()
default:
return s
}
})
return path
}
func ensureUniqueLocalAttachmentPath(path, uid string) string {
if _, err := os.Stat(path); err != nil {
return path
}
ext := filepath.Ext(path)
base := strings.TrimSuffix(path, ext)
return base + "_" + uid + ext
}
func validateFilename(filename string) bool {
// Reject path traversal attempts and make sure no additional directories are created
if !filepath.IsLocal(filename) || strings.ContainsAny(filename, "/\\") {
return false
}
// Reject filenames starting or ending with spaces or periods
if strings.HasPrefix(filename, " ") || strings.HasSuffix(filename, " ") ||
strings.HasPrefix(filename, ".") || strings.HasSuffix(filename, ".") {
return false
}
return true
}
func normalizeMimeType(mimeType string) (string, bool) {
mimeType = strings.TrimSpace(mimeType)
if mimeType == "" || len(mimeType) > 255 {
return "", false
}
mediaType, _, err := mime.ParseMediaType(mimeType)
if err != nil || mediaType == "" || len(mediaType) > 255 {
return "", false
}
return mediaType, true
}
func (s *APIV1Service) validateAttachmentFilter(ctx context.Context, filterStr string) error {
if filterStr == "" {
return errors.New("filter cannot be empty")
@@ -707,125 +464,3 @@ func (s *APIV1Service) checkAttachmentAccess(ctx context.Context, attachment *st
}
return nil
}
func validateClientMotionMedia(motion *v1pb.MotionMedia, attachmentUID string) (*storepb.MotionMedia, error) {
if motion == nil {
return nil, nil
}
if motion.Family != v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO {
return nil, status.Errorf(codes.InvalidArgument, "only Apple Live Photo motion metadata can be provided by clients")
}
if motion.Role != v1pb.MotionMediaRole_STILL && motion.Role != v1pb.MotionMediaRole_VIDEO {
return nil, status.Errorf(codes.InvalidArgument, "invalid Apple Live Photo motion role")
}
storeMotion := convertMotionMediaToStore(motion)
if storeMotion.GroupId == "" {
return nil, status.Errorf(codes.InvalidArgument, "motion media group_id is required")
}
if storeMotion.Family == storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO && storeMotion.GroupId == "" {
storeMotion.GroupId = attachmentUID
}
return storeMotion, nil
}
func detectAndroidMotionMedia(blob []byte, mimeType, attachmentUID string) *storepb.MotionMedia {
if mimeType != "image/jpeg" && mimeType != "image/jpg" {
return nil
}
detection := motionphoto.DetectJPEG(blob)
if detection == nil {
return nil
}
return &storepb.MotionMedia{
Family: storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO,
Role: storepb.MotionMediaRole_CONTAINER,
GroupId: attachmentUID,
PresentationTimestampUs: detection.PresentationTimestampUs,
HasEmbeddedVideo: true,
}
}
// shouldStripExif checks if the MIME type is an image format that may contain EXIF metadata.
// Returns true for formats like JPEG, TIFF, WebP, HEIC, and HEIF which commonly contain
// privacy-sensitive metadata such as GPS coordinates, camera settings, and device information.
func shouldStripExif(mimeType string) bool {
return exifCapableImageTypes[mimeType]
}
func (s *APIV1Service) acquireImageProcessingSlot(ctx context.Context) (func(), error) {
if s.imageProcessingSemaphore == nil {
return func() {}, nil
}
if err := s.imageProcessingSemaphore.Acquire(ctx, 1); err != nil {
return nil, err
}
return func() {
s.imageProcessingSemaphore.Release(1)
}, nil
}
func validateImagePixelCount(imageData []byte) error {
config, _, err := image.DecodeConfig(bytes.NewReader(imageData))
if err != nil {
// Some formats supported by imaging do not expose dimensions through
// the standard image registry. Let the full decoder handle those.
return nil //nolint:nilerr
}
if config.Width <= 0 || config.Height <= 0 {
return errors.New("invalid image dimensions")
}
if config.Width > maxImagePixels/config.Height {
return errors.Errorf("image dimensions exceed maximum of %d pixels", maxImagePixels)
}
return nil
}
// stripImageExif removes EXIF metadata from image files by decoding and re-encoding them.
// This prevents exposure of sensitive metadata such as GPS location, camera details, and timestamps.
//
// The function preserves the correct image orientation by applying EXIF orientation tags
// during decoding before stripping all metadata. Images are re-encoded with high quality
// to minimize visual degradation.
//
// Supported formats:
// - JPEG/JPG: Re-encoded as JPEG with quality 95
// - PNG: Re-encoded as PNG (lossless)
// - TIFF/WebP/HEIC/HEIF: Re-encoded as JPEG with quality 95
//
// Returns the cleaned image data without any EXIF metadata, or an error if processing fails.
func stripImageExif(imageData []byte, mimeType string) ([]byte, error) {
if err := validateImagePixelCount(imageData); err != nil {
return nil, err
}
// Decode image with automatic EXIF orientation correction.
// This ensures the image displays correctly after metadata removal.
img, err := imaging.Decode(bytes.NewReader(imageData), imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, "failed to decode image")
}
// Re-encode the image without EXIF metadata.
var buf bytes.Buffer
var encodeErr error
if mimeType == "image/png" {
// Preserve PNG format for lossless encoding
encodeErr = imaging.Encode(&buf, img, imaging.PNG)
} else {
// For JPEG, TIFF, WebP, HEIC, HEIF - re-encode as JPEG.
// This ensures EXIF is stripped and provides good compression.
encodeErr = imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(defaultJPEGQuality))
}
if encodeErr != nil {
return nil, errors.Wrap(encodeErr, "failed to encode image")
}
return buf.Bytes(), nil
}
@@ -0,0 +1,138 @@
package v1
import (
"bytes"
"context"
"image"
"github.com/disintegration/imaging"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/usememos/memos/internal/motionphoto"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
)
func validateClientMotionMedia(motion *v1pb.MotionMedia, attachmentUID string) (*storepb.MotionMedia, error) {
if motion == nil {
return nil, nil
}
if motion.Family != v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO {
return nil, status.Errorf(codes.InvalidArgument, "only Apple Live Photo motion metadata can be provided by clients")
}
if motion.Role != v1pb.MotionMediaRole_STILL && motion.Role != v1pb.MotionMediaRole_VIDEO {
return nil, status.Errorf(codes.InvalidArgument, "invalid Apple Live Photo motion role")
}
storeMotion := convertMotionMediaToStore(motion)
if storeMotion.GroupId == "" {
return nil, status.Errorf(codes.InvalidArgument, "motion media group_id is required")
}
if storeMotion.Family == storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO && storeMotion.GroupId == "" {
storeMotion.GroupId = attachmentUID
}
return storeMotion, nil
}
func detectAndroidMotionMedia(blob []byte, mimeType, attachmentUID string) *storepb.MotionMedia {
if mimeType != "image/jpeg" && mimeType != "image/jpg" {
return nil
}
detection := motionphoto.DetectJPEG(blob)
if detection == nil {
return nil
}
return &storepb.MotionMedia{
Family: storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO,
Role: storepb.MotionMediaRole_CONTAINER,
GroupId: attachmentUID,
PresentationTimestampUs: detection.PresentationTimestampUs,
HasEmbeddedVideo: true,
}
}
// shouldStripExif checks if the MIME type is an image format that may contain EXIF metadata.
// Returns true for formats like JPEG, TIFF, WebP, HEIC, and HEIF which commonly contain
// privacy-sensitive metadata such as GPS coordinates, camera settings, and device information.
func shouldStripExif(mimeType string) bool {
return exifCapableImageTypes[mimeType]
}
func (s *APIV1Service) acquireImageProcessingSlot(ctx context.Context) (func(), error) {
if s.imageProcessingSemaphore == nil {
return func() {}, nil
}
if err := s.imageProcessingSemaphore.Acquire(ctx, 1); err != nil {
return nil, err
}
return func() {
s.imageProcessingSemaphore.Release(1)
}, nil
}
func validateImagePixelCount(imageData []byte) error {
config, _, err := image.DecodeConfig(bytes.NewReader(imageData))
if err != nil {
// Some formats supported by imaging do not expose dimensions through
// the standard image registry. Let the full decoder handle those.
return nil //nolint:nilerr
}
if config.Width <= 0 || config.Height <= 0 {
return errors.New("invalid image dimensions")
}
if config.Width > maxImagePixels/config.Height {
return errors.Errorf("image dimensions exceed maximum of %d pixels", maxImagePixels)
}
return nil
}
// stripImageExif removes EXIF metadata from image files by decoding and re-encoding them.
// This prevents exposure of sensitive metadata such as GPS location, camera details, and timestamps.
//
// The function preserves the correct image orientation by applying EXIF orientation tags
// during decoding before stripping all metadata. Images are re-encoded with high quality
// to minimize visual degradation.
//
// Supported formats:
// - JPEG/JPG: Re-encoded as JPEG with quality 95
// - PNG: Re-encoded as PNG (lossless)
// - TIFF/WebP/HEIC/HEIF: Re-encoded as JPEG with quality 95
//
// Returns the cleaned image data without any EXIF metadata, or an error if processing fails.
func stripImageExif(imageData []byte, mimeType string) ([]byte, error) {
if err := validateImagePixelCount(imageData); err != nil {
return nil, err
}
// Decode image with automatic EXIF orientation correction.
// This ensures the image displays correctly after metadata removal.
img, err := imaging.Decode(bytes.NewReader(imageData), imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, "failed to decode image")
}
// Re-encode the image without EXIF metadata.
var buf bytes.Buffer
var encodeErr error
if mimeType == "image/png" {
// Preserve PNG format for lossless encoding
encodeErr = imaging.Encode(&buf, img, imaging.PNG)
} else {
// For JPEG, TIFF, WebP, HEIC, HEIF - re-encode as JPEG.
// This ensures EXIF is stripped and provides good compression.
encodeErr = imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(defaultJPEGQuality))
}
if encodeErr != nil {
return nil, errors.Wrap(encodeErr, "failed to encode image")
}
return buf.Bytes(), nil
}
@@ -0,0 +1,254 @@
package v1
import (
"bytes"
"context"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/pkg/errors"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/storage/s3"
"github.com/usememos/memos/internal/util"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func convertAttachmentFromStore(attachment *store.Attachment) *v1pb.Attachment {
attachmentMessage := &v1pb.Attachment{
Name: fmt.Sprintf("%s%s", AttachmentNamePrefix, attachment.UID),
CreateTime: timestamppb.New(time.Unix(attachment.CreatedTs, 0)),
Filename: attachment.Filename,
Type: attachment.Type,
Size: attachment.Size,
MotionMedia: convertMotionMediaFromStore(getAttachmentMotionMedia(attachment)),
}
if attachment.MemoUID != nil && *attachment.MemoUID != "" {
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, *attachment.MemoUID)
attachmentMessage.Memo = &memoName
}
if attachment.StorageType == storepb.AttachmentStorageType_EXTERNAL || attachment.StorageType == storepb.AttachmentStorageType_S3 {
attachmentMessage.ExternalLink = attachment.Reference
}
return attachmentMessage
}
// SaveAttachmentBlob saves the blob of attachment based on the storage config.
func SaveAttachmentBlob(ctx context.Context, profile *profile.Profile, stores *store.Store, create *store.Attachment) error {
instanceStorageSetting, err := stores.GetInstanceStorageSetting(ctx)
if err != nil {
return errors.Wrap(err, "Failed to find instance storage setting")
}
if instanceStorageSetting.StorageType == storepb.InstanceStorageSetting_LOCAL {
filepathTemplate := "assets/{timestamp}_{uuid}_{filename}"
if instanceStorageSetting.FilepathTemplate != "" {
filepathTemplate = instanceStorageSetting.FilepathTemplate
}
internalPath := filepathTemplate
if !strings.Contains(internalPath, "{filename}") {
internalPath = filepath.Join(internalPath, "{filename}")
}
internalPath = replaceFilenameWithPathTemplate(internalPath, create.Filename)
internalPath = filepath.ToSlash(internalPath)
// Ensure the directory exists.
osPath := filepath.FromSlash(internalPath)
if !filepath.IsAbs(osPath) {
osPath = filepath.Join(profile.Data, osPath)
}
osPath = ensureUniqueLocalAttachmentPath(osPath, create.UID)
internalPath = filepath.ToSlash(osPath)
if !filepath.IsAbs(filepath.FromSlash(internalPath)) {
internalPath, err = filepath.Rel(profile.Data, osPath)
if err != nil {
return errors.Wrap(err, "Failed to get relative path")
}
internalPath = filepath.ToSlash(internalPath)
}
dir := filepath.Dir(osPath)
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
return errors.Wrap(err, "Failed to create directory")
}
// Write the blob to the file.
if err := os.WriteFile(osPath, create.Blob, 0644); err != nil {
return errors.Wrap(err, "Failed to write file")
}
create.Reference = internalPath
create.Blob = nil
create.StorageType = storepb.AttachmentStorageType_LOCAL
} else if instanceStorageSetting.StorageType == storepb.InstanceStorageSetting_S3 {
s3Config := instanceStorageSetting.S3Config
if s3Config == nil {
return errors.Errorf("No activated external storage found")
}
s3Client, err := s3.NewClient(ctx, s3Config)
if err != nil {
return errors.Wrap(err, "Failed to create s3 client")
}
filepathTemplate := instanceStorageSetting.FilepathTemplate
if !strings.Contains(filepathTemplate, "{filename}") {
filepathTemplate = filepath.Join(filepathTemplate, "{filename}")
}
filepathTemplate = replaceFilenameWithPathTemplate(filepathTemplate, create.Filename)
key, err := s3Client.UploadObject(ctx, filepathTemplate, create.Type, bytes.NewReader(create.Blob))
if err != nil {
return errors.Wrap(err, "Failed to upload via s3 client")
}
presignURL, err := s3Client.PresignGetObject(ctx, key)
if err != nil {
return errors.Wrap(err, "Failed to presign via s3 client")
}
create.Reference = presignURL
create.Blob = nil
create.StorageType = storepb.AttachmentStorageType_S3
payload := ensureAttachmentPayload(create.Payload)
payload.Payload = &storepb.AttachmentPayload_S3Object_{
S3Object: &storepb.AttachmentPayload_S3Object{
S3Config: s3Config,
Key: key,
LastPresignedTime: timestamppb.New(time.Now()),
},
}
create.Payload = payload
}
return nil
}
func (s *APIV1Service) GetAttachmentBlob(attachment *store.Attachment) ([]byte, error) {
// For local storage, read the file from the local disk.
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
attachmentPath := filepath.FromSlash(attachment.Reference)
if !filepath.IsAbs(attachmentPath) {
attachmentPath = filepath.Join(s.Profile.Data, attachmentPath)
}
file, err := os.Open(attachmentPath)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.Wrap(err, "file not found")
}
return nil, errors.Wrap(err, "failed to open the file")
}
defer file.Close()
blob, err := io.ReadAll(file)
if err != nil {
return nil, errors.Wrap(err, "failed to read the file")
}
return blob, nil
}
// For S3 storage, download the file from S3.
if attachment.StorageType == storepb.AttachmentStorageType_S3 {
if attachment.Payload == nil {
return nil, errors.New("attachment payload is missing")
}
s3Object := attachment.Payload.GetS3Object()
if s3Object == nil {
return nil, errors.New("S3 object payload is missing")
}
if s3Object.S3Config == nil {
return nil, errors.New("S3 config is missing")
}
if s3Object.Key == "" {
return nil, errors.New("S3 object key is missing")
}
s3Client, err := s3.NewClient(context.Background(), s3Object.S3Config)
if err != nil {
return nil, errors.Wrap(err, "failed to create S3 client")
}
blob, err := s3Client.GetObject(context.Background(), s3Object.Key)
if err != nil {
return nil, errors.Wrap(err, "failed to get object from S3")
}
return blob, nil
}
// For database storage, return the blob from the database.
return attachment.Blob, nil
}
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
func replaceFilenameWithPathTemplate(path, filename string) string {
t := time.Now()
path = fileKeyPattern.ReplaceAllStringFunc(path, func(s string) string {
switch s {
case "{filename}":
return filename
case "{timestamp}":
return fmt.Sprintf("%d", t.Unix())
case "{year}":
return fmt.Sprintf("%d", t.Year())
case "{month}":
return fmt.Sprintf("%02d", t.Month())
case "{day}":
return fmt.Sprintf("%02d", t.Day())
case "{hour}":
return fmt.Sprintf("%02d", t.Hour())
case "{minute}":
return fmt.Sprintf("%02d", t.Minute())
case "{second}":
return fmt.Sprintf("%02d", t.Second())
case "{uuid}":
return util.GenUUID()
default:
return s
}
})
return path
}
func ensureUniqueLocalAttachmentPath(path, uid string) string {
if _, err := os.Stat(path); err != nil {
return path
}
ext := filepath.Ext(path)
base := strings.TrimSuffix(path, ext)
return base + "_" + uid + ext
}
func validateFilename(filename string) bool {
// Reject path traversal attempts and make sure no additional directories are created
if !filepath.IsLocal(filename) || strings.ContainsAny(filename, "/\\") {
return false
}
// Reject filenames starting or ending with spaces or periods
if strings.HasPrefix(filename, " ") || strings.HasSuffix(filename, " ") ||
strings.HasPrefix(filename, ".") || strings.HasSuffix(filename, ".") {
return false
}
return true
}
func normalizeMimeType(mimeType string) (string, bool) {
mimeType = strings.TrimSpace(mimeType)
if mimeType == "" || len(mimeType) > 255 {
return "", false
}
mediaType, _, err := mime.ParseMediaType(mimeType)
if err != nil || mediaType == "" || len(mediaType) > 255 {
return "", false
}
return mediaType, true
}
-676
View File
@@ -2,26 +2,13 @@ package v1
import (
"context"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/internal/idp"
"github.com/usememos/memos/internal/idp/oauth2"
"github.com/usememos/memos/internal/util"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/auth"
"github.com/usememos/memos/store"
)
@@ -131,666 +118,3 @@ func (s *APIV1Service) SignIn(ctx context.Context, request *v1pb.SignInRequest)
// that existing user instead. If the linkage insert loses a race on the unique
// (provider, extern_uid) constraint, the winning linkage's user is loaded and
// checked against the current user.
func (s *APIV1Service) resolveSSOUser(ctx context.Context, currentUser *store.User, identityProvider *storepb.IdentityProvider, userInfo *idp.IdentityProviderUserInfo) (*store.User, error) {
provider := identityProvider.Uid
externUID := userInfo.Identifier
user, err := s.getLinkedSSOUser(ctx, provider, externUID)
if err != nil {
return nil, err
}
if user != nil {
if currentUser != nil && currentUser.ID != user.ID {
return nil, status.Errorf(codes.AlreadyExists, "identity provider account is already linked to another user")
}
return user, nil
}
if currentUser != nil {
return s.bindSSOIdentityToUser(ctx, currentUser, provider, externUID)
}
// Miss path: enforce the registration gate before creating anything.
instanceGeneralSetting, err := s.Store.GetInstanceGeneralSetting(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get instance general setting, error: %v", err)
}
if instanceGeneralSetting.DisallowUserRegistration {
return nil, status.Errorf(codes.PermissionDenied, "user registration is not allowed")
}
password, err := util.RandomString(20)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate random password, error: %v", err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate password hash, error: %v", err)
}
username, err := deriveSSOUsername()
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to derive username, error: %v", err)
}
user, err = s.Store.CreateUser(ctx, &store.User{
Username: username,
Role: store.RoleUser,
Nickname: userInfo.DisplayName,
Email: userInfo.Email,
AvatarURL: userInfo.AvatarURL,
PasswordHash: string(passwordHash),
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create user, error: %v", err)
}
if _, err := s.Store.CreateUserIdentity(ctx, &store.UserIdentity{
UserID: user.ID,
Provider: provider,
ExternUID: externUID,
}); err != nil {
// Best-effort cleanup: the provisional user row has no linkage and should not remain.
_, _ = s.Store.DeleteUser(ctx, &store.DeleteUser{ID: user.ID})
if isUniqueConstraintViolation(err) {
// Concurrent first login won the race; load the winning linkage's user.
winner, getErr := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
Provider: &provider,
ExternUID: &externUID,
})
if getErr != nil {
return nil, status.Errorf(codes.Internal, "failed to reload user identity after race, error: %v", getErr)
}
if winner == nil {
return nil, status.Errorf(codes.Internal, "user identity conflict reported but no winning row found")
}
winnerUser, getErr := s.Store.GetUser(ctx, &store.FindUser{ID: &winner.UserID})
if getErr != nil {
return nil, status.Errorf(codes.Internal, "failed to get user after race, error: %v", getErr)
}
if winnerUser == nil {
return nil, status.Errorf(codes.Internal, "linked user %d not found after race", winner.UserID)
}
return winnerUser, nil
}
return nil, status.Errorf(codes.Internal, "failed to create user identity, error: %v", err)
}
return user, nil
}
func (s *APIV1Service) resolveSSOIdentity(ctx context.Context, idpName, code, redirectURI, codeVerifier string) (*storepb.IdentityProvider, *idp.IdentityProviderUserInfo, error) {
idpUID, err := ExtractIdentityProviderUIDFromName(idpName)
if err != nil {
return nil, nil, status.Errorf(codes.InvalidArgument, "invalid identity provider name: %v", err)
}
identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{
UID: &idpUID,
})
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to get identity provider, error: %v", err)
}
if identityProvider == nil {
return nil, nil, status.Errorf(codes.InvalidArgument, "identity provider not found")
}
var userInfo *idp.IdentityProviderUserInfo
if identityProvider.Type == storepb.IdentityProvider_OAUTH2 {
oauth2IdentityProvider, err := oauth2.NewIdentityProvider(identityProvider.Config.GetOauth2Config())
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to create oauth2 identity provider, error: %v", err)
}
// Pass code_verifier for PKCE support (empty string if not provided for backward compatibility)
token, err := oauth2IdentityProvider.ExchangeToken(ctx, redirectURI, code, codeVerifier)
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to exchange token, error: %v", err)
}
userInfo, err = oauth2IdentityProvider.UserInfo(ctx, token)
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to get user info, error: %v", err)
}
}
identifierFilter := identityProvider.IdentifierFilter
if identifierFilter != "" {
identifierFilterRegex, err := regexp.Compile(identifierFilter)
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to compile identifier filter regex, error: %v", err)
}
if !identifierFilterRegex.MatchString(userInfo.Identifier) {
return nil, nil, status.Errorf(codes.PermissionDenied, "identifier %s is not allowed", userInfo.Identifier)
}
}
return identityProvider, userInfo, nil
}
func (s *APIV1Service) getLinkedSSOUser(ctx context.Context, provider, externUID string) (*store.User, error) {
identity, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
Provider: &provider,
ExternUID: &externUID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user identity, error: %v", err)
}
if identity == nil {
return nil, nil
}
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &identity.UserID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user, error: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Internal, "linked user %d not found for identity %d", identity.UserID, identity.ID)
}
return user, nil
}
func (s *APIV1Service) bindSSOIdentityToUser(ctx context.Context, currentUser *store.User, provider, externUID string) (*store.User, error) {
existingForProvider, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
UserID: &currentUser.ID,
Provider: &provider,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get existing linked identity, error: %v", err)
}
if existingForProvider != nil {
if existingForProvider.ExternUID == externUID {
return currentUser, nil
}
return nil, status.Errorf(codes.AlreadyExists, "identity provider is already linked to another external account for this user")
}
if _, err := s.Store.CreateUserIdentity(ctx, &store.UserIdentity{
UserID: currentUser.ID,
Provider: provider,
ExternUID: externUID,
}); err != nil {
if isUniqueConstraintViolation(err) {
winner, getErr := s.getLinkedSSOUser(ctx, provider, externUID)
if getErr != nil {
return nil, getErr
}
if winner != nil {
if winner.ID != currentUser.ID {
return nil, status.Errorf(codes.AlreadyExists, "identity provider account is already linked to another user")
}
return currentUser, nil
}
existingForProvider, getErr := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
UserID: &currentUser.ID,
Provider: &provider,
})
if getErr != nil {
return nil, status.Errorf(codes.Internal, "failed to reload linked identity after race, error: %v", getErr)
}
if existingForProvider != nil {
if existingForProvider.ExternUID == externUID {
return currentUser, nil
}
return nil, status.Errorf(codes.AlreadyExists, "identity provider is already linked to another external account for this user")
}
return nil, status.Errorf(codes.Internal, "user identity conflict reported but no winning row found")
}
return nil, status.Errorf(codes.Internal, "failed to create user identity, error: %v", err)
}
return currentUser, nil
}
// isUniqueConstraintViolation matches the driver-specific error messages that each
// supported backend emits when any UNIQUE constraint rejects an insert. Callers
// disambiguate which constraint was hit from the insertion context (e.g. inserting
// a user_identity row can only violate UNIQUE(provider, extern_uid); inserting a
// user row can only violate UNIQUE(username)). Matches the pattern used in
// memo_service.go for the memo UID unique check.
func isUniqueConstraintViolation(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "UNIQUE constraint failed") ||
strings.Contains(msg, "duplicate key") ||
strings.Contains(msg, "Duplicate entry")
}
// doSignIn performs the actual sign-in operation by creating a session and setting the cookie.
//
// This function:
// 1. Generates refresh token and access token.
// 2. Stores refresh token metadata in user_setting.
// 3. Sets refresh token as HttpOnly cookie.
// 4. Returns access token and its expiry time.
func (s *APIV1Service) doSignIn(ctx context.Context, user *store.User) (string, time.Time, error) {
// Generate refresh token
tokenID := util.GenUUID()
refreshToken, refreshExpiresAt, err := auth.GenerateRefreshToken(user.ID, tokenID, []byte(s.Secret))
if err != nil {
return "", time.Time{}, status.Errorf(codes.Internal, "failed to generate refresh token: %v", err)
}
// Store refresh token metadata
clientInfo := s.extractClientInfo(ctx)
refreshTokenRecord := &storepb.RefreshTokensUserSetting_RefreshToken{
TokenId: tokenID,
ExpiresAt: timestamppb.New(refreshExpiresAt),
CreatedAt: timestamppb.Now(),
ClientInfo: clientInfo,
}
if err := s.Store.AddUserRefreshToken(ctx, user.ID, refreshTokenRecord); err != nil {
slog.Error("failed to store refresh token", "error", err)
}
// Set refresh token cookie
refreshCookie := s.buildRefreshTokenCookie(ctx, refreshToken, refreshExpiresAt)
if err := SetResponseHeader(ctx, "Set-Cookie", refreshCookie); err != nil {
return "", time.Time{}, status.Errorf(codes.Internal, "failed to set refresh token cookie: %v", err)
}
// Generate access token
accessToken, accessExpiresAt, err := auth.GenerateAccessTokenV2(
user.ID,
user.Username,
string(user.Role),
string(user.RowStatus),
[]byte(s.Secret),
)
if err != nil {
return "", time.Time{}, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
}
return accessToken, accessExpiresAt, nil
}
// SignOut terminates the user's authentication.
// Revokes the refresh token and clears the authentication cookie.
//
// Authentication: Required (access token).
// Returns: Empty response on success.
func (s *APIV1Service) SignOut(ctx context.Context, _ *v1pb.SignOutRequest) (*emptypb.Empty, error) {
// Get user from access token claims
claims := auth.GetUserClaims(ctx)
if claims != nil {
// Revoke refresh token if we can identify it
refreshToken := ""
if md, ok := metadata.FromIncomingContext(ctx); ok {
if cookies := md.Get("cookie"); len(cookies) > 0 {
refreshToken = auth.ExtractRefreshTokenFromCookie(cookies[0])
}
}
if refreshToken != "" {
refreshClaims, err := auth.ParseRefreshToken(refreshToken, []byte(s.Secret))
if err == nil {
// Remove refresh token from user_setting by token_id
_ = s.Store.RemoveUserRefreshToken(ctx, claims.UserID, refreshClaims.TokenID)
}
}
}
// Clear refresh token cookie
if err := s.clearAuthCookies(ctx); err != nil {
return nil, status.Errorf(codes.Internal, "failed to clear auth cookies, error: %v", err)
}
return &emptypb.Empty{}, nil
}
// RefreshToken exchanges a valid refresh token for a new access token.
//
// This endpoint implements refresh token rotation with sliding window sessions:
// 1. Extracts the refresh token from the HttpOnly cookie (memos_refresh)
// 2. Validates the refresh token against the database (checking expiry and revocation)
// 3. Rotates the refresh token: generates a new one with fresh 30-day expiry
// 4. Generates a new short-lived access token (15 minutes)
// 5. Sets the new refresh token as HttpOnly cookie
// 6. Returns the new access token and its expiry time
//
// Token rotation provides:
// - Sliding window sessions: active users stay logged in indefinitely
// - Better security: stolen refresh tokens become invalid after legitimate refresh
//
// Authentication: Requires valid refresh token in cookie (public endpoint)
// Returns: New access token and expiry timestamp.
func (s *APIV1Service) RefreshToken(ctx context.Context, _ *v1pb.RefreshTokenRequest) (*v1pb.RefreshTokenResponse, error) {
// Extract refresh token from cookie
refreshToken := ""
if md, ok := metadata.FromIncomingContext(ctx); ok {
if cookies := md.Get("cookie"); len(cookies) > 0 {
refreshToken = auth.ExtractRefreshTokenFromCookie(cookies[0])
}
}
if refreshToken == "" {
return nil, status.Errorf(codes.Unauthenticated, "refresh token not found")
}
// Validate refresh token and get old token ID for rotation
authenticator := auth.NewAuthenticator(s.Store, s.Secret)
user, oldTokenID, err := authenticator.AuthenticateByRefreshToken(ctx, refreshToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid refresh token: %v", err)
}
// --- Refresh Token Rotation ---
// Generate new refresh token with fresh 30-day expiry (sliding window)
newTokenID := util.GenUUID()
newRefreshToken, newRefreshExpiresAt, err := auth.GenerateRefreshToken(user.ID, newTokenID, []byte(s.Secret))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate refresh token: %v", err)
}
// Store new refresh token (add before remove to handle race conditions)
clientInfo := s.extractClientInfo(ctx)
newRefreshTokenRecord := &storepb.RefreshTokensUserSetting_RefreshToken{
TokenId: newTokenID,
ExpiresAt: timestamppb.New(newRefreshExpiresAt),
CreatedAt: timestamppb.Now(),
ClientInfo: clientInfo,
}
if err := s.Store.AddUserRefreshToken(ctx, user.ID, newRefreshTokenRecord); err != nil {
return nil, status.Errorf(codes.Internal, "failed to store refresh token: %v", err)
}
// Remove old refresh token
if err := s.Store.RemoveUserRefreshToken(ctx, user.ID, oldTokenID); err != nil {
// Log but don't fail - old token will expire naturally
slog.Warn("failed to remove old refresh token", "error", err, "userID", user.ID, "tokenID", oldTokenID)
}
// Set new refresh token cookie
newRefreshCookie := s.buildRefreshTokenCookie(ctx, newRefreshToken, newRefreshExpiresAt)
if err := SetResponseHeader(ctx, "Set-Cookie", newRefreshCookie); err != nil {
return nil, status.Errorf(codes.Internal, "failed to set refresh token cookie: %v", err)
}
// --- End Rotation ---
// Generate new access token
accessToken, expiresAt, err := auth.GenerateAccessTokenV2(
user.ID,
user.Username,
string(user.Role),
string(user.RowStatus),
[]byte(s.Secret),
)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
}
return &v1pb.RefreshTokenResponse{
AccessToken: accessToken,
ExpiresAt: timestamppb.New(expiresAt),
}, nil
}
func (s *APIV1Service) clearAuthCookies(ctx context.Context) error {
// Clear refresh token cookie
refreshCookie := s.buildRefreshTokenCookie(ctx, "", time.Time{})
if err := SetResponseHeader(ctx, "Set-Cookie", refreshCookie); err != nil {
return errors.Wrap(err, "failed to set refresh cookie")
}
return nil
}
func isSecureRequest(ctx context.Context) bool {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return false
}
for _, value := range md.Get("x-forwarded-proto") {
for _, proto := range strings.Split(value, ",") {
if strings.EqualFold(strings.TrimSpace(proto), "https") {
return true
}
}
}
for _, value := range md.Get("forwarded") {
lowerValue := strings.ToLower(value)
if strings.Contains(lowerValue, "proto=https") {
return true
}
}
for _, value := range md.Get("origin") {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), "https://") {
return true
}
}
return false
}
func (*APIV1Service) buildRefreshTokenCookie(ctx context.Context, refreshToken string, expireTime time.Time) string {
attrs := []string{
fmt.Sprintf("%s=%s", auth.RefreshTokenCookieName, refreshToken),
"Path=/",
"HttpOnly",
}
if expireTime.IsZero() {
attrs = append(attrs, "Expires=Thu, 01 Jan 1970 00:00:00 GMT")
} else {
// RFC 6265 requires cookie expiration dates to use GMT timezone
// Convert to UTC and format with explicit "GMT" to ensure browser compatibility
attrs = append(attrs, "Expires="+expireTime.UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT"))
}
if isSecureRequest(ctx) {
attrs = append(attrs, "SameSite=Lax", "Secure")
} else {
attrs = append(attrs, "SameSite=Lax")
}
return strings.Join(attrs, "; ")
}
func (s *APIV1Service) fetchCurrentUser(ctx context.Context) (*store.User, error) {
userID := auth.GetUserID(ctx)
if userID == 0 {
return nil, nil
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.Errorf("user %d not found", userID)
}
if user.RowStatus == store.Archived {
return nil, nil
}
return user, nil
}
// extractClientInfo extracts comprehensive client information from the request context.
//
// This function parses metadata from the gRPC context to extract:
// - User Agent: Raw user agent string for detailed parsing
// - IP Address: Client IP from X-Forwarded-For or X-Real-IP headers
// - Device Type: "mobile", "tablet", or "desktop" (parsed from user agent)
// - Operating System: OS name and version (e.g., "iOS 17.1", "Windows 10/11")
// - Browser: Browser name and version (e.g., "Chrome 120.0.0.0")
//
// This information enables users to:
// - See all active sessions with device details
// - Identify suspicious login attempts
// - Revoke specific sessions from unknown devices.
func (s *APIV1Service) extractClientInfo(ctx context.Context) *storepb.RefreshTokensUserSetting_ClientInfo {
clientInfo := &storepb.RefreshTokensUserSetting_ClientInfo{}
// Extract user agent from metadata if available
if md, ok := metadata.FromIncomingContext(ctx); ok {
if userAgents := md.Get("user-agent"); len(userAgents) > 0 {
userAgent := userAgents[0]
clientInfo.UserAgent = userAgent
// Parse user agent to extract device type, OS, browser info
s.parseUserAgent(userAgent, clientInfo)
}
if forwardedFor := md.Get("x-forwarded-for"); len(forwardedFor) > 0 {
ipAddress := strings.Split(forwardedFor[0], ",")[0] // Get the first IP in case of multiple
ipAddress = strings.TrimSpace(ipAddress)
clientInfo.IpAddress = ipAddress
} else if realIP := md.Get("x-real-ip"); len(realIP) > 0 {
clientInfo.IpAddress = realIP[0]
}
}
return clientInfo
}
// parseUserAgent extracts device type, OS, and browser information from user agent string.
//
// Detection logic:
// - Device Type: Checks for keywords like "mobile", "tablet", "ipad"
// - OS: Pattern matches for iOS, Android, Windows, macOS, Linux, Chrome OS
// - Browser: Identifies Edge, Chrome, Firefox, Safari, Opera
//
// Note: This is a simplified parser. For production use with high accuracy requirements,
// consider using a dedicated user agent parsing library.
func (*APIV1Service) parseUserAgent(userAgent string, clientInfo *storepb.RefreshTokensUserSetting_ClientInfo) {
if userAgent == "" {
return
}
userAgent = strings.ToLower(userAgent)
// Detect device type
if strings.Contains(userAgent, "ipad") || strings.Contains(userAgent, "tablet") {
clientInfo.DeviceType = "tablet"
} else if strings.Contains(userAgent, "mobile") || strings.Contains(userAgent, "android") ||
strings.Contains(userAgent, "iphone") || strings.Contains(userAgent, "ipod") ||
strings.Contains(userAgent, "windows phone") || strings.Contains(userAgent, "blackberry") {
clientInfo.DeviceType = "mobile"
} else {
clientInfo.DeviceType = "desktop"
}
// Detect operating system
if strings.Contains(userAgent, "iphone os") || strings.Contains(userAgent, "cpu os") {
// Extract iOS version
if idx := strings.Index(userAgent, "cpu os "); idx != -1 {
versionStart := idx + 7
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd != -1 {
version := strings.ReplaceAll(userAgent[versionStart:versionStart+versionEnd], "_", ".")
clientInfo.Os = "iOS " + version
} else {
clientInfo.Os = "iOS"
}
} else if idx := strings.Index(userAgent, "iphone os "); idx != -1 {
versionStart := idx + 10
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd != -1 {
version := strings.ReplaceAll(userAgent[versionStart:versionStart+versionEnd], "_", ".")
clientInfo.Os = "iOS " + version
} else {
clientInfo.Os = "iOS"
}
} else {
clientInfo.Os = "iOS"
}
} else if strings.Contains(userAgent, "android") {
// Extract Android version
if idx := strings.Index(userAgent, "android "); idx != -1 {
versionStart := idx + 8
versionEnd := strings.Index(userAgent[versionStart:], ";")
if versionEnd == -1 {
versionEnd = strings.Index(userAgent[versionStart:], ")")
}
if versionEnd != -1 {
version := userAgent[versionStart : versionStart+versionEnd]
clientInfo.Os = "Android " + version
} else {
clientInfo.Os = "Android"
}
} else {
clientInfo.Os = "Android"
}
} else if strings.Contains(userAgent, "windows nt 10.0") {
clientInfo.Os = "Windows 10/11"
} else if strings.Contains(userAgent, "windows nt 6.3") {
clientInfo.Os = "Windows 8.1"
} else if strings.Contains(userAgent, "windows nt 6.1") {
clientInfo.Os = "Windows 7"
} else if strings.Contains(userAgent, "windows") {
clientInfo.Os = "Windows"
} else if strings.Contains(userAgent, "mac os x") {
// Extract macOS version
if idx := strings.Index(userAgent, "mac os x "); idx != -1 {
versionStart := idx + 9
versionEnd := strings.Index(userAgent[versionStart:], ";")
if versionEnd == -1 {
versionEnd = strings.Index(userAgent[versionStart:], ")")
}
if versionEnd != -1 {
version := strings.ReplaceAll(userAgent[versionStart:versionStart+versionEnd], "_", ".")
clientInfo.Os = "macOS " + version
} else {
clientInfo.Os = "macOS"
}
} else {
clientInfo.Os = "macOS"
}
} else if strings.Contains(userAgent, "linux") {
clientInfo.Os = "Linux"
} else if strings.Contains(userAgent, "cros") {
clientInfo.Os = "Chrome OS"
}
// Detect browser
if strings.Contains(userAgent, "edg/") {
// Extract Edge version
if idx := strings.Index(userAgent, "edg/"); idx != -1 {
versionStart := idx + 4
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd == -1 {
versionEnd = len(userAgent) - versionStart
}
version := userAgent[versionStart : versionStart+versionEnd]
clientInfo.Browser = "Edge " + version
} else {
clientInfo.Browser = "Edge"
}
} else if strings.Contains(userAgent, "chrome/") && !strings.Contains(userAgent, "edg") {
// Extract Chrome version
if idx := strings.Index(userAgent, "chrome/"); idx != -1 {
versionStart := idx + 7
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd == -1 {
versionEnd = len(userAgent) - versionStart
}
version := userAgent[versionStart : versionStart+versionEnd]
clientInfo.Browser = "Chrome " + version
} else {
clientInfo.Browser = "Chrome"
}
} else if strings.Contains(userAgent, "firefox/") {
// Extract Firefox version
if idx := strings.Index(userAgent, "firefox/"); idx != -1 {
versionStart := idx + 8
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd == -1 {
versionEnd = len(userAgent) - versionStart
}
version := userAgent[versionStart : versionStart+versionEnd]
clientInfo.Browser = "Firefox " + version
} else {
clientInfo.Browser = "Firefox"
}
} else if strings.Contains(userAgent, "safari/") && !strings.Contains(userAgent, "chrome") && !strings.Contains(userAgent, "edg") {
// Extract Safari version
if idx := strings.Index(userAgent, "version/"); idx != -1 {
versionStart := idx + 8
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd == -1 {
versionEnd = len(userAgent) - versionStart
}
version := userAgent[versionStart : versionStart+versionEnd]
clientInfo.Browser = "Safari " + version
} else {
clientInfo.Browser = "Safari"
}
} else if strings.Contains(userAgent, "opera/") || strings.Contains(userAgent, "opr/") {
clientInfo.Browser = "Opera"
}
}
@@ -0,0 +1,458 @@
package v1
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/internal/util"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/auth"
"github.com/usememos/memos/store"
)
func (s *APIV1Service) doSignIn(ctx context.Context, user *store.User) (string, time.Time, error) {
// Generate refresh token
tokenID := util.GenUUID()
refreshToken, refreshExpiresAt, err := auth.GenerateRefreshToken(user.ID, tokenID, []byte(s.Secret))
if err != nil {
return "", time.Time{}, status.Errorf(codes.Internal, "failed to generate refresh token: %v", err)
}
// Store refresh token metadata
clientInfo := s.extractClientInfo(ctx)
refreshTokenRecord := &storepb.RefreshTokensUserSetting_RefreshToken{
TokenId: tokenID,
ExpiresAt: timestamppb.New(refreshExpiresAt),
CreatedAt: timestamppb.Now(),
ClientInfo: clientInfo,
}
if err := s.Store.AddUserRefreshToken(ctx, user.ID, refreshTokenRecord); err != nil {
slog.Error("failed to store refresh token", "error", err)
}
// Set refresh token cookie
refreshCookie := s.buildRefreshTokenCookie(ctx, refreshToken, refreshExpiresAt)
if err := SetResponseHeader(ctx, "Set-Cookie", refreshCookie); err != nil {
return "", time.Time{}, status.Errorf(codes.Internal, "failed to set refresh token cookie: %v", err)
}
// Generate access token
accessToken, accessExpiresAt, err := auth.GenerateAccessTokenV2(
user.ID,
user.Username,
string(user.Role),
string(user.RowStatus),
[]byte(s.Secret),
)
if err != nil {
return "", time.Time{}, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
}
return accessToken, accessExpiresAt, nil
}
// SignOut terminates the user's authentication.
// Revokes the refresh token and clears the authentication cookie.
//
// Authentication: Required (access token).
// Returns: Empty response on success.
func (s *APIV1Service) SignOut(ctx context.Context, _ *v1pb.SignOutRequest) (*emptypb.Empty, error) {
// Get user from access token claims
claims := auth.GetUserClaims(ctx)
if claims != nil {
// Revoke refresh token if we can identify it
refreshToken := ""
if md, ok := metadata.FromIncomingContext(ctx); ok {
if cookies := md.Get("cookie"); len(cookies) > 0 {
refreshToken = auth.ExtractRefreshTokenFromCookie(cookies[0])
}
}
if refreshToken != "" {
refreshClaims, err := auth.ParseRefreshToken(refreshToken, []byte(s.Secret))
if err == nil {
// Remove refresh token from user_setting by token_id
_ = s.Store.RemoveUserRefreshToken(ctx, claims.UserID, refreshClaims.TokenID)
}
}
}
// Clear refresh token cookie
if err := s.clearAuthCookies(ctx); err != nil {
return nil, status.Errorf(codes.Internal, "failed to clear auth cookies, error: %v", err)
}
return &emptypb.Empty{}, nil
}
// RefreshToken exchanges a valid refresh token for a new access token.
//
// This endpoint implements refresh token rotation with sliding window sessions:
// 1. Extracts the refresh token from the HttpOnly cookie (memos_refresh)
// 2. Validates the refresh token against the database (checking expiry and revocation)
// 3. Rotates the refresh token: generates a new one with fresh 30-day expiry
// 4. Generates a new short-lived access token (15 minutes)
// 5. Sets the new refresh token as HttpOnly cookie
// 6. Returns the new access token and its expiry time
//
// Token rotation provides:
// - Sliding window sessions: active users stay logged in indefinitely
// - Better security: stolen refresh tokens become invalid after legitimate refresh
//
// Authentication: Requires valid refresh token in cookie (public endpoint)
// Returns: New access token and expiry timestamp.
func (s *APIV1Service) RefreshToken(ctx context.Context, _ *v1pb.RefreshTokenRequest) (*v1pb.RefreshTokenResponse, error) {
// Extract refresh token from cookie
refreshToken := ""
if md, ok := metadata.FromIncomingContext(ctx); ok {
if cookies := md.Get("cookie"); len(cookies) > 0 {
refreshToken = auth.ExtractRefreshTokenFromCookie(cookies[0])
}
}
if refreshToken == "" {
return nil, status.Errorf(codes.Unauthenticated, "refresh token not found")
}
// Validate refresh token and get old token ID for rotation
authenticator := auth.NewAuthenticator(s.Store, s.Secret)
user, oldTokenID, err := authenticator.AuthenticateByRefreshToken(ctx, refreshToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid refresh token: %v", err)
}
// --- Refresh Token Rotation ---
// Generate new refresh token with fresh 30-day expiry (sliding window)
newTokenID := util.GenUUID()
newRefreshToken, newRefreshExpiresAt, err := auth.GenerateRefreshToken(user.ID, newTokenID, []byte(s.Secret))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate refresh token: %v", err)
}
// Store new refresh token (add before remove to handle race conditions)
clientInfo := s.extractClientInfo(ctx)
newRefreshTokenRecord := &storepb.RefreshTokensUserSetting_RefreshToken{
TokenId: newTokenID,
ExpiresAt: timestamppb.New(newRefreshExpiresAt),
CreatedAt: timestamppb.Now(),
ClientInfo: clientInfo,
}
if err := s.Store.AddUserRefreshToken(ctx, user.ID, newRefreshTokenRecord); err != nil {
return nil, status.Errorf(codes.Internal, "failed to store refresh token: %v", err)
}
// Remove old refresh token
if err := s.Store.RemoveUserRefreshToken(ctx, user.ID, oldTokenID); err != nil {
// Log but don't fail - old token will expire naturally
slog.Warn("failed to remove old refresh token", "error", err, "userID", user.ID, "tokenID", oldTokenID)
}
// Set new refresh token cookie
newRefreshCookie := s.buildRefreshTokenCookie(ctx, newRefreshToken, newRefreshExpiresAt)
if err := SetResponseHeader(ctx, "Set-Cookie", newRefreshCookie); err != nil {
return nil, status.Errorf(codes.Internal, "failed to set refresh token cookie: %v", err)
}
// --- End Rotation ---
// Generate new access token
accessToken, expiresAt, err := auth.GenerateAccessTokenV2(
user.ID,
user.Username,
string(user.Role),
string(user.RowStatus),
[]byte(s.Secret),
)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate access token: %v", err)
}
return &v1pb.RefreshTokenResponse{
AccessToken: accessToken,
ExpiresAt: timestamppb.New(expiresAt),
}, nil
}
func (s *APIV1Service) clearAuthCookies(ctx context.Context) error {
// Clear refresh token cookie
refreshCookie := s.buildRefreshTokenCookie(ctx, "", time.Time{})
if err := SetResponseHeader(ctx, "Set-Cookie", refreshCookie); err != nil {
return errors.Wrap(err, "failed to set refresh cookie")
}
return nil
}
func isSecureRequest(ctx context.Context) bool {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return false
}
for _, value := range md.Get("x-forwarded-proto") {
for _, proto := range strings.Split(value, ",") {
if strings.EqualFold(strings.TrimSpace(proto), "https") {
return true
}
}
}
for _, value := range md.Get("forwarded") {
lowerValue := strings.ToLower(value)
if strings.Contains(lowerValue, "proto=https") {
return true
}
}
for _, value := range md.Get("origin") {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), "https://") {
return true
}
}
return false
}
func (*APIV1Service) buildRefreshTokenCookie(ctx context.Context, refreshToken string, expireTime time.Time) string {
attrs := []string{
fmt.Sprintf("%s=%s", auth.RefreshTokenCookieName, refreshToken),
"Path=/",
"HttpOnly",
}
if expireTime.IsZero() {
attrs = append(attrs, "Expires=Thu, 01 Jan 1970 00:00:00 GMT")
} else {
// RFC 6265 requires cookie expiration dates to use GMT timezone
// Convert to UTC and format with explicit "GMT" to ensure browser compatibility
attrs = append(attrs, "Expires="+expireTime.UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT"))
}
if isSecureRequest(ctx) {
attrs = append(attrs, "SameSite=Lax", "Secure")
} else {
attrs = append(attrs, "SameSite=Lax")
}
return strings.Join(attrs, "; ")
}
func (s *APIV1Service) fetchCurrentUser(ctx context.Context) (*store.User, error) {
userID := auth.GetUserID(ctx)
if userID == 0 {
return nil, nil
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.Errorf("user %d not found", userID)
}
if user.RowStatus == store.Archived {
return nil, nil
}
return user, nil
}
// extractClientInfo extracts comprehensive client information from the request context.
//
// This function parses metadata from the gRPC context to extract:
// - User Agent: Raw user agent string for detailed parsing
// - IP Address: Client IP from X-Forwarded-For or X-Real-IP headers
// - Device Type: "mobile", "tablet", or "desktop" (parsed from user agent)
// - Operating System: OS name and version (e.g., "iOS 17.1", "Windows 10/11")
// - Browser: Browser name and version (e.g., "Chrome 120.0.0.0")
//
// This information enables users to:
// - See all active sessions with device details
// - Identify suspicious login attempts
// - Revoke specific sessions from unknown devices.
func (s *APIV1Service) extractClientInfo(ctx context.Context) *storepb.RefreshTokensUserSetting_ClientInfo {
clientInfo := &storepb.RefreshTokensUserSetting_ClientInfo{}
// Extract user agent from metadata if available
if md, ok := metadata.FromIncomingContext(ctx); ok {
if userAgents := md.Get("user-agent"); len(userAgents) > 0 {
userAgent := userAgents[0]
clientInfo.UserAgent = userAgent
// Parse user agent to extract device type, OS, browser info
s.parseUserAgent(userAgent, clientInfo)
}
if forwardedFor := md.Get("x-forwarded-for"); len(forwardedFor) > 0 {
ipAddress := strings.Split(forwardedFor[0], ",")[0] // Get the first IP in case of multiple
ipAddress = strings.TrimSpace(ipAddress)
clientInfo.IpAddress = ipAddress
} else if realIP := md.Get("x-real-ip"); len(realIP) > 0 {
clientInfo.IpAddress = realIP[0]
}
}
return clientInfo
}
// parseUserAgent extracts device type, OS, and browser information from user agent string.
//
// Detection logic:
// - Device Type: Checks for keywords like "mobile", "tablet", "ipad"
// - OS: Pattern matches for iOS, Android, Windows, macOS, Linux, Chrome OS
// - Browser: Identifies Edge, Chrome, Firefox, Safari, Opera
//
// Note: This is a simplified parser. For production use with high accuracy requirements,
// consider using a dedicated user agent parsing library.
func (*APIV1Service) parseUserAgent(userAgent string, clientInfo *storepb.RefreshTokensUserSetting_ClientInfo) {
if userAgent == "" {
return
}
userAgent = strings.ToLower(userAgent)
// Detect device type
if strings.Contains(userAgent, "ipad") || strings.Contains(userAgent, "tablet") {
clientInfo.DeviceType = "tablet"
} else if strings.Contains(userAgent, "mobile") || strings.Contains(userAgent, "android") ||
strings.Contains(userAgent, "iphone") || strings.Contains(userAgent, "ipod") ||
strings.Contains(userAgent, "windows phone") || strings.Contains(userAgent, "blackberry") {
clientInfo.DeviceType = "mobile"
} else {
clientInfo.DeviceType = "desktop"
}
// Detect operating system
if strings.Contains(userAgent, "iphone os") || strings.Contains(userAgent, "cpu os") {
// Extract iOS version
if idx := strings.Index(userAgent, "cpu os "); idx != -1 {
versionStart := idx + 7
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd != -1 {
version := strings.ReplaceAll(userAgent[versionStart:versionStart+versionEnd], "_", ".")
clientInfo.Os = "iOS " + version
} else {
clientInfo.Os = "iOS"
}
} else if idx := strings.Index(userAgent, "iphone os "); idx != -1 {
versionStart := idx + 10
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd != -1 {
version := strings.ReplaceAll(userAgent[versionStart:versionStart+versionEnd], "_", ".")
clientInfo.Os = "iOS " + version
} else {
clientInfo.Os = "iOS"
}
} else {
clientInfo.Os = "iOS"
}
} else if strings.Contains(userAgent, "android") {
// Extract Android version
if idx := strings.Index(userAgent, "android "); idx != -1 {
versionStart := idx + 8
versionEnd := strings.Index(userAgent[versionStart:], ";")
if versionEnd == -1 {
versionEnd = strings.Index(userAgent[versionStart:], ")")
}
if versionEnd != -1 {
version := userAgent[versionStart : versionStart+versionEnd]
clientInfo.Os = "Android " + version
} else {
clientInfo.Os = "Android"
}
} else {
clientInfo.Os = "Android"
}
} else if strings.Contains(userAgent, "windows nt 10.0") {
clientInfo.Os = "Windows 10/11"
} else if strings.Contains(userAgent, "windows nt 6.3") {
clientInfo.Os = "Windows 8.1"
} else if strings.Contains(userAgent, "windows nt 6.1") {
clientInfo.Os = "Windows 7"
} else if strings.Contains(userAgent, "windows") {
clientInfo.Os = "Windows"
} else if strings.Contains(userAgent, "mac os x") {
// Extract macOS version
if idx := strings.Index(userAgent, "mac os x "); idx != -1 {
versionStart := idx + 9
versionEnd := strings.Index(userAgent[versionStart:], ";")
if versionEnd == -1 {
versionEnd = strings.Index(userAgent[versionStart:], ")")
}
if versionEnd != -1 {
version := strings.ReplaceAll(userAgent[versionStart:versionStart+versionEnd], "_", ".")
clientInfo.Os = "macOS " + version
} else {
clientInfo.Os = "macOS"
}
} else {
clientInfo.Os = "macOS"
}
} else if strings.Contains(userAgent, "linux") {
clientInfo.Os = "Linux"
} else if strings.Contains(userAgent, "cros") {
clientInfo.Os = "Chrome OS"
}
// Detect browser
if strings.Contains(userAgent, "edg/") {
// Extract Edge version
if idx := strings.Index(userAgent, "edg/"); idx != -1 {
versionStart := idx + 4
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd == -1 {
versionEnd = len(userAgent) - versionStart
}
version := userAgent[versionStart : versionStart+versionEnd]
clientInfo.Browser = "Edge " + version
} else {
clientInfo.Browser = "Edge"
}
} else if strings.Contains(userAgent, "chrome/") && !strings.Contains(userAgent, "edg") {
// Extract Chrome version
if idx := strings.Index(userAgent, "chrome/"); idx != -1 {
versionStart := idx + 7
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd == -1 {
versionEnd = len(userAgent) - versionStart
}
version := userAgent[versionStart : versionStart+versionEnd]
clientInfo.Browser = "Chrome " + version
} else {
clientInfo.Browser = "Chrome"
}
} else if strings.Contains(userAgent, "firefox/") {
// Extract Firefox version
if idx := strings.Index(userAgent, "firefox/"); idx != -1 {
versionStart := idx + 8
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd == -1 {
versionEnd = len(userAgent) - versionStart
}
version := userAgent[versionStart : versionStart+versionEnd]
clientInfo.Browser = "Firefox " + version
} else {
clientInfo.Browser = "Firefox"
}
} else if strings.Contains(userAgent, "safari/") && !strings.Contains(userAgent, "chrome") && !strings.Contains(userAgent, "edg") {
// Extract Safari version
if idx := strings.Index(userAgent, "version/"); idx != -1 {
versionStart := idx + 8
versionEnd := strings.Index(userAgent[versionStart:], " ")
if versionEnd == -1 {
versionEnd = len(userAgent) - versionStart
}
version := userAgent[versionStart : versionStart+versionEnd]
clientInfo.Browser = "Safari " + version
} else {
clientInfo.Browser = "Safari"
}
} else if strings.Contains(userAgent, "opera/") || strings.Contains(userAgent, "opr/") {
clientInfo.Browser = "Opera"
}
}
+246
View File
@@ -0,0 +1,246 @@
package v1
import (
"context"
"regexp"
"strings"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/usememos/memos/internal/idp"
"github.com/usememos/memos/internal/idp/oauth2"
"github.com/usememos/memos/internal/util"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func (s *APIV1Service) resolveSSOUser(ctx context.Context, currentUser *store.User, identityProvider *storepb.IdentityProvider, userInfo *idp.IdentityProviderUserInfo) (*store.User, error) {
provider := identityProvider.Uid
externUID := userInfo.Identifier
user, err := s.getLinkedSSOUser(ctx, provider, externUID)
if err != nil {
return nil, err
}
if user != nil {
if currentUser != nil && currentUser.ID != user.ID {
return nil, status.Errorf(codes.AlreadyExists, "identity provider account is already linked to another user")
}
return user, nil
}
if currentUser != nil {
return s.bindSSOIdentityToUser(ctx, currentUser, provider, externUID)
}
// Miss path: enforce the registration gate before creating anything.
instanceGeneralSetting, err := s.Store.GetInstanceGeneralSetting(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get instance general setting, error: %v", err)
}
if instanceGeneralSetting.DisallowUserRegistration {
return nil, status.Errorf(codes.PermissionDenied, "user registration is not allowed")
}
password, err := util.RandomString(20)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate random password, error: %v", err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate password hash, error: %v", err)
}
username, err := deriveSSOUsername()
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to derive username, error: %v", err)
}
user, err = s.Store.CreateUser(ctx, &store.User{
Username: username,
Role: store.RoleUser,
Nickname: userInfo.DisplayName,
Email: userInfo.Email,
AvatarURL: userInfo.AvatarURL,
PasswordHash: string(passwordHash),
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create user, error: %v", err)
}
if _, err := s.Store.CreateUserIdentity(ctx, &store.UserIdentity{
UserID: user.ID,
Provider: provider,
ExternUID: externUID,
}); err != nil {
// Best-effort cleanup: the provisional user row has no linkage and should not remain.
_, _ = s.Store.DeleteUser(ctx, &store.DeleteUser{ID: user.ID})
if isUniqueConstraintViolation(err) {
// Concurrent first login won the race; load the winning linkage's user.
winner, getErr := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
Provider: &provider,
ExternUID: &externUID,
})
if getErr != nil {
return nil, status.Errorf(codes.Internal, "failed to reload user identity after race, error: %v", getErr)
}
if winner == nil {
return nil, status.Errorf(codes.Internal, "user identity conflict reported but no winning row found")
}
winnerUser, getErr := s.Store.GetUser(ctx, &store.FindUser{ID: &winner.UserID})
if getErr != nil {
return nil, status.Errorf(codes.Internal, "failed to get user after race, error: %v", getErr)
}
if winnerUser == nil {
return nil, status.Errorf(codes.Internal, "linked user %d not found after race", winner.UserID)
}
return winnerUser, nil
}
return nil, status.Errorf(codes.Internal, "failed to create user identity, error: %v", err)
}
return user, nil
}
func (s *APIV1Service) resolveSSOIdentity(ctx context.Context, idpName, code, redirectURI, codeVerifier string) (*storepb.IdentityProvider, *idp.IdentityProviderUserInfo, error) {
idpUID, err := ExtractIdentityProviderUIDFromName(idpName)
if err != nil {
return nil, nil, status.Errorf(codes.InvalidArgument, "invalid identity provider name: %v", err)
}
identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{
UID: &idpUID,
})
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to get identity provider, error: %v", err)
}
if identityProvider == nil {
return nil, nil, status.Errorf(codes.InvalidArgument, "identity provider not found")
}
var userInfo *idp.IdentityProviderUserInfo
if identityProvider.Type == storepb.IdentityProvider_OAUTH2 {
oauth2IdentityProvider, err := oauth2.NewIdentityProvider(identityProvider.Config.GetOauth2Config())
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to create oauth2 identity provider, error: %v", err)
}
// Pass code_verifier for PKCE support (empty string if not provided for backward compatibility)
token, err := oauth2IdentityProvider.ExchangeToken(ctx, redirectURI, code, codeVerifier)
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to exchange token, error: %v", err)
}
userInfo, err = oauth2IdentityProvider.UserInfo(ctx, token)
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to get user info, error: %v", err)
}
}
identifierFilter := identityProvider.IdentifierFilter
if identifierFilter != "" {
identifierFilterRegex, err := regexp.Compile(identifierFilter)
if err != nil {
return nil, nil, status.Errorf(codes.Internal, "failed to compile identifier filter regex, error: %v", err)
}
if !identifierFilterRegex.MatchString(userInfo.Identifier) {
return nil, nil, status.Errorf(codes.PermissionDenied, "identifier %s is not allowed", userInfo.Identifier)
}
}
return identityProvider, userInfo, nil
}
func (s *APIV1Service) getLinkedSSOUser(ctx context.Context, provider, externUID string) (*store.User, error) {
identity, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
Provider: &provider,
ExternUID: &externUID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user identity, error: %v", err)
}
if identity == nil {
return nil, nil
}
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &identity.UserID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user, error: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Internal, "linked user %d not found for identity %d", identity.UserID, identity.ID)
}
return user, nil
}
func (s *APIV1Service) bindSSOIdentityToUser(ctx context.Context, currentUser *store.User, provider, externUID string) (*store.User, error) {
existingForProvider, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
UserID: &currentUser.ID,
Provider: &provider,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get existing linked identity, error: %v", err)
}
if existingForProvider != nil {
if existingForProvider.ExternUID == externUID {
return currentUser, nil
}
return nil, status.Errorf(codes.AlreadyExists, "identity provider is already linked to another external account for this user")
}
if _, err := s.Store.CreateUserIdentity(ctx, &store.UserIdentity{
UserID: currentUser.ID,
Provider: provider,
ExternUID: externUID,
}); err != nil {
if isUniqueConstraintViolation(err) {
winner, getErr := s.getLinkedSSOUser(ctx, provider, externUID)
if getErr != nil {
return nil, getErr
}
if winner != nil {
if winner.ID != currentUser.ID {
return nil, status.Errorf(codes.AlreadyExists, "identity provider account is already linked to another user")
}
return currentUser, nil
}
existingForProvider, getErr := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
UserID: &currentUser.ID,
Provider: &provider,
})
if getErr != nil {
return nil, status.Errorf(codes.Internal, "failed to reload linked identity after race, error: %v", getErr)
}
if existingForProvider != nil {
if existingForProvider.ExternUID == externUID {
return currentUser, nil
}
return nil, status.Errorf(codes.AlreadyExists, "identity provider is already linked to another external account for this user")
}
return nil, status.Errorf(codes.Internal, "user identity conflict reported but no winning row found")
}
return nil, status.Errorf(codes.Internal, "failed to create user identity, error: %v", err)
}
return currentUser, nil
}
// isUniqueConstraintViolation matches the driver-specific error messages that each
// supported backend emits when any UNIQUE constraint rejects an insert. Callers
// disambiguate which constraint was hit from the insertion context (e.g. inserting
// a user_identity row can only violate UNIQUE(provider, extern_uid); inserting a
// user row can only violate UNIQUE(username)). Matches the pattern used in
// memo_service.go for the memo UID unique check.
func isUniqueConstraintViolation(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "UNIQUE constraint failed") ||
strings.Contains(msg, "duplicate key") ||
strings.Contains(msg, "Duplicate entry")
}
// doSignIn performs the actual sign-in operation by creating a session and setting the cookie.
//
// This function:
// 1. Generates refresh token and access token.
// 2. Stores refresh token metadata in user_setting.
// 3. Sets refresh token as HttpOnly cookie.
// 4. Returns access token and its expiry time.
+1 -1
View File
@@ -79,7 +79,7 @@ func isNilAnyResponse(resp connect.AnyResponse) bool {
return true
}
val := reflect.ValueOf(resp)
return val.Kind() == reflect.Ptr && val.IsNil()
return val.Kind() == reflect.Pointer && val.IsNil()
}
func (*MetadataInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
-527
View File
@@ -2,14 +2,9 @@ package v1
import (
"context"
"fmt"
"math"
"regexp"
"strings"
"github.com/lithammer/shortuuid/v4"
"github.com/pkg/errors"
colorpb "google.golang.org/genproto/googleapis/type/color"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
@@ -310,528 +305,6 @@ func sameSMTPConnectionIdentity(setting, existing *storepb.InstanceNotificationS
setting.UseSsl == existing.UseSsl
}
func convertInstanceSettingFromStore(setting *storepb.InstanceSetting) *v1pb.InstanceSetting {
instanceSetting := &v1pb.InstanceSetting{
Name: fmt.Sprintf("instance/settings/%s", setting.Key.String()),
}
switch setting.Value.(type) {
case *storepb.InstanceSetting_GeneralSetting:
instanceSetting.Value = &v1pb.InstanceSetting_GeneralSetting_{
GeneralSetting: convertInstanceGeneralSettingFromStore(setting.GetGeneralSetting()),
}
case *storepb.InstanceSetting_StorageSetting:
instanceSetting.Value = &v1pb.InstanceSetting_StorageSetting_{
StorageSetting: convertInstanceStorageSettingFromStore(setting.GetStorageSetting()),
}
case *storepb.InstanceSetting_MemoRelatedSetting:
instanceSetting.Value = &v1pb.InstanceSetting_MemoRelatedSetting_{
MemoRelatedSetting: convertInstanceMemoRelatedSettingFromStore(setting.GetMemoRelatedSetting()),
}
case *storepb.InstanceSetting_TagsSetting:
instanceSetting.Value = &v1pb.InstanceSetting_TagsSetting_{
TagsSetting: convertInstanceTagsSettingFromStore(setting.GetTagsSetting()),
}
case *storepb.InstanceSetting_NotificationSetting:
instanceSetting.Value = &v1pb.InstanceSetting_NotificationSetting_{
NotificationSetting: convertInstanceNotificationSettingFromStore(setting.GetNotificationSetting()),
}
case *storepb.InstanceSetting_AiSetting:
instanceSetting.Value = &v1pb.InstanceSetting_AiSetting{
AiSetting: convertInstanceAISettingFromStore(setting.GetAiSetting()),
}
default:
// Leave Value unset for unsupported setting variants.
}
return instanceSetting
}
func convertInstanceSettingToStore(setting *v1pb.InstanceSetting) *storepb.InstanceSetting {
settingKeyString, _ := ExtractInstanceSettingKeyFromName(setting.Name)
instanceSetting := &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey(storepb.InstanceSettingKey_value[settingKeyString]),
Value: &storepb.InstanceSetting_GeneralSetting{
GeneralSetting: convertInstanceGeneralSettingToStore(setting.GetGeneralSetting()),
},
}
switch instanceSetting.Key {
case storepb.InstanceSettingKey_GENERAL:
instanceSetting.Value = &storepb.InstanceSetting_GeneralSetting{
GeneralSetting: convertInstanceGeneralSettingToStore(setting.GetGeneralSetting()),
}
case storepb.InstanceSettingKey_STORAGE:
instanceSetting.Value = &storepb.InstanceSetting_StorageSetting{
StorageSetting: convertInstanceStorageSettingToStore(setting.GetStorageSetting()),
}
case storepb.InstanceSettingKey_MEMO_RELATED:
instanceSetting.Value = &storepb.InstanceSetting_MemoRelatedSetting{
MemoRelatedSetting: convertInstanceMemoRelatedSettingToStore(setting.GetMemoRelatedSetting()),
}
case storepb.InstanceSettingKey_TAGS:
instanceSetting.Value = &storepb.InstanceSetting_TagsSetting{
TagsSetting: convertInstanceTagsSettingToStore(setting.GetTagsSetting()),
}
case storepb.InstanceSettingKey_NOTIFICATION:
instanceSetting.Value = &storepb.InstanceSetting_NotificationSetting{
NotificationSetting: convertInstanceNotificationSettingToStore(setting.GetNotificationSetting()),
}
case storepb.InstanceSettingKey_AI:
instanceSetting.Value = &storepb.InstanceSetting_AiSetting{
AiSetting: convertInstanceAISettingToStore(setting.GetAiSetting()),
}
default:
// Keep the default GeneralSetting value
}
return instanceSetting
}
func convertInstanceGeneralSettingFromStore(setting *storepb.InstanceGeneralSetting) *v1pb.InstanceSetting_GeneralSetting {
if setting == nil {
return nil
}
generalSetting := &v1pb.InstanceSetting_GeneralSetting{
DisallowUserRegistration: setting.DisallowUserRegistration,
DisallowPasswordAuth: setting.DisallowPasswordAuth,
AdditionalScript: setting.AdditionalScript,
AdditionalStyle: setting.AdditionalStyle,
WeekStartDayOffset: setting.WeekStartDayOffset,
DisallowChangeUsername: setting.DisallowChangeUsername,
DisallowChangeNickname: setting.DisallowChangeNickname,
}
if setting.CustomProfile != nil {
generalSetting.CustomProfile = &v1pb.InstanceSetting_GeneralSetting_CustomProfile{
Title: setting.CustomProfile.Title,
Description: setting.CustomProfile.Description,
LogoUrl: setting.CustomProfile.LogoUrl,
}
}
return generalSetting
}
func convertInstanceGeneralSettingToStore(setting *v1pb.InstanceSetting_GeneralSetting) *storepb.InstanceGeneralSetting {
if setting == nil {
return nil
}
generalSetting := &storepb.InstanceGeneralSetting{
DisallowUserRegistration: setting.DisallowUserRegistration,
DisallowPasswordAuth: setting.DisallowPasswordAuth,
AdditionalScript: setting.AdditionalScript,
AdditionalStyle: setting.AdditionalStyle,
WeekStartDayOffset: setting.WeekStartDayOffset,
DisallowChangeUsername: setting.DisallowChangeUsername,
DisallowChangeNickname: setting.DisallowChangeNickname,
}
if setting.CustomProfile != nil {
generalSetting.CustomProfile = &storepb.InstanceCustomProfile{
Title: setting.CustomProfile.Title,
Description: setting.CustomProfile.Description,
LogoUrl: setting.CustomProfile.LogoUrl,
}
}
return generalSetting
}
func convertInstanceStorageSettingFromStore(settingpb *storepb.InstanceStorageSetting) *v1pb.InstanceSetting_StorageSetting {
if settingpb == nil {
return nil
}
setting := &v1pb.InstanceSetting_StorageSetting{
StorageType: v1pb.InstanceSetting_StorageSetting_StorageType(settingpb.StorageType),
FilepathTemplate: settingpb.FilepathTemplate,
UploadSizeLimitMb: settingpb.UploadSizeLimitMb,
}
if settingpb.S3Config != nil {
setting.S3Config = &v1pb.InstanceSetting_StorageSetting_S3Config{
AccessKeyId: settingpb.S3Config.AccessKeyId,
// AccessKeySecret is write-only: never returned in responses.
Endpoint: settingpb.S3Config.Endpoint,
Region: settingpb.S3Config.Region,
Bucket: settingpb.S3Config.Bucket,
UsePathStyle: settingpb.S3Config.UsePathStyle,
InsecureSkipTlsVerify: settingpb.S3Config.InsecureSkipTlsVerify,
}
}
return setting
}
func convertInstanceStorageSettingToStore(setting *v1pb.InstanceSetting_StorageSetting) *storepb.InstanceStorageSetting {
if setting == nil {
return nil
}
settingpb := &storepb.InstanceStorageSetting{
StorageType: storepb.InstanceStorageSetting_StorageType(setting.StorageType),
FilepathTemplate: setting.FilepathTemplate,
UploadSizeLimitMb: setting.UploadSizeLimitMb,
}
if setting.S3Config != nil {
settingpb.S3Config = &storepb.StorageS3Config{
AccessKeyId: setting.S3Config.AccessKeyId,
AccessKeySecret: setting.S3Config.AccessKeySecret,
Endpoint: setting.S3Config.Endpoint,
Region: setting.S3Config.Region,
Bucket: setting.S3Config.Bucket,
UsePathStyle: setting.S3Config.UsePathStyle,
InsecureSkipTlsVerify: setting.S3Config.InsecureSkipTlsVerify,
}
}
return settingpb
}
func convertInstanceMemoRelatedSettingFromStore(setting *storepb.InstanceMemoRelatedSetting) *v1pb.InstanceSetting_MemoRelatedSetting {
if setting == nil {
return nil
}
return &v1pb.InstanceSetting_MemoRelatedSetting{
ContentLengthLimit: setting.ContentLengthLimit,
EnableDoubleClickEdit: setting.EnableDoubleClickEdit,
Reactions: setting.Reactions,
}
}
func convertInstanceMemoRelatedSettingToStore(setting *v1pb.InstanceSetting_MemoRelatedSetting) *storepb.InstanceMemoRelatedSetting {
if setting == nil {
return nil
}
return &storepb.InstanceMemoRelatedSetting{
ContentLengthLimit: setting.ContentLengthLimit,
EnableDoubleClickEdit: setting.EnableDoubleClickEdit,
Reactions: setting.Reactions,
}
}
func convertInstanceTagsSettingFromStore(setting *storepb.InstanceTagsSetting) *v1pb.InstanceSetting_TagsSetting {
if setting == nil {
return nil
}
tags := make(map[string]*v1pb.InstanceSetting_TagMetadata, len(setting.Tags))
for tag, metadata := range setting.Tags {
tags[tag] = &v1pb.InstanceSetting_TagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
BlurContent: metadata.GetBlurContent(),
}
}
return &v1pb.InstanceSetting_TagsSetting{
Tags: tags,
}
}
func convertInstanceTagsSettingToStore(setting *v1pb.InstanceSetting_TagsSetting) *storepb.InstanceTagsSetting {
if setting == nil {
return nil
}
tags := make(map[string]*storepb.InstanceTagMetadata, len(setting.Tags))
for tag, metadata := range setting.Tags {
tags[tag] = &storepb.InstanceTagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
BlurContent: metadata.GetBlurContent(),
}
}
return &storepb.InstanceTagsSetting{
Tags: tags,
}
}
func convertInstanceNotificationSettingFromStore(setting *storepb.InstanceNotificationSetting) *v1pb.InstanceSetting_NotificationSetting {
if setting == nil {
return nil
}
notificationSetting := &v1pb.InstanceSetting_NotificationSetting{}
if setting.Email != nil {
notificationSetting.Email = &v1pb.InstanceSetting_NotificationSetting_EmailSetting{
Enabled: setting.Email.Enabled,
SmtpHost: setting.Email.SmtpHost,
SmtpPort: setting.Email.SmtpPort,
SmtpUsername: setting.Email.SmtpUsername,
// SmtpPassword is write-only: never returned in responses.
FromEmail: setting.Email.FromEmail,
FromName: setting.Email.FromName,
ReplyTo: setting.Email.ReplyTo,
UseTls: setting.Email.UseTls,
UseSsl: setting.Email.UseSsl,
}
}
return notificationSetting
}
func convertInstanceNotificationSettingToStore(setting *v1pb.InstanceSetting_NotificationSetting) *storepb.InstanceNotificationSetting {
if setting == nil {
return nil
}
notificationSetting := &storepb.InstanceNotificationSetting{}
if setting.Email != nil {
notificationSetting.Email = &storepb.InstanceNotificationSetting_EmailSetting{
Enabled: setting.Email.Enabled,
SmtpHost: setting.Email.SmtpHost,
SmtpPort: setting.Email.SmtpPort,
SmtpUsername: setting.Email.SmtpUsername,
SmtpPassword: setting.Email.SmtpPassword,
FromEmail: setting.Email.FromEmail,
FromName: setting.Email.FromName,
ReplyTo: setting.Email.ReplyTo,
UseTls: setting.Email.UseTls,
UseSsl: setting.Email.UseSsl,
}
}
return notificationSetting
}
func convertInstanceAISettingFromStore(setting *storepb.InstanceAISetting) *v1pb.InstanceSetting_AISetting {
if setting == nil {
return nil
}
aiSetting := &v1pb.InstanceSetting_AISetting{
Providers: make([]*v1pb.InstanceSetting_AIProviderConfig, 0, len(setting.Providers)),
Transcription: convertTranscriptionConfigFromStore(setting.GetTranscription()),
}
for _, provider := range setting.Providers {
if provider == nil {
continue
}
apiKey := provider.GetApiKey()
aiSetting.Providers = append(aiSetting.Providers, &v1pb.InstanceSetting_AIProviderConfig{
Id: provider.GetId(),
Title: provider.GetTitle(),
Type: v1pb.InstanceSetting_AIProviderType(provider.GetType()),
Endpoint: provider.GetEndpoint(),
ApiKeySet: apiKey != "",
ApiKeyHint: maskAPIKey(apiKey),
})
}
return aiSetting
}
func convertInstanceAISettingToStore(setting *v1pb.InstanceSetting_AISetting) *storepb.InstanceAISetting {
if setting == nil {
return nil
}
aiSetting := &storepb.InstanceAISetting{
Providers: make([]*storepb.AIProviderConfig, 0, len(setting.Providers)),
Transcription: convertTranscriptionConfigToStore(setting.GetTranscription()),
}
for _, provider := range setting.Providers {
if provider == nil {
continue
}
aiSetting.Providers = append(aiSetting.Providers, &storepb.AIProviderConfig{
Id: provider.GetId(),
Title: provider.GetTitle(),
Type: storepb.AIProviderType(provider.GetType()),
Endpoint: provider.GetEndpoint(),
ApiKey: provider.GetApiKey(),
})
}
return aiSetting
}
func convertTranscriptionConfigFromStore(setting *storepb.TranscriptionConfig) *v1pb.InstanceSetting_TranscriptionConfig {
if setting == nil {
return nil
}
return &v1pb.InstanceSetting_TranscriptionConfig{
ProviderId: setting.GetProviderId(),
Model: setting.GetModel(),
Language: setting.GetLanguage(),
Prompt: setting.GetPrompt(),
}
}
func convertTranscriptionConfigToStore(setting *v1pb.InstanceSetting_TranscriptionConfig) *storepb.TranscriptionConfig {
if setting == nil {
return nil
}
return &storepb.TranscriptionConfig{
ProviderId: setting.GetProviderId(),
Model: setting.GetModel(),
Language: setting.GetLanguage(),
Prompt: setting.GetPrompt(),
}
}
func validateInstanceSetting(setting *v1pb.InstanceSetting) error {
key, err := ExtractInstanceSettingKeyFromName(setting.Name)
if err != nil {
return err
}
if key != storepb.InstanceSettingKey_TAGS.String() {
return nil
}
return validateInstanceTagsSetting(setting.GetTagsSetting())
}
func (s *APIV1Service) prepareInstanceAISettingForUpdate(ctx context.Context, setting *storepb.InstanceAISetting) error {
if setting == nil {
return errors.New("AI setting is required")
}
existing, err := s.Store.GetInstanceAISetting(ctx)
if err != nil {
return errors.Wrap(err, "failed to get existing AI setting")
}
existingProviders := map[string]*storepb.AIProviderConfig{}
if existing != nil {
for _, provider := range existing.Providers {
if provider != nil && provider.Id != "" {
existingProviders[provider.Id] = provider
}
}
}
seenIDs := map[string]bool{}
for _, provider := range setting.Providers {
if provider == nil {
return errors.New("provider cannot be nil")
}
provider.Id = strings.TrimSpace(provider.Id)
if provider.Id == "" {
provider.Id = shortuuid.New()
}
if seenIDs[provider.Id] {
return errors.Errorf("duplicate provider ID %q", provider.Id)
}
seenIDs[provider.Id] = true
provider.Title = strings.TrimSpace(provider.Title)
if provider.Title == "" {
return errors.New("provider title is required")
}
if provider.Type != storepb.AIProviderType_OPENAI && provider.Type != storepb.AIProviderType_GEMINI {
return errors.Errorf("provider %q has unsupported type", provider.Id)
}
provider.Endpoint = strings.TrimSpace(provider.Endpoint)
if provider.Type == storepb.AIProviderType_OPENAI && provider.Endpoint == "" {
provider.Endpoint = "https://api.openai.com/v1"
}
if provider.Type == storepb.AIProviderType_GEMINI && provider.Endpoint == "" {
provider.Endpoint = "https://generativelanguage.googleapis.com/v1beta"
}
if provider.ApiKey == "" {
if existingProvider, ok := existingProviders[provider.Id]; ok {
provider.ApiKey = existingProvider.ApiKey
}
}
if provider.ApiKey == "" {
return errors.Errorf("provider %q API key is required", provider.Id)
}
}
if err := preparePersistedTranscriptionConfig(setting, existing); err != nil {
return err
}
return nil
}
func preparePersistedTranscriptionConfig(setting *storepb.InstanceAISetting, existing *storepb.InstanceAISetting) error {
// Preserve the previously stored transcription config when the request omits it,
// matching the same "absence == keep" semantics used for API keys. The preserved
// config still falls through to validation below, so a stale provider_id is
// rejected if the same update removed or renamed its referenced provider.
if setting.Transcription == nil && existing != nil {
setting.Transcription = existing.GetTranscription()
}
if setting.Transcription == nil {
return nil
}
cfg := setting.Transcription
cfg.ProviderId = strings.TrimSpace(cfg.ProviderId)
cfg.Model = strings.TrimSpace(cfg.Model)
cfg.Language = strings.TrimSpace(cfg.Language)
cfg.Prompt = strings.TrimSpace(cfg.Prompt)
if cfg.ProviderId != "" {
referenced := false
for _, provider := range setting.Providers {
if provider != nil && provider.Id == cfg.ProviderId {
referenced = true
break
}
}
if !referenced {
return errors.Errorf("transcription provider_id %q does not reference any configured provider", cfg.ProviderId)
}
}
if len(cfg.Model) > maxTranscriptionConfigModelLength {
return errors.Errorf("transcription model is too long; maximum length is %d characters", maxTranscriptionConfigModelLength)
}
if len(cfg.Language) > maxTranscriptionConfigLanguageLength {
return errors.Errorf("transcription language is too long; maximum length is %d characters", maxTranscriptionConfigLanguageLength)
}
if len(cfg.Prompt) > maxTranscriptionConfigPromptLength {
return errors.Errorf("transcription prompt is too long; maximum length is %d characters", maxTranscriptionConfigPromptLength)
}
return nil
}
func maskAPIKey(apiKey string) string {
if apiKey == "" {
return ""
}
if len(apiKey) <= 8 {
return "..."
}
prefixLength := min(4, len(apiKey))
return apiKey[:prefixLength] + "..." + apiKey[len(apiKey)-4:]
}
func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) error {
if setting == nil {
return errors.New("tags setting is required")
}
for tag, metadata := range setting.Tags {
if strings.TrimSpace(tag) == "" {
return errors.New("tag key cannot be empty")
}
if _, err := regexp.Compile(tag); err != nil {
return errors.Errorf("tag key %q is not a valid regex pattern: %v", tag, err)
}
if metadata == nil {
return errors.Errorf("tag metadata is required for %q", tag)
}
if metadata.GetBackgroundColor() != nil {
if err := validateInstanceColor(metadata.GetBackgroundColor()); err != nil {
return errors.Wrapf(err, "background_color for %q", tag)
}
}
}
return nil
}
func validateInstanceColor(color *colorpb.Color) error {
if err := validateInstanceColorComponent("red", color.GetRed()); err != nil {
return err
}
if err := validateInstanceColorComponent("green", color.GetGreen()); err != nil {
return err
}
if err := validateInstanceColorComponent("blue", color.GetBlue()); err != nil {
return err
}
if alpha := color.GetAlpha(); alpha != nil {
if err := validateInstanceColorComponent("alpha", alpha.GetValue()); err != nil {
return err
}
}
return nil
}
func validateInstanceColorComponent(name string, value float32) error {
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return errors.Errorf("%s must be a finite number", name)
}
if value < 0 || value > 1 {
return errors.Errorf("%s must be between 0 and 1", name)
}
return nil
}
func (s *APIV1Service) GetInstanceAdmin(ctx context.Context) (*v1pb.User, error) {
adminUserType := store.RoleAdmin
user, err := s.Store.GetUser(ctx, &store.FindUser{
@@ -0,0 +1,349 @@
package v1
import (
"fmt"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
)
func convertInstanceSettingFromStore(setting *storepb.InstanceSetting) *v1pb.InstanceSetting {
instanceSetting := &v1pb.InstanceSetting{
Name: fmt.Sprintf("instance/settings/%s", setting.Key.String()),
}
switch setting.Value.(type) {
case *storepb.InstanceSetting_GeneralSetting:
instanceSetting.Value = &v1pb.InstanceSetting_GeneralSetting_{
GeneralSetting: convertInstanceGeneralSettingFromStore(setting.GetGeneralSetting()),
}
case *storepb.InstanceSetting_StorageSetting:
instanceSetting.Value = &v1pb.InstanceSetting_StorageSetting_{
StorageSetting: convertInstanceStorageSettingFromStore(setting.GetStorageSetting()),
}
case *storepb.InstanceSetting_MemoRelatedSetting:
instanceSetting.Value = &v1pb.InstanceSetting_MemoRelatedSetting_{
MemoRelatedSetting: convertInstanceMemoRelatedSettingFromStore(setting.GetMemoRelatedSetting()),
}
case *storepb.InstanceSetting_TagsSetting:
instanceSetting.Value = &v1pb.InstanceSetting_TagsSetting_{
TagsSetting: convertInstanceTagsSettingFromStore(setting.GetTagsSetting()),
}
case *storepb.InstanceSetting_NotificationSetting:
instanceSetting.Value = &v1pb.InstanceSetting_NotificationSetting_{
NotificationSetting: convertInstanceNotificationSettingFromStore(setting.GetNotificationSetting()),
}
case *storepb.InstanceSetting_AiSetting:
instanceSetting.Value = &v1pb.InstanceSetting_AiSetting{
AiSetting: convertInstanceAISettingFromStore(setting.GetAiSetting()),
}
default:
// Leave Value unset for unsupported setting variants.
}
return instanceSetting
}
func convertInstanceSettingToStore(setting *v1pb.InstanceSetting) *storepb.InstanceSetting {
settingKeyString, _ := ExtractInstanceSettingKeyFromName(setting.Name)
instanceSetting := &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey(storepb.InstanceSettingKey_value[settingKeyString]),
Value: &storepb.InstanceSetting_GeneralSetting{
GeneralSetting: convertInstanceGeneralSettingToStore(setting.GetGeneralSetting()),
},
}
switch instanceSetting.Key {
case storepb.InstanceSettingKey_GENERAL:
instanceSetting.Value = &storepb.InstanceSetting_GeneralSetting{
GeneralSetting: convertInstanceGeneralSettingToStore(setting.GetGeneralSetting()),
}
case storepb.InstanceSettingKey_STORAGE:
instanceSetting.Value = &storepb.InstanceSetting_StorageSetting{
StorageSetting: convertInstanceStorageSettingToStore(setting.GetStorageSetting()),
}
case storepb.InstanceSettingKey_MEMO_RELATED:
instanceSetting.Value = &storepb.InstanceSetting_MemoRelatedSetting{
MemoRelatedSetting: convertInstanceMemoRelatedSettingToStore(setting.GetMemoRelatedSetting()),
}
case storepb.InstanceSettingKey_TAGS:
instanceSetting.Value = &storepb.InstanceSetting_TagsSetting{
TagsSetting: convertInstanceTagsSettingToStore(setting.GetTagsSetting()),
}
case storepb.InstanceSettingKey_NOTIFICATION:
instanceSetting.Value = &storepb.InstanceSetting_NotificationSetting{
NotificationSetting: convertInstanceNotificationSettingToStore(setting.GetNotificationSetting()),
}
case storepb.InstanceSettingKey_AI:
instanceSetting.Value = &storepb.InstanceSetting_AiSetting{
AiSetting: convertInstanceAISettingToStore(setting.GetAiSetting()),
}
default:
// Keep the default GeneralSetting value
}
return instanceSetting
}
func convertInstanceGeneralSettingFromStore(setting *storepb.InstanceGeneralSetting) *v1pb.InstanceSetting_GeneralSetting {
if setting == nil {
return nil
}
generalSetting := &v1pb.InstanceSetting_GeneralSetting{
DisallowUserRegistration: setting.DisallowUserRegistration,
DisallowPasswordAuth: setting.DisallowPasswordAuth,
AdditionalScript: setting.AdditionalScript,
AdditionalStyle: setting.AdditionalStyle,
WeekStartDayOffset: setting.WeekStartDayOffset,
DisallowChangeUsername: setting.DisallowChangeUsername,
DisallowChangeNickname: setting.DisallowChangeNickname,
}
if setting.CustomProfile != nil {
generalSetting.CustomProfile = &v1pb.InstanceSetting_GeneralSetting_CustomProfile{
Title: setting.CustomProfile.Title,
Description: setting.CustomProfile.Description,
LogoUrl: setting.CustomProfile.LogoUrl,
}
}
return generalSetting
}
func convertInstanceGeneralSettingToStore(setting *v1pb.InstanceSetting_GeneralSetting) *storepb.InstanceGeneralSetting {
if setting == nil {
return nil
}
generalSetting := &storepb.InstanceGeneralSetting{
DisallowUserRegistration: setting.DisallowUserRegistration,
DisallowPasswordAuth: setting.DisallowPasswordAuth,
AdditionalScript: setting.AdditionalScript,
AdditionalStyle: setting.AdditionalStyle,
WeekStartDayOffset: setting.WeekStartDayOffset,
DisallowChangeUsername: setting.DisallowChangeUsername,
DisallowChangeNickname: setting.DisallowChangeNickname,
}
if setting.CustomProfile != nil {
generalSetting.CustomProfile = &storepb.InstanceCustomProfile{
Title: setting.CustomProfile.Title,
Description: setting.CustomProfile.Description,
LogoUrl: setting.CustomProfile.LogoUrl,
}
}
return generalSetting
}
func convertInstanceStorageSettingFromStore(settingpb *storepb.InstanceStorageSetting) *v1pb.InstanceSetting_StorageSetting {
if settingpb == nil {
return nil
}
setting := &v1pb.InstanceSetting_StorageSetting{
StorageType: v1pb.InstanceSetting_StorageSetting_StorageType(settingpb.StorageType),
FilepathTemplate: settingpb.FilepathTemplate,
UploadSizeLimitMb: settingpb.UploadSizeLimitMb,
}
if settingpb.S3Config != nil {
setting.S3Config = &v1pb.InstanceSetting_StorageSetting_S3Config{
AccessKeyId: settingpb.S3Config.AccessKeyId,
// AccessKeySecret is write-only: never returned in responses.
Endpoint: settingpb.S3Config.Endpoint,
Region: settingpb.S3Config.Region,
Bucket: settingpb.S3Config.Bucket,
UsePathStyle: settingpb.S3Config.UsePathStyle,
InsecureSkipTlsVerify: settingpb.S3Config.InsecureSkipTlsVerify,
}
}
return setting
}
func convertInstanceStorageSettingToStore(setting *v1pb.InstanceSetting_StorageSetting) *storepb.InstanceStorageSetting {
if setting == nil {
return nil
}
settingpb := &storepb.InstanceStorageSetting{
StorageType: storepb.InstanceStorageSetting_StorageType(setting.StorageType),
FilepathTemplate: setting.FilepathTemplate,
UploadSizeLimitMb: setting.UploadSizeLimitMb,
}
if setting.S3Config != nil {
settingpb.S3Config = &storepb.StorageS3Config{
AccessKeyId: setting.S3Config.AccessKeyId,
AccessKeySecret: setting.S3Config.AccessKeySecret,
Endpoint: setting.S3Config.Endpoint,
Region: setting.S3Config.Region,
Bucket: setting.S3Config.Bucket,
UsePathStyle: setting.S3Config.UsePathStyle,
InsecureSkipTlsVerify: setting.S3Config.InsecureSkipTlsVerify,
}
}
return settingpb
}
func convertInstanceMemoRelatedSettingFromStore(setting *storepb.InstanceMemoRelatedSetting) *v1pb.InstanceSetting_MemoRelatedSetting {
if setting == nil {
return nil
}
return &v1pb.InstanceSetting_MemoRelatedSetting{
ContentLengthLimit: setting.ContentLengthLimit,
EnableDoubleClickEdit: setting.EnableDoubleClickEdit,
Reactions: setting.Reactions,
}
}
func convertInstanceMemoRelatedSettingToStore(setting *v1pb.InstanceSetting_MemoRelatedSetting) *storepb.InstanceMemoRelatedSetting {
if setting == nil {
return nil
}
return &storepb.InstanceMemoRelatedSetting{
ContentLengthLimit: setting.ContentLengthLimit,
EnableDoubleClickEdit: setting.EnableDoubleClickEdit,
Reactions: setting.Reactions,
}
}
func convertInstanceTagsSettingFromStore(setting *storepb.InstanceTagsSetting) *v1pb.InstanceSetting_TagsSetting {
if setting == nil {
return nil
}
tags := make(map[string]*v1pb.InstanceSetting_TagMetadata, len(setting.Tags))
for tag, metadata := range setting.Tags {
tags[tag] = &v1pb.InstanceSetting_TagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
BlurContent: metadata.GetBlurContent(),
}
}
return &v1pb.InstanceSetting_TagsSetting{
Tags: tags,
}
}
func convertInstanceTagsSettingToStore(setting *v1pb.InstanceSetting_TagsSetting) *storepb.InstanceTagsSetting {
if setting == nil {
return nil
}
tags := make(map[string]*storepb.InstanceTagMetadata, len(setting.Tags))
for tag, metadata := range setting.Tags {
tags[tag] = &storepb.InstanceTagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
BlurContent: metadata.GetBlurContent(),
}
}
return &storepb.InstanceTagsSetting{
Tags: tags,
}
}
func convertInstanceNotificationSettingFromStore(setting *storepb.InstanceNotificationSetting) *v1pb.InstanceSetting_NotificationSetting {
if setting == nil {
return nil
}
notificationSetting := &v1pb.InstanceSetting_NotificationSetting{}
if setting.Email != nil {
notificationSetting.Email = &v1pb.InstanceSetting_NotificationSetting_EmailSetting{
Enabled: setting.Email.Enabled,
SmtpHost: setting.Email.SmtpHost,
SmtpPort: setting.Email.SmtpPort,
SmtpUsername: setting.Email.SmtpUsername,
// SmtpPassword is write-only: never returned in responses.
FromEmail: setting.Email.FromEmail,
FromName: setting.Email.FromName,
ReplyTo: setting.Email.ReplyTo,
UseTls: setting.Email.UseTls,
UseSsl: setting.Email.UseSsl,
}
}
return notificationSetting
}
func convertInstanceNotificationSettingToStore(setting *v1pb.InstanceSetting_NotificationSetting) *storepb.InstanceNotificationSetting {
if setting == nil {
return nil
}
notificationSetting := &storepb.InstanceNotificationSetting{}
if setting.Email != nil {
notificationSetting.Email = &storepb.InstanceNotificationSetting_EmailSetting{
Enabled: setting.Email.Enabled,
SmtpHost: setting.Email.SmtpHost,
SmtpPort: setting.Email.SmtpPort,
SmtpUsername: setting.Email.SmtpUsername,
SmtpPassword: setting.Email.SmtpPassword,
FromEmail: setting.Email.FromEmail,
FromName: setting.Email.FromName,
ReplyTo: setting.Email.ReplyTo,
UseTls: setting.Email.UseTls,
UseSsl: setting.Email.UseSsl,
}
}
return notificationSetting
}
func convertInstanceAISettingFromStore(setting *storepb.InstanceAISetting) *v1pb.InstanceSetting_AISetting {
if setting == nil {
return nil
}
aiSetting := &v1pb.InstanceSetting_AISetting{
Providers: make([]*v1pb.InstanceSetting_AIProviderConfig, 0, len(setting.Providers)),
Transcription: convertTranscriptionConfigFromStore(setting.GetTranscription()),
}
for _, provider := range setting.Providers {
if provider == nil {
continue
}
apiKey := provider.GetApiKey()
aiSetting.Providers = append(aiSetting.Providers, &v1pb.InstanceSetting_AIProviderConfig{
Id: provider.GetId(),
Title: provider.GetTitle(),
Type: v1pb.InstanceSetting_AIProviderType(provider.GetType()),
Endpoint: provider.GetEndpoint(),
ApiKeySet: apiKey != "",
ApiKeyHint: maskAPIKey(apiKey),
})
}
return aiSetting
}
func convertInstanceAISettingToStore(setting *v1pb.InstanceSetting_AISetting) *storepb.InstanceAISetting {
if setting == nil {
return nil
}
aiSetting := &storepb.InstanceAISetting{
Providers: make([]*storepb.AIProviderConfig, 0, len(setting.Providers)),
Transcription: convertTranscriptionConfigToStore(setting.GetTranscription()),
}
for _, provider := range setting.Providers {
if provider == nil {
continue
}
aiSetting.Providers = append(aiSetting.Providers, &storepb.AIProviderConfig{
Id: provider.GetId(),
Title: provider.GetTitle(),
Type: storepb.AIProviderType(provider.GetType()),
Endpoint: provider.GetEndpoint(),
ApiKey: provider.GetApiKey(),
})
}
return aiSetting
}
func convertTranscriptionConfigFromStore(setting *storepb.TranscriptionConfig) *v1pb.InstanceSetting_TranscriptionConfig {
if setting == nil {
return nil
}
return &v1pb.InstanceSetting_TranscriptionConfig{
ProviderId: setting.GetProviderId(),
Model: setting.GetModel(),
Language: setting.GetLanguage(),
Prompt: setting.GetPrompt(),
}
}
func convertTranscriptionConfigToStore(setting *v1pb.InstanceSetting_TranscriptionConfig) *storepb.TranscriptionConfig {
if setting == nil {
return nil
}
return &storepb.TranscriptionConfig{
ProviderId: setting.GetProviderId(),
Model: setting.GetModel(),
Language: setting.GetLanguage(),
Prompt: setting.GetPrompt(),
}
}
@@ -0,0 +1,196 @@
package v1
import (
"context"
"math"
"regexp"
"strings"
"github.com/lithammer/shortuuid/v4"
"github.com/pkg/errors"
colorpb "google.golang.org/genproto/googleapis/type/color"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
)
func validateInstanceSetting(setting *v1pb.InstanceSetting) error {
key, err := ExtractInstanceSettingKeyFromName(setting.Name)
if err != nil {
return err
}
if key != storepb.InstanceSettingKey_TAGS.String() {
return nil
}
return validateInstanceTagsSetting(setting.GetTagsSetting())
}
func (s *APIV1Service) prepareInstanceAISettingForUpdate(ctx context.Context, setting *storepb.InstanceAISetting) error {
if setting == nil {
return errors.New("AI setting is required")
}
existing, err := s.Store.GetInstanceAISetting(ctx)
if err != nil {
return errors.Wrap(err, "failed to get existing AI setting")
}
existingProviders := map[string]*storepb.AIProviderConfig{}
if existing != nil {
for _, provider := range existing.Providers {
if provider != nil && provider.Id != "" {
existingProviders[provider.Id] = provider
}
}
}
seenIDs := map[string]bool{}
for _, provider := range setting.Providers {
if provider == nil {
return errors.New("provider cannot be nil")
}
provider.Id = strings.TrimSpace(provider.Id)
if provider.Id == "" {
provider.Id = shortuuid.New()
}
if seenIDs[provider.Id] {
return errors.Errorf("duplicate provider ID %q", provider.Id)
}
seenIDs[provider.Id] = true
provider.Title = strings.TrimSpace(provider.Title)
if provider.Title == "" {
return errors.New("provider title is required")
}
if provider.Type != storepb.AIProviderType_OPENAI && provider.Type != storepb.AIProviderType_GEMINI {
return errors.Errorf("provider %q has unsupported type", provider.Id)
}
provider.Endpoint = strings.TrimSpace(provider.Endpoint)
if provider.Type == storepb.AIProviderType_OPENAI && provider.Endpoint == "" {
provider.Endpoint = "https://api.openai.com/v1"
}
if provider.Type == storepb.AIProviderType_GEMINI && provider.Endpoint == "" {
provider.Endpoint = "https://generativelanguage.googleapis.com/v1beta"
}
if provider.ApiKey == "" {
if existingProvider, ok := existingProviders[provider.Id]; ok {
provider.ApiKey = existingProvider.ApiKey
}
}
if provider.ApiKey == "" {
return errors.Errorf("provider %q API key is required", provider.Id)
}
}
if err := preparePersistedTranscriptionConfig(setting, existing); err != nil {
return err
}
return nil
}
func preparePersistedTranscriptionConfig(setting *storepb.InstanceAISetting, existing *storepb.InstanceAISetting) error {
// Preserve the previously stored transcription config when the request omits it,
// matching the same "absence == keep" semantics used for API keys. The preserved
// config still falls through to validation below, so a stale provider_id is
// rejected if the same update removed or renamed its referenced provider.
if setting.Transcription == nil && existing != nil {
setting.Transcription = existing.GetTranscription()
}
if setting.Transcription == nil {
return nil
}
cfg := setting.Transcription
cfg.ProviderId = strings.TrimSpace(cfg.ProviderId)
cfg.Model = strings.TrimSpace(cfg.Model)
cfg.Language = strings.TrimSpace(cfg.Language)
cfg.Prompt = strings.TrimSpace(cfg.Prompt)
if cfg.ProviderId != "" {
referenced := false
for _, provider := range setting.Providers {
if provider != nil && provider.Id == cfg.ProviderId {
referenced = true
break
}
}
if !referenced {
return errors.Errorf("transcription provider_id %q does not reference any configured provider", cfg.ProviderId)
}
}
if len(cfg.Model) > maxTranscriptionConfigModelLength {
return errors.Errorf("transcription model is too long; maximum length is %d characters", maxTranscriptionConfigModelLength)
}
if len(cfg.Language) > maxTranscriptionConfigLanguageLength {
return errors.Errorf("transcription language is too long; maximum length is %d characters", maxTranscriptionConfigLanguageLength)
}
if len(cfg.Prompt) > maxTranscriptionConfigPromptLength {
return errors.Errorf("transcription prompt is too long; maximum length is %d characters", maxTranscriptionConfigPromptLength)
}
return nil
}
func maskAPIKey(apiKey string) string {
if apiKey == "" {
return ""
}
if len(apiKey) <= 8 {
return "..."
}
prefixLength := min(4, len(apiKey))
return apiKey[:prefixLength] + "..." + apiKey[len(apiKey)-4:]
}
func validateInstanceTagsSetting(setting *v1pb.InstanceSetting_TagsSetting) error {
if setting == nil {
return errors.New("tags setting is required")
}
for tag, metadata := range setting.Tags {
if strings.TrimSpace(tag) == "" {
return errors.New("tag key cannot be empty")
}
if _, err := regexp.Compile(tag); err != nil {
return errors.Errorf("tag key %q is not a valid regex pattern: %v", tag, err)
}
if metadata == nil {
return errors.Errorf("tag metadata is required for %q", tag)
}
if metadata.GetBackgroundColor() != nil {
if err := validateInstanceColor(metadata.GetBackgroundColor()); err != nil {
return errors.Wrapf(err, "background_color for %q", tag)
}
}
}
return nil
}
func validateInstanceColor(color *colorpb.Color) error {
if err := validateInstanceColorComponent("red", color.GetRed()); err != nil {
return err
}
if err := validateInstanceColorComponent("green", color.GetGreen()); err != nil {
return err
}
if err := validateInstanceColorComponent("blue", color.GetBlue()); err != nil {
return err
}
if alpha := color.GetAlpha(); alpha != nil {
if err := validateInstanceColorComponent("alpha", alpha.GetValue()); err != nil {
return err
}
}
return nil
}
func validateInstanceColorComponent(name string, value float32) error {
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return errors.Errorf("%s must be a finite number", name)
}
if value < 0 || value > 1 {
return errors.Errorf("%s must be between 0 and 1", name)
}
return nil
}
+1 -453
View File
@@ -11,13 +11,10 @@ import (
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/usememos/memos/internal/httpgetter"
"github.com/usememos/memos/internal/webhook"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/runner/memopayload"
"github.com/usememos/memos/store"
)
@@ -400,52 +397,7 @@ func (s *APIV1Service) GetMemo(ctx context.Context, request *v1pb.GetMemoRequest
return memoMessage, nil
}
// GetLinkMetadata gets metadata for a link.
func (*APIV1Service) GetLinkMetadata(_ context.Context, request *v1pb.GetLinkMetadataRequest) (*v1pb.LinkMetadata, error) {
return getLinkMetadata(request.GetUrl())
}
// BatchGetLinkMetadata gets metadata for links.
func (*APIV1Service) BatchGetLinkMetadata(_ context.Context, request *v1pb.BatchGetLinkMetadataRequest) (*v1pb.BatchGetLinkMetadataResponse, error) {
if len(request.Urls) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "urls are required")
}
if len(request.Urls) > maxBatchGetLinkMetadata {
return nil, status.Errorf(codes.InvalidArgument, "too many urls (max %d)", maxBatchGetLinkMetadata)
}
linkMetadata := make([]*v1pb.LinkMetadata, 0, len(request.Urls))
for _, url := range request.Urls {
metadata, err := getLinkMetadata(url)
if err != nil {
return nil, err
}
linkMetadata = append(linkMetadata, metadata)
}
return &v1pb.BatchGetLinkMetadataResponse{
LinkMetadata: linkMetadata,
}, nil
}
func getLinkMetadata(inputURL string) (*v1pb.LinkMetadata, error) {
url := strings.TrimSpace(inputURL)
if url == "" {
return nil, status.Errorf(codes.InvalidArgument, "url is required")
}
htmlMeta, err := fetchHTMLMeta(url)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to fetch link metadata: %v", err)
}
return &v1pb.LinkMetadata{
Url: inputURL,
Title: htmlMeta.Title,
Description: htmlMeta.Description,
Image: htmlMeta.Image,
}, nil
}
// UpdateMemo updates an existing memo.
func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoRequest) (*v1pb.Memo, error) {
memoUID, err := ExtractMemoUIDFromName(request.Memo.Name)
if err != nil {
@@ -640,273 +592,6 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR
return &emptypb.Empty{}, nil
}
func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.CreateMemoCommentRequest) (*v1pb.Memo, error) {
memoUID, err := ExtractMemoUIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
}
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get memo")
}
if relatedMemo == nil {
return nil, status.Errorf(codes.NotFound, "memo not found")
}
// Check memo visibility before allowing comment.
user, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if relatedMemo.Visibility == store.Private && relatedMemo.CreatorID != user.ID && !isSuperUser(user) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if request.Comment == nil {
return nil, status.Errorf(codes.InvalidArgument, "comment is required")
}
comment, ok := proto.Clone(request.Comment).(*v1pb.Memo)
if !ok {
return nil, status.Errorf(codes.Internal, "failed to clone memo comment")
}
comment.Visibility = convertVisibilityFromStore(relatedMemo.Visibility)
// Create the memo comment first; suppress the generic memo.created SSE event
// since CreateMemoComment broadcasts memo.comment.created for the parent instead.
memoComment, err := s.CreateMemo(withSuppressMentionNotifications(withSuppressSSE(ctx)), &v1pb.CreateMemoRequest{
Memo: comment,
MemoId: request.CommentId,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create memo")
}
memoUID, err = ExtractMemoUIDFromName(memoComment.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get memo")
}
// Build the relation between the comment memo and the original memo.
_, err = s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: memo.ID,
RelatedMemoID: relatedMemo.ID,
Type: store.MemoRelationComment,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create memo relation")
}
// The comment memo was converted before the relation above existed, so its
// Relations slice is empty. Reload the relations now so that both the API
// response and the memo.comment.created webhook payload carry the relation
// to the parent memo.
relations, err := s.loadMemoRelations(ctx, memo)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to load memo relations")
}
memoComment.Relations = relations
creator, err := ResolveUserByName(ctx, s.Store, memoComment.Creator)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo creator")
}
if creator == nil {
return nil, status.Errorf(codes.NotFound, "memo creator not found")
}
creatorID := creator.ID
if memoComment.Visibility != v1pb.Visibility_PRIVATE && creatorID != relatedMemo.CreatorID {
if _, err := s.createInboxWithEmailNotification(ctx, &store.Inbox{
SenderID: creatorID,
ReceiverID: relatedMemo.CreatorID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
Type: storepb.InboxMessage_MEMO_COMMENT,
Payload: &storepb.InboxMessage_MemoComment{
MemoComment: &storepb.InboxMessage_MemoCommentPayload{
MemoId: memo.ID,
RelatedMemoId: relatedMemo.ID,
},
},
},
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to create inbox")
}
}
if err := s.DispatchMemoCommentCreatedWebhook(ctx, memoComment, relatedMemo.CreatorID); err != nil {
slog.Warn("Failed to dispatch memo comment created webhook", slog.Any("err", err))
}
s.dispatchMemoMentionNotificationsBestEffort(ctx, memo, relatedMemo, "")
// Broadcast live refresh event for the parent memo so subscribers see the new comment.
s.SSEHub.Broadcast(&SSEEvent{
Type: SSEEventMemoCommentCreated,
Name: request.Name,
Visibility: relatedMemo.Visibility,
CreatorID: relatedMemo.CreatorID,
})
return memoComment, nil
}
func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListMemoCommentsRequest) (*v1pb.ListMemoCommentsResponse, error) {
memoUID, err := ExtractMemoUIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get memo")
}
if memo == nil {
return nil, status.Errorf(codes.NotFound, "memo not found")
}
if err := s.checkMemoReadAccess(ctx, memo); err != nil {
return nil, err
}
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user")
}
var memoFilter string
if currentUser == nil {
memoFilter = `visibility == "PUBLIC"`
} else {
memoFilter = fmt.Sprintf(`creator_id == %d || visibility in ["PUBLIC", "PROTECTED"]`, currentUser.ID)
}
memoRelationComment := store.MemoRelationComment
var limit, offset int
if request.PageToken != "" {
var pageToken v1pb.PageToken
if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
}
limit = normalizePageSize(pageToken.Limit)
offset = max(int(pageToken.Offset), 0)
} else {
limit = normalizePageSize(request.PageSize)
}
limitPlusOne := limit + 1
memoRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
RelatedMemoID: &memo.ID,
Type: &memoRelationComment,
MemoFilter: &memoFilter,
Limit: &limitPlusOne,
Offset: &offset,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo relations")
}
nextPageToken := ""
if len(memoRelations) == limitPlusOne {
memoRelations = memoRelations[:limit]
nextPageToken, err = getPageToken(limit, offset+limit)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get next page token, error: %v", err)
}
}
if len(memoRelations) == 0 {
response := &v1pb.ListMemoCommentsResponse{
Memos: []*v1pb.Memo{},
NextPageToken: nextPageToken,
}
return response, nil
}
memoRelationIDs := make([]int32, 0, len(memoRelations))
for _, m := range memoRelations {
memoRelationIDs = append(memoRelationIDs, m.MemoID)
}
memos, err := s.Store.ListMemos(ctx, &store.FindMemo{IDList: memoRelationIDs})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memos")
}
memoIDToNameMap := make(map[int32]string)
contentIDs := make([]string, 0, len(memos))
memoIDsForAttachments := make([]int32, 0, len(memos))
for _, memo := range memos {
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
memoIDToNameMap[memo.ID] = memoName
contentIDs = append(contentIDs, memoName)
memoIDsForAttachments = append(memoIDsForAttachments, memo.ID)
}
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ContentIDList: contentIDs})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list reactions")
}
memoReactionsMap := make(map[string][]*store.Reaction)
for _, reaction := range reactions {
memoReactionsMap[reaction.ContentID] = append(memoReactionsMap[reaction.ContentID], reaction)
}
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoIDList: memoIDsForAttachments})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list attachments")
}
attachmentMap := make(map[int32][]*store.Attachment)
for _, attachment := range attachments {
attachmentMap[*attachment.MemoID] = append(attachmentMap[*attachment.MemoID], attachment)
}
// RELATIONS (batch load to avoid N+1)
relationMap, err := s.batchConvertMemoRelations(ctx, memos, false)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to batch load memo relations")
}
creatorIDs := make([]int32, 0, len(memos)+len(reactions))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
for _, reaction := range reactions {
creatorIDs = append(creatorIDs, reaction.CreatorID)
}
creatorMap, err := s.listUsersByID(ctx, creatorIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
}
var memosResponse []*v1pb.Memo
for _, m := range memos {
memoName := memoIDToNameMap[m.ID]
reactions := memoReactionsMap[memoName]
attachments := attachmentMap[m.ID]
relations := relationMap[m.ID]
memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, m, reactions, attachments, relations, creatorMap)
if err != nil {
if stderrors.Is(err, errMemoCreatorNotFound) {
slog.Warn("Skipping memo comment with missing creator",
slog.Int64("memo_id", int64(m.ID)),
slog.String("memo_uid", m.UID),
slog.Int64("creator_id", int64(m.CreatorID)),
slog.String("parent_name", request.Name),
)
continue
}
return nil, errors.Wrap(err, "failed to convert memo")
}
memosResponse = append(memosResponse, memoMessage)
}
response := &v1pb.ListMemoCommentsResponse{
Memos: memosResponse,
NextPageToken: nextPageToken,
}
return response, nil
}
func (s *APIV1Service) getContentLengthLimit(ctx context.Context) (int, error) {
instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx)
if err != nil {
@@ -916,140 +601,3 @@ func (s *APIV1Service) getContentLengthLimit(ctx context.Context) (int, error) {
}
// DispatchMemoCreatedWebhook dispatches webhook when memo is created.
func (s *APIV1Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *v1pb.Memo) error {
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created")
}
// DispatchMemoUpdatedWebhook dispatches webhook when memo is updated.
func (s *APIV1Service) DispatchMemoUpdatedWebhook(ctx context.Context, memo *v1pb.Memo) error {
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.updated")
}
// DispatchMemoDeletedWebhook dispatches webhook when memo is deleted.
func (s *APIV1Service) DispatchMemoDeletedWebhook(ctx context.Context, memo *v1pb.Memo) error {
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.deleted")
}
// DispatchMemoCommentCreatedWebhook dispatches webhook to the related memo owner when a comment is created.
func (s *APIV1Service) DispatchMemoCommentCreatedWebhook(ctx context.Context, commentMemo *v1pb.Memo, relatedMemoCreatorID int32) error {
webhooks, err := s.Store.GetUserWebhooks(ctx, relatedMemoCreatorID)
if err != nil {
return err
}
for _, hook := range webhooks {
payload, err := convertMemoToWebhookPayload(commentMemo)
if err != nil {
return errors.Wrap(err, "failed to convert memo to webhook payload")
}
payload.ActivityType = "memos.memo.comment.created"
payload.URL = hook.Url
payload.SigningSecret = hook.SigningSecret
webhook.PostAsync(payload)
}
return nil
}
func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1pb.Memo, activityType string) error {
creator, err := ResolveUserByName(ctx, s.Store, memo.Creator)
if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid memo creator")
}
if creator == nil {
return status.Errorf(codes.NotFound, "memo creator not found")
}
creatorID := creator.ID
webhooks, err := s.Store.GetUserWebhooks(ctx, creatorID)
if err != nil {
return err
}
for _, hook := range webhooks {
payload, err := convertMemoToWebhookPayload(memo)
if err != nil {
return errors.Wrap(err, "failed to convert memo to webhook payload")
}
payload.ActivityType = activityType
payload.URL = hook.Url
payload.SigningSecret = hook.SigningSecret
// Use asynchronous webhook dispatch
webhook.PostAsync(payload)
}
return nil
}
func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayload, error) {
return &webhook.WebhookRequestPayload{
Creator: memo.Creator,
Memo: memo,
}, nil
}
func (s *APIV1Service) getMemoContentSnippet(content string) (string, error) {
// Use goldmark service for snippet generation
snippet, err := s.MarkdownService.GenerateSnippet([]byte(content), 64)
if err != nil {
return "", errors.Wrap(err, "failed to generate snippet")
}
return snippet, nil
}
// parseMemoOrderBy parses the order_by field and sets the appropriate ordering in memoFind.
// Follows AIP-132: supports comma-separated list of fields with optional "desc" suffix.
// Example: "pinned desc, create_time desc" or "update_time asc".
func (*APIV1Service) parseMemoOrderBy(orderBy string, memoFind *store.FindMemo) error {
if strings.TrimSpace(orderBy) == "" {
return errors.New("empty order_by")
}
// Split by comma to support multiple sort fields per AIP-132.
fields := strings.Split(orderBy, ",")
// Track if we've seen pinned field.
hasPinned := false
hasExplicitTimeField := false
for _, field := range fields {
parts := strings.Fields(strings.TrimSpace(field))
if len(parts) == 0 {
continue
}
fieldName := parts[0]
fieldDirection := "desc" // default per AIP-132 (we use desc as default for time fields)
if len(parts) > 1 {
fieldDirection = strings.ToLower(parts[1])
if fieldDirection != "asc" && fieldDirection != "desc" {
return errors.Errorf("invalid order direction: %s, must be 'asc' or 'desc'", parts[1])
}
}
switch fieldName {
case "pinned":
hasPinned = true
memoFind.OrderByPinned = true
// Note: pinned is always DESC (true first) regardless of direction specified.
case "create_time", "name":
// Only set if this is the first time field we encounter.
if !hasExplicitTimeField {
memoFind.OrderByTimeAsc = fieldDirection == "asc"
}
hasExplicitTimeField = true
case "update_time":
// Only set if this is the first time field we encounter.
if !hasExplicitTimeField {
memoFind.OrderByUpdatedTs = true
memoFind.OrderByTimeAsc = fieldDirection == "asc"
}
hasExplicitTimeField = true
default:
return errors.Errorf("unsupported order field: %s, supported fields are: pinned, create_time, update_time, name", fieldName)
}
}
// If only pinned was specified, still need to set a default time ordering.
if hasPinned && !memoFind.OrderByUpdatedTs && len(fields) == 1 {
memoFind.OrderByTimeAsc = false // default to desc
}
return nil
}
@@ -0,0 +1,285 @@
package v1
import (
"context"
stderrors "errors"
"fmt"
"log/slog"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
// CreateMemoComment creates a comment on an existing memo.
func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.CreateMemoCommentRequest) (*v1pb.Memo, error) {
memoUID, err := ExtractMemoUIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
}
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get memo")
}
if relatedMemo == nil {
return nil, status.Errorf(codes.NotFound, "memo not found")
}
// Check memo visibility before allowing comment.
user, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if relatedMemo.Visibility == store.Private && relatedMemo.CreatorID != user.ID && !isSuperUser(user) {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if request.Comment == nil {
return nil, status.Errorf(codes.InvalidArgument, "comment is required")
}
comment, ok := proto.Clone(request.Comment).(*v1pb.Memo)
if !ok {
return nil, status.Errorf(codes.Internal, "failed to clone memo comment")
}
comment.Visibility = convertVisibilityFromStore(relatedMemo.Visibility)
// Create the memo comment first; suppress the generic memo.created SSE event
// since CreateMemoComment broadcasts memo.comment.created for the parent instead.
memoComment, err := s.CreateMemo(withSuppressMentionNotifications(withSuppressSSE(ctx)), &v1pb.CreateMemoRequest{
Memo: comment,
MemoId: request.CommentId,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create memo")
}
memoUID, err = ExtractMemoUIDFromName(memoComment.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get memo")
}
// Build the relation between the comment memo and the original memo.
_, err = s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
MemoID: memo.ID,
RelatedMemoID: relatedMemo.ID,
Type: store.MemoRelationComment,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create memo relation")
}
// The comment memo was converted before the relation above existed, so its
// Relations slice is empty. Reload the relations now so that both the API
// response and the memo.comment.created webhook payload carry the relation
// to the parent memo.
relations, err := s.loadMemoRelations(ctx, memo)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to load memo relations")
}
memoComment.Relations = relations
creator, err := ResolveUserByName(ctx, s.Store, memoComment.Creator)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo creator")
}
if creator == nil {
return nil, status.Errorf(codes.NotFound, "memo creator not found")
}
creatorID := creator.ID
if memoComment.Visibility != v1pb.Visibility_PRIVATE && creatorID != relatedMemo.CreatorID {
if _, err := s.createInboxWithEmailNotification(ctx, &store.Inbox{
SenderID: creatorID,
ReceiverID: relatedMemo.CreatorID,
Status: store.UNREAD,
Message: &storepb.InboxMessage{
Type: storepb.InboxMessage_MEMO_COMMENT,
Payload: &storepb.InboxMessage_MemoComment{
MemoComment: &storepb.InboxMessage_MemoCommentPayload{
MemoId: memo.ID,
RelatedMemoId: relatedMemo.ID,
},
},
},
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to create inbox")
}
}
if err := s.DispatchMemoCommentCreatedWebhook(ctx, memoComment, relatedMemo.CreatorID); err != nil {
slog.Warn("Failed to dispatch memo comment created webhook", slog.Any("err", err))
}
s.dispatchMemoMentionNotificationsBestEffort(ctx, memo, relatedMemo, "")
// Broadcast live refresh event for the parent memo so subscribers see the new comment.
s.SSEHub.Broadcast(&SSEEvent{
Type: SSEEventMemoCommentCreated,
Name: request.Name,
Visibility: relatedMemo.Visibility,
CreatorID: relatedMemo.CreatorID,
})
return memoComment, nil
}
func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListMemoCommentsRequest) (*v1pb.ListMemoCommentsResponse, error) {
memoUID, err := ExtractMemoUIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
}
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get memo")
}
if memo == nil {
return nil, status.Errorf(codes.NotFound, "memo not found")
}
if err := s.checkMemoReadAccess(ctx, memo); err != nil {
return nil, err
}
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user")
}
var memoFilter string
if currentUser == nil {
memoFilter = `visibility == "PUBLIC"`
} else {
memoFilter = fmt.Sprintf(`creator_id == %d || visibility in ["PUBLIC", "PROTECTED"]`, currentUser.ID)
}
memoRelationComment := store.MemoRelationComment
var limit, offset int
if request.PageToken != "" {
var pageToken v1pb.PageToken
if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
}
limit = normalizePageSize(pageToken.Limit)
offset = max(int(pageToken.Offset), 0)
} else {
limit = normalizePageSize(request.PageSize)
}
limitPlusOne := limit + 1
memoRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
RelatedMemoID: &memo.ID,
Type: &memoRelationComment,
MemoFilter: &memoFilter,
Limit: &limitPlusOne,
Offset: &offset,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo relations")
}
nextPageToken := ""
if len(memoRelations) == limitPlusOne {
memoRelations = memoRelations[:limit]
nextPageToken, err = getPageToken(limit, offset+limit)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get next page token, error: %v", err)
}
}
if len(memoRelations) == 0 {
response := &v1pb.ListMemoCommentsResponse{
Memos: []*v1pb.Memo{},
NextPageToken: nextPageToken,
}
return response, nil
}
memoRelationIDs := make([]int32, 0, len(memoRelations))
for _, m := range memoRelations {
memoRelationIDs = append(memoRelationIDs, m.MemoID)
}
memos, err := s.Store.ListMemos(ctx, &store.FindMemo{IDList: memoRelationIDs})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memos")
}
memoIDToNameMap := make(map[int32]string)
contentIDs := make([]string, 0, len(memos))
memoIDsForAttachments := make([]int32, 0, len(memos))
for _, memo := range memos {
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
memoIDToNameMap[memo.ID] = memoName
contentIDs = append(contentIDs, memoName)
memoIDsForAttachments = append(memoIDsForAttachments, memo.ID)
}
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ContentIDList: contentIDs})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list reactions")
}
memoReactionsMap := make(map[string][]*store.Reaction)
for _, reaction := range reactions {
memoReactionsMap[reaction.ContentID] = append(memoReactionsMap[reaction.ContentID], reaction)
}
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoIDList: memoIDsForAttachments})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list attachments")
}
attachmentMap := make(map[int32][]*store.Attachment)
for _, attachment := range attachments {
attachmentMap[*attachment.MemoID] = append(attachmentMap[*attachment.MemoID], attachment)
}
// RELATIONS (batch load to avoid N+1)
relationMap, err := s.batchConvertMemoRelations(ctx, memos, false)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to batch load memo relations")
}
creatorIDs := make([]int32, 0, len(memos)+len(reactions))
for _, memo := range memos {
creatorIDs = append(creatorIDs, memo.CreatorID)
}
for _, reaction := range reactions {
creatorIDs = append(creatorIDs, reaction.CreatorID)
}
creatorMap, err := s.listUsersByID(ctx, creatorIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
}
var memosResponse []*v1pb.Memo
for _, m := range memos {
memoName := memoIDToNameMap[m.ID]
reactions := memoReactionsMap[memoName]
attachments := attachmentMap[m.ID]
relations := relationMap[m.ID]
memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, m, reactions, attachments, relations, creatorMap)
if err != nil {
if stderrors.Is(err, errMemoCreatorNotFound) {
slog.Warn("Skipping memo comment with missing creator",
slog.Int64("memo_id", int64(m.ID)),
slog.String("memo_uid", m.UID),
slog.Int64("creator_id", int64(m.CreatorID)),
slog.String("parent_name", request.Name),
)
continue
}
return nil, errors.Wrap(err, "failed to convert memo")
}
memosResponse = append(memosResponse, memoMessage)
}
response := &v1pb.ListMemoCommentsResponse{
Memos: memosResponse,
NextPageToken: nextPageToken,
}
return response, nil
}
@@ -0,0 +1,57 @@
package v1
import (
"context"
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
)
// GetLinkMetadata gets metadata for a link.
func (*APIV1Service) GetLinkMetadata(_ context.Context, request *v1pb.GetLinkMetadataRequest) (*v1pb.LinkMetadata, error) {
return getLinkMetadata(request.GetUrl())
}
// BatchGetLinkMetadata gets metadata for links.
func (*APIV1Service) BatchGetLinkMetadata(_ context.Context, request *v1pb.BatchGetLinkMetadataRequest) (*v1pb.BatchGetLinkMetadataResponse, error) {
if len(request.Urls) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "urls are required")
}
if len(request.Urls) > maxBatchGetLinkMetadata {
return nil, status.Errorf(codes.InvalidArgument, "too many urls (max %d)", maxBatchGetLinkMetadata)
}
linkMetadata := make([]*v1pb.LinkMetadata, 0, len(request.Urls))
for _, url := range request.Urls {
metadata, err := getLinkMetadata(url)
if err != nil {
return nil, err
}
linkMetadata = append(linkMetadata, metadata)
}
return &v1pb.BatchGetLinkMetadataResponse{
LinkMetadata: linkMetadata,
}, nil
}
func getLinkMetadata(inputURL string) (*v1pb.LinkMetadata, error) {
url := strings.TrimSpace(inputURL)
if url == "" {
return nil, status.Errorf(codes.InvalidArgument, "url is required")
}
htmlMeta, err := fetchHTMLMeta(url)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to fetch link metadata: %v", err)
}
return &v1pb.LinkMetadata{
Url: inputURL,
Title: htmlMeta.Title,
Description: htmlMeta.Description,
Image: htmlMeta.Image,
}, nil
}
@@ -0,0 +1,67 @@
package v1
import (
"strings"
"github.com/pkg/errors"
"github.com/usememos/memos/store"
)
func (*APIV1Service) parseMemoOrderBy(orderBy string, memoFind *store.FindMemo) error {
if strings.TrimSpace(orderBy) == "" {
return errors.New("empty order_by")
}
// Split by comma to support multiple sort fields per AIP-132.
fields := strings.Split(orderBy, ",")
// Track if we've seen pinned field.
hasPinned := false
hasExplicitTimeField := false
for _, field := range fields {
parts := strings.Fields(strings.TrimSpace(field))
if len(parts) == 0 {
continue
}
fieldName := parts[0]
fieldDirection := "desc" // default per AIP-132 (we use desc as default for time fields)
if len(parts) > 1 {
fieldDirection = strings.ToLower(parts[1])
if fieldDirection != "asc" && fieldDirection != "desc" {
return errors.Errorf("invalid order direction: %s, must be 'asc' or 'desc'", parts[1])
}
}
switch fieldName {
case "pinned":
hasPinned = true
memoFind.OrderByPinned = true
// Note: pinned is always DESC (true first) regardless of direction specified.
case "create_time", "name":
// Only set if this is the first time field we encounter.
if !hasExplicitTimeField {
memoFind.OrderByTimeAsc = fieldDirection == "asc"
}
hasExplicitTimeField = true
case "update_time":
// Only set if this is the first time field we encounter.
if !hasExplicitTimeField {
memoFind.OrderByUpdatedTs = true
memoFind.OrderByTimeAsc = fieldDirection == "asc"
}
hasExplicitTimeField = true
default:
return errors.Errorf("unsupported order field: %s, supported fields are: pinned, create_time, update_time, name", fieldName)
}
}
// If only pinned was specified, still need to set a default time ordering.
if hasPinned && !memoFind.OrderByUpdatedTs && len(fields) == 1 {
memoFind.OrderByTimeAsc = false // default to desc
}
return nil
}
@@ -0,0 +1,94 @@
package v1
import (
"context"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/usememos/memos/internal/webhook"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
)
// DispatchMemoCreatedWebhook dispatches a webhook when a memo is created.
func (s *APIV1Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *v1pb.Memo) error {
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created")
}
// DispatchMemoUpdatedWebhook dispatches webhook when memo is updated.
func (s *APIV1Service) DispatchMemoUpdatedWebhook(ctx context.Context, memo *v1pb.Memo) error {
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.updated")
}
// DispatchMemoDeletedWebhook dispatches webhook when memo is deleted.
func (s *APIV1Service) DispatchMemoDeletedWebhook(ctx context.Context, memo *v1pb.Memo) error {
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.deleted")
}
// DispatchMemoCommentCreatedWebhook dispatches webhook to the related memo owner when a comment is created.
func (s *APIV1Service) DispatchMemoCommentCreatedWebhook(ctx context.Context, commentMemo *v1pb.Memo, relatedMemoCreatorID int32) error {
webhooks, err := s.Store.GetUserWebhooks(ctx, relatedMemoCreatorID)
if err != nil {
return err
}
for _, hook := range webhooks {
payload, err := convertMemoToWebhookPayload(commentMemo)
if err != nil {
return errors.Wrap(err, "failed to convert memo to webhook payload")
}
payload.ActivityType = "memos.memo.comment.created"
payload.URL = hook.Url
payload.SigningSecret = hook.SigningSecret
webhook.PostAsync(payload)
}
return nil
}
func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1pb.Memo, activityType string) error {
creator, err := ResolveUserByName(ctx, s.Store, memo.Creator)
if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid memo creator")
}
if creator == nil {
return status.Errorf(codes.NotFound, "memo creator not found")
}
creatorID := creator.ID
webhooks, err := s.Store.GetUserWebhooks(ctx, creatorID)
if err != nil {
return err
}
for _, hook := range webhooks {
payload, err := convertMemoToWebhookPayload(memo)
if err != nil {
return errors.Wrap(err, "failed to convert memo to webhook payload")
}
payload.ActivityType = activityType
payload.URL = hook.Url
payload.SigningSecret = hook.SigningSecret
// Use asynchronous webhook dispatch
webhook.PostAsync(payload)
}
return nil
}
func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayload, error) {
return &webhook.WebhookRequestPayload{
Creator: memo.Creator,
Memo: memo,
}, nil
}
func (s *APIV1Service) getMemoContentSnippet(content string) (string, error) {
// Use goldmark service for snippet generation
snippet, err := s.MarkdownService.GenerateSnippet([]byte(content), 64)
if err != nil {
return "", errors.Wrap(err, "failed to generate snippet")
}
return snippet, nil
}
// parseMemoOrderBy parses the order_by field and sets the appropriate ordering in memoFind.
// Follows AIP-132: supports comma-separated list of fields with optional "desc" suffix.
// Example: "pinned desc, create_time desc" or "update_time asc".
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
package v1
import (
"context"
"fmt"
"strings"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/internal/util"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/auth"
)
func (s *APIV1Service) ListPersonalAccessTokens(ctx context.Context, request *v1pb.ListPersonalAccessTokensRequest) (*v1pb.ListPersonalAccessTokensResponse, error) {
user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
userID := user.ID
// Verify permission
if _, err := s.authorizeUserResourceAccess(ctx, userID, true); err != nil {
return nil, err
}
tokens, err := s.Store.GetUserPersonalAccessTokens(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get access tokens: %v", err)
}
personalAccessTokens := make([]*v1pb.PersonalAccessToken, len(tokens))
for i, token := range tokens {
personalAccessTokens[i] = &v1pb.PersonalAccessToken{
Name: fmt.Sprintf("%s/personalAccessTokens/%s", BuildUserName(user.Username), token.TokenId),
Description: token.Description,
ExpiresAt: token.ExpiresAt,
CreatedAt: token.CreatedAt,
LastUsedAt: token.LastUsedAt,
}
}
return &v1pb.ListPersonalAccessTokensResponse{PersonalAccessTokens: personalAccessTokens}, nil
}
// CreatePersonalAccessToken creates a new Personal Access Token (PAT) for a user.
//
// Use cases:
// - User manually creates token in settings for mobile app
// - User creates token for CLI tool
// - User creates token for third-party integration
//
// Token properties:
// - Random string with memos_pat_ prefix
// - SHA-256 hash stored in database
// - Optional expiration time (can be never-expiring)
// - User-provided description for identification
//
// Security considerations:
// - Full token is only shown ONCE (in this response)
// - User should copy and store it securely
// - Token can be revoked by deleting it from settings
//
// Authentication: Required (session cookie or access token)
// Authorization: User can only create tokens for themselves.
func (s *APIV1Service) CreatePersonalAccessToken(ctx context.Context, request *v1pb.CreatePersonalAccessTokenRequest) (*v1pb.CreatePersonalAccessTokenResponse, error) {
user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
userID := user.ID
// Verify permission
if _, err := s.authorizeUserResourceAccess(ctx, userID, false); err != nil {
return nil, err
}
// Generate PAT
tokenID := util.GenUUID()
token := auth.GeneratePersonalAccessToken()
tokenHash := auth.HashPersonalAccessToken(token)
var expiresAt *timestamppb.Timestamp
if request.ExpiresInDays > 0 {
expiresAt = timestamppb.New(time.Now().AddDate(0, 0, int(request.ExpiresInDays)))
}
patRecord := &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{
TokenId: tokenID,
TokenHash: tokenHash,
Description: request.Description,
ExpiresAt: expiresAt,
CreatedAt: timestamppb.Now(),
}
if err := s.Store.AddUserPersonalAccessToken(ctx, userID, patRecord); err != nil {
return nil, status.Errorf(codes.Internal, "failed to create access token: %v", err)
}
return &v1pb.CreatePersonalAccessTokenResponse{
PersonalAccessToken: &v1pb.PersonalAccessToken{
Name: fmt.Sprintf("%s/personalAccessTokens/%s", BuildUserName(user.Username), tokenID),
Description: request.Description,
ExpiresAt: expiresAt,
CreatedAt: patRecord.CreatedAt,
},
Token: token, // Only returned on creation
}, nil
}
// DeletePersonalAccessToken revokes a Personal Access Token.
//
// This endpoint:
// 1. Removes the token from the user's access tokens list
// 2. Immediately invalidates the token (subsequent API calls with it will fail)
//
// Use cases:
// - User revokes a compromised token
// - User removes token for unused app/device
// - User cleans up old tokens
//
// Authentication: Required (session cookie or access token)
// Authorization: User can only delete their own tokens.
func (s *APIV1Service) DeletePersonalAccessToken(ctx context.Context, request *v1pb.DeletePersonalAccessTokenRequest) (*emptypb.Empty, error) {
parts := strings.Split(request.Name, "/")
if len(parts) != 4 || parts[0] != "users" || parts[2] != "personalAccessTokens" {
return nil, status.Errorf(codes.InvalidArgument, "invalid personal access token name")
}
user, err := s.resolveUserFromName(ctx, BuildUserName(parts[1]))
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
userID := user.ID
tokenID := parts[3]
// Verify permission
if _, err := s.authorizeUserResourceAccess(ctx, userID, false); err != nil {
return nil, err
}
if err := s.Store.RemoveUserPersonalAccessToken(ctx, userID, tokenID); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete access token: %v", err)
}
return &emptypb.Empty{}, nil
}
@@ -0,0 +1,286 @@
package v1
import (
"fmt"
"regexp"
"time"
"github.com/pkg/errors"
"google.golang.org/protobuf/types/known/timestamppb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func convertUserFromStore(user *store.User, viewer *store.User) *v1pb.User {
userpb := &v1pb.User{
Name: BuildUserName(user.Username),
State: convertStateFromStore(user.RowStatus),
CreateTime: timestamppb.New(time.Unix(user.CreatedTs, 0)),
UpdateTime: timestamppb.New(time.Unix(user.UpdatedTs, 0)),
Role: convertUserRoleFromStore(user.Role),
Username: user.Username,
DisplayName: user.Nickname,
AvatarUrl: user.AvatarURL,
Description: user.Description,
}
if canViewerAccessUserEmail(viewer, user) {
userpb.Email = user.Email
}
// Use the avatar URL instead of raw base64 image data to reduce the response size.
if user.AvatarURL != "" {
// Check if avatar url is base64 format.
_, _, err := extractImageInfo(user.AvatarURL)
if err == nil {
userpb.AvatarUrl = fmt.Sprintf("/file/%s/avatar", userpb.Name)
} else {
userpb.AvatarUrl = user.AvatarURL
}
}
return userpb
}
func canViewerAccessUserEmail(viewer, user *store.User) bool {
if viewer == nil || user == nil {
return false
}
return viewer.Role == store.RoleAdmin || viewer.ID == user.ID
}
func convertUserRoleFromStore(role store.Role) v1pb.User_Role {
switch role {
case store.RoleAdmin:
return v1pb.User_ADMIN
case store.RoleUser:
return v1pb.User_USER
default:
return v1pb.User_ROLE_UNSPECIFIED
}
}
func convertUserRoleToStore(role v1pb.User_Role) store.Role {
switch role {
case v1pb.User_ADMIN:
return store.RoleAdmin
default:
return store.RoleUser
}
}
// extractImageInfo extracts image type and base64 data from a data URI.
// Data URI format: data:image/png;base64,iVBORw0KGgo...
func extractImageInfo(dataURI string) (string, string, error) {
dataURIRegex := regexp.MustCompile(`^data:(?P<type>.+);base64,(?P<base64>.+)`)
matches := dataURIRegex.FindStringSubmatch(dataURI)
if len(matches) != 3 {
return "", "", errors.New("invalid data URI format")
}
imageType := matches[1]
base64Data := matches[2]
return imageType, base64Data, nil
}
// convertSettingKeyToStore converts API setting key to store enum.
func convertSettingKeyToStore(key string) (storepb.UserSetting_Key, error) {
switch key {
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_GENERAL)]:
return storepb.UserSetting_GENERAL, nil
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_WEBHOOKS)]:
return storepb.UserSetting_WEBHOOKS, nil
case v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_TAGS)]:
return storepb.UserSetting_TAGS, nil
default:
return storepb.UserSetting_KEY_UNSPECIFIED, errors.Errorf("unknown setting key: %s", key)
}
}
// convertSettingKeyFromStore converts store enum to API setting key.
func convertSettingKeyFromStore(key storepb.UserSetting_Key) string {
switch key {
case storepb.UserSetting_GENERAL:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_GENERAL)]
case storepb.UserSetting_SHORTCUTS:
return "SHORTCUTS" // Not defined in API proto
case storepb.UserSetting_WEBHOOKS:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_WEBHOOKS)]
case storepb.UserSetting_TAGS:
return v1pb.UserSetting_Key_name[int32(v1pb.UserSetting_TAGS)]
default:
return "unknown"
}
}
func convertUserTagsSettingFromStore(setting *storepb.TagsUserSetting) *v1pb.UserSetting_TagsSetting {
if setting == nil {
return &v1pb.UserSetting_TagsSetting{Tags: map[string]*v1pb.UserSetting_TagMetadata{}}
}
tags := make(map[string]*v1pb.UserSetting_TagMetadata, len(setting.Tags))
for tag, metadata := range setting.Tags {
if metadata == nil {
tags[tag] = &v1pb.UserSetting_TagMetadata{}
continue
}
tags[tag] = &v1pb.UserSetting_TagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
BlurContent: metadata.GetBlurContent(),
}
}
return &v1pb.UserSetting_TagsSetting{Tags: tags}
}
func convertUserTagsSettingToStore(setting *v1pb.UserSetting_TagsSetting) *storepb.TagsUserSetting {
if setting == nil {
return &storepb.TagsUserSetting{Tags: map[string]*storepb.UserTagMetadata{}}
}
tags := make(map[string]*storepb.UserTagMetadata, len(setting.Tags))
for tag, metadata := range setting.Tags {
if metadata == nil {
tags[tag] = &storepb.UserTagMetadata{}
continue
}
tags[tag] = &storepb.UserTagMetadata{
BackgroundColor: metadata.GetBackgroundColor(),
BlurContent: metadata.GetBlurContent(),
}
}
return &storepb.TagsUserSetting{Tags: tags}
}
// convertUserSettingFromStore converts store UserSetting to API UserSetting.
func convertUserSettingFromStore(storeSetting *storepb.UserSetting, user *store.User, key storepb.UserSetting_Key) *v1pb.UserSetting {
if storeSetting == nil {
// Return default setting if none exists
settingKey := convertSettingKeyFromStore(key)
setting := &v1pb.UserSetting{
Name: fmt.Sprintf("%s/settings/%s", BuildUserName(user.Username), settingKey),
}
switch key {
case storepb.UserSetting_GENERAL:
setting.Value = &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: getDefaultUserGeneralSetting(),
}
case storepb.UserSetting_WEBHOOKS:
setting.Value = &v1pb.UserSetting_WebhooksSetting_{
WebhooksSetting: &v1pb.UserSetting_WebhooksSetting{
Webhooks: []*v1pb.UserWebhook{},
},
}
case storepb.UserSetting_TAGS:
setting.Value = &v1pb.UserSetting_TagsSetting_{
TagsSetting: &v1pb.UserSetting_TagsSetting{Tags: map[string]*v1pb.UserSetting_TagMetadata{}},
}
default:
return nil
}
return setting
}
settingKey := convertSettingKeyFromStore(storeSetting.Key)
setting := &v1pb.UserSetting{
Name: fmt.Sprintf("%s/settings/%s", BuildUserName(user.Username), settingKey),
}
switch storeSetting.Key {
case storepb.UserSetting_GENERAL:
if general := storeSetting.GetGeneral(); general != nil {
setting.Value = &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: &v1pb.UserSetting_GeneralSetting{
Locale: general.Locale,
MemoVisibility: general.MemoVisibility,
Theme: general.Theme,
},
}
} else {
setting.Value = &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: getDefaultUserGeneralSetting(),
}
}
case storepb.UserSetting_WEBHOOKS:
webhooks := storeSetting.GetWebhooks()
apiWebhooks := make([]*v1pb.UserWebhook, 0)
if webhooks != nil {
apiWebhooks = make([]*v1pb.UserWebhook, 0, len(webhooks.Webhooks))
for _, webhook := range webhooks.Webhooks {
apiWebhook := &v1pb.UserWebhook{
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
Url: webhook.Url,
DisplayName: webhook.Title,
SigningSecretSet: webhook.SigningSecret != "",
}
apiWebhooks = append(apiWebhooks, apiWebhook)
}
}
setting.Value = &v1pb.UserSetting_WebhooksSetting_{
WebhooksSetting: &v1pb.UserSetting_WebhooksSetting{
Webhooks: apiWebhooks,
},
}
case storepb.UserSetting_TAGS:
setting.Value = &v1pb.UserSetting_TagsSetting_{
TagsSetting: convertUserTagsSettingFromStore(storeSetting.GetTags()),
}
default:
return nil
}
return setting
}
// convertUserSettingToStore converts API UserSetting to store UserSetting.
func convertUserSettingToStore(apiSetting *v1pb.UserSetting, userID int32, key storepb.UserSetting_Key) (*storepb.UserSetting, error) {
storeSetting := &storepb.UserSetting{
UserId: userID,
Key: key,
}
switch key {
case storepb.UserSetting_GENERAL:
if general := apiSetting.GetGeneralSetting(); general != nil {
storeSetting.Value = &storepb.UserSetting_General{
General: &storepb.GeneralUserSetting{
Locale: general.Locale,
MemoVisibility: general.MemoVisibility,
Theme: general.Theme,
},
}
} else {
return nil, errors.Errorf("general setting is required")
}
case storepb.UserSetting_WEBHOOKS:
if webhooks := apiSetting.GetWebhooksSetting(); webhooks != nil {
storeWebhooks := make([]*storepb.WebhooksUserSetting_Webhook, 0, len(webhooks.Webhooks))
for _, webhook := range webhooks.Webhooks {
storeWebhook := &storepb.WebhooksUserSetting_Webhook{
Id: extractWebhookIDFromName(webhook.Name),
Title: webhook.DisplayName,
Url: webhook.Url,
}
storeWebhooks = append(storeWebhooks, storeWebhook)
}
storeSetting.Value = &storepb.UserSetting_Webhooks{
Webhooks: &storepb.WebhooksUserSetting{
Webhooks: storeWebhooks,
},
}
} else {
return nil, errors.Errorf("webhooks setting is required")
}
case storepb.UserSetting_TAGS:
if tags := apiSetting.GetTagsSetting(); tags != nil {
storeSetting.Value = &storepb.UserSetting_Tags{
Tags: convertUserTagsSettingToStore(tags),
}
} else {
return nil, errors.Errorf("tags setting is required")
}
default:
return nil, errors.Errorf("unsupported setting key: %v", key)
}
return storeSetting, nil
}
// extractWebhookIDFromName extracts webhook ID from resource name.
// e.g., "users/123/webhooks/webhook-id" -> "webhook-id".
+114
View File
@@ -0,0 +1,114 @@
package v1
import (
"strings"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/ast"
"github.com/pkg/errors"
)
func extractWebhookIDFromName(name string) string {
parts := strings.Split(name, "/")
if len(parts) >= 4 && parts[0] == "users" && parts[2] == "webhooks" {
return parts[3]
}
return ""
}
// extractUsernameFromFilter extracts username from the filter string using CEL.
// Supported filter format: "username == 'steven'"
// Returns the username value and an error if the filter format is invalid.
func extractUsernameFromFilter(filterStr string) (string, error) {
filterStr = strings.TrimSpace(filterStr)
if filterStr == "" {
return "", nil
}
// Create CEL environment with username variable
env, err := cel.NewEnv(
cel.Variable("username", cel.StringType),
)
if err != nil {
return "", errors.Wrap(err, "failed to create CEL environment")
}
// Parse and check the filter expression
celAST, issues := env.Compile(filterStr)
if issues != nil && issues.Err() != nil {
return "", errors.Wrapf(issues.Err(), "invalid filter expression: %s", filterStr)
}
// Extract username from the AST
username, err := extractUsernameFromAST(celAST.NativeRep().Expr())
if err != nil {
return "", err
}
return username, nil
}
// extractUsernameFromAST extracts the username value from a CEL AST expression.
func extractUsernameFromAST(expr ast.Expr) (string, error) {
if expr == nil {
return "", errors.New("empty expression")
}
// Check if this is a call expression (for ==, !=, etc.)
if expr.Kind() != ast.CallKind {
return "", errors.New("filter must be a comparison expression (e.g., username == 'value')")
}
call := expr.AsCall()
// We only support == operator
if call.FunctionName() != "_==_" {
return "", errors.Errorf("unsupported operator: %s (only '==' is supported)", call.FunctionName())
}
// The call should have exactly 2 arguments
args := call.Args()
if len(args) != 2 {
return "", errors.New("invalid comparison expression")
}
// Try to extract username from either left or right side
if username, ok := extractUsernameFromComparison(args[0], args[1]); ok {
return username, nil
}
if username, ok := extractUsernameFromComparison(args[1], args[0]); ok {
return username, nil
}
return "", errors.New("filter must compare 'username' field with a string constant")
}
// extractUsernameFromComparison tries to extract username value if left is 'username' ident and right is a string constant.
func extractUsernameFromComparison(left, right ast.Expr) (string, bool) {
// Check if left side is 'username' identifier
if left.Kind() != ast.IdentKind {
return "", false
}
ident := left.AsIdent()
if ident != "username" {
return "", false
}
// Right side should be a constant string
if right.Kind() != ast.LiteralKind {
return "", false
}
literal := right.AsLiteral()
// literal is a ref.Val, we need to get the Go value
str, ok := literal.Value().(string)
if !ok || str == "" {
return "", false
}
return str, true
}
// ListUserNotifications lists all notifications for a user.
// Notifications are backed by the inbox storage layer and represent activities
// that require user attention (e.g., memo comments).
@@ -0,0 +1,151 @@
package v1
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/store"
)
func (s *APIV1Service) ListLinkedIdentities(ctx context.Context, request *v1pb.ListLinkedIdentitiesRequest) (*v1pb.ListLinkedIdentitiesResponse, error) {
user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %v", err)
}
userID := user.ID
if _, err := s.authorizeUserResourceAccess(ctx, userID, true); err != nil {
return nil, err
}
identities, err := s.Store.ListUserIdentities(ctx, &store.FindUserIdentity{UserID: &userID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list linked identities: %v", err)
}
response := &v1pb.ListLinkedIdentitiesResponse{
LinkedIdentities: []*v1pb.LinkedIdentity{},
}
for _, identity := range identities {
response.LinkedIdentities = append(response.LinkedIdentities, convertLinkedIdentityFromStore(user, identity))
}
return response, nil
}
func (s *APIV1Service) CreateLinkedIdentity(ctx context.Context, request *v1pb.CreateLinkedIdentityRequest) (*v1pb.LinkedIdentity, error) {
user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %v", err)
}
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != user.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
identityProvider, userInfo, err := s.resolveSSOIdentity(ctx, request.IdpName, request.Code, request.RedirectUri, request.CodeVerifier)
if err != nil {
return nil, err
}
provider := identityProvider.Uid
externUID := userInfo.Identifier
if _, err := s.bindSSOIdentityToUser(ctx, currentUser, provider, externUID); err != nil {
return nil, err
}
identity, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
UserID: &currentUser.ID,
Provider: &provider,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get linked identity: %v", err)
}
if identity == nil {
return nil, status.Errorf(codes.Internal, "linked identity not found after creation")
}
return convertLinkedIdentityFromStore(user, identity), nil
}
func (s *APIV1Service) GetLinkedIdentity(ctx context.Context, request *v1pb.GetLinkedIdentityRequest) (*v1pb.LinkedIdentity, error) {
user, provider, err := s.resolveUserAndLinkedIdentityProviderFromName(ctx, request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid linked identity name: %v", err)
}
userID := user.ID
if _, err := s.authorizeUserResourceAccess(ctx, userID, true); err != nil {
return nil, err
}
identity, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
UserID: &userID,
Provider: &provider,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get linked identity: %v", err)
}
if identity == nil {
return nil, status.Errorf(codes.NotFound, "linked identity not found")
}
return convertLinkedIdentityFromStore(user, identity), nil
}
func (s *APIV1Service) DeleteLinkedIdentity(ctx context.Context, request *v1pb.DeleteLinkedIdentityRequest) (*emptypb.Empty, error) {
user, provider, err := s.resolveUserAndLinkedIdentityProviderFromName(ctx, request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid linked identity name: %v", err)
}
userID := user.ID
if _, err := s.authorizeUserResourceAccess(ctx, userID, true); err != nil {
return nil, err
}
existing, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
UserID: &userID,
Provider: &provider,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get linked identity: %v", err)
}
if existing == nil {
return nil, status.Errorf(codes.NotFound, "linked identity not found")
}
if err := s.Store.DeleteUserIdentities(ctx, &store.DeleteUserIdentity{
UserID: &userID,
Provider: &provider,
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete linked identity: %v", err)
}
return &emptypb.Empty{}, nil
}
// ListPersonalAccessTokens retrieves all Personal Access Tokens (PATs) for a user.
//
// Personal Access Tokens are used for:
// - Mobile app authentication
// - CLI tool authentication
// - API client authentication
// - Any programmatic access requiring Bearer token auth
//
// Security:
// - Only the token owner can list their tokens
// - Returns token metadata only (not the actual token value)
// - Invalid or expired tokens are filtered out
//
// Authentication: Required (session cookie or access token)
// Authorization: User can only list their own tokens.
@@ -0,0 +1,396 @@
package v1
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func (s *APIV1Service) ListUserNotifications(ctx context.Context, request *v1pb.ListUserNotificationsRequest) (*v1pb.ListUserNotificationsResponse, error) {
user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
userID := user.ID
// Verify the requesting user has permission to view these notifications
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Fetch inbox items from storage.
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ReceiverID: &userID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list inboxes: %v", err)
}
// Convert storage layer inboxes to API notifications.
userIDs := make([]int32, 0, len(inboxes)*2)
for _, inbox := range inboxes {
userIDs = append(userIDs, inbox.ReceiverID, inbox.SenderID)
}
usersByID, err := s.listUsersByID(ctx, userIDs)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification users: %v", err)
}
memosByID, err := s.listMemosByID(ctx, collectInboxMemoIDs(inboxes))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification memos: %v", err)
}
notifications := []*v1pb.UserNotification{}
for _, inbox := range inboxes {
notification, err := s.convertInboxToUserNotificationWithUsersAndMemos(inbox, currentUser, usersByID, memosByID)
if err != nil {
if status.Code(err) == codes.NotFound {
slog.Warn("Skipping notification with missing user",
slog.Int64("notification_id", int64(inbox.ID)),
slog.Int64("receiver_id", int64(inbox.ReceiverID)),
slog.Int64("sender_id", int64(inbox.SenderID)),
)
continue
}
return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err)
}
if notification.Type == v1pb.UserNotification_TYPE_UNSPECIFIED {
continue
}
notifications = append(notifications, notification)
}
return &v1pb.ListUserNotificationsResponse{
Notifications: notifications,
}, nil
}
// UpdateUserNotification updates a notification's status (e.g., marking as read/archived).
// Only the notification owner can update their notifications.
func (s *APIV1Service) UpdateUserNotification(ctx context.Context, request *v1pb.UpdateUserNotificationRequest) (*v1pb.UserNotification, error) {
if request.Notification == nil {
return nil, status.Errorf(codes.InvalidArgument, "notification is required")
}
user, notificationID, err := s.resolveUserAndNotificationIDFromName(ctx, request.Notification.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid notification name: %v", err)
}
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != user.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Verify ownership before updating
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ID: &notificationID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err)
}
if len(inboxes) == 0 {
return nil, status.Errorf(codes.NotFound, "notification not found")
}
inbox := inboxes[0]
if inbox.ReceiverID != currentUser.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Build update request based on field mask
update := &store.UpdateInbox{
ID: notificationID,
}
for _, path := range request.UpdateMask.Paths {
switch path {
case "status":
// Convert API status enum to storage enum
var inboxStatus store.InboxStatus
switch request.Notification.Status {
case v1pb.UserNotification_UNREAD:
inboxStatus = store.UNREAD
case v1pb.UserNotification_ARCHIVED:
inboxStatus = store.ARCHIVED
default:
return nil, status.Errorf(codes.InvalidArgument, "invalid status")
}
update.Status = inboxStatus
default:
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", path)
}
}
updatedInbox, err := s.Store.UpdateInbox(ctx, update)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err)
}
notification, err := s.convertInboxToUserNotification(ctx, updatedInbox, currentUser)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert inbox: %v", err)
}
return notification, nil
}
// DeleteUserNotification permanently deletes a notification.
// Only the notification owner can delete their notifications.
func (s *APIV1Service) DeleteUserNotification(ctx context.Context, request *v1pb.DeleteUserNotificationRequest) (*emptypb.Empty, error) {
user, notificationID, err := s.resolveUserAndNotificationIDFromName(ctx, request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid notification name: %v", err)
}
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != user.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Verify ownership before deletion
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
ID: &notificationID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err)
}
if len(inboxes) == 0 {
return nil, status.Errorf(codes.NotFound, "notification not found")
}
inbox := inboxes[0]
if inbox.ReceiverID != currentUser.ID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if err := s.Store.DeleteInbox(ctx, &store.DeleteInbox{
ID: notificationID,
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete inbox: %v", err)
}
return &emptypb.Empty{}, nil
}
// convertInboxToUserNotification converts a storage-layer inbox to an API notification.
// This handles the mapping between the internal inbox representation and the public API.
func (s *APIV1Service) convertInboxToUserNotification(ctx context.Context, inbox *store.Inbox, viewer *store.User) (*v1pb.UserNotification, error) {
usersByID, err := s.listUsersByID(ctx, []int32{inbox.ReceiverID, inbox.SenderID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification users: %v", err)
}
memosByID, err := s.listMemosByID(ctx, collectInboxMemoIDs([]*store.Inbox{inbox}))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list notification memos: %v", err)
}
return s.convertInboxToUserNotificationWithUsersAndMemos(inbox, viewer, usersByID, memosByID)
}
func collectInboxMemoIDs(inboxes []*store.Inbox) []int32 {
memoIDs := make([]int32, 0, len(inboxes)*2)
for _, inbox := range inboxes {
if inbox == nil || inbox.Message == nil {
continue
}
switch inbox.Message.Type {
case storepb.InboxMessage_MEMO_COMMENT:
payload := inbox.Message.GetMemoComment()
if payload != nil {
memoIDs = append(memoIDs, payload.MemoId, payload.RelatedMemoId)
}
case storepb.InboxMessage_MEMO_MENTION:
payload := inbox.Message.GetMemoMention()
if payload != nil {
memoIDs = append(memoIDs, payload.MemoId, payload.RelatedMemoId)
}
default:
// Ignore notification types without memo references.
}
}
return memoIDs
}
func (s *APIV1Service) convertInboxToUserNotificationWithUsersAndMemos(inbox *store.Inbox, viewer *store.User, usersByID map[int32]*store.User, memosByID map[int32]*store.Memo) (*v1pb.UserNotification, error) {
receiver := usersByID[inbox.ReceiverID]
if receiver == nil {
return nil, status.Errorf(codes.NotFound, "notification receiver not found")
}
sender := usersByID[inbox.SenderID]
if sender == nil {
return nil, status.Errorf(codes.NotFound, "notification sender not found")
}
notification := &v1pb.UserNotification{
Name: fmt.Sprintf("%s/notifications/%d", BuildUserName(receiver.Username), inbox.ID),
Sender: BuildUserName(sender.Username),
SenderUser: convertUserFromStore(sender, viewer),
CreateTime: timestamppb.New(time.Unix(inbox.CreatedTs, 0)),
}
// Convert status from storage enum to API enum
switch inbox.Status {
case store.UNREAD:
notification.Status = v1pb.UserNotification_UNREAD
case store.ARCHIVED:
notification.Status = v1pb.UserNotification_ARCHIVED
default:
notification.Status = v1pb.UserNotification_STATUS_UNSPECIFIED
}
// Extract notification type and payload from the inbox message.
if inbox.Message != nil {
switch inbox.Message.Type {
case storepb.InboxMessage_MEMO_COMMENT:
notification.Type = v1pb.UserNotification_MEMO_COMMENT
payload, err := s.convertMemoCommentNotificationPayload(viewer, inbox.Message, memosByID)
if err != nil {
return nil, err
}
if payload != nil {
notification.Payload = &v1pb.UserNotification_MemoComment{
MemoComment: payload,
}
}
case storepb.InboxMessage_MEMO_MENTION:
notification.Type = v1pb.UserNotification_MEMO_MENTION
payload, err := s.convertMemoMentionNotificationPayload(viewer, inbox.Message, memosByID)
if err != nil {
return nil, err
}
if payload != nil {
notification.Payload = &v1pb.UserNotification_MemoMention{
MemoMention: payload,
}
}
default:
notification.Type = v1pb.UserNotification_TYPE_UNSPECIFIED
}
}
return notification, nil
}
func canViewerAccessMemo(viewer *store.User, memo *store.Memo) bool {
if memo == nil {
return false
}
if viewer != nil && isSuperUser(viewer) {
return true
}
if memo.Visibility == store.Private {
return viewer != nil && viewer.ID == memo.CreatorID
}
if memo.Visibility == store.Protected {
return viewer != nil
}
return true
}
func (s *APIV1Service) memoNotificationSnippet(memo *store.Memo) (string, error) {
if memo == nil || memo.Content == "" {
return "", nil
}
snippet, err := s.getMemoContentSnippet(memo.Content)
if err != nil {
return "", err
}
return snippet, nil
}
func (s *APIV1Service) convertMemoCommentNotificationPayload(viewer *store.User, message *storepb.InboxMessage, memosByID map[int32]*store.Memo) (*v1pb.UserNotification_MemoCommentPayload, error) {
memoComment := message.GetMemoComment()
if message == nil || message.Type != storepb.InboxMessage_MEMO_COMMENT || memoComment == nil {
return nil, nil
}
commentMemo := memosByID[memoComment.MemoId]
if !canViewerAccessMemo(viewer, commentMemo) {
return nil, nil
}
relatedMemo := memosByID[memoComment.RelatedMemoId]
if !canViewerAccessMemo(viewer, relatedMemo) {
return nil, nil
}
memoSnippet, err := s.memoNotificationSnippet(commentMemo)
if err != nil {
return nil, errors.Wrap(err, "failed to get comment memo snippet")
}
relatedMemoSnippet, err := s.memoNotificationSnippet(relatedMemo)
if err != nil {
return nil, errors.Wrap(err, "failed to get related memo snippet")
}
return &v1pb.UserNotification_MemoCommentPayload{
Memo: fmt.Sprintf("%s%s", MemoNamePrefix, commentMemo.UID),
RelatedMemo: fmt.Sprintf("%s%s", MemoNamePrefix, relatedMemo.UID),
MemoSnippet: memoSnippet,
RelatedMemoSnippet: relatedMemoSnippet,
}, nil
}
func (s *APIV1Service) convertMemoMentionNotificationPayload(viewer *store.User, message *storepb.InboxMessage, memosByID map[int32]*store.Memo) (*v1pb.UserNotification_MemoMentionPayload, error) {
memoMention := message.GetMemoMention()
if message == nil || message.Type != storepb.InboxMessage_MEMO_MENTION || memoMention == nil {
return nil, nil
}
memo := memosByID[memoMention.MemoId]
if !canViewerAccessMemo(viewer, memo) {
return nil, nil
}
memoSnippet, err := s.memoNotificationSnippet(memo)
if err != nil {
return nil, errors.Wrap(err, "failed to get mention memo snippet")
}
payload := &v1pb.UserNotification_MemoMentionPayload{
Memo: fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID),
MemoSnippet: memoSnippet,
}
if memoMention.RelatedMemoId != 0 {
relatedMemo := memosByID[memoMention.RelatedMemoId]
if canViewerAccessMemo(viewer, relatedMemo) {
payload.RelatedMemo = fmt.Sprintf("%s%s", MemoNamePrefix, relatedMemo.UID)
relatedMemoSnippet, err := s.memoNotificationSnippet(relatedMemo)
if err != nil {
return nil, errors.Wrap(err, "failed to get related memo snippet")
}
payload.RelatedMemoSnippet = relatedMemoSnippet
}
}
return payload, nil
}
@@ -0,0 +1,242 @@
package v1
import (
"context"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUserSettingRequest) (*v1pb.UserSetting, error) {
// Parse resource name: users/{user}/settings/{setting}
user, settingKey, err := s.resolveUserAndSettingKeyFromName(ctx, request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid resource name: %v", err)
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Only allow user to get their own settings
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Convert setting key string to store enum
storeKey, err := convertSettingKeyToStore(settingKey)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid setting key: %v", err)
}
userSetting, err := s.Store.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &userID,
Key: storeKey,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user setting: %v", err)
}
return convertUserSettingFromStore(userSetting, user, storeKey), nil
}
func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.UpdateUserSettingRequest) (*v1pb.UserSetting, error) {
// Parse resource name: users/{user}/settings/{setting}
user, settingKey, err := s.resolveUserAndSettingKeyFromName(ctx, request.Setting.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid resource name: %v", err)
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Only allow user to update their own settings
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "update mask is empty")
}
// Convert setting key string to store enum
storeKey, err := convertSettingKeyToStore(settingKey)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid setting key: %v", err)
}
var updatedSetting *v1pb.UserSetting
switch storeKey {
case storepb.UserSetting_GENERAL:
existingUserSetting, _ := s.Store.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &userID,
Key: storeKey,
})
generalSetting := &storepb.GeneralUserSetting{}
if existingUserSetting != nil {
// Start with existing general setting values.
generalSetting = existingUserSetting.GetGeneral()
}
updatedGeneral := &v1pb.UserSetting_GeneralSetting{
MemoVisibility: generalSetting.GetMemoVisibility(),
Locale: generalSetting.GetLocale(),
Theme: generalSetting.GetTheme(),
}
incomingGeneral := request.Setting.GetGeneralSetting()
if incomingGeneral == nil {
return nil, status.Errorf(codes.InvalidArgument, "general setting is required")
}
for _, field := range request.UpdateMask.Paths {
switch field {
case "memo_visibility":
updatedGeneral.MemoVisibility = incomingGeneral.MemoVisibility
case "theme":
updatedGeneral.Theme = incomingGeneral.Theme
case "locale":
updatedGeneral.Locale = incomingGeneral.Locale
default:
// Ignore unsupported fields.
}
}
updatedSetting = &v1pb.UserSetting{
Name: request.Setting.Name,
Value: &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: updatedGeneral,
},
}
case storepb.UserSetting_TAGS:
var shouldUpdateTags bool
for _, field := range request.UpdateMask.Paths {
switch field {
case "tags":
shouldUpdateTags = true
default:
return nil, status.Errorf(codes.InvalidArgument, "unsupported update mask path for tags setting: %s", field)
}
}
if !shouldUpdateTags {
return nil, status.Errorf(codes.InvalidArgument, "update mask must include tags")
}
incomingTags := request.Setting.GetTagsSetting()
if incomingTags == nil {
return nil, status.Errorf(codes.InvalidArgument, "tags setting is required")
}
if err := validateUserTagsSetting(incomingTags); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user tags setting: %v", err)
}
updatedSetting = &v1pb.UserSetting{
Name: request.Setting.Name,
Value: &v1pb.UserSetting_TagsSetting_{
TagsSetting: incomingTags,
},
}
default:
return nil, status.Errorf(codes.InvalidArgument, "setting type %s should not be updated via UpdateUserSetting", storeKey.String())
}
// Convert API setting to store setting
storeSetting, err := convertUserSettingToStore(updatedSetting, userID, storeKey)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to convert setting: %v", err)
}
// Upsert the setting
if _, err := s.Store.UpsertUserSetting(ctx, storeSetting); err != nil {
return nil, status.Errorf(codes.Internal, "failed to upsert user setting: %v", err)
}
return s.GetUserSetting(ctx, &v1pb.GetUserSettingRequest{Name: request.Setting.Name})
}
func (s *APIV1Service) ListUserSettings(ctx context.Context, request *v1pb.ListUserSettingsRequest) (*v1pb.ListUserSettingsResponse, error) {
user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent name: %v", err)
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
// Only allow user to list their own settings
if currentUser.ID != userID {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
userSettings, err := s.Store.ListUserSettings(ctx, &store.FindUserSetting{
UserID: &userID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list user settings: %v", err)
}
settings := make([]*v1pb.UserSetting, 0, len(userSettings))
for _, storeSetting := range userSettings {
apiSetting := convertUserSettingFromStore(storeSetting, user, storeSetting.Key)
if apiSetting != nil {
settings = append(settings, apiSetting)
}
}
hasGeneral := false
for _, setting := range settings {
if setting.GetGeneralSetting() != nil {
hasGeneral = true
}
}
if !hasGeneral {
defaultGeneral := &v1pb.UserSetting{
Name: fmt.Sprintf("%s/settings/%s", BuildUserName(user.Username), convertSettingKeyFromStore(storepb.UserSetting_GENERAL)),
Value: &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: getDefaultUserGeneralSetting(),
},
}
settings = append([]*v1pb.UserSetting{defaultGeneral}, settings...)
}
response := &v1pb.ListUserSettingsResponse{
Settings: settings,
TotalSize: int32(len(settings)),
}
return response, nil
}
func (s *APIV1Service) authorizeUserResourceAccess(ctx context.Context, userID int32, allowAdmin bool) (*store.User, error) {
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID == userID || (allowAdmin && currentUser.Role == store.RoleAdmin) {
return currentUser, nil
}
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
@@ -0,0 +1,297 @@
package v1
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/usememos/memos/internal/webhook"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func (s *APIV1Service) ListUserWebhooks(ctx context.Context, request *v1pb.ListUserWebhooksRequest) (*v1pb.ListUserWebhooksResponse, error) {
user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %v", err)
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
webhooks, err := s.Store.GetUserWebhooks(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user webhooks: %v", err)
}
userWebhooks := make([]*v1pb.UserWebhook, 0, len(webhooks))
for _, webhook := range webhooks {
userWebhooks = append(userWebhooks, convertUserWebhookFromUserSetting(webhook, user))
}
return &v1pb.ListUserWebhooksResponse{
Webhooks: userWebhooks,
}, nil
}
func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.CreateUserWebhookRequest) (*v1pb.UserWebhook, error) {
user, err := s.resolveUserFromName(ctx, request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %v", err)
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
if request.Webhook.Url == "" {
return nil, status.Errorf(codes.InvalidArgument, "webhook URL is required")
}
if err := webhook.ValidateURL(strings.TrimSpace(request.Webhook.Url)); err != nil {
return nil, err
}
// The signing secret is generated server-side so it always meets the Standard
// Webhooks length requirement. A client-supplied secret is still accepted (and
// validated) for backward compatibility.
signingSecret := strings.TrimSpace(request.Webhook.SigningSecret)
if signingSecret == "" {
signingSecret, err = webhook.GenerateSigningSecret()
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate signing secret: %v", err)
}
} else if err := webhook.ValidateSigningSecret(signingSecret); err != nil {
return nil, err
}
webhookID := generateUserWebhookID()
webhook := &storepb.WebhooksUserSetting_Webhook{
Id: webhookID,
Title: request.Webhook.DisplayName,
Url: strings.TrimSpace(request.Webhook.Url),
SigningSecret: signingSecret,
}
err = s.Store.AddUserWebhook(ctx, userID, webhook)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create webhook: %v", err)
}
return convertUserWebhookFromUserSetting(webhook, user), nil
}
func (s *APIV1Service) UpdateUserWebhook(ctx context.Context, request *v1pb.UpdateUserWebhookRequest) (*v1pb.UserWebhook, error) {
if request.Webhook == nil {
return nil, status.Errorf(codes.InvalidArgument, "webhook is required")
}
user, webhookID, err := s.resolveUserAndWebhookIDFromName(ctx, request.Webhook.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid webhook name: %v", err)
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Get existing webhooks
webhooks, err := s.Store.GetUserWebhooks(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user webhooks: %v", err)
}
// Find the webhook to update
var targetWebhook *storepb.WebhooksUserSetting_Webhook
for _, webhook := range webhooks {
if webhook.Id == webhookID {
targetWebhook = webhook
break
}
}
if targetWebhook == nil {
return nil, status.Errorf(codes.NotFound, "webhook not found")
}
// Update the webhook
updatedWebhook := &storepb.WebhooksUserSetting_Webhook{
Id: webhookID,
Title: targetWebhook.Title,
Url: targetWebhook.Url,
SigningSecret: targetWebhook.SigningSecret,
}
if request.UpdateMask != nil {
for _, path := range request.UpdateMask.Paths {
switch path {
case "url":
if request.Webhook.Url != "" {
trimmed := strings.TrimSpace(request.Webhook.Url)
if err := webhook.ValidateURL(trimmed); err != nil {
return nil, err
}
updatedWebhook.Url = trimmed
}
case "display_name":
updatedWebhook.Title = request.Webhook.DisplayName
case "signing_secret":
secret := strings.TrimSpace(request.Webhook.SigningSecret)
if err := webhook.ValidateSigningSecret(secret); err != nil {
return nil, err
}
updatedWebhook.SigningSecret = secret
default:
// Ignore unsupported fields
}
}
} else {
// If no update mask is provided, update all fields
if request.Webhook.Url != "" {
trimmed := strings.TrimSpace(request.Webhook.Url)
if err := webhook.ValidateURL(trimmed); err != nil {
return nil, err
}
updatedWebhook.Url = trimmed
}
updatedWebhook.Title = request.Webhook.DisplayName
if request.Webhook.SigningSecret != "" {
secret := strings.TrimSpace(request.Webhook.SigningSecret)
if err := webhook.ValidateSigningSecret(secret); err != nil {
return nil, err
}
updatedWebhook.SigningSecret = secret
}
}
err = s.Store.UpdateUserWebhook(ctx, userID, updatedWebhook)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update webhook: %v", err)
}
return convertUserWebhookFromUserSetting(updatedWebhook, user), nil
}
func (s *APIV1Service) DeleteUserWebhook(ctx context.Context, request *v1pb.DeleteUserWebhookRequest) (*emptypb.Empty, error) {
user, webhookID, err := s.resolveUserAndWebhookIDFromName(ctx, request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid webhook name: %v", err)
}
userID := user.ID
currentUser, err := s.fetchCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if currentUser == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
if currentUser.ID != userID && currentUser.Role != store.RoleAdmin {
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}
// Get existing webhooks to verify the webhook exists
webhooks, err := s.Store.GetUserWebhooks(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user webhooks: %v", err)
}
// Check if webhook exists
found := false
for _, webhook := range webhooks {
if webhook.Id == webhookID {
found = true
break
}
}
if !found {
return nil, status.Errorf(codes.NotFound, "webhook not found")
}
err = s.Store.RemoveUserWebhook(ctx, userID, webhookID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete webhook: %v", err)
}
return &emptypb.Empty{}, nil
}
// GetUserWebhookSigningSecret reveals the signing secret for a single webhook.
// This is the only endpoint that returns the secret value; it is gated to the
// webhook owner (or an admin) and the secret is never included in list/create/update responses.
func (s *APIV1Service) GetUserWebhookSigningSecret(ctx context.Context, request *v1pb.GetUserWebhookSigningSecretRequest) (*v1pb.GetUserWebhookSigningSecretResponse, error) {
user, webhookID, err := s.resolveUserAndWebhookIDFromName(ctx, request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid webhook name: %v", err)
}
userID := user.ID
if _, err := s.authorizeUserResourceAccess(ctx, userID, true); err != nil {
return nil, err
}
webhooks, err := s.Store.GetUserWebhooks(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user webhooks: %v", err)
}
for _, webhook := range webhooks {
if webhook.Id == webhookID {
return &v1pb.GetUserWebhookSigningSecretResponse{SigningSecret: webhook.SigningSecret}, nil
}
}
return nil, status.Errorf(codes.NotFound, "webhook not found")
}
// Helper functions for webhook operations
// generateUserWebhookID generates a unique ID for user webhooks.
func generateUserWebhookID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
// convertUserWebhookFromUserSetting converts a storepb webhook to a v1pb UserWebhook.
func convertUserWebhookFromUserSetting(webhook *storepb.WebhooksUserSetting_Webhook, user *store.User) *v1pb.UserWebhook {
return &v1pb.UserWebhook{
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
Url: webhook.Url,
DisplayName: webhook.Title,
SigningSecretSet: webhook.SigningSecret != "",
// Note: create_time and update_time are not available in the user setting webhook structure
// This is a limitation of storing webhooks in user settings vs the dedicated webhook table
}
}
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { identityProviderServiceClient } from "@/connect";
import { absolutifyLink } from "@/helpers/utils";
import { absolutifyLink } from "@/lib/browser";
import { handleError } from "@/lib/error";
import {
FieldMapping,
+1 -1
View File
@@ -3,8 +3,8 @@ import { useState } from "react";
import MemoEditor from "@/components/MemoEditor";
import MemoView from "@/components/MemoView";
import { Button } from "@/components/ui/button";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser";
import { extractMemoIdFromName } from "@/lib/resource-names";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
@@ -1,10 +1,10 @@
import { create } from "@bufbuild/protobuf";
import { useEffect, useMemo, useState } from "react";
import { memoServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { buildMemoCreatorFilter } from "@/helpers/resource-names";
import { useDebouncedEffect } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/lib/constants";
import { buildMemoCreatorFilter } from "@/lib/resource-names";
import {
type Memo,
type MemoRelation,
@@ -1,6 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { FileIcon } from "lucide-react";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { extractMemoIdFromName } from "@/lib/resource-names";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
@@ -1,9 +1,9 @@
import { ArrowUpRightIcon } from "lucide-react";
import { Link } from "react-router-dom";
import { MemoPreview } from "@/components/MemoPreview";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { useMemoComments } from "@/hooks/useMemoQueries";
import { useUsersByNames } from "@/hooks/useUserQueries";
import { extractMemoIdFromName } from "@/lib/resource-names";
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
const MemoCommentListView: React.FC = () => {
@@ -1,5 +1,5 @@
import { Link } from "react-router-dom";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { extractMemoIdFromName } from "@/lib/resource-names";
import { cn } from "@/lib/utils";
interface MemoSnippetLinkProps {
@@ -8,11 +8,11 @@ import { userServiceClient } from "@/connect";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { useView } from "@/contexts/ViewContext";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE, LOADING_INDICATOR_DELAY_MS } from "@/helpers/consts";
import { useDelayedFlag } from "@/hooks/useDelayedFlag";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { hoistMemoToFront } from "@/hooks/useMemoSorting";
import { userKeys } from "@/hooks/useUserQueries";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE, LOADING_INDICATOR_DELAY_MS } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
@@ -1,12 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import InfoChip from "@/components/Settings/InfoChip";
import { getIdentityProviderTypeLabel, getSSOProviderUid } from "@/components/Settings/sso-display";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { identityProviderServiceClient, userServiceClient } from "@/connect";
import { getIdentityProviderTypeLabel, getSSOProviderUid } from "@/helpers/sso-display";
import { absolutifyLink } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser";
import { absolutifyLink } from "@/lib/browser";
import { handleError } from "@/lib/error";
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";
import { LinkedIdentity } from "@/types/proto/api/v1/user_service_pb";
+6 -1
View File
@@ -3,11 +3,16 @@ import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import InfoChip from "@/components/Settings/InfoChip";
import {
getIdentityProviderTypeLabel,
getOAuth2SummaryItems,
getSSOProviderUid,
type SummaryItem,
} from "@/components/Settings/sso-display";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { identityProviderServiceClient } from "@/connect";
import { getIdentityProviderTypeLabel, getOAuth2SummaryItems, getSSOProviderUid, type SummaryItem } from "@/helpers/sso-display";
import { useDialog } from "@/hooks/useDialog";
import { handleError } from "@/lib/error";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
+1 -1
View File
@@ -8,9 +8,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/contexts/AuthContext";
import { buildUserSettingName } from "@/helpers/resource-names";
import { useTagCounts, useUpdateUserSetting } from "@/hooks/useUserQueries";
import { colorToHex } from "@/lib/color";
import { buildUserSettingName } from "@/lib/resource-names";
import { isValidTagPattern } from "@/lib/tag";
import { cn } from "@/lib/utils";
import {
@@ -1,4 +1,4 @@
import { extractIdentityProviderUidFromName } from "@/helpers/resource-names";
import { extractIdentityProviderUidFromName } from "@/lib/resource-names";
import { type FieldMapping, type IdentityProvider, IdentityProvider_Type, type OAuth2Config } from "@/types/proto/api/v1/idp_service_pb";
import type { Translations } from "@/utils/i18n";
+1 -1
View File
@@ -9,9 +9,9 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import { convertFileToBase64 } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUpdateUser } from "@/hooks/useUserQueries";
import { convertFileToBase64 } from "@/lib/browser";
import { handleError } from "@/lib/error";
import { useTranslate } from "@/utils/i18n";
import UserAvatar from "./UserAvatar";
@@ -7,8 +7,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useInstance } from "@/contexts/InstanceContext";
import { buildInstanceSettingName } from "@/helpers/resource-names";
import { handleError } from "@/lib/error";
import { buildInstanceSettingName } from "@/lib/resource-names";
import {
InstanceSetting_GeneralSetting_CustomProfile,
InstanceSetting_GeneralSetting_CustomProfileSchema,
@@ -8,8 +8,8 @@ import { MapContainer, Marker, Popup, useMap } from "react-leaflet";
import MarkerClusterGroup from "react-leaflet-cluster";
import { Link } from "react-router-dom";
import { defaultMarkerIcon, ThemedTileLayer } from "@/components/map/map-utils";
import { buildMemoCreatorFilter } from "@/helpers/resource-names";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { buildMemoCreatorFilter } from "@/lib/resource-names";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { buildMemoCreatorFilter } from "@/helpers/resource-names";
import { buildMemoCreatorFilter } from "@/lib/resource-names";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
const getVisibilityName = (visibility: Visibility): string => {
+1 -1
View File
@@ -3,8 +3,8 @@ import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import type { InfiniteData } from "@tanstack/react-query";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { memoServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { userKeys } from "@/hooks/useUserQueries";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/lib/constants";
import type { ListMemosRequest, ListMemosResponse, Memo } from "@/types/proto/api/v1/memo_service_pb";
import { ListMemoCommentsRequestSchema, ListMemosRequestSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
+1 -1
View File
@@ -2,8 +2,8 @@ import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { shortcutServiceClient, userServiceClient } from "@/connect";
import { buildUserSettingName } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser";
import { buildUserSettingName } from "@/lib/resource-names";
import {
type ListAllUserStatsRequest,
ListAllUserStatsRequestSchema,
+1 -1
View File
@@ -4,8 +4,8 @@ import { useSearchParams } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
import { authServiceClient, userServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo";
import { absolutifyLink } from "@/lib/browser";
import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { getSafeRedirectPath } from "@/utils/auth-redirect";
+1 -1
View File
@@ -7,11 +7,11 @@ import { MentionResolutionProvider } from "@/components/MemoContent/MentionResol
import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar";
import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import { memoNamePrefix } from "@/helpers/resource-names";
import useMediaQuery from "@/hooks/useMediaQuery";
import useMemoDetailError from "@/hooks/useMemoDetailError";
import { useInfiniteMemoComments, useMemo } from "@/hooks/useMemoQueries";
import { useSharedMemo, withShareAttachmentLinks } from "@/hooks/useMemoShareQueries";
import { memoNamePrefix } from "@/lib/resource-names";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
+1 -1
View File
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/connect";
import { useInstance } from "@/contexts/InstanceContext";
import { absolutifyLink } from "@/helpers/utils";
import { absolutifyLink } from "@/lib/browser";
import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";