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:
@@ -1 +0,0 @@
|
||||
Fork from https://github.com/robfig/cron
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: ¤tUser.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: ¤tUser.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"
|
||||
}
|
||||
}
|
||||
@@ -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: ¤tUser.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: ¤tUser.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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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".
|
||||
@@ -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: ¤tUser.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: ¬ificationID,
|
||||
})
|
||||
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: ¬ificationID,
|
||||
})
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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 => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user