commit ce3af7c682a75e4a650b6dd74ea689c0bf330f48 Author: Yi-Ting Shih Date: Thu Oct 16 05:07:56 2025 +0800 Feat: works on my machine diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2a6a28 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# NMSL + +## TODO + +- Lobby + - Login count + - Logout and duplicate login +- Connection + - Scan port +- Game + - Game end reason diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..6f2f839 --- /dev/null +++ b/auth.go @@ -0,0 +1,18 @@ +package main + +import ( + "gitea.konchin.com/ytshih/inp2025/workflows" + "github.com/spf13/cobra" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Run: func(cmd *cobra.Command, args []string) { + workflows.AuthServer() + }, +} + +func init() { + authCmd.Flags(). + String("listen-addr", "localhost:8888", "") +} diff --git a/cmd.go b/cmd.go new file mode 100644 index 0000000..c2a8aaa --- /dev/null +++ b/cmd.go @@ -0,0 +1,30 @@ +package main + +import ( + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var RootCmd = &cobra.Command{ + Use: "game", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + viper.AutomaticEnv() + viper.SetEnvKeyReplacer(strings.NewReplacer("_", "-")) + viper.BindPFlags(cmd.PersistentFlags()) + viper.BindPFlags(cmd.Flags()) + }, +} + +func main() { + RootCmd.Execute() +} + +func init() { + cobra.EnableTraverseRunHooks = true + + RootCmd.AddCommand(authCmd) + RootCmd.AddCommand(playerCmd) + RootCmd.AddCommand(testCmd) +} diff --git a/dict b/dict new file mode 100644 index 0000000..b4d88d6 --- /dev/null +++ b/dict @@ -0,0 +1,2331 @@ +ABOUT +OTHER +WHICH +THEIR +THERE +FIRST +WOULD +THESE +CLICK +PRICE +STATE +EMAIL +WORLD +MUSIC +AFTER +VIDEO +WHERE +ORDER +GROUP +UNDER +COULD +GREAT +HOTEL +STORE +RIGHT +LOCAL +THOSE +USING +PHONE +FORUM +BLACK +CHECK +INDEX +BEING +WOMEN +TODAY +SOUTH +FOUND +HOUSE +PHOTO +POWER +WHILE +THREE +TOTAL +PLACE +THINK +NORTH +MEDIA +WATER +SINCE +GUIDE +BOARD +WHITE +SMALL +LEVEL +IMAGE +TITLE +SHALL +CLASS +STILL +MONEY +EVERY +VISIT +REPLY +VALUE +PRESS +LEARN +PRINT +STOCK +POINT +LARGE +TABLE +START +MODEL +HUMAN +MOVIE +MARCH +GOING +STUDY +STAFF +AGAIN +NEVER +TOPIC +BELOW +PARTY +LOGIN +LEGAL +ABOVE +QUOTE +STORY +YOUNG +FIELD +PAPER +NIGHT +POKER +ISSUE +RANGE +COURT +AUDIO +LIGHT +WRITE +OFFER +GIVEN +EVENT +CHINA +MIGHT +MONTH +MAJOR +SPACE +CHILD +ENTER +SHARE +RADIO +UNTIL +COLOR +TRACK +LEAST +TRADE +GREEN +CLOSE +DRIVE +SHORT +DAILY +BEACH +STYLE +FRONT +EARLY +SOUND +FINAL +ADULT +THING +CHEAP +THIRD +COVER +OFTEN +WATCH +HEART +ERROR +CLEAR +TAKEN +KNOWN +QUICK +WHOLE +LATER +BASIC +ALONG +AMONG +DEATH +SPEED +BRAND +STUFF +DOING +ENTRY +FORCE +RIVER +ALBUM +BUILD +APPLY +CROSS +LOWER +UNION +LEAVE +WOMAN +CABLE +SCORE +SHOWN +FLASH +ALLOW +SUPER +CAUSE +FOCUS +VOICE +BROWN +GLASS +HAPPY +SMITH +THANK +PRIOR +SPORT +READY +ROUND +BUILT +BLOOD +EARTH +BASIS +AWARD +EXTRA +QUITE +HORSE +OWNER +BRING +INPUT +AGENT +VALID +GRAND +TRIAL +WROTE +METAL +GUEST +TRUST +GRADE +PANEL +FLOOR +MATCH +PLANT +SENSE +STAGE +MAYBE +YOUTH +BREAK +DANCE +APPLE +ENJOY +BLOCK +CIVIL +STEEL +WRONG +FULLY +WORTH +PEACE +COAST +GRANT +AGREE +SCALE +STAND +FRAME +CHIEF +HEARD +BEGIN +ROYAL +CLEAN +BIBLE +SUITE +PIECE +SHEET +SEVEN +OLDER +WHOSE +STONE +BUYER +LABEL +CANON +WASTE +CHAIR +PHASE +MOTOR +SHIRT +CRIME +COUNT +CLAIM +PATCH +ALONE +SAINT +JOINT +FRESH +UPPER +PRIME +LIMIT +BEGAN +CREEK +URBAN +LABOR +ADMIN +HEAVY +SOLID +THEME +TOUCH +SERVE +MAGIC +MOUNT +SMART +AVOID +BIRTH +VIRUS +ABUSE +FAITH +CHAIN +REACH +SORRY +GAMMA +TRUTH +DRAFT +CHART +EQUAL +FUNNY +TRIED +LASER +HARRY +MOUSE +BRAIN +DREAM +FALSE +CARRY +HELLO +BRIEF +EIGHT +ALERT +QUEEN +SWEET +TRUCK +OCEAN +DEPTH +TRAIN +ROUTE +FRANK +ANIME +SPEAK +QUERY +RURAL +JUDGE +FIGHT +MINOR +SPENT +CYCLE +SLEEP +SCENE +DRINK +GUESS +AHEAD +DELTA +ALPHA +BONUS +ADOBE +DRESS +REFER +LAYER +SPEND +CLOCK +RATIO +PROOF +EMPTY +IDEAL +CREAM +AWARE +SHAPE +USAGE +EXIST +WHEEL +ANGEL +WIDTH +NOISE +ARRAY +SHARP +OCCUR +COACH +PLATE +LOGIC +PLAIN +TRAIL +BUDDY +SETUP +SCOPE +CRAZY +MOUTH +METER +FRUIT +SUGAR +STICK +GENRE +SLIDE +EXACT +BOUND +STORM +MICRO +PAINT +DELAY +PILOT +NOVEL +ULTRA +TRULY +LODGE +BROAD +GUARD +NEWLY +RAISE +DRAMA +LUNCH +AUDIT +TOWER +SHELL +SOLAR +CATCH +DOUBT +FORTH +SPLIT +TWICE +SHIFT +TREAT +PIANO +TEACH +RAPID +HAIRY +DUTCH +PULSE +METRO +STRIP +PEARL +OPERA +ASSET +BLANK +HUMOR +TIGHT +MEANT +PLANE +GRACE +VILLA +INNER +TASTE +CACHE +LEASE +PROUD +GIANT +ALARM +USUAL +ANGLE +VINYL +WORST +HONOR +EAGLE +NURSE +QUIET +COMIC +CROWN +MAKER +CRACK +SMOKE +CRAFT +APART +BLIND +GROSS +ACTOR +FIFTH +PRIZE +DIRTY +ALIVE +PROVE +RIDGE +MODEM +SKILL +THROW +TREND +WORSE +FIBER +GRAPH +FRAUD +ROGER +CRASH +INTER +GROVE +SPRAY +MAYOR +YIELD +HENCE +RADAR +DIARY +BAKER +SHOCK +EBONY +DRAWN +BEAST +DODGE +PIZZA +GLOBE +GHOST +PRIDE +BRASS +PLAZA +QUEST +BOOTY +VENUE +VITAL +EXCEL +ENEMY +LUCKY +THICK +VISTA +FLOOD +ARENA +GROWN +SMILE +CANDY +TIGER +BOOST +MORAL +POUND +BREAD +TOUGH +CHEST +BILLY +SOLVE +SIGHT +WORRY +GLORY +FAULT +RUGBY +FLUID +DEVIL +GRASS +MANGA +THEFT +SWING +SHOOT +ELITE +ROBOT +GNOME +NOBLE +SHORE +LOOSE +HORNY +RALPH +LIVER +DECOR +AGING +INTRO +CLERK +FAVOR +SIGMA +ASIDE +ESSAY +TRACE +SPOKE +ARROW +ROUGH +WEIRD +BLADE +ROBIN +STRAP +CROWD +CLOUD +VALVE +KNIFE +SHELF +ADOPT +OUTER +STEAM +ACUTE +STOOD +CAROL +STACK +CURVE +AMBER +TRUNK +CAMEL +JUICE +CHASE +SAUCE +FEWER +PROXY +SLAVE +HAVEN +CHARM +BASIN +RANCH +DRUNK +ALIEN +BROKE +NYLON +ROCKY +FLEET +BUNCH +OMEGA +CIVIC +GRILL +GRAIN +SALON +TURBO +RESET +BRUSH +SPARE +SKIRT +HONEY +GAUGE +SIXTH +CHEAT +SANDY +MACRO +LAUGH +PITCH +DOZEN +TEETH +CLOTH +STAMP +CARGO +MAPLE +DEPOT +BLEND +PROBE +DEBUG +CHUCK +BINGO +SUNNY +CEDAR +MASON +CHOSE +BLAST +BRAKE +OLIVE +CYBER +CLONE +RELAY +ANGRY +LOVER +DADDY +FERRY +MOTEL +RALLY +DYING +STUCK +VOCAL +ORGAN +LEMON +TOXIC +BENCH +RIDER +BOBBY +SHEEP +SALAD +PASTE +RELAX +SWORD +CORAL +PIXEL +FLOAT +DAIRY +ADMIT +FANCY +SQUAD +CHAOS +WHEAT +UNITY +BRIDE +BEGUN +FEVER +ROVER +FLAME +SPELL +ANNEX +ARGUE +ARISE +CHESS +CANAL +LYING +DRILL +HOBBY +TRICK +WIDER +SCREW +BLAME +FIFTY +UNCLE +RANDY +BRICK +NAVAL +CABIN +RETRO +ANGER +HANDY +GUILD +TRIBE +BATCH +ALTER +AMEND +CHICK +THONG +MEDAL +BOOTH +INDIE +BREED +POLAR +PATIO +SNAKE +BERRY +OUGHT +TIMER +VERSE +NASTY +TUMOR +FORTY +QUEUE +WELSH +BELLY +ELDER +SONIC +THUMB +TWIST +DEBUT +PENNY +IVORY +NEWER +SPICE +DONOR +TRASH +MANOR +DISCO +MINUS +SHADE +DIGIT +LYRIC +GRAVE +LOBBY +PUNCH +KARMA +SHAKE +HOLLY +SILLY +MERCY +FENCE +SHAME +FATAL +FLESH +SHEER +WITCH +PUPPY +SMELL +SATIN +NERVE +RENEW +REBEL +SLOPE +REHAB +MERIT +CONDO +FAIRY +SHAFT +KITTY +DRAIN +PANIC +ONION +MERRY +SCUBA +DRIED +DERBY +STEAL +ALIKE +SCOUT +DEALT +BADGE +WRIST +HEATH +REALM +ROUGE +YEAST +BROOK +ARMOR +VIRAL +LADEN +MERGE +SPERM +FROST +SALLY +YACHT +WHALE +SHARK +CLIFF +TRACT +SHINE +OZONE +PASTA +SERUM +SWIFT +INBOX +FOCAL +WOUND +BELLE +CUBIC +ELECT +BUNNY +FLYER +CLIMB +TOKEN +FLUSH +JEWEL +TEDDY +DRYER +FUNKY +SCARY +TOOTH +DROVE +UPSET +LANCE +COLON +PURSE +ALIGN +BLESS +CREST +ALLOY +BLOOM +SURGE +SPANK +VAULT +ORBIT +BACON +SPINE +TROUT +FATTY +OXIDE +BADLY +SCOOP +BLINK +FUZZY +FORGE +DENSE +BRAVE +AWFUL +WAGON +KNOCK +QUILT +MAMBO +FLOUR +CHOIR +BLOND +BURST +FIBRE +DAISY +CRUDE +SAFER +MARSH +THETA +STAKE +ARBOR +RIFLE +WAIST +SEWER +RESIN +LINEN +DECAY +USHER +SKATE +LYNCH +VOTER +URINE +TOWEL +CRANE +HABIT +COUPE +SIXTY +SPARK +SPIKE +TONGA +SEDAN +FLORA +HARDY +DENIM +GLOVE +PLUSH +ADAPT +STERN +TUTOR +IDIOT +DEBIT +RAVEN +SLICE +PAUSE +DEMON +COUCH +ROGUE +OPTIC +CHILI +GRIEF +SWEAT +QUAKE +ALLEY +LOYAL +RENAL +SPITE +IMPLY +CHILL +LINER +VIVID +SKULL +NINJA +STEAK +COBRA +THREW +NINTH +MARRY +DRAKE +FRIED +WOODY +CRIED +RIVAL +HOMER +WOVEN +RIGID +SALSA +BLOWN +BATON +ABBEY +SAUNA +CRUEL +EAGER +PUPIL +FEAST +ANKLE +BLUNT +REACT +FLUTE +HARSH +CEASE +EQUIP +HEDGE +CURRY +POUCH +SPOON +NICHE +CIGAR +CURSE +TITAN +SHOUT +STRAW +REUSE +PEACH +UNCUT +STOVE +FREAK +BLUFF +SADLY +AVAIL +HATCH +STEIN +SPILL +DRIFT +CRISP +ONSET +ASSAY +SNACK +MAXIM +SLATE +PAGAN +WIDOW +CANOE +JUICY +MOODY +PEDAL +SCRAP +TERRA +VAPOR +ALOUD +GOOSE +HYDRO +NOISY +ABIDE +BLISS +PARSE +JELLY +MANIA +CHEER +CLAMP +GRAPE +RACER +GUILT +SWEEP +LUNAR +BOXER +WEIGH +RODEO +MOOSE +CRUSH +LEVER +TASTY +TAROT +COCOA +HURRY +CLASH +STAIN +REIGN +BARON +STIFF +RABBI +SUSHI +PUFFY +ELBOW +STARK +CIRCA +RAZOR +COUGH +INLET +GLOSS +PORCH +EATEN +STEEP +CREED +CARAT +PLUMP +MIDST +BORNE +TEMPO +TORCH +ATTIC +PIPER +TENTH +CUTIE +NOTCH +SCENT +GRASP +OUNCE +TOAST +KINKY +QUOTA +JUMBO +FLINT +DUMMY +AWAKE +BURNT +ROAST +PETTY +SHINY +SMASH +AMPLE +SCARF +SPICY +BEARD +WEDGE +HYPER +GAMER +SAVVY +FETAL +CHORD +COMET +SYRUP +ERASE +PROSE +SWEAR +CLOWN +TABOO +DWARF +DOUGH +STOOL +WELCH +HORDE +MOMMY +NANNY +ROACH +NATAL +LOCUS +PRONE +SCARE +THIEF +MOTIF +SPEAR +BIRCH +SLASH +HELIX +SHOOK +MATTE +ZEBRA +FETCH +UNITE +SHEAR +TRUMP +AVIAN +CHAMP +RECAP +CRAWL +HAZEL +RUPEE +STOLE +QUASI +EXILE +KAPPA +SNOOP +VAGUE +RUSTY +STING +BRAVO +BASIL +SHACK +SLEEK +HITCH +TANGO +QUEER +COMMA +FREED +CHEEK +BOWEL +MAFIA +SHIRE +LIPID +PRISM +VEGAN +GROOM +KAYAK +ALTAR +RISEN +RHINO +RULER +SWEPT +TROOP +AROSE +FLOCK +SHAVE +SWAMP +FAINT +GLAND +STOKE +NASAL +LOSER +JOLLY +FEMME +SIEGE +BUTTE +CHALK +WRATH +GRIND +BLITZ +RAINY +VIOLA +RUMOR +DIVER +BLAZE +WRECK +RISKY +TULIP +OWING +DITCH +SLICK +CHUNK +SLEPT +TENOR +SCRUB +CELLO +TOPAZ +DUSTY +PATTY +CRATE +SWORN +BEECH +TENSE +DECAL +FRITZ +MOVER +FAUNA +DETOX +QUARK +SNEAK +OCTET +WILLY +TIDAL +CRUST +DOLLY +MINER +DINER +MOUND +SCION +REGAL +CURLY +HOUND +WHARF +FLICK +DATUM +MAIZE +PSALM +SWELL +IRONY +VIPER +GYPSY +FLARE +WIGHT +CRANK +BRACE +MANGO +THIGH +WINDY +STEER +VOGUE +VODKA +MOIST +STALL +SERIF +UTTER +CATER +PINCH +TROLL +FILTH +ALGAE +SHADY +ERECT +VALET +MADAM +TEASE +AROMA +DWELL +STAIR +ROTOR +QUART +BISON +FUNGI +GREED +BLEED +INCUR +FUDGE +WEAVE +BUGGY +SLACK +GORGE +BANJO +STOUT +STARE +MISSY +FLAIR +AISLE +SEIZE +SPAWN +EPOXY +STONY +CRYPT +TYING +DIODE +MOTTO +DETER +FURRY +RINSE +VENOM +MUMMY +ETHER +HAUTE +WACKY +MUDDY +SHALT +VISOR +NAIVE +FOLIO +FIERY +ACORN +BASAL +SMOKY +FLIRT +SLANG +FINCH +TALLY +CREEP +AGILE +KIOSK +IONIC +STRAY +POPPY +FORTE +WAIVE +GREET +LYMPH +LATCH +DRANK +TORSO +HINGE +STUNT +WITTY +FLOWN +SILKY +REPAY +AWAIT +FETUS +CIDER +LILAC +PIVOT +GLIDE +CREME +WALTZ +BLUSH +MODAL +CADET +TWEAK +TRAIT +EATER +BEZEL +HAVOC +SLING +AXIAL +EPOCH +PLAID +FABLE +OBESE +SOBER +TREAD +PADDY +OTTER +SASSY +DREAD +NEEDY +WEARY +TWEED +SNOWY +GENIE +APRON +HUSKY +BLAND +ADEPT +ESTER +SNAIL +MOWER +SWINE +HERON +GRAFT +ENVOY +ABORT +DUVET +SPADE +GLARE +WAFER +STASH +SEMEN +HOVER +AGONY +LUPUS +BULLY +RHYME +SNORT +TRIAD +CAMEO +LEACH +FANNY +MILKY +NAVEL +SPOOL +ANNOY +TOXIN +AXIOM +JOKER +BRINK +TRUSS +KHAKI +PENAL +LAPSE +SHRUB +FINER +SMACK +CLOAK +MANIC +CHOKE +GRAVY +PAYER +GLAZE +DIZZY +VERGE +NOMAD +THORN +SPOIL +SISSY +PALSY +BAYOU +TONIC +DITTO +ODDLY +UNDUE +CHANT +HUTCH +PARRY +MAMMA +FOLLY +MURAL +WAGER +PURGE +POSER +PERKY +STUMP +SCALP +MELON +SIREN +CLASP +SONAR +ULCER +ETHIC +OPIUM +ENEMA +BARGE +SLANT +BROOM +SNARE +SHANK +LEASH +GEESE +MECCA +BROTH +TAPER +REVUE +SMEAR +SLAIN +QUAIL +ICING +STRUT +PLUME +PLANK +ENACT +DEITY +MANLY +PERIL +SWIRL +ABODE +SAVOY +COMFY +POLKA +NICER +BOAST +PERCH +ANGST +GECKO +FACET +VERVE +SPREE +EMBED +BRUTE +BUTCH +DEFER +CRAVE +SALTY +VALOR +GOOFY +MIMIC +VIGIL +ITCHY +BULKY +BOOZE +WIDEN +ADORE +FLUKE +STOMP +GLADE +CASTE +DRUID +SWARM +LEDGE +DROWN +ABYSS +NIECE +FLASK +HIPPO +PLEAD +SHEEN +MEDIC +GRAIL +LAPEL +PECAN +CHIME +REMIT +EXERT +PINTO +LUCID +INFER +DANDY +SYNOD +LOUSY +CADDY +BLEAK +TRAMP +LATHE +BYLAW +CROOK +STALE +PUTTY +PATSY +HASTE +PRONG +BERTH +LINGO +PIGGY +LAGER +FOYER +BROOD +AZURE +SNIFF +POSSE +PIXIE +UNSET +MELEE +LLAMA +VOWEL +FECAL +HUMID +GUILE +LOFTY +MORON +DOGMA +WINCH +UNZIP +FLOSS +SHAWL +PENCE +THYME +OVARY +ABBOT +MAGMA +ARSON +GEEKY +HAUNT +BRAID +LEFTY +VIGOR +DEUCE +RISER +KNOLL +INLAY +CRAZE +TOTEM +CARVE +APNEA +SWISH +RELIC +ETHOS +CLING +NUDGE +SKUNK +ANVIL +STALK +INERT +EJECT +RAYON +MOCHA +SQUAT +PUBIC +TIARA +KOALA +CROCK +RETRY +PRIMO +FLANK +ATOLL +SPOOF +SPOUT +HEFTY +HOIST +AGATE +SWAMI +FERAL +TRUCE +PETAL +PLUCK +PRICK +ASCOT +VIXEN +BULGE +SLUMP +GLOOM +STINK +OVERT +SLIME +SWUNG +LIBEL +SKIER +SNUFF +TALON +STINT +SIEVE +BINGE +JAZZY +LIMBO +SHOVE +FLAKE +WISER +FLUNG +JUROR +DONUT +TENET +CHUTE +MULCH +WHINE +VICAR +PRANK +SUING +BRINE +FLUFF +UNFIT +ROUSE +SPIEL +SCAMP +EVOKE +GULLY +AGORA +MACHO +BAGEL +AMAZE +EASEL +QUILL +VERSO +CLEFT +GROIN +LATTE +MOURN +GIRLY +BLURB +ERODE +BRISK +EERIE +SHALE +ANODE +CREPE +AVERT +GUISE +VOMIT +BONGO +SHAKY +BLOKE +PHONY +JETTY +RERUN +JERKY +MORPH +SPECK +PAPAL +MOGUL +DROIT +FROWN +PRIVY +SEPIA +GOODY +STORK +TUNIC +FARCE +HOWDY +WHACK +DRONE +GODLY +SPIRE +STEAD +SHRUG +COVEY +WINCE +BOSOM +PICKY +LUMEN +PIOUS +TONAL +FOGGY +SHONE +LEAFY +TROVE +FILER +LUSTY +STEED +HASTY +MUNCH +CLOVE +LEECH +GIVER +NYMPH +FRAIL +CHURN +AWOKE +PARKA +PRUNE +CAIRN +NUTTY +SEVER +FLING +DRIER +CRUMB +PINKY +GRATE +FIEND +TAUPE +PIQUE +SWORE +FISHY +FILMY +TIMID +LEAKY +MOLAR +GULCH +BRIBE +AORTA +DELVE +CRIMP +CLOUT +FELLA +GLYPH +PLUMB +UNIFY +BRIAR +JUNTA +GROUT +TAKER +SCORN +WHIRL +TIBIA +PESKY +KNACK +RIVET +GRUNT +RABID +CUMIN +PAYEE +HIPPY +ROOST +EVADE +SHUNT +TAWNY +MUCUS +APTLY +LIEGE +SHEIK +PIETY +FROZE +TACIT +WHISK +UNMET +MAUVE +SPORE +CREPT +BUGLE +RADII +ROOMY +LASSO +BEVEL +DODGY +BRAWL +TUBAL +CHORE +OCTAL +GAUZE +AMUSE +FIXER +IDIOM +TRAWL +GAMUT +FILET +LORRY +SAUTE +REVEL +MADLY +GRIPE +LOWLY +SCANT +FLIER +AMITY +DROOL +ZONAL +FREER +MURKY +CABAL +CYNIC +PESTO +BOUGH +SLOTH +TACKY +CHASM +MASSE +DECOY +GRIME +RIGOR +GUSTO +DWELT +HILLY +PRAWN +ROWDY +TORUS +CHOCK +CURIO +VOILA +SAVOR +TABBY +GOURD +FORAY +FILLY +ALOFT +SLURP +BATHE +GIRTH +BRUNT +EMBER +ISLET +BALSA +CAPER +QUACK +DRAPE +GIDDY +BROIL +CACTI +JIFFY +SAUCY +ASKEW +GROAN +ABATE +MOSSY +HATER +EXPEL +SWOOP +GUMBO +ADORN +LARVA +GROWL +BRASH +CHARD +SULLY +INEPT +THUMP +SALVO +FLECK +ALIBI +CREDO +FEMUR +BUSHY +GOLEM +AFFIX +QUIRK +FLAKY +REPEL +PUREE +SWATH +HAREM +FUSSY +SHOAL +HEADY +HOARD +SPELT +NERDY +KNELT +CINCH +SCRUM +SNIPE +SALVE +FRIAR +PYGMY +PANSY +DINGO +BAGGY +HEIST +WIELD +ADAGE +BUDGE +KNEEL +BATTY +TWINE +AGAPE +BILGE +SCOUR +LEAPT +TAFFY +BONEY +BITTY +FLACK +CRASS +RUMBA +SPUNK +STUNG +SMIRK +CRAMP +FUGUE +PENNE +BLEEP +IGLOO +ALLOT +MEATY +EGRET +PINEY +FELON +SPASM +DUSKY +SLUSH +VOUCH +GIPSY +RUDDY +FJORD +DIRGE +EDICT +SHREW +MIRTH +BOOBY +CONCH +CORNY +NOOSE +RECUR +TAINT +CRIER +BESET +RAMEN +LADLE +SURLY +CLEAT +BEEFY +SPINY +CLUNG +GRAZE +AFOOT +WOKEN +HOTLY +STAVE +CRICK +STOOP +CURVY +TITHE +IDLER +GLEAM +TAMER +TRICE +NADIR +GAVEL +SPLAT +VYING +POOCH +ENSUE +SLIMY +CLUMP +TILDE +PROWL +DIMLY +HYMEN +FORGO +POISE +DUCHY +ETUDE +GAUNT +SUAVE +TULLE +GOOEY +TANGY +ARTSY +WHOOP +CACAO +SOGGY +BERET +GOLLY +CRUMP +MINCE +HUNCH +SPURT +UMBRA +AMPLY +INANE +SPOOK +OLDEN +REEDY +WREAK +BLOAT +WENCH +MIDGE +SLEET +SOAPY +TARDY +SMELT +YEARN +ERUPT +LUMPY +HEAVE +RARER +COVEN +ELEGY +SLOOP +TAUNT +APHID +MYRRH +CHAFF +SHOWY +EMCEE +SPIED +ALOOF +SNORE +QUELL +POSIT +DOWNY +LEMUR +TERSE +UTILE +TRITE +SHARD +DOWEL +SEEDY +QUOTH +DITTY +SKIFF +GUMMY +REBAR +HUNKY +TIPSY +CRESS +FICUS +FROTH +SMOTE +AXION +MUSHY +DOWRY +SNUCK +AWASH +ABACK +SCALY +GLINT +STOIC +AMISS +LIVID +SMITE +CHUMP +HYENA +PLEAT +SWASH +CLANK +BLIMP +LOOPY +MACAW +PLIER +BANAL +GUAVA +WIMPY +SNOUT +BIGOT +WOOLY +IRATE +PUSHY +DINGY +TRUER +SLUNG +BURLY +MANGE +GAUDY +SWOON +KEBAB +GHOUL +CLACK +WHIFF +CAULK +TUBER +PLIED +TWANG +OMBRE +GOUGE +CHIRP +HALVE +ROWER +AUNTY +SNAFU +SNIDE +GRUFF +BUXOM +MUSTY +KNEAD +REBUT +SEGUE +TWIRL +INGOT +GUPPY +GLEAN +PASTY +CRONE +REBUS +FOAMY +LURID +WHINY +MISER +TEPID +SMOCK +SOOTY +COVET +MINTY +WAVER +CONIC +SAPPY +ELUDE +TRIPE +SPILT +SHYLY +SCONE +THROB +EVICT +NEIGH +BALMY +ZESTY +CANNY +KRILL +MOLDY +TRYST +LURCH +QUASH +SNEER +ALLAY +DECRY +GUSTY +LEPER +LUNGE +POLYP +REFIT +SLINK +DROLL +ENDOW +JOIST +FIZZY +OFFAL +STAID +PITHY +SHIRK +KNELL +EXALT +GUANO +BEGAT +RIPEN +BRAWN +WRING +STILT +CLANG +HUMUS +FROCK +WREST +ASHEN +SNARL +LEGGY +AMASS +ILIAC +SPIKY +SUMAC +WORDY +JAUNT +LOUSE +SCOFF +MUSKY +FUROR +UDDER +BIOME +FLUME +BORAX +USURP +SCOLD +CROAK +ABHOR +DOPEY +TWEET +LEERY +ELFIN +JUMPY +TESTY +MINIM +CLINK +BOSSY +BICEP +DICEY +DOWDY +DILLY +BAWDY +BEAUT +JOUST +WEEDY +GROPE +AFOUL +RAJAH +SULKY +DROOP +FLAIL +ARDOR +DROSS +BALER +ATONE +PALER +FRISK +LOATH +EXULT +EXTOL +KNAVE +CROUP +RATTY +SCREE +ANTIC +RECUT +OVINE +UNTIE +ANNUL +LANKY +OUTDO +MAMMY +ENNUI +PURER +BLEAT +SPRIG +OVATE +DALLY +SHORN +AMBLE +SOOTH +AIDER +SCRAM +GRIMY +LOAMY +BIDDY +CRONY +TEARY +SOWER +SATYR +LITHE +CLUCK +KAZOO +GAFFE +LIKEN +NOSEY +TODDY +GAILY +TATTY +GAZER +JUNTO +UNDID +BETEL +UNWED +CLUED +CREAK +ECLAT +ABLED +WRUNG +AUGUR +IDYLL +BEGET +WISPY +CHAFE +NOBLY +FEIGN +MEALY +BOULE +SHUCK +SCOWL +BELCH +AFIRE +GLOAT +SQUIB +DUNCE +DRAWL +WARTY +MUCKY +VAPID +AGLOW +TROPE +CATTY +SKIMP +HARPY +SINGE +PLUNK +CAPUT +RASPY +OVOID +IMBUE +ACRID +MATEY +BELIE +SLYLY +SHIED +OPINE +PLAIT +SCALD +LEANT +STANK +FRILL +DRYLY +CORER +GONAD +BASTE +COWER +DEIGN +UNLIT +WRYLY +PRUDE +BOTCH +SWILL +COPSE +RUDER +PUDGY +DUMPY +BEADY +TAPIR +BLURT +FROND +IMPEL +HUSSY +SANER +PRIED +GRUEL +GONER +BLUER +FLUNK +SPURN +HOVEL +STUNK +FETID +MOULT +ELOPE +SINEW +RIPER +TWIXT +PREEN +SURER +WOOZY +FLOUT +BOOZY +KNEED +TIZZY +WHELP +POUTY +EDIFY +OAKEN +WRACK +PUPAL +BRINY +CAGEY +DULLY +CHIDE +GASSY +BUSED +POESY +GAYER +CABBY +ODDER +HUMPH +GNASH +SHUSH +KEFIR +FOIST +MANGY +DEBAR +COYLY +TEPEE +SLOSH +UVULA +BLARE +REARM +DEMUR +APING +PULPY +SLUNK +DAUNT +NINNY +PARER +RETCH +QUALM +OUTGO +WAXEN +GAWKY +EYING +BEFIT +GOFER +ABASE +GAYLY +THRUM +CAVIL +EKING +SKULK +ELIDE +ICILY +UNFED +SNAKY +VAUNT +ELATE +WOOER diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7ca7048 --- /dev/null +++ b/go.mod @@ -0,0 +1,68 @@ +module gitea.konchin.com/ytshih/inp2025 + +go 1.25.2 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/go-resty/resty/v2 v2.16.5 + github.com/gorilla/websocket v1.5.3 + github.com/spf13/cobra v1.10.1 + github.com/spf13/viper v1.21.0 + github.com/uptrace/bun v1.2.15 + github.com/uptrace/bun/dialect/sqlitedialect v1.2.15 + github.com/uptrace/bun/driver/sqliteshim v1.2.15 + github.com/uptrace/bunrouter v1.0.23 + github.com/vmihailenco/msgpack/v5 v5.4.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-sqlite3 v1.14.28 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.28.0 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1ff855f --- /dev/null +++ b/go.sum @@ -0,0 +1,173 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= +github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.2.15 h1:Ut68XRBLDgp9qG9QBMa9ELWaZOmzHNdczHQdrOZbEFE= +github.com/uptrace/bun v1.2.15/go.mod h1:Eghz7NonZMiTX/Z6oKYytJ0oaMEJ/eq3kEV4vSqG038= +github.com/uptrace/bun/dialect/sqlitedialect v1.2.15 h1:7upGMVjFRB1oI78GQw6ruNLblYn5CR+kxqcbbeBBils= +github.com/uptrace/bun/dialect/sqlitedialect v1.2.15/go.mod h1:c7YIDaPNS2CU2uI1p7umFuFWkuKbDcPDDvp+DLHZnkI= +github.com/uptrace/bun/driver/sqliteshim v1.2.15 h1:M/rZJSjOPV4OmfTVnDPtL+wJmdMTqDUn8cuk5ycfABA= +github.com/uptrace/bun/driver/sqliteshim v1.2.15/go.mod h1:YqwxFyvM992XOCpGJtXyKPkgkb+aZpIIMzGbpaw1hIk= +github.com/uptrace/bunrouter v1.0.23 h1:Bi7NKw3uCQkcA/GUCtDNPq5LE5UdR9pe+UyWbjHB/wU= +github.com/uptrace/bunrouter v1.0.23/go.mod h1:O3jAcl+5qgnF+ejhgkmbceEk0E/mqaK+ADOocdNpY8M= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= +golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= +modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/go.work b/go.work new file mode 100644 index 0000000..864cc4b --- /dev/null +++ b/go.work @@ -0,0 +1,3 @@ +go 1.25.2 + +use . diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..c21dce4 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,22 @@ +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/handlers/auth/handlers.go b/handlers/auth/handlers.go new file mode 100644 index 0000000..498b304 --- /dev/null +++ b/handlers/auth/handlers.go @@ -0,0 +1,11 @@ +package auth + +import "github.com/uptrace/bun" + +type Handlers struct { + db *bun.DB +} + +func NewHandlers(db *bun.DB) *Handlers { + return &Handlers{db: db} +} diff --git a/handlers/auth/postLogin.go b/handlers/auth/postLogin.go new file mode 100644 index 0000000..6211200 --- /dev/null +++ b/handlers/auth/postLogin.go @@ -0,0 +1,59 @@ +package auth + +import ( + "net/http" + + "gitea.konchin.com/ytshih/inp2025/middlewares" + "gitea.konchin.com/ytshih/inp2025/models" + "gitea.konchin.com/ytshih/inp2025/types" + "gitea.konchin.com/ytshih/inp2025/utils" + + "github.com/uptrace/bunrouter" +) + +func (self *Handlers) PostLogin( + w http.ResponseWriter, + req bunrouter.Request, +) error { + ctx := req.Context() + user, ok := ctx.Value(types.UserKey).(models.User) + if !ok { + return middlewares.HTTPError{ + StatusCode: http.StatusUnauthorized, + Message: "user not login", + } + } + + res, err := self.db.NewUpdate(). + Model((*models.User)(nil)). + Set("login_count = login_count + ?", 1). + Set("is_logged = ?", true). + Where("is_logged = ?", false). + Where("username = ?", user.Username). + Exec(ctx) + if err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusInternalServerError, + Message: "failed to update login count", + OriginError: err, + } + } + if cnt, err := res.RowsAffected(); err != nil || cnt == 0 { + if err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusInternalServerError, + Message: "failed to get affected row count", + OriginError: err, + } + } + // debug + return utils.Success(w) + if cnt == 0 { + return middlewares.HTTPError{ + StatusCode: http.StatusUnauthorized, + Message: "already logged in", + } + } + } + return utils.Success(w) +} diff --git a/handlers/auth/postLogout.go b/handlers/auth/postLogout.go new file mode 100644 index 0000000..1137c00 --- /dev/null +++ b/handlers/auth/postLogout.go @@ -0,0 +1,39 @@ +package auth + +import ( + "net/http" + + "gitea.konchin.com/ytshih/inp2025/middlewares" + "gitea.konchin.com/ytshih/inp2025/models" + "gitea.konchin.com/ytshih/inp2025/types" + "gitea.konchin.com/ytshih/inp2025/utils" + "github.com/uptrace/bunrouter" +) + +func (self *Handlers) PostLogout( + w http.ResponseWriter, + req bunrouter.Request, +) error { + ctx := req.Context() + user, ok := ctx.Value(types.UserKey).(models.User) + if !ok { + return middlewares.HTTPError{ + StatusCode: http.StatusUnauthorized, + Message: "user not login", + } + } + + _, err := self.db.NewUpdate(). + Set("is_logged = ?", false). + Where("username = ?", user.Username). + Exec(ctx) + if err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusInternalServerError, + Message: "failed to update logged in status", + OriginError: err, + } + } + + return utils.Success(w) +} diff --git a/handlers/auth/postRegister.go b/handlers/auth/postRegister.go new file mode 100644 index 0000000..898b3ae --- /dev/null +++ b/handlers/auth/postRegister.go @@ -0,0 +1,66 @@ +package auth + +import ( + "encoding/json" + "io" + "net/http" + + "gitea.konchin.com/ytshih/inp2025/middlewares" + "gitea.konchin.com/ytshih/inp2025/models" + "gitea.konchin.com/ytshih/inp2025/utils" + "github.com/uptrace/bunrouter" +) + +func (self *Handlers) PostRegister( + w http.ResponseWriter, + req bunrouter.Request, +) error { + ctx := req.Context() + + b, err := io.ReadAll(req.Body) + if err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusBadRequest, + Message: "failed to read body payload", + OriginError: err, + } + } + + var user models.User + if err := json.Unmarshal(b, &user); err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusBadRequest, + Message: "failed to unmarshal json into user", + OriginError: err, + } + } + + res, err := self.db.NewInsert(). + Model(&user). + On("CONFLICT (username) DO NOTHING"). + Exec(ctx) + if err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusInternalServerError, + Message: "failed to insert user", + OriginError: err, + } + } + if cnt, err := res.RowsAffected(); err != nil || cnt == 0 { + if err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusInternalServerError, + Message: "failed to get affected row count", + OriginError: err, + } + } + if cnt == 0 { + return middlewares.HTTPError{ + StatusCode: http.StatusBadRequest, + Message: "username already exist", + } + } + } + + return utils.Success(w) +} diff --git a/handlers/wordle/getState.go b/handlers/wordle/getState.go new file mode 100644 index 0000000..1ea5b75 --- /dev/null +++ b/handlers/wordle/getState.go @@ -0,0 +1,55 @@ +package wordle + +import ( + "net/http" + + "gitea.konchin.com/ytshih/inp2025/middlewares" + "gitea.konchin.com/ytshih/inp2025/types" + "gitea.konchin.com/ytshih/inp2025/utils" + "github.com/gorilla/websocket" + "github.com/uptrace/bunrouter" + "github.com/vmihailenco/msgpack/v5" +) + +func (self *Handlers) GetState( + w http.ResponseWriter, + req bunrouter.Request, +) error { + // fmt.Fprintf(os.Stderr, "GET /api/state\n") + c, err := self.upgrader.Upgrade(w, req.Request, nil) + if err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusInternalServerError, + Message: "failed to upgrade websocket", + OriginError: err, + } + } + defer c.Close() + + username, _, ok := req.BasicAuth() + if !ok { + return middlewares.HTTPError{ + StatusCode: http.StatusBadRequest, + Message: "username not exist", + } + } + dataCh := make(chan types.WordleState) + self.opCh <- &OperationSubs{ + Username: username, + SubsCh: &dataCh, + } + + for data := range dataCh { + b, err := msgpack.Marshal(data) + if err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusInternalServerError, + Message: "failed to marshal data into msgpack", + OriginError: err, + } + } + c.WriteMessage(websocket.BinaryMessage, b) + } + + return utils.Success(w) +} diff --git a/handlers/wordle/handlers.go b/handlers/wordle/handlers.go new file mode 100644 index 0000000..8c1952a --- /dev/null +++ b/handlers/wordle/handlers.go @@ -0,0 +1,98 @@ +package wordle + +import ( + "fmt" + "net/http" + + "gitea.konchin.com/ytshih/inp2025/types" + "github.com/gorilla/websocket" +) + +type Operation interface { + Run(self *Handlers) error +} + +type OperationSubs struct { + Username string + SubsCh *chan types.WordleState +} + +func (op *OperationSubs) Run(self *Handlers) error { + _, ok := self.state.History[op.Username] + if !ok { + self.state.History[op.Username] = [types.GUESS_COUNT_LIMIT]types.Guess{} + } + // fmt.Fprintf(os.Stderr, "[DEBUG] %+v\n", self.state.History) + self.subs = append(self.subs, op.SubsCh) + + return nil +} + +type OperationGuess struct { + Username string `msgpack:"username"` + Guess string `msgpack:"guess"` +} + +func (op *OperationGuess) Run(self *Handlers) error { + self.state.CurrentGuess[op.Username] = op.Guess + + if len(self.state.CurrentGuess) < len(self.state.History) { + return nil + } + + for user, guess := range self.state.CurrentGuess { + if guess == self.ans { + self.state.GameEnd = true + } + guesses := self.state.History[user] + guesses[self.state.Round] = types.NewGuess(guess, self.ans) + self.state.History[user] = guesses + } + self.state.CurrentGuess = make(map[types.UsernameType]string) + self.state.Round++ + if self.state.Round >= types.GUESS_COUNT_LIMIT { + self.state.GameEnd = true + } + + return nil +} + +type Handlers struct { + ans string + state types.WordleState + upgrader websocket.Upgrader + subs []*chan types.WordleState + + opCh chan Operation +} + +func NewHandlers(ans string) *Handlers { + ret := &Handlers{ + ans: ans, + state: types.NewWordleState(), + upgrader: websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, + }, + + opCh: make(chan Operation), + } + go ret.GameFlow() + return ret +} + +func (self *Handlers) GameFlow() { + for op := range self.opCh { + if err := op.Run(self); err != nil { + panic(fmt.Errorf("game flow operation failed, %w", err)) + } + + // fmt.Fprintf(os.Stderr, "[DEBUG] %+v\n", self.state.History) + for _, ch := range self.subs { + *ch <- self.state + } + } +} diff --git a/handlers/wordle/postGuess.go b/handlers/wordle/postGuess.go new file mode 100644 index 0000000..dee397b --- /dev/null +++ b/handlers/wordle/postGuess.go @@ -0,0 +1,54 @@ +package wordle + +import ( + "io" + "net/http" + + "gitea.konchin.com/ytshih/inp2025/middlewares" + "gitea.konchin.com/ytshih/inp2025/utils" + "github.com/uptrace/bunrouter" + "github.com/vmihailenco/msgpack/v5" +) + +type PostGuessInput struct { + Guess string `msgpack:"guess"` +} + +func (self *Handlers) PostGuess( + w http.ResponseWriter, + req bunrouter.Request, +) error { + // fmt.Fprintf(os.Stderr, "POST /api/guess\n") + b, err := io.ReadAll(req.Body) + if err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusBadRequest, + Message: "failed to read body payload", + OriginError: err, + } + } + + var input PostGuessInput + if err := msgpack.Unmarshal(b, &input); err != nil { + return middlewares.HTTPError{ + StatusCode: http.StatusBadRequest, + Message: "failed to unmarshal from msgpack", + OriginError: err, + } + } + + username, _, ok := req.BasicAuth() + if !ok { + return middlewares.HTTPError{ + StatusCode: http.StatusBadRequest, + Message: "username not exist", + } + } + + self.opCh <- &OperationGuess{ + Username: username, + Guess: input.Guess, + } + + return utils.Success(w) +} diff --git a/logs/wordle-stderr.log b/logs/wordle-stderr.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/wordle-stdout.log b/logs/wordle-stdout.log new file mode 100644 index 0000000..e69de29 diff --git a/middlewares/auth.go b/middlewares/auth.go new file mode 100644 index 0000000..3714c64 --- /dev/null +++ b/middlewares/auth.go @@ -0,0 +1,60 @@ +package middlewares + +import ( + "context" + "database/sql" + "errors" + "net/http" + + "gitea.konchin.com/ytshih/inp2025/models" + "gitea.konchin.com/ytshih/inp2025/types" + "github.com/uptrace/bunrouter" +) + +func (self *Handlers) Auth( + next bunrouter.HandlerFunc, +) bunrouter.HandlerFunc { + return func(w http.ResponseWriter, req bunrouter.Request) error { + ctx := req.Context() + + username, password, ok := req.BasicAuth() + if !ok { + return HTTPError{ + StatusCode: http.StatusNotFound, + Message: "username not exist", + } + } + + dbUser := models.User{Username: username} + err := self.db.NewSelect(). + Model(&dbUser). + WherePK(). + Scan(ctx) + if errors.Is(err, sql.ErrNoRows) { + return HTTPError{ + StatusCode: http.StatusUnauthorized, + Message: "username not exist", + OriginError: err, + } + } + if err != nil { + return HTTPError{ + StatusCode: http.StatusInternalServerError, + Message: "failed to select user from db", + OriginError: err, + } + } + if password != dbUser.Password { + return HTTPError{ + StatusCode: http.StatusUnauthorized, + Message: "password incorrect", + OriginError: err, + } + } + return next(w, req.WithContext(context.WithValue( + ctx, types.UserKey, models.User{ + Username: username, + Password: password, + }))) + } +} diff --git a/middlewares/errorHandler.go b/middlewares/errorHandler.go new file mode 100644 index 0000000..dc9be12 --- /dev/null +++ b/middlewares/errorHandler.go @@ -0,0 +1,63 @@ +package middlewares + +import ( + "fmt" + "net/http" + "os" + + "github.com/uptrace/bunrouter" + "go.uber.org/zap" +) + +type HTTPError struct { + StatusCode int `json:"code"` + Message string `json:"message"` + OriginError error `json:"-"` +} + +func (e HTTPError) Error() string { + return e.Message +} + +func NewHTTPError(err error) HTTPError { + return HTTPError{ + StatusCode: http.StatusInternalServerError, + Message: "Internal server error with unknown reason", + OriginError: err, + } +} + +func (self *Handlers) ErrorHandler( + next bunrouter.HandlerFunc, +) bunrouter.HandlerFunc { + return func(w http.ResponseWriter, req bunrouter.Request) error { + err := next(w, req) + + var httpErr HTTPError + switch err := err.(type) { + case nil: + return nil + + case HTTPError: + httpErr = err + + default: + fmt.Fprintf(os.Stderr, "unhandled error, %v\n", err) + zap.L().Error("unhandled error", + zap.Error(err)) + httpErr = NewHTTPError(err) + } + + if httpErr.OriginError == nil { + zap.L().Warn(httpErr.Message) + } else { + zap.L().Warn(httpErr.Message, + zap.Error(httpErr.OriginError)) + } + + w.WriteHeader(httpErr.StatusCode) + _ = bunrouter.JSON(w, httpErr) + + return err + } +} diff --git a/middlewares/handlers.go b/middlewares/handlers.go new file mode 100644 index 0000000..573380c --- /dev/null +++ b/middlewares/handlers.go @@ -0,0 +1,11 @@ +package middlewares + +import "github.com/uptrace/bun" + +type Handlers struct { + db *bun.DB +} + +func NewHandlers(db *bun.DB) *Handlers { + return &Handlers{db: db} +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..48f5aae --- /dev/null +++ b/models/user.go @@ -0,0 +1,13 @@ +package models + +import "github.com/uptrace/bun" + +type User struct { + bun.BaseModel `bun:"table:user" json:"-"` + + Username string `bun:"username,pk" json:"username"` + Password string `bun:"password" json:"password"` + + IsLogged bool `bun:"is_logged" json:"isLogged"` + LoginCount int `bun:"login_count" json:"loginCount"` +} diff --git a/player.go b/player.go new file mode 100644 index 0000000..ef5bf62 --- /dev/null +++ b/player.go @@ -0,0 +1,37 @@ +package main + +import ( + "gitea.konchin.com/ytshih/inp2025/stages" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var playerCmd = &cobra.Command{ + Use: "player", + Run: func(cmd *cobra.Command, args []string) { + queue := []*tea.Program{} + + base := stages.NewBaseModel(&queue, viper.GetString("auth-endpoint")) + queue = append(queue, + tea.NewProgram(stages.NewLandingModel(base))) + + for len(queue) > 0 { + program := queue[0] + queue = queue[1:] + _, err := program.Run() + if err != nil { + panic(err) + } + } + }, +} + +func init() { + playerCmd.Flags(). + Int("udp-listen-port", 18787, "") + playerCmd.Flags(). + StringSlice("udp-endpoints", []string{"localhost:18787"}, "") + playerCmd.Flags(). + String("auth-endpoint", "http://localhost:8888", "") +} diff --git a/stages/base.go b/stages/base.go new file mode 100644 index 0000000..59a5199 --- /dev/null +++ b/stages/base.go @@ -0,0 +1,23 @@ +package stages + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/go-resty/resty/v2" +) + +type BaseModel struct { + queue *[]*tea.Program + client *resty.Client +} + +func NewBaseModel( + queue *[]*tea.Program, + endpoint string, +) *BaseModel { + return &BaseModel{ + queue: queue, + client: resty.New(). + SetBaseURL(endpoint). + SetDisableWarn(true), + } +} diff --git a/stages/landing.go b/stages/landing.go new file mode 100644 index 0000000..f2fc6e8 --- /dev/null +++ b/stages/landing.go @@ -0,0 +1,241 @@ +package stages + +import ( + "fmt" + "net/http" + "strings" + + "gitea.konchin.com/ytshih/inp2025/models" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type landingOperationType int + +const ( + landingOperationChoose landingOperationType = iota + landingOperationLoginCred + landingOperationRegistCred +) + +type focusTargetType int + +const ( + focusTargetUsername focusTargetType = iota + focusTargetPassword +) + +type LandingModel struct { + *BaseModel + op landingOperationType + + focus focusTargetType + username textinput.Model + password textinput.Model + + info string + err error +} + +func NewLandingModel(base *BaseModel) *LandingModel { + username := textinput.New() + username.Placeholder = "Username" + username.CharLimit = 32 + username.Width = 32 + username.Blur() + + password := textinput.New() + password.Placeholder = "Password" + password.CharLimit = 32 + password.Width = 32 + password.Blur() + password.EchoMode = textinput.EchoPassword + password.EchoCharacter = '•' + + return &LandingModel{ + BaseModel: base, + op: landingOperationChoose, + + username: username, + password: password, + } +} + +func (m *LandingModel) reset() { + m.op = landingOperationChoose + m.username.Blur() + m.username.Reset() + m.password.Blur() + m.password.Reset() +} + +type postLoginMsg struct{} + +func (m *LandingModel) postLogin() tea.Cmd { + return func() tea.Msg { + resp, err := m.BaseModel.client.R(). + SetBasicAuth(m.username.Value(), m.password.Value()). + Post("/auth/login") + if err == nil { + switch resp.StatusCode() { + case http.StatusOK: + m.BaseModel.client.SetBasicAuth( + m.username.Value(), m.password.Value()) + m.info = "login success.\n" + m.err = nil + case http.StatusUnauthorized: + m.err = fmt.Errorf("user not exist or password incorrect, %s", + string(resp.Body())) + default: + m.err = fmt.Errorf("unknown server error, %s", + string(resp.Body())) + } + } else { + m.err = fmt.Errorf("failed to post login, %w", err) + } + return postLoginMsg{} + } +} + +type postRegisterMsg struct{} + +func (m *LandingModel) postRegister() tea.Cmd { + return func() tea.Msg { + resp, err := m.BaseModel.client.R(). + SetBody(models.User{ + Username: m.username.Value(), + Password: m.password.Value(), + }). + Post("/auth/register") + if err == nil { + switch resp.StatusCode() { + case http.StatusOK: + m.info = "register success.\n" + m.err = nil + case http.StatusBadRequest: + m.err = fmt.Errorf("username already exist, %s", + string(resp.Body())) + default: + m.err = fmt.Errorf("unknown server error, %s", + string(resp.Body())) + } + } else { + m.err = fmt.Errorf("failed to post register, %s", + string(resp.Body())) + } + return postRegisterMsg{} + } +} + +func (m *LandingModel) Init() tea.Cmd { + return tea.ClearScreen +} + +func (m *LandingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "L", "l": + if m.op == landingOperationChoose { + m.op = landingOperationLoginCred + m.focus = focusTargetUsername + cmds = append(cmds, m.username.Focus()) + return m, tea.Batch(cmds...) + } + case "R", "r": + if m.op == landingOperationChoose { + m.op = landingOperationRegistCred + m.focus = focusTargetUsername + cmds = append(cmds, m.username.Focus()) + return m, tea.Batch(cmds...) + } + case "shift+tab", "up": + if m.op == landingOperationLoginCred || + m.op == landingOperationRegistCred { + switch m.focus { + case focusTargetUsername: + m.username.Blur() + m.focus = focusTargetPassword + cmds = append(cmds, m.password.Focus()) + case focusTargetPassword: + m.password.Blur() + m.focus = focusTargetUsername + cmds = append(cmds, m.username.Focus()) + } + } + case "tab", "down", "enter": + if m.op == landingOperationLoginCred || + m.op == landingOperationRegistCred { + switch m.focus { + case focusTargetUsername: + m.username.Blur() + m.focus = focusTargetPassword + cmds = append(cmds, m.password.Focus()) + case focusTargetPassword: + if msg.String() == "enter" { + switch m.op { + case landingOperationLoginCred: + cmds = append(cmds, m.postLogin()) + case landingOperationRegistCred: + cmds = append(cmds, m.postRegister()) + } + } else { + m.password.Blur() + m.focus = focusTargetUsername + cmds = append(cmds, m.username.Focus()) + } + } + } + } + case postLoginMsg: + if m.err == nil { + *m.queue = append(*m.queue, + tea.NewProgram(NewLobbyModel(m.BaseModel))) + return m, tea.Quit + } else { + m.reset() + } + case postRegisterMsg: + m.reset() + } + + var cmd tea.Cmd + m.username, cmd = m.username.Update(msg) + cmds = append(cmds, cmd) + m.password, cmd = m.password.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *LandingModel) View() string { + var b strings.Builder + + switch m.op { + case landingOperationChoose: + fmt.Fprintf(&b, "Choose Operation\n(L)ogin / (R)egister\n") + case landingOperationLoginCred: + fmt.Fprintf(&b, "Login: \n") + b.WriteString(m.username.View() + "\n") + b.WriteString(m.password.View() + "\n") + case landingOperationRegistCred: + fmt.Fprintf(&b, "Register: \n") + b.WriteString(m.username.View() + "\n") + b.WriteString(m.password.View() + "\n") + } + + if m.info != "" { + b.WriteString(m.info + "\n") + } + + if m.err != nil { + b.WriteString("----------\n") + b.WriteString(m.err.Error() + "\n") + } + + return b.String() +} diff --git a/stages/lobby.go b/stages/lobby.go new file mode 100644 index 0000000..08adaff --- /dev/null +++ b/stages/lobby.go @@ -0,0 +1,283 @@ +package stages + +import ( + "fmt" + "net" + "strings" + "time" + + "gitea.konchin.com/ytshih/inp2025/types" + "gitea.konchin.com/ytshih/inp2025/utils" + "gitea.konchin.com/ytshih/inp2025/workflows" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/viper" + "github.com/vmihailenco/msgpack/v5" +) + +const ( + REFRESH_TIME = time.Second +) + +type lobbyOperationType int + +const ( + lobbyOperationChoose lobbyOperationType = iota + lobbyOperationServerWaiting + lobbyOperationServerChoose + lobbyOperationClientScannning +) + +type LobbyModel struct { + *BaseModel + op lobbyOperationType + + shutdown types.ShutdownFunc + local string + remote string + + // server + remoteUser string + listener net.Listener + + // client + cursor int + endpoints []string + + info string + err error +} + +func NewLobbyModel(base *BaseModel) *LobbyModel { + return &LobbyModel{ + BaseModel: base, + op: lobbyOperationChoose, + + shutdown: func() {}, + } +} + +type serverListenMsg struct{} + +func (m *LobbyModel) serverListen() tea.Cmd { + return func() tea.Msg { + dataCh := make(chan string) + m.local, m.shutdown, m.err = utils.ListenUDPData( + viper.GetInt("udp-listen-port"), dataCh) + if m.err != nil { + m.err = fmt.Errorf("failed to listen, %w", m.err) + return serverListenMsg{} + } + + req := <-dataCh + m.shutdown() + m.shutdown = func() {} + + var joinRequest types.JoinRequest + if err := msgpack.Unmarshal([]byte(req), &joinRequest); err != nil { + m.err = fmt.Errorf("failed to unmarshal msgpack, %w", err) + return serverListenMsg{} + } + + m.remote = joinRequest.Endpoint + m.remoteUser = joinRequest.Username + return serverListenMsg{} + } +} + +type serverSendReplyMsg struct{} + +func (m *LobbyModel) serverSendReply(response bool) tea.Cmd { + return func() tea.Msg { + if response { + // Start Wordle Server listener + m.listener, m.err = net.Listen("tcp4", ":0") + if m.err != nil { + m.err = fmt.Errorf("failed to listen on anonymous port, %w", m.err) + return serverSendReplyMsg{} + } + + local := fmt.Sprintf("%s:%d", + m.listener.Addr().(*net.TCPAddr).IP.String(), + m.listener.Addr().(*net.TCPAddr).Port) + m.err = utils.SendPayload(local, m.remote, + types.JoinResponse{Endpoint: local}) + + // Store wordle server endpoint + m.remote = local + } else { + m.err = utils.SendPayload("", m.remote, + types.JoinResponse{Endpoint: ""}) + } + return serverSendReplyMsg{} + } +} + +type clientScanMsg time.Time + +func (m *LobbyModel) clientScan() tea.Cmd { + return tea.Tick(REFRESH_TIME, func(t time.Time) tea.Msg { + m.endpoints = []string{} + for _, endpoint := range viper.GetStringSlice("udp-endpoints") { + if err := utils.Ping(endpoint); err == nil { + m.endpoints = append(m.endpoints, endpoint) + } + } + return clientScanMsg(t) + }) +} + +type clientJoinMsg struct{} + +func (m *LobbyModel) clientJoin() tea.Cmd { + return func() tea.Msg { + dataCh := make(chan string) + m.local, m.shutdown, m.err = utils.ListenUDPData(0, dataCh) + if m.err != nil { + m.err = fmt.Errorf("failed to listen udp data, %w", m.err) + return clientJoinMsg{} + } + + m.err = utils.SendPayload(m.local, m.remote, types.JoinRequest{ + Endpoint: m.local, + Username: m.client.UserInfo.Username, + }) + if m.err != nil { + m.err = fmt.Errorf("failed to send invitation, %w", m.err) + return clientJoinMsg{} + } + + data := <-dataCh + m.shutdown() + m.shutdown = func() {} + + var joinResponse types.JoinResponse + if err := msgpack.Unmarshal([]byte(data), &joinResponse); err != nil { + m.err = fmt.Errorf("failed to unmarshal msgpack, %w", err) + return clientJoinMsg{} + } + + m.remote = joinResponse.Endpoint + return clientJoinMsg{} + } +} + +func (m *LobbyModel) Init() tea.Cmd { + return tea.ClearScreen +} + +func (m *LobbyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + } + } + + switch m.op { + case lobbyOperationChoose: + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "S", "s": + m.op = lobbyOperationServerWaiting + cmds = append(cmds, m.serverListen()) + case "C", "c": + m.op = lobbyOperationClientScannning + cmds = append(cmds, m.clientScan()) + } + } + case lobbyOperationServerWaiting: + switch msg.(type) { + case serverListenMsg: + m.op = lobbyOperationServerChoose + } + case lobbyOperationServerChoose: + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "Y", "y", "enter": + cmds = append(cmds, m.serverSendReply(true)) + case "N", "n": + m.op = lobbyOperationServerWaiting + cmds = append(cmds, m.serverSendReply(false)) + } + case serverSendReplyMsg: + if m.err == nil { + m.shutdown() + m.client.SetBaseURL("http://" + m.remote) + shutdown := workflows.WordleServer(m.listener) + *m.queue = append(*m.queue, + tea.NewProgram(NewWordleClientModel(m.BaseModel, shutdown))) + return m, tea.Quit + } else { + m.op = lobbyOperationServerWaiting + } + } + case lobbyOperationClientScannning: + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "shift+tab", "up": + if n := len(m.endpoints); n > 0 { + m.cursor = (m.cursor - 1 + n) % n + } + case "tab", "down": + if n := len(m.endpoints); n > 0 { + m.cursor = (m.cursor + 1) % n + } + case "enter": + m.remote = m.endpoints[m.cursor] + cmds = append(cmds, m.clientJoin()) + } + case clientScanMsg: + cmds = append(cmds, m.clientScan()) + case clientJoinMsg: + if m.err == nil && m.remote != "" { + m.shutdown() + m.client.SetBaseURL("http://" + m.remote) + *m.BaseModel.queue = append(*m.BaseModel.queue, + tea.NewProgram(NewWordleClientModel(m.BaseModel, func() {}))) + return m, tea.Quit + } + } + } + + return m, tea.Batch(cmds...) +} + +func (m *LobbyModel) View() string { + var b strings.Builder + + switch m.op { + case lobbyOperationChoose: + b.WriteString("Choose Role\n(S)erver / (C)lient\n") + case lobbyOperationServerWaiting: + b.WriteString("Wait for user to join...\n") + case lobbyOperationServerChoose: + fmt.Fprintf(&b, "Receive Join Request by '%s'\nAccept (Y)/N\n", + m.remoteUser) + case lobbyOperationClientScannning: + b.WriteString("Scanning server...\nChoose one to join\n") + for i, endpoint := range m.endpoints { + if i == m.cursor { + fmt.Fprintf(&b, "(•) %s\n", endpoint) + } else { + fmt.Fprintf(&b, "( ) %s\n", endpoint) + } + } + } + + if m.info != "" { + b.WriteString(m.info + "\n") + } + + if m.err != nil { + b.WriteString("----------\n") + b.WriteString(m.err.Error() + "\n") + } + + return b.String() +} diff --git a/stages/udp.go b/stages/udp.go new file mode 100644 index 0000000..439c360 --- /dev/null +++ b/stages/udp.go @@ -0,0 +1 @@ +package stages diff --git a/stages/wordle.go b/stages/wordle.go new file mode 100644 index 0000000..02dff2a --- /dev/null +++ b/stages/wordle.go @@ -0,0 +1,179 @@ +package stages + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "gitea.konchin.com/ytshih/inp2025/handlers/wordle" + "gitea.konchin.com/ytshih/inp2025/types" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/gorilla/websocket" + "github.com/vmihailenco/msgpack/v5" +) + +const ( + WEBSOCKET_RETRY int = 5 + WEBSOCKET_BACKOFF time.Duration = 100 * time.Millisecond +) + +type WordleClientModel struct { + *BaseModel + conn *websocket.Conn + state types.WordleState + shutdown types.ShutdownFunc + + input textinput.Model + err error +} + +func NewWordleClientModel( + base *BaseModel, + shutdown types.ShutdownFunc, +) *WordleClientModel { + input := textinput.New() + input.Focus() + input.CharLimit = types.GUESS_WORD_LENGTH + input.Width = types.GUESS_WORD_LENGTH + return &WordleClientModel{ + BaseModel: base, + input: input, + shutdown: shutdown, + } +} + +func (m *WordleClientModel) getState() tea.Cmd { + return func() tea.Msg { + if m.conn == nil { + u, err := url.Parse(m.client.BaseURL) + if err != nil { + m.err = fmt.Errorf("failed to parse BaseURL, %w", err) + } + u.Scheme = "ws" + u.Path = "/api/state" + + for try := 0; try < WEBSOCKET_RETRY; try++ { + req, _ := http.NewRequest("GET", + /*placeholder*/ "http://localhost", nil) + req.SetBasicAuth( + m.client.UserInfo.Username, + m.client.UserInfo.Password) + m.conn, _, err = websocket.DefaultDialer.Dial( + u.String(), req.Header) + if err == nil { + break + } + time.Sleep(WEBSOCKET_BACKOFF) + } + if err != nil { + m.err = fmt.Errorf("failed to dial, %w", err) + } + } + + _, b, err := m.conn.ReadMessage() + if err == nil { + var state types.WordleState + if err := msgpack.Unmarshal(b, &state); err != nil { + m.err = fmt.Errorf("failed to unmarshal state, %w", err) + } + return state + } else { + m.err = fmt.Errorf("failed to read message, %w", err) + return nil + } + } +} + +func (m *WordleClientModel) postGuess(guess string) tea.Cmd { + return func() tea.Msg { + b, err := msgpack.Marshal(wordle.OperationGuess{ + Username: m.client.UserInfo.Username, + Guess: guess, + }) + if err != nil { + m.err = fmt.Errorf("failed to post guess, %w", err) + } + _, err = m.client.R(). + SetBody(b). + Post("/api/guess") + if err != nil { + m.err = fmt.Errorf("failed to post guess, %w", err) + } + return nil + } +} + +func (m *WordleClientModel) Init() tea.Cmd { + return tea.Sequence(tea.ClearScreen, + tea.Batch(m.getState(), textinput.Blink)) +} + +func (m *WordleClientModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + m.conn.Close() + m.shutdown() + return m, tea.Quit + case "enter": + if m.state.GameEnd { + m.conn.Close() + m.shutdown() + *m.queue = append(*m.queue, + tea.NewProgram(NewLobbyModel(m.BaseModel))) + return m, tea.Quit + } + if len(m.input.Value()) == types.GUESS_WORD_LENGTH { + input := strings.ToUpper(m.input.Value()) + m.input.Reset() + cmds = append(cmds, m.postGuess(input)) + } + } + case types.WordleState: + m.state = msg + if m.state.GameEnd { + m.input.Blur() + } + if _, ok := m.state.CurrentGuess[m.client.UserInfo.Username]; ok { + m.input.Blur() + } else { + cmds = append(cmds, m.input.Focus()) + } + cmds = append(cmds, m.getState()) + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m *WordleClientModel) View() string { + var b strings.Builder + + fmt.Fprintf(&b, "Login as '%s'\n", m.client.UserInfo.Username) + fmt.Fprintf(&b, "remote addr: %s\n", m.client.BaseURL) + + b.WriteString(m.state.View() + "\n") + b.WriteString(types.NewWordBank(m.state).View() + "\n") + + if m.state.GameEnd { + fmt.Fprintf(&b, "Game End. Press 'Enter' to lobby.\n") + } else { + if guess, ok := m.state.CurrentGuess[m.client.UserInfo.Username]; ok { + fmt.Fprintf(&b, "Current guess for the round: %s\n", guess) + } else { + fmt.Fprintf(&b, "guess: %s\n", m.input.View()) + } + if m.err != nil { + fmt.Fprintf(&b, "error: %+v\n", m.err) + } + } + + return b.String() +} diff --git a/test.go b/test.go new file mode 100644 index 0000000..54df20c --- /dev/null +++ b/test.go @@ -0,0 +1,38 @@ +package main + +import ( + "gitea.konchin.com/ytshih/inp2025/utils" + "github.com/spf13/cobra" +) + +var testCmd = &cobra.Command{ + Use: "test", +} + +var testServerCmd = &cobra.Command{ + Use: "server", + Run: func(cmd *cobra.Command, args []string) { + dataCh := make(chan string) + _, shutdown, err := utils.ListenUDPData(18787, dataCh) + if err != nil { + panic(err) + } + defer shutdown() + <-dataCh + }, +} + +var testClientCmd = &cobra.Command{ + Use: "client", + Run: func(cmd *cobra.Command, args []string) { + err := utils.Ping("localhost:18787") + if err != nil { + panic(err) + } + }, +} + +func init() { + testCmd.AddCommand(testServerCmd) + testCmd.AddCommand(testClientCmd) +} diff --git a/types/auth.go b/types/auth.go new file mode 100644 index 0000000..dd30c15 --- /dev/null +++ b/types/auth.go @@ -0,0 +1,7 @@ +package types + +type UserType struct{} + +var ( + UserKey = UserType{} +) diff --git a/types/types.go b/types/types.go new file mode 100644 index 0000000..6097cbe --- /dev/null +++ b/types/types.go @@ -0,0 +1,5 @@ +package types + +type ShutdownFunc func() + +type UsernameType = string diff --git a/types/udp.go b/types/udp.go new file mode 100644 index 0000000..8d58c6d --- /dev/null +++ b/types/udp.go @@ -0,0 +1,10 @@ +package types + +type JoinRequest struct { + Endpoint string + Username UsernameType +} + +type JoinResponse struct { + Endpoint string +} diff --git a/types/wordle.go b/types/wordle.go new file mode 100644 index 0000000..e2c9769 --- /dev/null +++ b/types/wordle.go @@ -0,0 +1,209 @@ +package types + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const ( + GUESS_COUNT_LIMIT int = 6 + GUESS_WORD_LENGTH int = 5 + + ALPHABET_COUNT int = 26 +) + +type GuessStateType int + +const ( + GuessStateNotGuessed GuessStateType = iota + GuessStateWrongChar + GuessStateWrongPos + GuessStateCorrect +) + +type GuessChar struct { + Char rune + State GuessStateType +} + +func (self GuessChar) View() string { + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")) + switch self.State { + case GuessStateWrongChar: + style = style.Background(lipgloss.Color("#3a3a3c")) + case GuessStateWrongPos: + style = style.Background(lipgloss.Color("#b59f3b")) + case GuessStateCorrect: + style = style.Background(lipgloss.Color("#538d4e")) + } + return style.Render(string(self.Char)) +} + +type Guess struct { + Data [GUESS_WORD_LENGTH]GuessChar +} + +type guessLeftType struct { + Rune rune + Idx int +} + +func NewGuess(guess, ans string) Guess { + var ret Guess + + var ansLeft []rune + var guessLeft []guessLeftType + for i := range ret.Data { + ret.Data[i] = GuessChar{ + Char: rune(guess[i]), + State: GuessStateWrongChar, + } + if guess[i] == ans[i] { + ret.Data[i].State = GuessStateCorrect + } else { + ansLeft = append(ansLeft, rune(ans[i])) + guessLeft = append(guessLeft, guessLeftType{ + Rune: rune(guess[i]), + Idx: i, + }) + } + } + + sort.Slice(ansLeft, func(i, j int) bool { + return int32(ansLeft[i]) < int32(ansLeft[j]) + }) + + sort.Slice(guessLeft, func(i, j int) bool { + return int32(guessLeft[i].Rune) < int32(guessLeft[j].Rune) + }) + + i, j := 0, 0 + for i < len(ansLeft) && j < len(guessLeft) { + if int32(ansLeft[i]) == int32(guessLeft[j].Rune) { + ret.Data[guessLeft[j].Idx].State = GuessStateWrongPos + i++ + j++ + } else { + if int32(ansLeft[i]) < int32(guessLeft[j].Rune) { + i++ + } else { + j++ + } + } + } + + return ret +} + +func (self Guess) View() string { + var b strings.Builder + for _, ch := range self.Data { + b.WriteString(ch.View()) + } + return b.String() +} + +type WordleState struct { + History map[UsernameType][GUESS_COUNT_LIMIT]Guess + CurrentGuess map[UsernameType]string + + Round int + GameEnd bool +} + +func NewWordleState() WordleState { + return WordleState{ + History: make(map[UsernameType][GUESS_COUNT_LIMIT]Guess), + CurrentGuess: make(map[UsernameType]string), + + Round: 0, + GameEnd: false, + } +} + +func (self *WordleState) View() string { + var b strings.Builder + + col := []string{} + for user := range self.History { + col = append(col, user) + } + + sort.Slice(col, func(i, j int) bool { + return col[i] < col[j] + }) + + fmt.Fprintf(&b, " ") + for _, user := range col { + fmt.Fprintf(&b, "%5s ", user) + } + b.WriteRune('\n') + + table := make([][]string, GUESS_COUNT_LIMIT) + for i := range table { + table[i] = make([]string, len(col)) + } + for j, user := range col { + for i, guess := range self.History[user] { + table[i][j] = guess.View() + } + } + + for i, row := range table { + fmt.Fprintf(&b, "%1d ", i+1) + for _, e := range row { + b.WriteString(e + " ") + } + b.WriteRune('\n') + } + + return b.String() +} + +type WordBank struct { + Data [ALPHABET_COUNT]GuessStateType +} + +func NewWordBank(state WordleState) WordBank { + // FIXME: breaks when enter ^A-Z + var ret WordBank + for _, guesses := range state.History { + for i := 0; i < state.Round; i++ { + for _, ch := range guesses[i].Data { + ret.Data[int32(ch.Char)-'A'] = max( + ret.Data[int32(ch.Char)-'A'], + ch.State) + } + } + } + return ret +} + +func (self WordBank) View() string { + var b strings.Builder + + for i, state := range self.Data { + style := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")) + switch state { + case GuessStateNotGuessed: + style = style.Background(lipgloss.Color("#2c3032")) + case GuessStateWrongChar: + style = style.Background(lipgloss.Color("#3a3a3c")) + case GuessStateWrongPos: + style = style.Background(lipgloss.Color("#b59f3b")) + case GuessStateCorrect: + style = style.Background(lipgloss.Color("#538d4e")) + } + b.WriteString(style.Render(string('A' + i))) + if i%10 == 9 { + b.WriteRune('\n') + } + } + + return b.String() +} diff --git a/utils/initDB.go b/utils/initDB.go new file mode 100644 index 0000000..f017db5 --- /dev/null +++ b/utils/initDB.go @@ -0,0 +1,13 @@ +package utils + +import ( + "context" + + "gitea.konchin.com/ytshih/inp2025/models" + "github.com/uptrace/bun" +) + +func InitDB(ctx context.Context, db *bun.DB) error { + return db.ResetModel(ctx, + (*models.User)(nil)) +} diff --git a/utils/success.go b/utils/success.go new file mode 100644 index 0000000..6fd2f3b --- /dev/null +++ b/utils/success.go @@ -0,0 +1,14 @@ +package utils + +import ( + "io" + "net/http" +) + +func Success(w http.ResponseWriter) error { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/plain") + _, err := io.WriteString(w, + `{"code":200, "message": "success"}`+"\n") + return err +} diff --git a/utils/udp.go b/utils/udp.go new file mode 100644 index 0000000..e33b37a --- /dev/null +++ b/utils/udp.go @@ -0,0 +1,143 @@ +package utils + +import ( + "fmt" + "net" + + "gitea.konchin.com/ytshih/inp2025/types" + "github.com/vmihailenco/msgpack/v5" +) + +const ( + BUFFER_SIZE int = 1024 + MAGIC_NUMBER int = 114514 +) + +type UDPReqType int + +const ( + UDPReqTypeData UDPReqType = iota + UDPReqTypePingRequest + UDPReqTypePingReply +) + +type UDPPayload struct { + MagicNumber int `msgpack:"magicNumber"` + Endpoint string `msgpack:"endpoint"` + Type UDPReqType `msgpack:"type"` + + Data string `msgpack:"data"` +} + +func ListenUDPData( + port int, + dataCh chan string, +) (string, types.ShutdownFunc, error) { + return ListenUDP(port, dataCh, nil) +} + +func Ping(endpoint string) error { + pingCh := make(chan string) + local, shutdown, err := ListenUDP(0, nil, pingCh) + if err != nil { + return err + } + defer shutdown() + + err = SendRawPayload(endpoint, UDPPayload{ + MagicNumber: MAGIC_NUMBER, + Endpoint: local, + Type: UDPReqTypePingRequest, + }) + if err != nil { + return err + } + <-pingCh + return nil +} + +func ListenUDP( + port int, + dataCh chan string, + pingCh chan string, +) (string, types.ShutdownFunc, error) { + conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: port}) + if err != nil { + return "", nil, fmt.Errorf("failed to listen udp, %w", err) + } + local := conn.LocalAddr().String() + + go func() { + for { + buffer := make([]byte, BUFFER_SIZE) + + n, _, err := conn.ReadFromUDP(buffer) + if err != nil { + continue + } + + var payload UDPPayload + err = msgpack.Unmarshal(buffer[:n], &payload) + if err == nil && payload.MagicNumber == MAGIC_NUMBER { + switch payload.Type { + case UDPReqTypeData: + if dataCh != nil { + dataCh <- payload.Data + } + case UDPReqTypePingRequest: + SendRawPayload(payload.Endpoint, UDPPayload{ + MagicNumber: MAGIC_NUMBER, + Endpoint: local, + Type: UDPReqTypePingReply, + }) + case UDPReqTypePingReply: + if pingCh != nil { + pingCh <- payload.Endpoint + } + } + } + } + }() + + return local, func() { conn.Close() }, nil +} + +func SendPayload( + local, remote string, + data any, +) error { + sdata, err := msgpack.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal data, %w", err) + } + + return SendRawPayload(remote, UDPPayload{ + MagicNumber: MAGIC_NUMBER, + Endpoint: local, + Type: UDPReqTypeData, + Data: string(sdata), + }) +} + +func SendRawPayload( + endpoint string, + payload UDPPayload, +) error { + conn, err := net.Dial("udp", endpoint) + if err != nil { + return fmt.Errorf("failed to dial endpoint, %w", err) + } + defer conn.Close() + + b, err := msgpack.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload, %w", err) + } + + _, err = conn.Write(b) + if err != nil { + return fmt.Errorf("failed to send payload, %w", err) + } + + return nil +} diff --git a/workflows/authServer.go b/workflows/authServer.go new file mode 100644 index 0000000..013fd4a --- /dev/null +++ b/workflows/authServer.go @@ -0,0 +1,63 @@ +package workflows + +import ( + "context" + "database/sql" + "fmt" + "net/http" + + "gitea.konchin.com/ytshih/inp2025/handlers/auth" + "gitea.konchin.com/ytshih/inp2025/middlewares" + "gitea.konchin.com/ytshih/inp2025/utils" + "github.com/spf13/viper" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect/sqlitedialect" + "github.com/uptrace/bun/driver/sqliteshim" + "github.com/uptrace/bunrouter" + "go.uber.org/zap" +) + +func AuthServer() { + sqldb, err := sql.Open(sqliteshim.ShimName, "file::memory:?cache=shared") + if err != nil { + panic(fmt.Errorf("failed to init sqlite, %w", err)) + } + db := bun.NewDB(sqldb, sqlitedialect.New()) + if err := utils.InitDB(context.Background(), db); err != nil { + panic(fmt.Errorf("failed to init db schema, %w", err)) + } + + logger := zap.NewExample() + defer logger.Sync() + undo := zap.ReplaceGlobals(logger) + defer undo() + + authHandlers := auth.NewHandlers(db) + middlewareHandlers := middlewares.NewHandlers(db) + + router := bunrouter.New() + + authGroup := router.NewGroup("/auth"). + Use(middlewareHandlers.ErrorHandler) + authGroup.POST("/register", + authHandlers.PostRegister) + authGroup.POST("/login", + middlewareHandlers.Auth(authHandlers.PostLogin)) + authGroup.POST("/logout", + middlewareHandlers.Auth(authHandlers.PostLogout)) + + server := &http.Server{ + Addr: viper.GetString("listen-addr"), + Handler: http.Handler(router), + } + + defer func() { + sqldb.Close() + if err := server.Shutdown(context.Background()); err != nil { + panic(fmt.Errorf("failed to shutdown wordle server, %w", err)) + } + }() + + fmt.Printf("server up\n") + fmt.Println(server.ListenAndServe()) +} diff --git a/workflows/wordleServer.go b/workflows/wordleServer.go new file mode 100644 index 0000000..fa3152a --- /dev/null +++ b/workflows/wordleServer.go @@ -0,0 +1,86 @@ +package workflows + +import ( + "context" + "fmt" + "math/rand" + "net" + "net/http" + "os" + + "gitea.konchin.com/ytshih/inp2025/handlers/wordle" + "gitea.konchin.com/ytshih/inp2025/middlewares" + "gitea.konchin.com/ytshih/inp2025/types" + "github.com/uptrace/bunrouter" + "go.uber.org/zap" +) + +const ( + DICT_FILE string = "./dict" +) + +func generateWordleAns() (string, error) { + file, err := os.Open(DICT_FILE) + if err != nil { + return "", fmt.Errorf("failed to open dictionary, %w", err) + } + + dict := []string{} + for { + var w string + _, err := fmt.Fscanf(file, "%s", &w) + if err != nil { + break + } + dict = append(dict, w) + } + + return dict[rand.Intn(len(dict))], nil +} + +func WordleServer(listener net.Listener) types.ShutdownFunc { + ans, err := generateWordleAns() + if err != nil { + panic(fmt.Errorf("failed to generate answer, %w", err)) + } + + logger, _ := zap.Config{ + Level: zap.NewAtomicLevelAt(zap.InfoLevel), + Encoding: "json", + OutputPaths: []string{"logs/wordle-stdout.log"}, + ErrorOutputPaths: []string{"logs/wordle-stderr.log"}, + EncoderConfig: zap.NewProductionEncoderConfig(), + }.Build() + undo := zap.ReplaceGlobals(logger) + + wordleHandlers := wordle.NewHandlers(ans) + middlewareHandlers := middlewares.NewHandlers(nil) + + router := bunrouter.New() + + apiGroup := router.NewGroup("/api"). + Use(middlewareHandlers.ErrorHandler) + apiGroup.GET("/state", + wordleHandlers.GetState) + apiGroup.POST("/guess", + wordleHandlers.PostGuess) + + server := &http.Server{ + Handler: http.Handler(router), + } + + go func() { + fmt.Printf("server up\n") + if err := server.Serve(listener); err != http.ErrServerClosed { + panic(fmt.Errorf("wordle server failed, %w", err)) + } + }() + + return func() { + logger.Sync() + undo() + if err := server.Shutdown(context.Background()); err != nil { + panic(fmt.Errorf("failed to shutdown wordle server, %w", err)) + } + } +}