...

Source file src/codeberg.org/tslocum/sriracha/internal/server/server_import.go

Documentation: codeberg.org/tslocum/sriracha/internal/server

     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  	// Connect to the database.
    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  	// Validate tables.
    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  	// Collect post IDs.
   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]+)">&gt;&gt;([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">&gt;&gt;%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