package engine import ( "context" "encoding/json" "fmt" "net/url" "regexp" "slices" "strings" "time" "jarl/internal/auth" "jarl/internal/blocking" "jarl/internal/model" ) // ---- blocking: blocklists and rules (docs/blocking.md) var blocklistNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`) func blockingAction() auth.Action { return auth.Action{Resource: auth.ResBlocking, Write: true} } // viewIDs resolves the views of a blocklist or rule input: names or IDs, // in view order, without duplicates. nil, nil means "not given". func viewIDs(st *model.State, names *[]string, ids *[]int64) ([]int64, error) { if names == nil && ids == nil { return nil, nil } want := map[int64]bool{} if names != nil { for _, n := range *names { v := st.ViewByName(strings.TrimSpace(n)) if v == nil { return nil, errf(KindBadRequest, "view %q does not exist", n) } want[v.ID] = true } } if ids != nil { for _, id := range *ids { if st.Views[id] == nil { return nil, errf(KindBadRequest, "view %d does not exist", id) } want[id] = true } } out := []int64{} for _, v := range st.OrderedViews() { if want[v.ID] { out = append(out, v.ID) } } return out, nil } // CheckBlocklistURL checks a blocklist URL: http or https, with a host. func CheckBlocklistURL(raw string) (string, error) { raw = strings.TrimSpace(raw) u, err := url.Parse(raw) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return "", fmt.Errorf("%q is not an http or https URL", raw) } return raw, nil } func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error { if err := c.require(blockingAction()); err != nil { return err } var in BlocklistInput if err := decode(op.Data, &in); err != nil { return err } var b *model.Blocklist if op.Kind == OpBlocklistCreate { if op.ID == 0 || st.Blocklists[op.ID] != nil { return errf(KindConflict, "blocklist ID %d is already in use", op.ID) } if in.Name == nil || in.URL == nil { return errf(KindBadRequest, "a blocklist needs a name and a URL") } b = &model.Blocklist{ID: op.ID, ViewIDs: []int64{}} } else { b = st.Blocklists[op.ID] if b == nil { return errf(KindNotFound, "blocklist %d does not exist", op.ID) } if err := checkVersion(op, b.Version, "blocklist "+b.Name); err != nil { return err } } if in.Name != nil { name := strings.ToLower(strings.TrimSpace(*in.Name)) if !blocklistNameRe.MatchString(name) { return errf(KindBadRequest, "blocklist name %q: use 1 to 40 lowercase letters, digits and hyphens, like hagezi-pro", *in.Name) } if other := st.BlocklistByName(name); other != nil && other.ID != b.ID { return errf(KindConflict, "blocklist %s already exists", name) } b.Name = name } if in.URL != nil { u, err := CheckBlocklistURL(*in.URL) if err != nil { return errf(KindBadRequest, "%v", err) } b.URL = u } ids, err := viewIDs(st, in.Views, in.ViewIDs) if err != nil { return err } if ids != nil { b.ViewIDs = ids } if in.Disabled != nil { b.Disabled = *in.Disabled } if in.Comment != nil { b.Comment = *in.Comment } b.Touch(c.user(), c.now) st.Blocklists[b.ID] = b return nil } func applyBlocklistDelete(st *model.State, op model.Op, c *applyCtx) error { if err := c.require(blockingAction()); err != nil { return err } b := st.Blocklists[op.ID] if b == nil { return errf(KindNotFound, "blocklist %d does not exist", op.ID) } if err := checkVersion(op, b.Version, "blocklist "+b.Name); err != nil { return err } delete(st.Blocklists, b.ID) return nil } func applyBlockRulePut(st *model.State, op model.Op, c *applyCtx) error { if err := c.require(blockingAction()); err != nil { return err } var in BlockRuleInput if err := decode(op.Data, &in); err != nil { return err } var r *model.BlockRule if op.Kind == OpBlockRuleCreate { if op.ID == 0 || st.BlockRules[op.ID] != nil { return errf(KindConflict, "rule ID %d is already in use", op.ID) } if in.Name == nil { return errf(KindBadRequest, "a rule needs a domain name") } r = &model.BlockRule{ID: op.ID, Action: model.BlockActionBlock, ViewIDs: []int64{}} } else { r = st.BlockRules[op.ID] if r == nil { return errf(KindNotFound, "rule %d does not exist", op.ID) } if err := checkVersion(op, r.Version, "rule "+r.Name); err != nil { return err } } if in.Name != nil { n, err := blocking.NormalizeName(*in.Name) if err != nil { return errf(KindBadRequest, "%v", err) } r.Name = n } if in.Action != nil { a := strings.ToLower(strings.TrimSpace(*in.Action)) if a != model.BlockActionBlock && a != model.BlockActionAllow { return errf(KindBadRequest, "a rule's action is block or allow, not %q", *in.Action) } r.Action = a } if in.Exact != nil { r.Exact = *in.Exact } ids, err := viewIDs(st, in.Views, in.ViewIDs) if err != nil { return err } if ids != nil { r.ViewIDs = ids } if in.Comment != nil { r.Comment = *in.Comment } r.Touch(c.user(), c.now) st.BlockRules[r.ID] = r return nil } func applyBlockRuleDelete(st *model.State, op model.Op, c *applyCtx) error { if err := c.require(blockingAction()); err != nil { return err } r := st.BlockRules[op.ID] if r == nil { return errf(KindNotFound, "rule %d does not exist", op.ID) } if err := checkVersion(op, r.Version, "rule "+r.Name); err != nil { return err } delete(st.BlockRules, r.ID) return nil } // blockingUsers lists the blocklists and rules that name a view. func blockingUsers(st *model.State, viewID int64) []string { var out []string for _, b := range st.SortedBlocklists() { if slices.Contains(b.ViewIDs, viewID) { out = append(out, "blocklist "+b.Name) } } for _, r := range st.SortedBlockRules() { if slices.Contains(r.ViewIDs, viewID) { out = append(out, ruleLabel(r)) } } return out } // ruleLabel describes a rule: "block ads.example (and below)". func ruleLabel(r *model.BlockRule) string { s := r.Action + " " + r.Name if !r.Exact { s += " (and below)" } return s } // ---- expansion func (x *expander) blocklistCreate(req Request) ([]model.Op, error) { return x.simpleCreate(req, OpBlocklistCreate, func(data json.RawMessage) (string, error) { var in BlocklistInput if err := decode(data, &in); err != nil { return "", err } if in.Name == nil || in.URL == nil { return "", errf(KindBadRequest, "a blocklist needs a name and a URL") } return "add blocklist " + *in.Name + " (" + *in.URL + ")", nil }) } func (x *expander) blockRuleCreate(req Request) ([]model.Op, error) { return x.simpleCreate(req, OpBlockRuleCreate, func(data json.RawMessage) (string, error) { var in BlockRuleInput if err := decode(data, &in); err != nil { return "", err } if in.Name == nil { return "", errf(KindBadRequest, "a rule needs a domain name") } r := &model.BlockRule{Name: strings.ToLower(strings.TrimSuffix(strings.TrimSpace(*in.Name), ".")), Action: model.BlockActionBlock} if in.Action != nil { r.Action = strings.ToLower(*in.Action) } if in.Exact != nil { r.Exact = *in.Exact } return ruleLabel(r), nil }) } func applyBlockingPause(st *model.State, op model.Op, c *applyCtx) error { if err := c.require(blockingAction()); err != nil { return err } var in BlockingPauseInput if err := decode(op.Data, &in); err != nil { return err } if in.Until != nil && in.Until.Sub(c.now) > MaxBlockingPause+time.Minute { return errf(KindBadRequest, "blocking can be paused for at most %s; disable lists to stop using them", MaxBlockingPause) } st.Settings.BlockingPausedUntil = in.Until return nil } // ResumeBlocking ends a pause whose time is up, and returns when the // current pause ends (zero when blocking isn't paused). func (e *Engine) ResumeBlocking(ctx context.Context) time.Time { st, err := e.store.LoadState(ctx) if err != nil || st.Settings.BlockingPausedUntil == nil { return time.Time{} } until := *st.Settings.BlockingPausedUntil if e.Now().Before(until) { return until } if _, err := e.Submit(ctx, []Request{NewRequest(OpBlockingPause, 0, BlockingPauseInput{})}, auth.System, "system", "resume blocking after the pause", ApplyOptions{}); err != nil { e.log.Warn("resuming blocking after the pause failed; it will be tried again", "err", err) return e.Now().Add(time.Minute) } e.log.Info("blocking resumed after the pause") return time.Time{} }