mlisting

Mailing list service

git clone git://git.lin.moe/go/mlisting.git

  1package service
  2
  3import (
  4	"bytes"
  5	"database/sql"
  6	"embed"
  7	"errors"
  8	"fmt"
  9	"html/template"
 10	"io"
 11	"io/fs"
 12	"net/http"
 13	"path/filepath"
 14	"slices"
 15	"strconv"
 16
 17	"git.lin.moe/go/mlisting/config"
 18	"git.lin.moe/go/mlisting/storage"
 19	humanize "github.com/dustin/go-humanize"
 20	mbox "github.com/emersion/go-mbox"
 21)
 22
 23//go:embed templates/http
 24var httpFiles embed.FS
 25
 26type HTTPMux struct {
 27	*http.ServeMux
 28	storage storage.Storage
 29	logger  config.Logger
 30
 31	templates map[string]*template.Template
 32}
 33
 34func NewHTTPMux(cfg *config.Config, st storage.Storage, l config.Logger) *HTTPMux {
 35	httpMux := http.NewServeMux()
 36	mux := new(HTTPMux)
 37	mux.storage = st
 38	mux.logger = l
 39	mux.ServeMux = httpMux
 40
 41	httpMux.HandleFunc(cfg.HttpRealUrl("/list/{list_addr}/"), mux.listIndex)
 42	httpMux.HandleFunc(cfg.HttpRealUrl("/list/{list_addr}/{msg_id}/"), mux.msgThread)
 43	httpMux.HandleFunc(cfg.HttpRealUrl("/list/{list_addr}/{msg_id}/mbox/"), mux.msgMbox)
 44
 45	cssFS, _ := fs.Sub(httpFiles, "templates/http")
 46	httpMux.Handle(cfg.HttpRealUrl("/css/"), http.StripPrefix(cfg.HttpRealUrl("/"), http.FileServer(http.FS(cssFS))))
 47	httpMux.HandleFunc(cfg.HttpRealUrl("/list/"), mux.lists)
 48	httpMux.HandleFunc(cfg.HttpRealUrl("/"), mux.index)
 49
 50	var tmpl_map = map[string]string{
 51		"listIndex": "list_index.tpl",
 52		"msgThread": "msg_thread.tpl",
 53		"lists":     "lists.tpl",
 54	}
 55
 56	mux.templates = make(map[string]*template.Template)
 57	var t_funcmap = template.FuncMap{
 58		"timediff": humanize.Time,
 59		"realurl":  cfg.HttpRealUrl,
 60		"addr_subscribe": func(addr string) string {
 61			return (&Address{Address: addr}).WithAction(CMD_SUBSCRIBE).String()
 62		},
 63	}
 64
 65	var err error
 66	for k, tpl := range tmpl_map {
 67		mux.templates[k], err = template.New(tpl).
 68			Funcs(t_funcmap).
 69			ParseFS(httpFiles, fmt.Sprintf("templates/http/%s", tpl))
 70		if err != nil {
 71			panic(err)
 72		}
 73	}
 74
 75	mux.templates["index"], err =
 76		template.New(filepath.Base(cfg.Http.IndexPage)).
 77			Funcs(t_funcmap).
 78			ParseFiles(cfg.Http.IndexPage)
 79	if err != nil {
 80		panic(err)
 81	}
 82
 83	return mux
 84}
 85
 86func (m *HTTPMux) msgThread(w http.ResponseWriter, r *http.Request) {
 87	var (
 88		ctx   = r.Context()
 89		addr  = r.PathValue("list_addr")
 90		msgid = r.PathValue("msg_id")
 91		l     = m.logger
 92		st    = m.storage
 93	)
 94
 95	list, err := st.GetList(ctx, addr)
 96	if err != nil {
 97		if errors.Is(err, sql.ErrNoRows) {
 98			l.Debug("not found list", "list", addr)
 99			http.NotFound(w, r)
100			return
101		}
102		l.Error("failed to load list",
103			"err", err,
104			"list", addr)
105
106		code := http.StatusInternalServerError
107		http.Error(w, http.StatusText(code), code)
108		return
109	}
110	if list.DefaultPerm()&storage.PERM_BROWSE == 0 {
111		code := http.StatusUnauthorized
112		http.Error(w, http.StatusText(code), code)
113		return
114	}
115
116	msg, err := list.Message(ctx, msgid)
117	if err != nil {
118		if errors.Is(err, sql.ErrNoRows) {
119			l.Debug("not found message", "list", addr)
120			http.NotFound(w, r)
121			return
122		}
123		l.Error("failed to load message",
124			"err", err,
125			"message-id", msgid,
126			"list", addr)
127
128		code := http.StatusInternalServerError
129		http.Error(w, http.StatusText(code), code)
130		return
131	}
132	submsgs, err := msg.SubMessages(ctx, true)
133	if err != nil && !errors.Is(err, sql.ErrNoRows) {
134		l.Error("failed to load sub message message",
135			"err", err,
136			"message-id", msgid,
137			"list", addr)
138
139		code := http.StatusInternalServerError
140		http.Error(w, http.StatusText(code), code)
141		return
142	}
143	slices.Reverse(submsgs)
144	m.templates["msgThread"].Execute(w, struct {
145		storage.Message
146		List        storage.List
147		Parent      storage.Message
148		SubMessages []storage.Message
149	}{msg, list, msg.Parent(r.Context()), submsgs})
150}
151
152func (m *HTTPMux) msgMbox(w http.ResponseWriter, r *http.Request) {
153	var (
154		ctx   = r.Context()
155		addr  = r.PathValue("list_addr")
156		msgid = r.PathValue("msg_id")
157		l     = m.logger
158		st    = m.storage
159	)
160
161	list, err := st.GetList(ctx, addr)
162	if err != nil {
163		if errors.Is(err, sql.ErrNoRows) {
164			l.Debug("not found list", "list", addr)
165			http.NotFound(w, r)
166			return
167		}
168		l.Error("failed to load list",
169			"err", err,
170			"list", addr)
171
172		code := http.StatusInternalServerError
173		http.Error(w, http.StatusText(code), code)
174		return
175	}
176	if list.DefaultPerm()&storage.PERM_BROWSE == 0 {
177		code := http.StatusUnauthorized
178		http.Error(w, http.StatusText(code), code)
179		return
180	}
181	msg, err := list.Message(ctx, msgid)
182	if err != nil {
183		if errors.Is(err, sql.ErrNoRows) {
184			l.Debug("not found message", "list", addr)
185			http.NotFound(w, r)
186			return
187		}
188		l.Error("failed to load message",
189			"err", err,
190			"message-id", msgid,
191			"list", addr)
192
193		code := http.StatusInternalServerError
194		http.Error(w, http.StatusText(code), code)
195		return
196	}
197	submsgs, err := msg.SubMessages(ctx, true)
198	if err != nil && !errors.Is(err, sql.ErrNoRows) {
199		l.Error("failed to load sub message message",
200			"err", err,
201			"message-id", msgid,
202			"list", addr)
203
204		code := http.StatusInternalServerError
205		http.Error(w, http.StatusText(code), code)
206		return
207	}
208	msgs := append(submsgs, msg)
209	slices.Reverse(msgs)
210
211	respbuf := bytes.NewBuffer(nil)
212	mbox_writer := mbox.NewWriter(respbuf)
213	defer mbox_writer.Close()
214	for _, msg := range msgs {
215		var buf = bytes.NewBuffer(nil)
216		entity := msg.Entity()
217		entity.WriteTo(buf)
218
219		msg_writer, err := mbox_writer.CreateMessage(msg.Header().Get("From"), msg.CreateAt())
220		if err != nil {
221			l.Error("create mbox mseesage writer error", "err", err, "parent-msgid", msgid)
222			code := http.StatusInternalServerError
223			http.Error(w, http.StatusText(code), code)
224			return
225		}
226		if _, err := io.Copy(msg_writer, buf); err != nil {
227			l.Error("write mbox messsage failed", "err", err, "parent-msgid", msgid)
228			code := http.StatusInternalServerError
229			http.Error(w, http.StatusText(code), code)
230			return
231		}
232	}
233	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.mbox\"", msgid))
234	w.Header().Set("Content-Type", "application/mbox")
235	w.WriteHeader(http.StatusOK)
236	io.Copy(w, respbuf)
237}
238
239func (m *HTTPMux) listIndex(w http.ResponseWriter, r *http.Request) {
240	const COUNT_PER_PAGE = 50
241	var (
242		ctx     = r.Context()
243		addr    = r.PathValue("list_addr")
244		pageStr = r.URL.Query().Get("p")
245		search  = r.URL.Query().Get("s")
246		l       = m.logger
247		st      = m.storage
248	)
249	page, _ := strconv.Atoi(pageStr)
250	list, err := st.GetList(ctx, addr)
251	if err != nil {
252		if errors.Is(err, sql.ErrNoRows) {
253			l.Debug("not found list", "list", addr)
254			http.NotFound(w, r)
255			return
256		}
257		l.Error("failed to load list",
258			"err", err,
259			"list", addr)
260
261		code := http.StatusInternalServerError
262		http.Error(w, http.StatusText(code), code)
263		return
264	}
265	if list.DefaultPerm()&storage.PERM_BROWSE == 0 {
266		code := http.StatusUnauthorized
267		http.Error(w, http.StatusText(code), code)
268		return
269	}
270
271	isheader := (search == "")
272	msgs, total, err := list.Messages(ctx, isheader, search, uint(page)*COUNT_PER_PAGE, COUNT_PER_PAGE)
273	if err != nil && !errors.Is(err, sql.ErrNoRows) {
274		l.Error("load messages error",
275			"err", err,
276			"list", addr)
277		code := http.StatusInternalServerError
278		http.Error(w, http.StatusText(code), code)
279		return
280	}
281	nextP := page + 1
282	if total <= int64(page+1)*COUNT_PER_PAGE {
283		nextP = -1
284	}
285	prevP := page - 1
286	if prevP < 0 {
287		prevP = -1
288	}
289
290	m.templates["listIndex"].Execute(w, struct {
291		storage.List
292		Messages []storage.Message
293		Search   string
294		NextPage int
295		PrevPage int
296	}{list, msgs, search, nextP, prevP})
297}
298
299func (m *HTTPMux) lists(w http.ResponseWriter, r *http.Request) {
300	var (
301		ctx = r.Context()
302		st  = m.storage
303		l   = m.logger
304	)
305	lists, err := st.Lists(ctx)
306	if err != nil && !errors.Is(err, sql.ErrNoRows) {
307		l.Error("load lists error", "err", err)
308	}
309	public_lists := []storage.List{}
310	for _, list := range lists {
311		if list.DefaultPerm()&storage.PERM_BROWSE == 0 {
312			continue
313		}
314		public_lists = append(public_lists, list)
315	}
316	m.templates["lists"].Execute(w, struct {
317		Lists []storage.List
318	}{public_lists})
319}
320
321func (m *HTTPMux) index(w http.ResponseWriter, r *http.Request) {
322	var (
323		ctx = r.Context()
324		st  = m.storage
325		l   = m.logger
326	)
327
328	lists, err := st.Lists(ctx)
329	if err != nil && !errors.Is(err, sql.ErrNoRows) {
330		l.Error("load lists error", "err", err)
331	}
332	public_lists := []storage.List{}
333	for _, list := range lists {
334		if list.DefaultPerm()&storage.PERM_BROWSE == 0 {
335			continue
336		}
337		public_lists = append(public_lists, list)
338	}
339
340	m.templates["index"].Execute(w, struct {
341		Lists []storage.List
342	}{public_lists})
343}