1 package server
2
3 import (
4 "context"
5 "crypto/sha512"
6 "encoding/base64"
7 "fmt"
8 "html"
9 "html/template"
10 "net/http"
11 "os"
12 "path/filepath"
13 "regexp"
14 "strconv"
15 "strings"
16
17 "codeberg.org/tslocum/sriracha/internal/database"
18 . "codeberg.org/tslocum/sriracha/model"
19 . "codeberg.org/tslocum/sriracha/util"
20 "github.com/gabriel-vasile/mimetype"
21 "github.com/jackc/pgx/v5"
22 "golang.org/x/sys/unix"
23 )
24
25 func (s *Server) serveImport(data *templateData, db *database.DB, w http.ResponseWriter, r *http.Request) {
26 data.Template = "manage_info"
27 data.Message = `<h2 class="managetitle">Import</h2><b>Warning:</b> Backup all files and databases before importing a board.<br><br>`
28
29 const completeMessage = "<b>Import complete.</b><br>Please remove the import option from config.yml and restart Sriracha.<br>"
30 if data.forbidden(w, RoleSuperAdmin) {
31 return
32 } else if !s.config.ImportMode {
33 data.ManageError("Sriracha is not running in import mode.")
34 return
35 } else if s.config.ImportComplete {
36 data.Message += template.HTML(completeMessage)
37 return
38 }
39 c := s.config.Import
40
41 commit := FormBool(r, "import") && FormBool(r, "confirmation")
42 defer func() {
43 if commit && s.config.ImportComplete {
44 data.Message += template.HTML("Committing changes...<br>")
45 err := db.CommitWithErr()
46 if err != nil {
47 data.Message += template.HTML("<b>Error:</b> Failed to commit changes: " + html.EscapeString(err.Error()))
48 } else {
49 data.Message += template.HTML("<b>Changes committed.</b><br><br>" + completeMessage)
50 }
51 } else {
52 db.RollBack()
53 }
54 }()
55
56
57 data.Message += template.HTML("Connecting to database...<br>")
58 databaseURL := fmt.Sprintf("postgres://%s:%s@%s/%s", c.Username, c.Password, c.Address, c.DBName)
59 conn, err := pgx.Connect(context.Background(), databaseURL)
60 if err != nil {
61 data.Message += template.HTML("<b>Error:</b> Failed to connect to database: " + html.EscapeString(err.Error()))
62 return
63 }
64 _, err = conn.Exec(context.Background(), "BEGIN")
65 if err != nil {
66 data.Message += template.HTML("<b>Error:</b> Failed to verify connection status: " + html.EscapeString(err.Error()))
67 return
68 }
69 data.Message += template.HTML("<b>Connected.</b><br><br>")
70
71
72 data.Message += template.HTML("Validating tables...<br>")
73 tableEntries := func(name string) (int, error) {
74 var entries int
75 err := conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM "+name).Scan(&entries)
76 if err == pgx.ErrNoRows {
77 return 0, nil
78 } else if err != nil {
79 return 0, fmt.Errorf("failed to select from table %s: %s", name, err)
80 }
81 return entries, nil
82 }
83
84 postEntries, err := tableEntries(c.Posts)
85 if err != nil {
86 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Failed to validate table %s: %s", html.EscapeString(c.Posts), html.EscapeString(err.Error())))
87 return
88 } else if postEntries == 0 {
89 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> No posts were found in table %s.", html.EscapeString(c.Posts)))
90 return
91 }
92 data.Message += template.HTML(fmt.Sprintf("<b>Found %d posts</b> in table %s.<br>", postEntries, html.EscapeString(c.Posts)))
93
94 if c.Keywords != "" {
95 keywordEntries, err := tableEntries(c.Keywords)
96 if err != nil {
97 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Failed to validate table %s: %s", html.EscapeString(c.Keywords), html.EscapeString(err.Error())))
98 return
99 }
100 data.Message += template.HTML(fmt.Sprintf("<b>Found %d keywords</b> in table %s.<br>", keywordEntries, html.EscapeString(c.Keywords)))
101 }
102
103 data.Message += template.HTML("<b>Validation complete.</b><br><br>")
104
105 var rewriteIDs bool
106 for _, b := range db.AllBoards() {
107 if len(db.AllThreads(b, false)) != 0 {
108 rewriteIDs = true
109 break
110 }
111 }
112 if rewriteIDs {
113 data.Message += template.HTML("Found existing Sriracha posts.<br><b>Post IDs will be rewritten.</b><br><br>")
114 }
115
116 doImport := FormBool(r, "import")
117 if !doImport {
118 data.Message += template.HTML(`<form method="post"><input type="hidden" name="import" value="1">
119 <table border="0" class="manageform">
120 <tr>
121 <td class="postblock"><label for="dir">Board Directory</label></td>
122 <td><input type="text" name="dir"></td>
123 <td>The directory where the board files are located. If the board is located at the server root, leave blank.</td>
124 </tr>
125 <tr>
126 <td class="postblock"><label for="name">Board Name</label></td>
127 <td><input type="text" name="name"></td>
128 <td>The name of the board, which is displayed in the page title and header.</td>
129 </tr>
130 <tr>
131 <td></td>
132 <td><input type="submit" value="Start dry run"></td>
133 <td></td>
134 </tr>
135 </table>
136 </form>`)
137 return
138 }
139
140 data.Message += template.HTML("Creating board...<br>")
141 b := NewBoard()
142 b.Dir = FormString(r, "dir")
143 b.Name = FormString(r, "name")
144 err = b.Validate()
145 if err != nil {
146 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Failed to validate board: %s", html.EscapeString(err.Error())))
147 return
148 }
149 match := db.BoardByDir(b.Dir)
150 if match != nil {
151 data.Message += template.HTML("<b>Error:</b> A board with that directory already exists in Sriracha.")
152 return
153 }
154 db.AddBoard(b)
155 data.Message += template.HTML("<b>Board created.</b><br><br>")
156
157
158 data.Message += template.HTML("Collecting post IDs...<br>")
159 rows, err := conn.Query(context.Background(), "SELECT id FROM "+c.Posts+" ORDER BY id ASC")
160 if err != nil {
161 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Failed to select posts in table %s: %s", html.EscapeString(c.Posts), err))
162 return
163 }
164 var postIDs []int
165 for rows.Next() {
166 var postID int
167 err := rows.Scan(&postID)
168 if err != nil {
169 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Failed to select posts in table %s: %s", html.EscapeString(c.Posts), err))
170 return
171 }
172 postIDs = append(postIDs, postID)
173 }
174 data.Message += template.HTML("<b>Post IDs collected.</b><br><br>")
175
176 data.Message += template.HTML("Verifying board directories...<br>")
177 dirs := []string{b.Dir, filepath.Join(b.Dir, "src"), filepath.Join(b.Dir, "thumb"), filepath.Join(b.Dir, "res")}
178 for _, dir := range dirs {
179 dirPath := filepath.Join(s.config.Root, dir)
180 _, err := os.Stat(dirPath)
181 if os.IsNotExist(err) {
182 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Board directory %s does not exist.", html.EscapeString(dirPath)))
183 return
184 }
185 if unix.Access(dirPath, unix.W_OK) != nil {
186 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Board directory %s is not writable.", html.EscapeString(dirPath)))
187 return
188 }
189 }
190 data.Message += template.HTML("<b>Board directories exist and are writable.</b><br><br>")
191
192 type importPost struct {
193 ID int
194 Parent int
195 Timestamp int64
196 Bumped int64
197 IP string
198 Name string
199 Tripcode string
200 Email string
201 NameBlock string
202 Subject string
203 Message string
204 Password string
205 File string
206 FileHash string
207 FileOriginal string
208 FileSize int64
209 FileSizeFormatted string
210 FileWidth int
211 FileHeight int
212 Thumb string
213 ThumbWidth int
214 ThumbHeight int
215 Moderated int
216 Stickied int
217 Locked int
218 }
219
220 data.Message += template.HTML("Importing posts...<br>")
221 newIDs := make(map[int]int)
222 var lastPostID int
223 for _, postID := range postIDs {
224 var p importPost
225 err := conn.QueryRow(context.Background(), "SELECT * FROM "+c.Posts+" WHERE id = $1", postID).Scan(
226 &p.ID,
227 &p.Parent,
228 &p.Timestamp,
229 &p.Bumped,
230 &p.IP,
231 &p.Name,
232 &p.Tripcode,
233 &p.Email,
234 &p.NameBlock,
235 &p.Subject,
236 &p.Message,
237 &p.Password,
238 &p.File,
239 &p.FileHash,
240 &p.FileOriginal,
241 &p.FileSize,
242 &p.FileSizeFormatted,
243 &p.FileWidth,
244 &p.FileHeight,
245 &p.Thumb,
246 &p.ThumbWidth,
247 &p.ThumbHeight,
248 &p.Moderated,
249 &p.Stickied,
250 &p.Locked)
251 if err != nil {
252 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Failed to select posts in table %s: %s", html.EscapeString(c.Posts), err))
253 return
254 }
255 pp := &Post{
256 ID: p.ID,
257 Board: b,
258 Parent: p.Parent,
259 Timestamp: p.Timestamp,
260 Bumped: p.Bumped,
261 IP: "",
262 Name: p.Name,
263 Tripcode: p.Tripcode,
264 Email: p.Email,
265 NameBlock: p.NameBlock,
266 Subject: p.Subject,
267 Message: p.Message,
268 Password: "",
269 File: p.File,
270 FileHash: "",
271 FileOriginal: "",
272 FileSize: p.FileSize,
273 FileWidth: p.FileWidth,
274 FileHeight: p.FileHeight,
275 Thumb: p.Thumb,
276 ThumbWidth: p.ThumbWidth,
277 ThumbHeight: p.ThumbHeight,
278 Moderated: PostModerated(p.Moderated),
279 Stickied: p.Stickied == 1,
280 Locked: p.Locked == 1,
281 }
282 hashLen := len(p.FileHash)
283 isEmbed := hashLen != 0 && hashLen < 32
284 if isEmbed {
285 pp.FileHash = fmt.Sprintf("e %s %s", p.FileHash, p.FileOriginal)
286 } else {
287 pp.FileOriginal = p.FileOriginal
288 if p.File != "" {
289 srcPath := filepath.Join(s.config.Root, b.Dir, "src", p.File)
290
291 buf, err := os.ReadFile(srcPath)
292 if err != nil {
293 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> File not found at %s", html.EscapeString(srcPath)))
294 return
295 }
296
297 pp.FileMIME = mimetype.Detect(buf).String()
298
299 checksum := sha512.Sum384(buf)
300 pp.FileHash = base64.URLEncoding.EncodeToString(checksum[:])
301
302 if p.Thumb != "" {
303 thumbPath := filepath.Join(s.config.Root, b.Dir, "thumb", p.Thumb)
304 _, err := os.Stat(thumbPath)
305 if os.IsNotExist(err) {
306 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Thumbnail not found at %s", html.EscapeString(srcPath)))
307 return
308 }
309 }
310 }
311 }
312
313 carriageReturn := regexp.MustCompile(`(?s)\r.?`)
314 pp.Message = carriageReturn.ReplaceAllStringFunc(pp.Message, func(s string) string {
315 if len(s) == 1 || s[1] == '\n' {
316 return "\n"
317 }
318 return "\n" + string(s[1])
319 })
320
321 resPattern := regexp.MustCompile(`<a href="res\/([0-9]+).html#([0-9]+)" class="([A-Aa-z]+)">>>([0-9]+)</a>`)
322 pp.Message = resPattern.ReplaceAllStringFunc(pp.Message, func(s string) string {
323 match := resPattern.FindStringSubmatch(s)
324 threadID := ParseInt(match[1])
325 postID := ParseInt(match[2])
326 return fmt.Sprintf(`<a href="%sres/%d.html#%d" class="%s">>>%d</a>`, b.Path(), newIDs[threadID], newIDs[postID], match[3], newIDs[postID])
327 })
328
329 if pp.Parent != 0 {
330 pp.Parent = newIDs[pp.Parent]
331 }
332 if rewriteIDs {
333 db.AddPost(pp)
334 } else {
335 var parent *int
336 if pp.Parent != 0 {
337 parent = &pp.Parent
338 }
339 var fileHash *string
340 if pp.FileHash != "" {
341 fileHash = &pp.FileHash
342 }
343 var stickied int
344 if pp.Stickied {
345 stickied = 1
346 }
347 var locked int
348 if pp.Locked {
349 locked = 1
350 }
351 err = db.QueryRow("INSERT INTO post VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26) RETURNING id",
352 pp.ID,
353 parent,
354 pp.Board.ID,
355 pp.Timestamp,
356 pp.Bumped,
357 pp.IP,
358 pp.Name,
359 pp.Tripcode,
360 pp.Email,
361 pp.NameBlock,
362 pp.Subject,
363 pp.Message,
364 pp.Password,
365 pp.File,
366 fileHash,
367 pp.FileOriginal,
368 pp.FileSize,
369 pp.FileWidth,
370 pp.FileHeight,
371 pp.Thumb,
372 pp.ThumbWidth,
373 pp.ThumbHeight,
374 pp.Moderated,
375 stickied,
376 locked,
377 pp.FileMIME,
378 ).Scan(&pp.ID)
379 if err != nil || pp.ID == 0 {
380 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Failed to insert post: %s", err))
381 return
382 }
383 }
384 lastPostID = pp.ID
385 newIDs[p.ID] = pp.ID
386 }
387 data.Message += template.HTML(fmt.Sprintf("<b>Imported %d posts.</b><br><br>", len(postIDs)))
388
389 type importKeyword struct {
390 ID int
391 Text string
392 Action string
393 }
394
395 if c.Keywords != "" {
396 var imported int
397 data.Message += template.HTML("Importing keywords...<br>")
398 rows, err := conn.Query(context.Background(), "SELECT * FROM "+c.Keywords+" ORDER BY id ASC")
399 if err != nil {
400 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Failed to select keywords in table %s: %s", html.EscapeString(c.Keywords), err))
401 return
402 }
403 for rows.Next() {
404 k := &importKeyword{}
405 err := rows.Scan(&k.ID, &k.Text, &k.Action)
406 if err != nil {
407 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Failed to select keywords in table %s: %s", html.EscapeString(c.Keywords), err))
408 return
409 }
410 kk := &Keyword{
411 Text: k.Text,
412 Action: k.Action,
413 Boards: []*Board{b},
414 }
415 if strings.HasPrefix(kk.Text, "regexp:") {
416 kk.Text = strings.TrimPrefix(kk.Text, "regexp:")
417 } else {
418 kk.Text = regexp.QuoteMeta(kk.Text)
419 }
420 err = kk.Validate()
421 if err != nil {
422 data.Message += template.HTML(fmt.Sprintf("<b>Warning:</b> Skipping keyword #%d: %s<br>", k.ID, err))
423 continue
424 }
425 match := db.KeywordByText(kk.Text)
426 if match != nil {
427 data.Message += template.HTML(fmt.Sprintf("<b>Warning:</b> Skipping keyword #%d: keyword already exists in Sriracha<br>", k.ID))
428 continue
429 }
430 db.AddKeyword(kk)
431 imported++
432 }
433 data.Message += template.HTML(fmt.Sprintf("<b>Imported %d keywords.</b><br><br>", imported))
434 }
435
436 if lastPostID != 0 {
437 _, err := db.Exec("ALTER SEQUENCE post_id_seq RESTART WITH " + strconv.Itoa(lastPostID+1))
438 if err != nil {
439 data.Message += template.HTML(fmt.Sprintf("<b>Error:</b> Failed to update post auto-increment value: %s", html.EscapeString(err.Error())))
440 return
441 }
442 }
443
444 if !commit {
445 data.Message += template.HTML("<b>Dry run successful.</b><br>Ready to import.<br><br>")
446 data.Message += template.HTML(`<form method="post">
447 <input type="hidden" name="import" value="1">
448 <input type="hidden" name="confirmation" value="1">
449 <input type="hidden" name="dir" value="` + html.EscapeString(b.Dir) + `">
450 <input type="hidden" name="name" value="` + html.EscapeString(b.Name) + `">
451 <input type="submit" value="Start import">
452 </form>`)
453 return
454 }
455
456 s.config.ImportComplete = true
457 s.rebuildBoard(db, b)
458 }
459
View as plain text