@@ -16,6 +16,50 @@ import (
1616 "github.com/johnwmail/nclip/utils"
1717)
1818
19+ // Helper: generateUniqueSlug tries to generate a unique slug using batch logic
20+ func (h * PasteHandler ) generateUniqueSlug () (string , error ) {
21+ batchSize := 5
22+ lengths := []int {5 , 6 , 7 }
23+ var slug string
24+ for _ , length := range lengths {
25+ candidates , err := utils .GenerateSlugBatch (batchSize , length )
26+ if err != nil {
27+ return "" , fmt .Errorf ("failed to generate slug" )
28+ }
29+ for _ , candidate := range candidates {
30+ existing , err := h .store .Get (candidate )
31+ if err != nil || existing == nil || existing .IsExpired () {
32+ slug = candidate
33+ return slug , nil
34+ }
35+ }
36+ }
37+ return "" , fmt .Errorf ("failed to generate unique slug after 3 batches" )
38+ }
39+
40+ // Helper: parseBurnTTL parses TTL for burn-after-read
41+ func (h * PasteHandler ) parseBurnTTL (c * gin.Context ) (time.Time , error ) {
42+ ttlStr := c .GetHeader ("X-TTL" )
43+ if ttlStr != "" {
44+ d , err := time .ParseDuration (ttlStr )
45+ minTTL := time .Hour
46+ maxTTL := 7 * 24 * time .Hour
47+ if utils .IsDebugEnabled () {
48+ log .Printf ("[DEBUG] Parsed X-TTL duration: %v (raw: %s)" , d , ttlStr )
49+ }
50+ if err != nil || d < minTTL || d > maxTTL {
51+ return time.Time {}, fmt .Errorf ("X-TTL must be between 1h and 7d" )
52+ }
53+ return time .Now ().Add (d ), nil
54+ }
55+ return time .Now ().Add (h .config .DefaultTTL ), nil
56+ }
57+
58+ // Helper: respondError sends a JSON error response
59+ func respondError (c * gin.Context , status int , msg string ) {
60+ c .JSON (status , gin.H {"error" : msg })
61+ }
62+
1963// PasteHandler handles paste-related operations
2064type PasteHandler struct {
2165 store storage.PasteStore
@@ -279,94 +323,51 @@ func (h *PasteHandler) UploadBurn(c *gin.Context) {
279323 var filename string
280324 var err error
281325
282- // Check if it's a multipart form (file upload)
283- if c .Request .Header .Get ("Content-Type" ) != "" &&
284- strings .HasPrefix (c .Request .Header .Get ("Content-Type" ), "multipart/form-data" ) {
285-
326+ // Extract content
327+ if c .Request .Header .Get ("Content-Type" ) != "" && strings .HasPrefix (c .Request .Header .Get ("Content-Type" ), "multipart/form-data" ) {
286328 file , header , err := c .Request .FormFile ("file" )
287329 if err != nil {
288- c . JSON ( http .StatusBadRequest , gin. H { "error" : " No file provided"} )
330+ respondError ( c , http .StatusBadRequest , " No file provided" )
289331 return
290332 }
291- defer func () { _ = file .Close () }() // Ignore close errors in defer
292-
333+ defer func () { _ = file .Close () }()
293334 filename = header .Filename
294335 content , err = io .ReadAll (io .LimitReader (file , h .config .BufferSize ))
295336 if err != nil {
296- c . JSON ( http .StatusInternalServerError , gin. H { "error" : " Failed to read file"} )
337+ respondError ( c , http .StatusInternalServerError , " Failed to read file" )
297338 return
298339 }
299340 } else {
300- // Raw content upload
301341 content , err = io .ReadAll (io .LimitReader (c .Request .Body , h .config .BufferSize ))
302342 if err != nil {
303- c . JSON ( http .StatusInternalServerError , gin. H { "error" : " Failed to read content"} )
343+ respondError ( c , http .StatusInternalServerError , " Failed to read content" )
304344 return
305345 }
306346 }
307-
308347 if len (content ) == 0 {
309- c . JSON ( http .StatusBadRequest , gin. H { "error" : " Empty content"} )
348+ respondError ( c , http .StatusBadRequest , " Empty content" )
310349 return
311350 }
312351
313- // Generate unique slug using batch logic (lengths: 5, 6, 7)
314- batchSize := 5
315- lengths := []int {5 , 6 , 7 }
316- var slug string
317- var lastCandidates []string
318- var lastCollisions []string
319- found := false
320- for _ , length := range lengths {
321- candidates , err := utils .GenerateSlugBatch (batchSize , length )
322- if err != nil {
323- c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to generate slug" })
324- return
325- }
326- lastCandidates = candidates
327- lastCollisions = nil
328- for _ , candidate := range candidates {
329- existing , err := h .store .Get (candidate )
330- if err != nil || existing == nil || existing .IsExpired () {
331- slug = candidate
332- found = true
333- break
334- } else {
335- lastCollisions = append (lastCollisions , candidate )
336- }
337- }
338- if found {
339- break
340- }
341- }
342- if ! found {
343- log .Printf ("[ERROR] Could not generate unique slug after 3 batches. Last candidates: %v. Collisions: %v" , lastCandidates , lastCollisions )
344- c .JSON (http .StatusInternalServerError , gin.H {"error" : "Failed to generate unique slug after 3 batches" })
352+ // Generate unique slug
353+ slug , err := h .generateUniqueSlug ()
354+ if err != nil {
355+ log .Printf ("[ERROR] %v" , err )
356+ respondError (c , http .StatusInternalServerError , err .Error ())
345357 return
346358 }
347359
348360 // Detect content type
349361 contentType := utils .DetectContentType (filename , content )
350362
351- // Support custom TTL via X-TTL header
352- ttlStr := c .GetHeader ("X-TTL" )
353- var expiresAt time.Time
354- if ttlStr != "" {
355- d , err := time .ParseDuration (ttlStr )
356- minTTL := time .Hour
357- maxTTL := 7 * 24 * time .Hour
358- if utils .IsDebugEnabled () {
359- log .Printf ("[DEBUG] Parsed X-TTL duration: %v (raw: %s)" , d , ttlStr )
360- }
361- if err != nil || d < minTTL || d > maxTTL {
362- log .Printf ("[ERROR] X-TTL out of range or invalid: %v (raw: %s)" , d , ttlStr )
363- c .JSON (http .StatusBadRequest , gin.H {"error" : "X-TTL must be between 1h and 7d" })
364- return
365- }
366- expiresAt = time .Now ().Add (d )
367- } else {
368- expiresAt = time .Now ().Add (h .config .DefaultTTL )
363+ // Parse TTL
364+ expiresAt , err := h .parseBurnTTL (c )
365+ if err != nil {
366+ log .Printf ("[ERROR] %v" , err )
367+ respondError (c , http .StatusBadRequest , err .Error ())
368+ return
369369 }
370+
370371 paste := & models.Paste {
371372 ID : slug ,
372373 CreatedAt : time .Now (),
@@ -379,20 +380,19 @@ func (h *PasteHandler) UploadBurn(c *gin.Context) {
379380
380381 // Store content and metadata
381382 if err := h .store .StoreContent (slug , content ); err != nil {
382- c . JSON ( http .StatusInternalServerError , gin. H { "error" : " Failed to store content"} )
383+ respondError ( c , http .StatusInternalServerError , " Failed to store content" )
383384 return
384385 }
385386 if err := h .store .Store (paste ); err != nil {
386- c . JSON ( http .StatusInternalServerError , gin. H { "error" : " Failed to store metadata"} )
387+ respondError ( c , http .StatusInternalServerError , " Failed to store metadata" )
387388 return
388389 }
389390
390391 // Generate URL
391392 pasteURL := h .generatePasteURL (c , slug )
392393
393394 // Return URL as plain text for cli tools compatibility
394- if h .isCli (c ) ||
395- c .Request .Header .Get ("Accept" ) == "text/plain" {
395+ if h .isCli (c ) || c .Request .Header .Get ("Accept" ) == "text/plain" {
396396 c .String (http .StatusOK , pasteURL + "\n " )
397397 return
398398 }
0 commit comments